Skip to content

The agent protocol

Diffo never spawns an agent, calls a model, or holds an API key. The agent that wrote the code attaches to the review itself, by running one command, and everything it is asked to do arrives in the JSON that command prints.

This page explains that mechanism for a person: what "attached" means, what the agent receives, and what it is obliged to do with it. It is the page to read when you want to know why feedback went where it went.

The instructions the agent itself follows are generated, not written here. They live in the Agent Skill and in the prompt the server builds for every payload, both produced from the same source strings, so the two cannot drift apart. For command syntax, see the CLI reference.

What "attached" means

An agent is attached when it is holding an open diffo poll. That is the whole relationship: no registration, no handshake, no process of Diffo's running on the agent's behalf. Three things follow from it.

  • Feedback lands where the context is. The poll runs inside the session that wrote the code, so a comment arrives in the conversation that already knows why the code looks like that.
  • An agent that is not polling is not reachable. Sends queue instead, and the review says so (presence reads waiting). Nothing is lost; the next poll collects it.
  • Diffo does not care which agent it is. Any process that can run a shell command can drive the loop, registered or not.

The loop, from the agent's side

StepWhat the agent runsWhy it matters
1. Opendiffo --no-openStarts the review and prints the URL. --no-open because an agent should never throw a browser window at someone; it hands over the URL instead
2. Guidediffo comment -m "…"One orientation comment, only when the changeset needs it. See the guide comment
3. Attachdiffo pollBlocks until the reviewer acts, then prints one payload
4. Act(edits, and diffo reply)Work the threads the payload named, and answer each one
5. Re-attachdiffo pollAlso a statement: see re-polling closes the batch
6. Detachdiffo endWhen the review is over

Step 1 returns immediately, leaving a background server, so it is a short command and not something the agent waits on. Only the poll blocks.

What a poll payload carries

One JSON object, discriminated by status:

statusMeaning
feedbackWork is waiting. The only status that carries any
timeoutThe 30-minute cap on this poll closed. Nothing is lost; poll again
supersededAnother session took the review. Do not re-poll
endedThe agent side detached with diffo end. Do not re-poll

A feedback payload:

jsonc
{
  "status": "feedback",
  "kind": "threads",           // "threads" = individual sends · "finish" = the reviewer is done reading
  "threadIds": ["4f1c9a2e-…"], // reply to each of these
  "prompt": "…",               // the authoritative instruction (see below)
  "next_step": "…"             // what to do once they are all handled
}

prompt is prose, built server-side, and it is what the agent actually acts on. It carries the thread text, a snapshot of the hunk each thread is anchored to as it was when the comment was written, the intent contract below, and the reply protocol. Because it is assembled per payload, an agent with no skill installed and no memory of this page still receives complete instructions.

A kind: "finish" payload is the reviewer pressing Finish review: the whole batch at once, with coverage attached (38/42 hunks read, 2 files skipped). Any closing note leads that batch as its own thread on the whole changeset, with an id to reply to. It re-ships every thread that was sent, including ones already answered, and the prompt tells the agent not to answer those twice.

Change or Question: the intent contract

Every thread carries the reviewer's intent, and the contract is two lines, shipped verbatim in the prompt:

question threads want an answer, not an edit. Reply in the thread; change no code for them unless the reviewer asks.

issue threads want a code change. Address each one, or push back in the thread with your reasoning.

This is the load-bearing distinction in the whole tool. A reviewer asking "why a Map here?" wants a sentence, and an agent that answers with a refactor has made the diff worse and moved the code under a reader mid-review. Disagreeing with a change request is allowed, in the thread, with reasons. Ignoring it is not.

The prompt adds two rules with the same intent: change only what the threads ask about, and re-read the current file before editing, because the snapshot in the prompt was frozen when the comment was written.

Re-polling closes the batch

The single behaviour most worth understanding, because it is not obvious from the command.

A new poll is read as "I have finished the previous lot." Asking for more work is a statement about the old work. So when a poll arrives, every thread from the previous batch that never got a reply stops saying waiting on the agent and starts saying no answer, and the review shows it that way to the reviewer.

The consequence for an agent is: handle the payload, then poll. Not poll, then start working. And a thread the agent decided not to act on still needs a reply saying so, because a reason is an answer and silence is not.

A batch also closes when the agent runs diffo end, or when its connection dies. Whatever was still owed is recorded as unanswered, so the review stops waiting on a thread the agent has moved past.

The guide comment

Before handing over the URL, an agent is asked to judge whether a cold reader needs orientation, and if so to post exactly one comment on the whole changeset. The doctrine is stated once in the source and interpolated into every surface that teaches it, so all of them say the same thing:

WhenMulti-file, structural, or subtle. Skipped when the diff explains itself
WhatOne sentence on what the change does, plus a small ```mermaid diagram if a picture explains the shape better than words
The line it must not crossOrient reading, never pre-review: no verdicts, nothing is "fine"
When it goes staleThe agent replies to its own guide thread with a short update, rather than posting a second guide

That last constraint is the point of the whole step. A guide that says the change is correct has pre-reviewed the code for the person whose independent judgment is the reason Diffo exists. If a takeover happens, the new agent inherits the existing guide and updates it in place, so the reviewer never ends up with two.

Presence: what the reviewer can see

The review always shows whether a Send is reaching anybody. The states are derived from the connection and the queue, never asserted by the agent:

StateReasonWhat it means
waitingno-agentNothing attached. Sends queue for the next poll
endedAn agent detached deliberately
disconnectedA poll died without detaching
listeningpollingA poll is open. A Send arrives now
workingdeliveredFeedback handed over, no reply yet
repliedA reply just landed; a brief grace window before the next state
stalledDelivered over 5 minutes ago with nothing back

Two deliberate choices here. stalled stays inside working rather than becoming its own state, because a slow agent and a dead agent look identical from outside; the reviewer gets the elapsed time and decides. And there is a 90-second grace after each reply, so an agent working through a batch does not flicker between states on every diffo reply.

If nothing is attached at all, every thread carries a Copy prompt button: the same prompt the poll would have delivered, ready to paste into any agent.

One session owns a review

Each diffo poll identifies its session by walking up the process tree for a recognisable coding-agent process (claude, cursor, codex, copilot, and friends), skipping shell processes on the way, and sends that pid as x-diffo-session-pid.

The newest poll wins. When a second session polls a repo another session already holds:

  • the new poll gets an x-diffo-took-over-from: <pid> response header, and is told to mention the takeover, since the reviewer's feedback now arrives there;
  • the displaced poll returns status: "superseded" and is told to stand down rather than re-poll, because two agents trading a review back and forth helps nobody.

Nothing queued is lost in a handover: undelivered feedback lives in the review, not in the poll, so it is re-derived and re-queued for whoever is listening.

Reviews are scoped per branch as well. A checkout under a running server swaps both the review and the delivery queue to that branch's work, so feedback queued on one branch cannot surface against another branch's hunks.

Why nothing gets lost

The guarantee is at-least-once, and it holds because feedback lives in the review rather than in any process:

  • A payload is not marked delivered until the write to the poll succeeds. Hono swallows stream write errors, so a "successful" write is not proof of receipt; an unconfirmed payload is delivered again on the next poll.
  • The delivery queue is not persisted. On startup it is rebuilt from the threads themselves, which means a restart cannot drop feedback nobody collected.
  • Killing the poll, the agent, or the server changes nothing about what the reviewer sent. The next poll, from any session, picks it up.

The cost of at-least-once is the occasional duplicate, which is why finish re-ships answered threads and the prompt tells the agent to leave them alone.

Where the agent's instructions live

None of the text an agent reads is maintained by hand:

SurfaceGenerated from
skills/diffo/SKILL.mdsrc/skill.ts, via pnpm build:skill. A test fails if the committed file drifts, which is why it is never hand-edited
Every poll payload's promptsrc/server/prompt.ts, the same module the skill imports its command strings from
diffo help agentThe same strings again, for an agent whose context was compacted mid-review

See also

Released under the Apache-2.0 License.