Keep a team of independent AI agents coherent. They report what they're working on over MCP, find out when they collide, and settle it between themselves — across sessions, providers, machines, and people.
Three terminals, three different products from three different vendors, no shared session between them, all pointed at one PACL. dev-alice takes the checkout refactor. dev-bob goes for the same file and is told before he touches it, then messages her directly and hands it back. dev-carol, told nothing by anyone, asks what the team is doing and describes it correctly.
Then the part worth watching: alice moves on to src/refunds.py, and bob's message arrives on the response to that. She never asked. (higher-quality mp4 — the editing is cutting the pauses while models think, and a camera move to whichever terminal is talking, because three panes of terminal text are unreadable at this size otherwise. Nothing inside the terminals is altered.)
uv sync --extra dev
cp .env.example .env # add a Gemini AI Studio key
uv run pacl # MCP server on http://127.0.0.1:8080/mcpPoint any MCP client at it:
{
"mcpServers": {
"pacl": {
"url": "http://localhost:8080/mcp",
"headers": { "X-PACL-Agent": "your-agent-id" }
}
}
}Two agents on the same PACL now hear about each other on their next tool call.
Set the header. Identity comes from X-PACL-Agent, which is the only source that is both stable across reconnects and outside the model's control — nothing carried in request _meta is honoured, because the caller supplies it and could otherwise claim someone else's identity. Without the header an agent still gets a distinct id from its transport session, but a reconnect makes it a new agent.
| Tool | When to call it |
|---|---|
update_intent(intent, domain) |
the user states a new goal or changes direction |
share_context(content) |
something substantive was discussed, decided, or found |
report_activity(action, target) |
you're about to act on a file or resource |
query(question) |
you want to know what the rest of the team is doing |
send_to_agent(to_agent, message) |
you want to say something to another agent directly |
Every call returns an alerts list. When it's non-empty, those are messages for you — from PACL, or from another agent. There is no push channel and no inbox to check: coordination rides on the next tool response you were making anyway.
That's a design constraint, not a shortcut. PACL doesn't depend on server-initiated push, because client support for it is inconsistent — the response you were already getting is the one channel every client honours.
sequenceDiagram
participant A as dev-alice
participant P as PACL
participant B as dev-bob
A->>P: update_intent('refactor the checkout payment flow', ['src/checkout.py'])
B->>P: report_activity('edit', 'src/checkout.py')
P-->>B: alerts: scope collision — dev-alice declared src/checkout.py
Note over P: both sides are told, not just the newcomer
Note over B: what to do about it is bob's call
B->>P: send_to_agent('dev-alice', "I'll take the tests, it's yours")
A->>P: query('anything for me?')
P-->>A: alerts: scope collision — dev-bob just took src/checkout.py<br/>[from dev-bob] I'll take the tests, it's yours
Note over P: sees the exchange, stays silent
Coordination lives in a prompt, not in code. There is no rule engine, no policy table, no if overlap: warn(), and no branch per coordination pattern — prompts.py is the whole policy, and you can read it in a minute.
What that prompt actually contains: one objective (keep the team coherent), four example situations marked explicitly as illustrations rather than a checklist, an instruction to generalise beyond them, and a bias toward silence. So overlap detection, escalation routing and context handoff are named as examples — what isn't specified is how to recognise any of them, when they apply, or what to do about each one.
Agents get the same treatment. PACL tells them a collision exists, never how to divide the work. They have send_to_agent and each other, and the resolution is theirs.
The honest test of whether that generalises is the eval suite below, which contains cases the prompt never anticipates — two agents doing identical work under completely different words, adjacent work that must not be flagged, a bystander who must be left out of someone else's collision. Those pass without a line of code per case.
The cost: coordination is only as good as the model's judgement, and nothing underneath guarantees a correct call. The failures are in the table below rather than hidden.
The one deterministic thing, and why it isn't a contradiction
Literal scope collisions — two agents naming the same file — are caught by a sensor, not a coordinator. Set intersection over the live scope map, delivered on the same tool response. Measured over loopback: ~1.3ms for a full collision-bearing report_activity round trip, of which the detection itself is ~0.003ms.
Scope collision: dev-alice already declared src/checkout.py (intent: "refactoring the checkout payment flow", since …).
That is the whole message. It states what is true and stops. A test asserts it never grows directive language.
It exists for latency, not judgement. The model path runs several sequential calls and lands in the tens of seconds, and "told before it touches anything" isn't true at that speed for an agent working quickly. Two rules keep it from eroding the design:
- It never enters the intermediary's prompt. The model still gets no precomputed overlap and must notice it by reasoning, which is the only way the semantic cases get caught — two agents doing the same work under completely different words. The sensor is blind to those.
- It never decides anything. The moment it says what an agent should do, coordination stops being emergent and becomes a lookup table with a model bolted on the side.
Emergent behaviour is easy to claim and easy to fake with one good demo, so it's measured. 38 scenarios, 152 live runs against real models, fresh substrate per run, every run graded by one ruleset. 140 of 152 pass. The suite drives the intermediary directly rather than over MCP — it grades the coordination reasoning, not the transport. The transport is what the video exercises.
Roughly a third of the suite asserts silence, because a coordinator that alerts on everything is worse than none — agents learn to ignore it. Precision is graded as hard as detection.
| result | reading | |
|---|---|---|
| Detection | 91/92 | one miss in 152 runs, and it was a context handoff rather than a collision — every semantic case passed, including the ones where the two agents shared no vocabulary at all |
| Restraint | 38/48 | it over-alerts. The problem is chattiness, not blindness |
Of the twelve failures, ten are the coordinator speaking when it should have stayed quiet, one is a missed durable record, and one is the handoff case above. Nothing was tuned to make any of it look better, and the per-scenario breakdown is in the full report.
The headline blends two models, and they are not equal — the faster one scores better on restraint than gemini-2.5-pro, which is the default. The by-model table splits it rather than leaving you to assume the default is the best case.
# exactly what produced the published numbers: 38 x 3 on flash, 38 x 1 on pro
EVAL_K=3 EVAL_MODELS=gemini-2.5-flash uv run python -m pacl.evals.run_matrix
EVAL_K=1 EVAL_MODELS=gemini-2.5-pro uv run python -m pacl.evals.run_matrix
uv run python scripts/eval_report.py --in .eval_runs/runs.jsonlFull report: docs/eval-report.html. Every raw run, including the exact messages produced and the assertions it was graded against: docs/eval-runs.jsonl — the numbers above recompute from that file alone, without needing this repo's scenario definitions.
Two details that make the numbers mean something. Deterministic sensor output is stripped before grading, so a literal tag match can never inflate a result — without that split, an "expect alert" scenario with matching tags would pass with a no-op model. And the same suite runs unchanged across models, because coordination that only works on one model is falling out of that model's habits rather than the objective.
| Layer | Choice |
|---|---|
| Agent interface | MCP (Streamable HTTP), mounted at /mcp |
| Reasoning | Gemini via the Google Agent Development Kit |
| Egress | per-agent in-memory queue, drained onto tool responses |
| Substrate | local-disk markdown (shared/durable storage is future work) |
| Hosting | Cloud Run |
Can you swap the models? The agents, yes — entirely. PACL only needs an MCP endpoint, so any MCP-capable client from any vendor works, which is the whole point. The intermediary is currently Gemini via ADK; ADK supports other providers through LiteLlm, so swapping it means google-adk[extensions] and passing a model object instead of a string. Small change, untested.
| Variable | Default | Purpose |
|---|---|---|
GEMINI_API_KEY |
— | Gemini AI Studio key |
GEMINI_MODEL |
gemini-2.5-pro |
the intermediary's model |
HOST |
127.0.0.1 |
bind address — set 0.0.0.0 only when you mean to expose it |
PORT |
8080 |
listen port |
PACL_INTENT_TTL |
3600 |
seconds before a stale intent ages out of overlap detection |
SUBSTRATE_LOCAL_ROOT |
./substrate |
substrate directory |
PACL_BATCH_WINDOW |
2.0 |
seconds spent collecting more events before the intermediary runs |
LOG_LEVEL |
INFO |
uvicorn log level |
uv sync --extra dev
uv run python -m pytest # 67 testsAGPL-3.0-or-later — Copyright (C) 2026 Dylan Porter. See LICENSE.

