Git finds conflicts after the code is written. We wanted to catch them first.

Inspiration

Everybody at this hackathon is running coding agents. Almost nobody is running three at once on the same repo, and when we tried it we found out why. Two of our agents both decided to edit src/auth/session.ts. Both patches were fine on their own. Together they were a mess, and we didn't find out until the merge.

That isn't a model problem. The agents just had no way to tell each other what they were about to do. Git only notices a conflict once the code exists, and by then you're choosing between two reasonable implementations instead of preventing the overlap.

So we stopped asking how to make the agents smarter and asked something smaller: what if an agent had to say what it planned to do before it got write access?

What it does

Synapse sits between coding agents and a shared codebase. Before an agent can write anything, it has to join and prove who it is, claim a workstream, and file an Intention Document: which files it plans to touch, what symbols it'll add, what API contract it provides or consumes. Only after the coordinator checks that intention against everyone else's does the agent get approved to run. When it finally submits a ChangeSet, that gets checked against the intention that was approved.

The coordinator blocks on three rules, all of them boring on purpose:

  • Two workstreams claiming overlapping paths (glob-aware, so src/auth/** and src/auth/session.ts count as a collision).
  • Two workstreams disagreeing about a contract: same method and path, one provides, one consumes, and the consumer wants a field the provider never returns.
  • Any submitted file outside the workstream's assigned scope. That's a 409, no exceptions.

Our demo is an OAuth login. Agent 1 returns {token, user}. Agent 2 expects {accessToken, profile}. Neither plan is wrong by itself, and neither agent can see the problem from where it's standing. Synapse refuses both before either one runs, pulls up the team's past decision on scope boundaries, and waits for someone to reassign the paths. Then all three agents go and it works.

Why the rules aren't an LLM

We had a model arbitrating conflicts for about four hours on Saturday. It was good at explaining a conflict and bad at deciding one. Feed it the same two intentions twice and you'd get two different answers. That's the one property you can't have in something that's supposed to be the source of truth, on stage or in a real codebase.

So we split it. The models propose, the coordinator decides. Collision detection is a pure function over path globs and contract shapes: same inputs, same conflicts, in tests and in the demo. The generative side does the creative work, planning and writing code, and the deterministic side holds the line.

Identity, or: agent_id used to be a string

For most of the first day, our join endpoint took agent_id as plain text in a JSON body and checked it against a hardcoded list. Anyone who could reach port 8000 could claim to be backend-agent and land a ChangeSet. For a project whose whole pitch is "only a verified agent gets to own files," that was the thing holding everything up, and it was made of paper.

We replaced it with Agent Name Service. With IDENTITY_MODE=ans, every privileged call has to clear three checks:

  • Identity. Is this certificate actually sealed in the transparency log for this agent? We look up _ans-badge.<host> in DNS, fetch the badge, and match the certificate fingerprint against identityCerts[].
  • Liveness. Is that registration still good right now? The badge status has to be ACTIVE, WARNING or DEPRECATED.
  • Possession. Does whoever is calling actually hold the private key, for this specific request? That's an ANS-6 Method B DPoP proof.

You need all three. Drop possession and you'll accept replayed public artifacts. Drop liveness and you'll happily accept an agent that was revoked an hour ago. We also check that the identity certificate chains to the registrar's issuing CA, which the badge alone would already catch, but the two checks fail in different ways, so an attacker has to beat both.

The part that actually matters: the proven name comes out of the certificate's URI SAN, never out of the request body. Coordinator._bind rejects the call if the proven name doesn't match the agent_id being claimed. That one comparison is what turns agent_id from something you assert into something you have to earn.

We're specific about what tier this is. It's badge tier, not SCITT: liveness is a live query against the log rather than a COSE receipt we verify locally. ANS-6 allows it, revocation shows up immediately instead of after a token expires, and the cost is a network call on the hot path. There's also no DANE/TLSA, because TLSA only buys you anything under DNSSEC and our demo zone is unsigned. Both of those are written into docs/ANS.md so nobody on the team can accidentally oversell it during the demo.

How we built it

The coordinator is FastAPI and Pydantic over SQLite, with the whole state living in one JSON document. Every transition does the same thing: take the lock, read, mutate, append an event, write, hand the event off to memory and tracing. There are eleven event types and they're fixed, because the UI and the agents both key off them.

The live agents are three roles (backend, frontend, integration), each with its own provider, key and model. All three plan in parallel, the coordinator hands every intention to every agent, and then backend and frontend implement at the same time while integration reviews what they staged. Everything they generate lands in .local/live-runs/<run-id>, its own Git repo. It never touches the Synapse repository and it never gets executed.

The dashboard is React 19 and Vite, streaming run events over SSE. The app the agents build renders in a sandboxed iframe on an opaque origin: inline JS is allowed so the thing is actually clickable, but network access, parent-page access, navigation and form submission are all off.

The Pydantic models are the only source of truth for the API. npm run contracts exports JSON Schema and OpenAPI and regenerates the UI's TypeScript types, so changing a model breaks the frontend build instead of breaking the demo. The orchestration v1 contract is frozen and the test suite fails if you change its operations.

Coordinator events go to a Databricks Delta table when it's configured, and you can read them back at GET /api/traces.

We also shipped an MCP server over stdio that exposes the same six coordinator operations as tools. Any MCP-capable coding agent, Claude Code included, can join, declare scope and submit through Synapse instead of editing files directly.

Four of us, about 36 hours, roughly 11k lines across the server, UI, agents and scripts. We ended up using the tool's own rules on ourselves: one person per directory, and interface changes had to be declared before they were made.

Challenges

Getting clean JSON out of open models. gpt-oss-120b returns good JSON most of the time, and occasionally wraps it in prose, sneaks a raw control character into a string, or ignores the file cap entirely. We didn't want to loosen the validator, so we made failure recoverable instead: the parser tolerates raw control characters, and a rejected proposal goes back to the agent with the actual reason ("you returned 9 files, the limit is 6, merge related code") and exactly one retry. The caps themselves never moved: 6 files per agent, 50k characters per file, 180k total.

Three agents, one API key. Predictably, 429s. We gave each role its own key slot and model override and wrapped every call in bounded retry with backoff. The failure mode now is "the provider is overloaded, here's which agent and here's why," rather than a progress bar that stops moving.

ARC doesn't leave campus. VT's ARC endpoint only answers from the VT network or VPN. Great on our laptops, impossible from Fly.io. That's why the provider layer is a swap: ARC locally, Gemini deployed, Hugging Face as a third option, one .env line apart.

Planning for the demo going wrong. We wrote out the fallback chain ahead of time: live agents with real ANS and real Databricks, then flip identity and tracing back to local fixtures, then the guided simulation that needs no credentials, then bash scripts/scenario.sh against Swagger, then the recorded video. Everything from step two down runs with no network at all. Making local fixtures the default, so a fresh clone works with zero credentials, was probably the best decision we made all weekend.

What we learned

Keep the thing that proposes separate from the thing that decides. Once we stopped asking a model to be the arbiter, everything downstream became testable.

Identity isn't a feature you bolt on at the end. "Only verified agents can write" is worth nothing if identity is a string in a request body, and we shipped that version for a day before we noticed.

Being precise about what's real is more convincing than claiming everything is. We wrote down in the repo which parts are live and which are fixtures: mock identity is a local allowlist, decision memory is seeded in every mode, agent-reported tests are not executed tests. Being able to answer "is that real?" without hedging went over better with people than a bigger claim would have.

What's next

Put the live agent path behind the ANS gate. Right now it runs in its own role namespace and never calls join. Move liveness from badge tier to SCITT receipts we verify locally. And replace the seeded decision memory with real retrieval over the Databricks trace history, so when the coordinator recommends something on a new conflict, it's because of how the team actually resolved the last one.

Built With

Share this project:

Updates

Submission history