HTTP server
serve β the same runtime behind an OpenAI-compatible API, when a port suits you better than a pipe.
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.
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.
type | Payload | Response 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:
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.skill_install and skill_uninstall throw when no session is active; skill_list returns an empty list instead.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 } |
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? } |
Several assistant_delta events stream the reply as it is generated, followed by one 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.
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 unreachable. It is informational β the session is still created β and hint carries the actionable next step.
The SidecarEventType union also declares pong and trace. Nothing emits them today β ping answers with a normal response, not a pong event.
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. |
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.
The typical host lifecycle, start to finish:
atomic-agent-sidecar; attach line-buffered readers to stdout.ping to confirm the process is alive and see which llama-server it resolved.start_session with the workingDir the tools should resolve against. Keep the returned sessionId.send_message. Render assistant_delta as it streams, answer any approval_request with approval_response, and wait for the response frame.cancel if the user interrupts.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.