Skip to content

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.

Terminal window
atomic-agent-sidecar

It 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:

kindDirectionMeaning
requesthost β†’ sidecarAsk the agent to do something. Carries id, type, payload.
eventsidecar β†’ hostUnsolicited notification. Carries id, type, payload, optional correlationId.
responsesidecar β†’ hostExactly 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"}}

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

typePayloadResponse payload
pingβ€”{ ok, llamaUrl, stateDir, version }
start_session{ workingDir, metadata? }{ sessionId }
send_message{ sessionId, text, maxSteps? }{ reason, turnCount, stepCount }
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_session disposes 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 use serve instead.
  • send_message serialises per session. Two rapid messages on the same session queue through the turn controller FIFO rather than racing.
  • cancel and get_session are scoped to the active session. cancel on any other id returns { cancelled: false } rather than an error. get_session falls back to reading SQLite for a non-active id, but only while a runtime exists β€” otherwise it returns null.
  • Skills need a session. skill_install and skill_uninstall throw when no session is active; skill_list returns 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.

typePayload
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.

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"}}
codeWhen
unknown_requestNo handler registered for that type (including run_step).
handler_failedThe handler threw. The message is the thrown Error.message.
parse_errorA 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:

  1. Spawn atomic-agent-sidecar; attach line-buffered readers to stdout.
  2. Send ping to confirm the process is alive and see which llama-server it resolved.
  3. Send start_session with the workingDir the tools should resolve against. Keep the returned sessionId.
  4. Send send_message. Render assistant_delta as it streams, answer any approval_request with approval_response, and wait for the response frame.
  5. Send cancel if the user interrupts.
  6. Send shutdown before 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",…}}

HTTP server

serve β€” the same runtime behind an OpenAI-compatible API, when a port suits you better than a pipe.

Approval gates

What approval_request is asking, and which categories can fire.

Architecture

How the runtime, turn controller, and agent loop sit behind both front ends.

Traces and replay

Recording a session for later inspection.