Sidecar protocol
atomic-agent serve is how a network client drives the agent. The sidecar is how a desktop app embeds it: you spawn atomic-agent-sidecar as a child process and talk to it over its own stdin and stdout, with no port, no bearer token, and no HTTP stack in between.
That is the shape a Tauri or Electron host wants. The agent runs as a child of your app, dies with it, and streams its whole event feed β steps, tool calls, reasoning deltas, approval requests, logs, metrics β back up the pipe as it happens.
atomic-agent-sidecarIt takes no flags. Configuration comes from the usual config.json and ATOMIC_AGENT_* environment variables, resolved at boot exactly as they are for the CLI.
The envelope
Every frame in both directions is one JSON object on one line, terminated by \n. No length prefixes, no framing beyond the newline. A line that fails to parse does not kill the process β it comes back as an error event.
There are exactly three kinds of frame, distinguished by kind:
kind | Direction | Meaning |
|---|---|---|
request | host β sidecar | Ask the agent to do something. Carries id, type, payload. |
event | sidecar β host | Unsolicited notification. Carries id, type, payload, optional correlationId. |
response | sidecar β host | Exactly one per request. Carries id, correlationId, ok, payload, optional error. |
correlationId on a response is the requestβs id β that is how you match a reply to what you asked. Every frame also carries its own fresh id (a UUID).
{"kind":"request","id":"a1b2","type":"send_message","payload":{"sessionId":"s-9f3","text":"List the TypeScript files here"}}{"kind":"event","id":"e77c","type":"tool_call_started","payload":{"sessionId":"s-9f3","stepIndex":-1,"tool":"fs.list","args":{"path":"."}}}{"kind":"response","id":"r04e","correlationId":"a1b2","ok":true,"payload":{"reason":"reply","turnCount":1,"stepCount":3}}Events and responses interleave freely on stdout. A single send_message typically emits dozens of events before its one response lands, so a host must read the stream continuously rather than blocking on the reply.
Requests
type | Payload | Response payload |
|---|---|---|
ping | β | { ok, llamaUrl, stateDir, version } |
start_session | { workingDir, metadata? } | { sessionId } |
send_message | { sessionId, text, maxSteps? } | { reason, turnCount, stepCount } |
steer_message | { sessionId, text } | { steered } |
cancel | { sessionId } | { cancelled } |
approval_response | { approvalId, approved, reason? } | { resolved } |
get_session | { sessionId } | The SessionState object, or null |
skill_install | { sourcePath, force? } | { name, installedAt } |
skill_uninstall | { name } | { removed } |
skill_list | β | { skills: [{ name, version, description, source }] } |
shutdown | β | { ok: true } |
A few behaviours that are not obvious from the table:
- One session at a time. The sidecar process hosts exactly one active session. A second
start_sessiondisposes the first β aborting its turn and shutting down its runtime β before building the new one. If your app needs concurrent sessions, spawn a sidecar per session or useserveinstead. send_messageserialises per session. Two rapid messages on the same session queue through the turn controller FIFO rather than racing.send_messagewithoutmaxStepsstops at 25 steps. WhenmaxStepsis omitted, the sidecar passesagent.maxSteps(the leg length,25by default) as the taskβs hard ceiling, so a turn does not continue into further legs the way it does inrunortui. Pass a largermaxSteps(for example1000, theagent.task.maxStepsdefault) if you want longer tasks.steer_messagereaches the running turn. It foldstextinto the turn that is already running on the active session and returns{ steered: true }, or{ steered: false }when nothing is running (or the id is not the active session). Onfalse, send it withsend_messageinstead. It bypasses the per-session queue on purpose, so it does not wait behind the turn it is meant to reach.cancelandget_sessionare scoped to the active session.cancelon any other id returns{ cancelled: false }rather than an error.get_sessionfalls back to reading SQLite for a non-active id, but only while a runtime exists β otherwise it returnsnull.- Skills need a session.
skill_installandskill_uninstallthrow when no session is active;skill_listreturns an empty list instead.
Events
Everything the agent does surfaces as an event. Hosts typically render the first group, log the middle group, and gate on approval_request.
type | Payload |
|---|---|
session_started | { sessionId, workingDir } |
turn_started | { sessionId, turnIndex } |
turn_finished | { sessionId, turnIndex, reason, stepCount, durationMs } |
step_started | { sessionId, stepIndex } |
step_finished | { sessionId, stepIndex, tokensUsed, durationMs } |
session_completed | { sessionId, reason } |
session_failed | { sessionId, error, category } |
turn_finished.reason is one of reply, finish, max_steps, cancelled, failed.
type | Payload |
|---|---|
user_message | { sessionId, text } |
assistant_delta | { sessionId, text } |
assistant_reply | { sessionId, text, attachments?, progressNote? } |
reasoning_delta | { sessionId, stepIndex, text } |
tool_call_started | { sessionId, stepIndex, tool, args, batchIndex?, batchSize? } |
tool_call_result | { sessionId, stepIndex, tool, status, summary, truncated?, batchIndex?, batchSize? } |
steer_applied | { sessionId, text, stepIndex } |
steer_undelivered | { sessionId, text } |
Several assistant_delta events stream the reply as it is generated, followed by a terminal assistant_reply carrying the full text. A host that renders deltas live must ignore the final body or diff it against its own buffer, or the reply appears twice.
A turn can emit more than one assistant_reply. Replies flagged progressNote: true are interim notes the model sent while still working; the reply without that flag ends the turn. attachments is present only when the reply carries some.
steer_applied fires when a steer_message is folded into the turn. steer_undelivered fires once per steer message the turn accepted but never read, after the turn ends; re-send it with send_message if it still matters.
batchIndex / batchSize are present only when a step emitted more than one parallel tool call. Solo calls omit both.
type | Payload |
|---|---|
approval_request | { approvalId, sessionId, tool, category?, reason, preview?, affectedResources? } |
llm_request | { sessionId, slotId, promptTokens, cacheReused } |
llm_response | { sessionId, completionTokens, durationMs } |
llm_unavailable | { url, error, mode, hint } |
skill_registry_updated | { installed: [{ name, source }] } |
log | { level, message, context? } |
metric | { name, value, tags? } |
error | { message, code?, stack? } |
approval_request is the one event a host must handle: the turn is blocked until you answer with an approval_response request carrying the same approvalId. category tells you why the gate fired (shell, fs_write_home, trust_config, β¦) so you can render something better than the tool name.
llm_unavailable fires at start_session when llama-server is the active text provider and is unreachable. It is informational β the session is still created β and hint carries the next step. In external mode the hint mentions an ATOMIC_AGENT_LLAMA_URL variable; no such variable exists, so set localModels.url instead.
The SidecarEventType union also declares pong and trace. Nothing emits them today β ping answers with a normal response, not a pong event.
Errors
A failed request still produces exactly one response, with ok: false and a populated error:
{"kind":"response","id":"r91a","correlationId":"a1b2","ok":false,"payload":{},"error":{"message":"no active session with id s-000","code":"handler_failed"}}code | When |
|---|---|
unknown_request | No handler registered for that type (including run_step). |
handler_failed | The handler threw. The message is the thrown Error.message. |
step_error:<category> | A step inside a turn failed (for example a model or tool error). Emitted as an error event (with the stack in stack), not a response; the turnβs own outcome still arrives in the send_message response. |
parse_error | A stdin line was not valid JSON. Emitted as an event, not a response β an unparseable line has no id to correlate against. |
A handler_failed response is always accompanied by an error event carrying the same message plus a stack trace. For parse_error, the offending raw line is placed in the eventβs stack field.
No handler failure kills the sidecar. Every route is wrapped, so a bad request degrades to one error frame and the process keeps serving.
A minimal session
The typical host lifecycle, start to finish:
- Spawn
atomic-agent-sidecar; attach line-buffered readers to stdout. - Send
pingto confirm the process is alive and see which llama-server it resolved. - Send
start_sessionwith theworkingDirthe tools should resolve against. Keep the returnedsessionId. - Send
send_message. Renderassistant_deltaas it streams, answer anyapproval_requestwithapproval_response, and wait for the response frame. - Send
cancelif the user interrupts. - Send
shutdownbefore exiting, so the runtime closes its databases cleanly.
host β {"kind":"request","id":"1","type":"ping","payload":{}} β {"kind":"response","id":"β¦","correlationId":"1","ok":true,"payload":{"ok":true,β¦}}host β {"kind":"request","id":"2","type":"start_session","payload":{"workingDir":"/Users/you/project"}} β {"kind":"event","id":"β¦","type":"session_started","payload":{"sessionId":"s-9f3",β¦}} β {"kind":"response","id":"β¦","correlationId":"2","ok":true,"payload":{"sessionId":"s-9f3"}}host β {"kind":"request","id":"3","type":"send_message","payload":{"sessionId":"s-9f3","text":"β¦"}} β {"kind":"event",β¦,"type":"turn_started",β¦} β {"kind":"event",β¦,"type":"assistant_delta",β¦} β many β {"kind":"event",β¦,"type":"assistant_reply",β¦} β {"kind":"response","id":"β¦","correlationId":"3","ok":true,"payload":{"reason":"reply",β¦}}