For the complete documentation index, see llms.txt. This page is also available as Markdown.

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

helloready

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.

requestresponse

Field
Meaning

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:

Variable
Meaning

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] fields

  • Plugin protocol — the one-shot JSON-stdio contract

  • Env vars — everything dotagent injects

Last updated

Was this helpful?