Persistent protocol
The contract for agents declaring
[lifecycle] mode = "persistent".
A one-shot agent needs no protocol. stdout is the answer, the exit code is the verdict, and the process ending is the boundary between one answer and the next. A process that does not end has to say where an answer stops — and that is all this is.
One JSON object per line, both directions. stdout is the channel; stderr is the log. Same split the plugin protocol already uses.
For the why, see Lifecycle. A complete implementation in ~20 lines of bash is in examples/hello-persistent.
The exchange
hello → ready
Written once, immediately after spawn.
Answer ready only once whatever you needed to warm up is warm — that is what the handshake is for. dotagent sends no request until it arrives, and gives up after [lifecycle] startup_timeout_seconds (default 30).
Refusing is legal and better than dying silently:
Either failure kills the instance and surfaces as a spawn error, rather than letting every message time out against a process that was never going to answer.
request → response
id
Correlation handle. Echo it back.
deadline_seconds
How long dotagent will wait. Bail first and say something useful — being killed mid-sentence says nothing.
trigger
Present when a message or a tool call caused the run, absent when a clock did. When present, it has source and optional session_id, actor, reply_to, and payload fields.
output
Goes back to whoever asked.
ok
Optional, defaults to true.
exit_code
Optional. Defaults to 0 when ok, 1 otherwise. Recorded in the heartbeat and the audit log exactly like a one-shot exit code.
error
Used as the output when output is absent — a failure with something to say beats silence in the chat.
The minimum viable answer is {"output":"hi"}.
Rules the reader follows
A line that does not parse is dropped. A stray echo on stdout costs a debug line, not the conversation. Do not rely on it — put logs on stderr — but one mistake will not corrupt the stream.
A frame whose id is not the one in flight is dropped. This is what stops a late answer from being handed to the next question. Without it, one slow reply would shift every subsequent answer by one and the bot would silently start replying to the previous message.
Omitting id is allowed. There is only ever one request in flight per instance, so an answer with no id is taken as the answer to it. Echoing it back is still better: it is what makes the dropping rule work.
A timeout recycles the instance. dotagent does not wait for a late answer and does not reuse the process — its state is now uncertain, and reciprocating that uncertainty is cheaper than reasoning about it.
The environment
The stable AGENT_* block is set at spawn, as always: AGENT_NAME, AGENT_HOME, AGENT_TMPDIR, AGENT_SLUG, AGENT_SCHEDULE_ID, AGENT_HEARTBEAT_FILE, AGENT_ARGV, plus [env.extra]. Two are specific to this mode:
AGENT_LIFECYCLE
persistent. Absent for a one-shot run, so one script can support both.
AGENT_PERSIST_KEY
Which slice this instance answers for — the resolved [lifecycle] key, or default.
AGENT_TRIGGER_* and AGENT_SESSION_ID are not defined in the persistent process environment. Those values describe one message, and this process will see many; fixed at spawn, they would read as perfectly valid while being permanently stale. Trigger context, including the optional session id, arrives in the trigger field of each request instead.
AGENT_TMPDIR belongs to the instance, not to a request — it survives between requests and is removed when the instance is recycled.
Shutting down
Recycling sends SIGTERM to the instance's process group, then SIGKILL after the grace window. stdin also closes. Either is a valid signal to flush and exit; a while read loop gets EOF and falls out of the loop on its own.
Nothing is asked of a well-behaved agent here. If you hold something that must be persisted, persist it as you go — a recycle can happen between any two requests, and the reasons are listed in Lifecycle.
Testing one by hand
The protocol is plain text, so a pipe is enough:
Two lines out, ready then a response carrying id: "1", and your logs on stderr — that agent integrates.
Through dotagent, with the real pool:
See also
Lifecycle — when to use this at all
Agent spec — the
[lifecycle]fieldsPlugin protocol — the one-shot JSON-stdio contract
Env vars — everything dotagent injects
Last updated
Was this helpful?