You are adding support for a coding agent to Duo, a tool that runs two AI coding agents in separate Git worktrees and has them plan, build, review and deliver a change together. You are going to write a plugin so Duo can drive your agent.
You do not need to understand Duo. You do not need to read Duo's source. You do not need its Go module. If your agent has a CLI, you can write this plugin in an afternoon.
The normative reference is the Driver Plugin Protocol v1. This guide is the tutorial. Where they disagree, the protocol document wins.
- What you are building
- Before you start
- Step 1: answer
describe - Step 2: answer
probe - Step 3: answer
prepare - Step 4: add an Agent Adapter, if your agent has an API
- Step 5: models and thinking
- Step 6: test it
- Step 7: install it
- Checklist
- Common mistakes
When using an AI coding agent, have it follow this guide in order and verify each stage before moving on. In particular, do not skip the capability contract, state round-trip, or real-binary checks below.
Duo runs two agents, Austin and Tony, each in its own worktree, and hands the approved result back to your repository. How much they negotiate depends on the mode: Fast has Austin implement and Tony verify; Goal has both plan, build, cross-review and sign; Step runs a dual-approved queue under either policy.
Duo does not know anything about coding agents. It has no idea what a "model" is for your agent, whether your agent can be resumed, or what flags it takes. Your plugin tells it. That is the entire job.
Concretely, your plugin is one executable that answers questions:
| question | why Duo asks it |
|---|---|
| Who are you, and what can you do? | so Duo knows how to treat your agent |
| Can I run your agent right now? | so Duo can say "install the CLI" instead of failing |
| What command line should I run? | so Duo never has to know your flags |
| What conversation was I in? | so a resumed session continues instead of restarting |
| What models can I choose from? | so the model picker works |
| What reasoning levels exist? | so the thinking picker works |
Duo starts your plugin as a background process and talks to it over stdin/stdout. Separately, your plugin may observe the running agent and report what it is doing — that is your choice, and the most common reason a plugin is worth writing well.
You need your agent's CLI: how to launch it, how to resume a session, how to list models, and how to set a reasoning level. That is the only requirement.
A way to observe it — an extension API, a plugin API, a log file, or anything else you can read — is optional and only buys the richer adapter below.
You do not need Duo's source code. You do need its protocol document, which is the first link at the top.
Your driver name is <name>, and your executable must be duo-plugin-<name>.
codex, claude, qoder are all fine. duo, duo-mcp-server and duo-plugin-test
are reserved.
Any language. The wire format is newline-delimited JSON on stdin/stdout, so Python,
TypeScript, Rust and Go are all equally viable. If you write Go, the reference
implementation below is directly usable and there is an SDK in Duo's repository at
internal/driver plus a runnable example at cmd/duo-plugin-example. If you write
something else, this guide plus the protocol document is all you need.
This is the most important method and the one to get right first. Duo reads nothing else about your agent, and it refuses to start a session whose manifest it does not understand.
Start here, and add the rest as you implement it:
{
"protocol": 1,
"name": "example",
"version": "1.0.0",
"description": "Example coding agent",
"agent": {"cli": "example-agent", "defaultCommand": "example-agent"},
"defaultModel": "",
"modelReference": "qualified",
"capabilities": {
"resume": "client",
"models": false,
"thinking": false,
"liveModelSwitch": false,
"liveThinkingSwitch": false,
"contextUsage": false,
"tokenRate": false,
"bridge": "none",
"ptyFallback": true,
"liveSteering": false,
"selfReports": false
},
"activity": false,
"mcp": false
}Every field is described in the protocol document. Three decisions to make now:
resume — who owns the conversation identity?
client— your agent accepts a session id you choose. Then mint one on the first run and replay it. This is the easy case.server— your agent assigns the id itself and rejects one it never issued. Then send nothing until you have learned a real id.none— no resume at all. The user loses their conversation on restart. Use this only if you genuinely have no option.
modelReference — does your agent accept provider/id, or only a bare id?
Declare qualified or bare. If you get this backwards the model picker shows names
your agent will reject.
defaultModel — return "" unless you have a real reason. An empty value means
your agent resolves its own default, which is what a human wants. A guessed id makes
most agents abort with "Model not found".
Then read the capability table and apply the one rule:
Every capability must change something Duo does. If Duo never branches on it, it is descriptive metadata, not a capability, and declaring it is a lie that will be believed.
Leave a capability false until the corresponding code works. A false capability
costs the user a feature; a false positive capability costs them a feature that
appears to exist and does nothing.
Your agent CLI might not be installed. That is not an error — it is information.
{"available": true, "agentPath": "/usr/local/bin/example-agent"}or
{"available": false, "reason": "the example-agent CLI is not on PATH"}The reason is required and is shown to the user verbatim. "Command not found" with no explanation is the failure mode you are avoiding.
Do not return an error here. An error makes Duo report a broken plugin when the truth is a missing dependency.
This is where all your agent's specifics live, and where the rest of your plugin's value is. Duo asks for a command line; you build one.
{
"command": "example-agent --model 'acme/model' --session-id \"$EXAMPLE_SESSION\"",
"env": {"EXAMPLE_SESSION": "abc-123"},
"state": {"sessionId": "abc-123"},
"sessionIdentity": "abc-123",
"cleanup": ["/tmp/example-austin.log"],
"notices": []
}Duo runs your command as sh -lc "exec <command>" in a pseudo-terminal. So it is a
shell command line, not an argv array.
Three rules, each of which has cost somebody a session:
-
Quote for the shell, not for Go or Python. Use single quotes, or better, put the value in
envand reference"$VAR". A model id containing$or a backtick will otherwise be expanded or executed. -
An unset value adds no flag at all. If no model is selected, do not emit
--model ''. That is not "no flag"; it is a flag whose argument is the empty string, and most CLIs reject it. -
Respect the operator's
baseCommand. If they configuredexample-agent --config /tmp/mine.json, that is deliberate. Add to it; do not replace it.
Start with the minimum that works, then add:
func (p plugin) Prepare(req driver.LaunchRequest) (*driver.LaunchPlan, error) {
var prior state
if len(req.State) > 0 {
if err := json.Unmarshal(req.State, &prior); err != nil {
// Report it. Silently treating unreadable state as a first run turns a
// resume into a new conversation that looks like it worked.
return nil, &driver.RPCError{Code: driver.CodeInvalid, Message: err.Error()}
}
}
base := strings.TrimSpace(req.BaseCommand)
if base == "" {
base = "example-agent" // your agent.defaultCommand
}
// ResumeClient: mint once, replay forever.
id := prior.SessionID
if id == "" {
id = newID()
}
command := base
if model := strings.TrimSpace(req.Model); model != "" && !hasFlag(command, "--model") {
command += " --model " + shellQuote(model)
}
if id != "" && !hasFlag(command, sessionFlags) {
command += ` --session-id "$EXAMPLE_SESSION"`
}
raw, err := json.Marshal(state{SessionID: id})
if err != nil {
return nil, err
}
return &driver.LaunchPlan{
Command: command,
Env: map[string]string{"EXAMPLE_SESSION": id},
State: raw,
SessionIdentity: id,
}, nil
}Match flags exactly, never by substring. A test for -c as a substring also
matches --config, and an agent that silently loses its --config is a bug you will
chase for a day.
state is yours. Duo stores the JSON you return and hands it back verbatim. It never
reads it, never migrates it, never changes its schema. Put whatever you need in it.
Legacy-session compatibility. Your blob stays opaque, but a session recorded
before the plugin format can be resumed with the saved identity under the one word
every driver shares: {"sessionId":"…"}. Accept sessionId as the identity in
addition to whatever spelling you prefer:
type state struct {
ConversationID string `json:"conversationId,omitempty"` // yours, and what you write
SessionID string `json:"sessionId,omitempty"` // read-only, for compatibility
}
func (s state) conversation() string {
if s.ConversationID != "" {
return s.ConversationID
}
return s.SessionID
}If you declare resume: server, this is the mistake that makes your driver look
broken while appearing to work: an agent that rejects an unissued id opens a new
conversation, you persist the rejected id, you replay it next launch, and the agent
starts fresh every single time. Nothing reports an error. The symptom is "it never
resumes".
So: send no identity until you have a real one.
And once you have one, you have to be able to report it, because prepare has
already returned by the time your Agent Adapter learns anything. Declare resume: server and you must implement state:
type Plugin struct {
mu sync.Mutex
conversation string // learned from the Agent Adapter, after prepare
}
func (p *Plugin) State() (json.RawMessage, error) {
p.mu.Lock()
defer p.mu.Unlock()
return json.Marshal(state{ConversationID: p.conversation})
}Keep it in memory, set it when the Adapter reports, and return whatever you know
now. Prefer it over the stored blob in prepare too, so a restart inside one
session resumes instead of forking. duo plugins test fails a driver that declares
resume: server and does not answer state.
cleanup lists paths Duo deletes before your next launch. Use it for anything your
agent writes that a stale value could be misread from — a log file, a reported session
id. This is the generic replacement for hard-coded paths.
notices are operator-facing diagnostics discovered while preparing this launch.
They belong here rather than in describe because they are a property of the
machine right now: a helper tool may be installed between the two calls, and a static
string would be a claim Duo cannot keep true. The agy driver reports a missing
sqlite3 this way.
This is optional and it is where the difference between "works" and "pleasant to use" is. Without an adapter, the user sees a terminal and Duo can only write to it. With one, Duo shows what the agent is doing, injects prompts mid-turn, and can switch models without a restart.
Three levels, pick what your agent supports:
| your agent has | adapter | declare |
|---|---|---|
| nothing readable | none. ptyFallback: true covers you. |
bridge: "none" |
| a log or transcript file | watch it, and report activity | bridge: "plugin" |
| an extension or plugin API running inside it | connect from inside the agent | bridge: "agent" |
The Duo bridge protocol is the whole reference. If your
agent has a plugin API — and most modern coding agents do — the shape is the same as
both shipped adapters: read DUO_ACTIVE (be completely inert without it), read the
bridge address from DUO_HOST/DUO_PORT/DUO_SESSION/DUO_TOKEN, connect, send
hello, then report activity as things happen and deliver inbound prompts.
The one-endpoint rule. Duo keeps exactly one bridge connection per agent, and a second connection replaces and closes the first. So a driver has exactly one endpoint: inside the agent, or the plugin process. Never both, or they will evict each other in a loop and the agent will appear to connect and drop repeatedly.
What each capability buys you, and the failure if you claim it wrongly:
| declare | effect | if untrue |
|---|---|---|
liveSteering: true |
peer messages arrive mid-turn | they queue until the turn ends |
liveModelSwitch: true |
a new model applies without a restart | the model silently does not change |
liveThinkingSwitch: true |
same for reasoning effort | same |
selfReports: true |
Duo trusts your adapter to announce itself | the agent appears missing when it is working |
contextUsage: true |
Duo shows the context figure you send | Duo ignores the numbers you send |
tokenRate: true |
Duo shows the rate you estimate | Duo ignores the rate you send |
liveSteering and the two live*Switch flags are the ones that cause silent
failures. Claim them only once you handle the corresponding message.
activity and mcp are not capabilities. They sit on the manifest next to
capabilities, because Duo does the same thing for every driver either way and
never branches on them: activity records that your adapter reports what the agent
is doing over the bridge, and mcp records that your launch command wires Duo's
coordination tools in. Both are optional, both default to false, and both are safe
to declare even if untrue. A field inside capabilities is a promise Duo acts on,
so nothing that Duo ignores belongs there — see
the protocol's capability table.
Once describe says models: true, Duo calls your models method and shows a
picker.
{"models": [
{"provider":"acme","id":"large","displayName":"Acme Large","contextWindow":200000,"thinking":true,"images":true},
{"provider":"acme","id":"small","contextWindow":128000}
]}This is where your agent's own catalog becomes Duo's. Ask your agent for it, parse whatever it prints, and return this shape. An empty catalog is an error, not an empty picker — the user needs to be told the CLI failed rather than shown a blank list.
Same for thinking:
{"levels": ["off", "low", "medium", "high"], "default": "medium"}Return the levels your agent actually has, in escalation order.
Three levels, cheapest first. All three have caught real bugs.
Test the methods directly. Fast, and catches most logic errors. If your language has a test framework, this is ninety percent of the value.
func TestFirstRunSendsNoIdentity(t *testing.T) {
plan, err := plugin{}.Prepare(driver.LaunchRequest{BaseCommand: "example-agent"})
if err != nil {
t.Fatal(err)
}
if strings.Contains(plan.Command, "--session-id") {
t.Errorf("a first run was handed an identity the agent never saw: %q", plan.Command)
}
}
func TestResumeReplaysTheStoredIdentity(t *testing.T) {
first, _ := plugin{}.Prepare(driver.LaunchRequest{BaseCommand: "example-agent"})
again, _ := plugin{}.Prepare(driver.LaunchRequest{BaseCommand: "example-agent", State: first.State})
// The second launch must reattach, not start over.
}Test the manifest against your implementations. The cheapest possible guard, and worth having because the failure is invisible:
func TestDeclaredCapabilitiesAreImplemented(t *testing.T) {
var h driver.Handler = plugin{}
man, _ := h.Describe()
if man.Capabilities.Models {
if _, ok := h.(driver.ModelLister); !ok {
t.Error("capabilities.models is declared but the models method is not implemented")
}
}
if man.Capabilities.Thinking {
if _, ok := h.(driver.ThinkingProvider); !ok {
t.Error("capabilities.thinking is declared but the thinking method is not implemented")
}
}
}Test the built binary over its real pipes. This is the only level that can catch a disagreement about the wire format. Build the executable, run it, write JSON to its stdin, read JSON from its stdout:
func TestBinarySpeaksTheProtocol(t *testing.T) {
bin := build(t) // your executable
c, err := driver.Start(ctx, "example", bin, nil, nil)
if err != nil {
t.Fatal(err)
}
defer c.Close()
man, err := c.Describe(ctx) // must not error
if err != nil {
t.Fatal(err)
}
// assert your capabilities survived the JSON round trip
plan, err := c.Prepare(ctx, driver.LaunchRequest{BaseCommand: "example-agent"})
if err != nil {
t.Fatal(err)
}
if plan.Command == "" {
t.Error("prepare returned no command")
}
}You can also do this by hand in one line, and it is worth doing before anything else:
echo '{"protocol":1,"id":1,"method":"describe"}' | ./duo-plugin-exampleThen let Duo check it. duo plugins test runs the shared conformance suite
against your executable and prints one line per check, so you do not have to
reproduce Duo's own expectations by hand:
duo plugins test ./duo-plugin-example -v
duo plugins test --all # every driver this Duo can seeThen run it for real. In a scratch repository:
duo plugins # your plugin should be listed
duo --agent example # start a sessionWatch the session log. Start a session, stop it, duo resume, and confirm your
agent continued the same conversation. Resume is the feature users notice last and
miss most.
mkdir -p ~/.duo/plugins
cp ./duo-plugin-example ~/.duo/plugins/
duo plugins # listed, with the path that will actually run~/.duo/plugins takes priority over $PATH, so you can develop a plugin in place
without uninstalling anything.
Then hand it to someone: a plugin is one executable plus, if your agent has an extension API, whatever that extension needs installed. That is the entire distribution story.
- Executable is named
duo-plugin-<name>; notduo,duo-mcp-serverorduo-plugin-test -
describereturnsprotocol: 1, a name, and aresumemode - Every declared capability has working code behind it
-
modelReferencematches whether your agent accepts a provider prefix -
probereports unavailability with a reason instead of erroring -
preparereturns a shell command line, quoted, preservingbaseCommand - An unset model or effort adds no flag at all
-
prepareacceptssessionIdas an identity spelling in stored state -
resume: serversends no identity until a real one is known -
resume: serverimplementsstateand reports the learned identity - A restart replays the stored state and reattaches
- Corrupt state is reported, not treated as a first run
-
cleanuplists anything a stale value could be misread from - Adapter is inert unless
DUO_ACTIVE=1 - Exactly one bridge endpoint, inside the agent or the plugin process
- The built binary has been driven over its real pipes
-
duo --agent <name>starts, resumes, and delivers
Each one is silent when it happens.
Declaring a capability whose method is missing. A method with a subtly different
signature compiles, passes every unit test, and answers unsupported at runtime. The
model picker came up empty with nothing reporting an error. Declare the capability
only when the method is implemented, and assert it in a test.
Emitting a flag for an unset value. --model '' looks like a harmless no-op and
is a flag whose argument is the empty string. Most CLIs reject it.
Replaying an identity the agent never issued. Produces a new conversation on every
launch, with no error anywhere. This is what resume: server exists to prevent.
Learning an identity with nowhere to put it. prepare runs before the agent
starts, so a resume: server driver that does not implement state cannot persist
the id it learns afterwards. Every launch opens a new conversation, and every other
check still passes. The conformance suite fails this driver.
Matching flags by substring. -c matches --config. The agent silently loses
its configuration, or its session, depending on which flag you were guarding.
Quoting for your language instead of for the shell. "$USER" expands. `id`
runs.
Reporting a static diagnostic from describe. True when it was written, false by
the time it is read. Report it from prepare.
Two bridge endpoints. They evict each other forever and the agent appears to connect and drop.
Swallowing an unreadable resume state. A resume turns into a fresh conversation that looks like it worked.
Next: Driver Plugin Protocol v1 for the full reference · Duo bridge protocol for the Agent Adapter