A Claude Code plugin that stops your app and your API from drifting apart.
Keep a client app and its backend API in lockstep. Catches the exact class of bug where the app calls an endpoint the backend doesn't expose (or vice-versa), field names drift, or a method/path disagrees — and fixes the named side.
Stack-agnostic; tuned for Flutter + FastAPI (the layout it was built against) but detects Node/Django/Go/Rails on the backend and Flutter/RN/web on the client.
The classic full-stack bug: the app calls /v3/users/{id}/orders, the backend serves
/v3/users/{id}/order. Types compile, tests pass, and it 404s in production.
fullstack-sync diffs the app's endpoint constants and services against the backend's
routes and schemas, names the side that is wrong, and fixes it. Nothing runs and no
tokens are spent until a contract file actually changes.
claude plugin marketplace add Eugen24/fullstack-sync
claude plugin install fullstack-sync@neo-plugins
(Or, for local dev, point the marketplace at a local clone path instead of the GitHub slug.)
From inside the app repo:
/fullstack-setup
It asks for the backend path + any extra folders, scans both repos for
OpenAPI / markdown route docs / endpoint constants, and writes
.fullstack-sync.json at the app root. Then /add-dir <backend-root> so both
repos are first-class in the session.
| Skill | Use it for |
|---|---|
api-contract-sync |
"check API contracts", endpoint mismatch, 404/405 from app, after changing a route — diffs app constants/services ↔ backend routes/schemas and fixes the drift |
fullstack-feature |
"add this endpoint on backend and app" — scaffolds route+schema AND constant+service+model with identical field names |
fullstack-run |
"run backend and app together" — boots the API, health-checks it, points the app at local |
fullstack-verify |
"verify before commit" — app analyze + backend tests + migration drift + contract recheck |
parallel-sync-status |
two-session parallel mode — "am I in sync", "did the backend change", report contract drift between the backend and app sessions (report-only; fixes go through api-contract-sync) |
Lives at the app repo root, written by /fullstack-setup, read by every skill.
Records app/backend roots + stacks, the endpoints file, router/schema globs, the
doc/spec files to treat as contract truth, and version base-paths.
For building a feature with one session per side at the same time:
/fullstack-parallel-init— creates.fullstack-sync/, auto-detects shared-file ownership, prints thegit worktreecommands.- Open one
claudesession per worktree (backend / app). - Each session:
parallel-sync-statusto check drift,fullstack-featureto build its side. Drift is caught by comparing a structural contract fingerprint — no event log, no locks, no orchestrator. Seereferences/parallel-sync.md. Once both sides agree,/fullstack-reconcile(run from the app session) records the reconcile point so later drift is detected.
sync_level in .fullstack-sync.json controls the post-change nudge. Default low —
the plugin asks for your dirs, then stays quiet until a contract file actually changes; it
spends no tokens and touches no code or commits unless you accept the nudge.
| Level | Behaviour |
|---|---|
off |
silent, fully manual |
low (default) |
drift-gated Stop hook prints a one-line "contract moved — review?" suggestion; you decide. /fullstack-sync-review runs a scoped, lens-selected, report-only review |
hard |
/fullstack-sync-review --hard (or the hook in hard mode) reviews both repos across all lenses and can auto-sync + commit — only on a feature branch; it refuses on master/main |
FastAPI backends get a precise fingerprint gate (nudges only when the contract hash truly
moves, and stays quiet on comment-only edits to a route file). Other stacks get a coarser
"contract files changed" gate until more extractors exist — it can't confirm the hash
moved, so it re-nudges each turn a contract file shows as changed, until you act. See
docs/superpowers/specs/2026-06-30-sync-level-review-design.md.
scripts/drift_check.py checks the contract on every run: paths, HTTP methods, response fields and request bodies.
python3 scripts/drift_check.py # PATH, METHOD, per-endpoint FIELD, BODY
python3 scripts/drift_check.py --fields # + FIELD? review list
python3 scripts/drift_check.py --strict-fields # FIELD? findings fail the run too
python3 scripts/drift_check.py --openapi spec.json # backend from an OpenAPI file
python3 scripts/drift_check.py --json --warn # machine output, never fails
| Finding | Meaning | Fails the run |
|---|---|---|
PATH |
the app calls a path no backend route serves | yes |
METHOD |
the path exists, but not with the verb the app uses (.get/.post/...) |
yes |
FIELD |
per endpoint: the app parses the response with Model.fromJson and reads a key with a non-null cast (j['id'] as String, which throws when absent) that the backend's response model for that endpoint does not declare |
yes |
BODY |
POST/PUT/PATCH with an inline JSON map (data: {'k': v}): the app sends a key the backend's request model does not declare (Pydantic drops it silently), or leaves out a field the model requires (a 422 at runtime). Judged only when the whole map is visible (no ...spread) and the backend model resolves; keys under if (...) count as sent |
yes |
FIELD? |
review item: a key the app reads that the backend response model lacks but the app may tolerate (?? x, wrappers), or (loose, --fields) a name the backend never mentions anywhere |
only with --strict-fields |
DYNAMIC |
info: a path built from a runtime variable ('$base/v4/$path'), checked only as a prefix |
no |
Exit 0 clean, 1 drift, 2 bad input. The Stop hook runs the same check and puts the concrete
PATH/METHOD/FIELD/BODY lines in its nudge, so what you see is always against the current code.
How it resolves things, so findings are about the contract and not about plumbing:
- Dart constants:
static const v4 = '/v4'; '$v4/app_feedback'becomes/v4/app_feedback. - Backend models resolve across files, with inheritance,
List[X]/Optional[X]/X | None, the return annotation when there is noresponse_model, andField(alias=...). - Backend routing: static
include_router(mod.router, prefix=...)is applied. Backends that mount routers by directory (e.g.routers/v4/tasks.pybecomes/v4/tasks) set"backend": {"prefix_rule": {"from_path": "routers", "skip": ["root"]}}in.fullstack-sync.json. - Backend responses that are plain dicts have no declared fields: they are skipped, never guessed.
What it still cannot see:
- A prefix present on only one side is tolerated, so it can miss a drift but should not invent one.
- Nested response/request fields are not flattened; no multi-level router-in-router chains.
- Request bodies built from a variable (
data: payload.toJson()) or a list are not seen, only inline maps.Optional[X]fields without a default are treated as not required (Pydantic v1 vs v2 differ), so a missing one is never reported. - Whether the app handles a missing key: only a plain non-null cast counts as required.
- Dynamic paths beyond a trailing
$path-style variable. Opt out of a call with// fss-ignore, or list paths in"drift_ignore": ["/v4/remo/{id}"]in.fullstack-sync.json. - With a dict-returning backend, per-endpoint
FIELDhas nothing to compare; the looseFIELD?list then only says which names the backend never mentions.
A ready workflow is in docs/examples/drift-gate.yml.
bash tests/run.sh
Runs the whole suite (python3 + git, no other deps): fingerprint unit tests
(determinism + camel/snake normalization), sync_state unit tests (slice write,
single-writer reconcile, drift status), and an end-to-end test_protocol.sh that drives
two real git repos through init → slice → reconcile → drift via the sync_state CLI. Runs
in CI on every push/PR (.github/workflows/test.yml). The mechanical state ops live in the
tested scripts/sync_state.py; the model only does contract extraction.
See CHANGELOG.md. Latest: v0.7.0, the drift check also compares request bodies (BODY), per call site. v0.4.0 added the CI gate; v0.3.1 made the Stop hook about 10x faster.
Issues and PRs welcome. Run bash tests/run.sh (python3 + git only) before opening a PR.
Real drift caught on day one of dogfooding: the app called an endpoint whose path didn't match the backend's, and several URL constants were missing or pointed at routes that didn't exist — each a silent runtime 404 the analyzer couldn't see. This plugin makes that visible before it ships.