HTTP server
atomic-agent serve turns your machine into an agent API. It starts a Node HTTP server that speaks the OpenAI Chat Completions protocol — so any OpenAI SDK can point at it — plus a set of Atomic Agent management routes for sessions, approvals, tasks, skills, and config.
It boots one shared runtime and serves every request from it. The same per-session FIFO turn controller applies: requests on different sessions run in parallel, requests on the same session serialise.
atomic-agent serve [--host H] [--port P] [--cwd DIR] [--api-key K] [--no-approval]The server listens until SIGINT or SIGTERM (Ctrl-C).
serve boots the same runtime as the TUI, so enabled Telegram and Discord channels (and swarm bots that have a token) run in this process too. A channel runs in one process at a time; if another process already runs it, serve reports it as already running.
Authentication is optional — set a key anyway
This is the one thing to get right before anything else.
If you pass no --api-key and set no ATOMIC_AGENT_API_KEY, the server starts with authentication disabled. It does not refuse to boot and it does not warn beyond one word in the startup line:
[atomic-agent] serve listening on http://127.0.0.1:8787 (auth=none, cwd=/Users/you/project)With a key it reads auth=bearer instead. That token is your only check on who gets to drive an agent that can write files and run shell commands on your machine.
# Safe: authenticated, reachable from the local networkATOMIC_AGENT_API_KEY="$(openssl rand -hex 32)" \ atomic-agent serve --host 0.0.0.0 --port 8787Authenticated routes expect a standard bearer header:
Authorization: Bearer <token>Flags
| Flag | Default | Description |
|---|---|---|
--host H | 127.0.0.1 | Address to bind. |
--port P | 8787 | Port to listen on. Must be an integer in 0..65535. |
--cwd DIR / --working-dir DIR | current directory | Working directory the OS tools and sessions resolve against. |
--api-key K | ATOMIC_AGENT_API_KEY env var | Bearer token required on every route except /health and /v1/models. If both flag and env var are omitted, auth is disabled. |
--no-approval | off | Force approval level 5 (approve everything) for all requests handled by this process. |
An unknown flag, a missing value, or an out-of-range port exits 1 and prints the help text.
Routes
Two routes are public by design so OpenAI SDKs can probe the server before they have a key. Everything else requires the bearer token when one is configured.
| Method & path | Auth | Purpose |
|---|---|---|
POST /v1/chat/completions | required | Chat completions, streaming or sync. |
POST /v1/chat/completions/{completion_id}/cancel | required | Abort a streaming completion by id. |
GET /v1/models | public | Model catalog — a single atomic-agent entry. |
| Method & path | Auth | Purpose |
|---|---|---|
GET /health | public | Liveness plus llama-server reachability. Returns 200 with status: "degraded" when llama-server is down; add ?strict=1 to get 503 instead. |
GET /api/capabilities | required | Runtime wiring summary. |
GET /api/config | required | Read the resolved user config. |
PATCH /api/config | required | Rewrite the user config file. Known issue: resets most blocks, see below. |
GET /api/skills | required | List installed skills. |
GET /api/skills/{name} | required | Inspect one skill. |
POST /api/skills/install | required | Install a skill. |
POST /api/skills/uninstall | required | Uninstall a skill. |
GET /api/sessions | required | List sessions. |
GET /api/sessions/{id} | required | Fetch one session. |
DELETE /api/sessions/{id} | required | Delete a session. |
POST /api/sessions/{id}/steer | required | Fold a message into the turn running on that session. See Steering a running turn. |
GET /api/sessions/{id}/steer | required | List steer messages the session’s turns accepted but never consumed. |
DELETE /api/sessions/{id}/steer | required | Acknowledge them (?through={seq} and/or ?discarded={n}). |
| Method & path | Auth | Purpose |
|---|---|---|
GET /api/events | required | SSE stream of pending approval requests. |
POST /api/approval/resolve | required | Resolve one pending approval. |
POST /api/tasks | required | Create a task. |
GET /api/tasks | required | List tasks (?session=, ?status=, ?limit=). |
GET /api/tasks/{id} | required | Fetch one task. |
DELETE /api/tasks/{id} | required | Cancel a task. |
POST /api/tasks/{id}/run | required | Run one task now. |
POST /api/tasks/drain | required | Drain due tasks. |
POST /api/webhooks/{name} | required | Webhook ingress, materialised as a task. |
A first request
Start the server, then talk to it like any OpenAI endpoint:
atomic-agent serve --api-key "$MY_KEY"curl http://127.0.0.1:8787/v1/chat/completions \ -H "Authorization: Bearer $MY_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "atomic-agent", "messages": [ { "role": "user", "content": "List the TypeScript files in this directory." } ] }'Check liveness without a key at all:
curl http://127.0.0.1:8787/healthCustom headers
| Header | Direction | Purpose |
|---|---|---|
X-Atomic-Session-Id | request and response | Pin the request to a session; returned so you can continue the conversation. |
X-Atomic-Completion-Id | response | Returned on streaming and non-streaming completions; pass it to the cancel route. |
X-Atomic-Extensions | request | Opt into named SSE events (for example tool_progress) instead of the strict OpenAI subset. |
X-Webhook-Secret | request | Authenticates posts to /api/webhooks/{name}. |
Streaming: the two shapes of the SSE stream
POST /v1/chat/completions with "stream": true emits one of two different streams, depending on whether you opt in with X-Atomic-Extensions.
Accepted truthy values are 1, true, on, and yes (case-insensitive, whitespace trimmed). Anything else — including the header being absent, or a value like 0 or false — leaves extensions off.
Off (the default): a strict OpenAI subset
Every frame is an unnamed data: line carrying a standard chat.completion.chunk, terminated by data: [DONE]. This is deliberate: off-the-shelf clients like the Vercel AI SDK and the OpenAI Node SDK validate each frame against a strict schema, and a named event would break them.
Mid-stream failures also take the OpenAI shape here — an unnamed frame carrying { error: { message, type, param, code } }, with our failure category folded into type as agent.<category> so you can still branch on it.
On: extra events
With the header set you get the same content chunks plus these named events:
| Event | When | Payload |
|---|---|---|
session_id | Once, at stream start | { id, object, created, model, session_id } — the session this request landed on. |
tool_progress | Each parsed tool call | { …, session_id, tool, label }. label is the call’s arguments as JSON, cut to 120 characters. |
reasoning_progress | Each <think> chunk | { …, session_id, step_index, text }: incremental reasoning text. |
progress_note | A reply the model sent while still working | { …, session_id, step_index, text }. The turn continues after it. |
steer_undelivered | Once, at the end, if any steer message was never consumed | { …, session_id, undelivered: [...] }: every steer the turn accepted but did not read. See Steering a running turn. |
usage | Once, before the final chunk | { …, usage: { prompt_tokens, completion_tokens, total_tokens } }. |
error | On a mid-stream failure | { error: "<message>", category?: "<failure category>" }. |
It also sends an unnamed data: frame { "object": "atomic.steer_applied", text, step_index } each time a steer message is folded into the turn.
curl -N http://127.0.0.1:8787/v1/chat/completions \ -H "Authorization: Bearer $MY_KEY" \ -H "X-Atomic-Extensions: 1" \ -H "Content-Type: application/json" \ -d '{ "model": "atomic-agent", "stream": true, "messages": [{ "role": "user", "content": "List the TypeScript files here." }] }'Capabilities
GET /api/capabilities is the one call an admin UI or status dashboard should make on connect. It returns the resolved runtime wiring — paths, backend URLs, the live approval level, and the full tool and skill catalogs — so a client can decide what to render without probing the agent loop.
curl http://127.0.0.1:8787/api/capabilities -H "Authorization: Bearer $MY_KEY"{ "runtime": "atomic-agent", "capabilities": { "…": "runtime feature flags" }, "paths": { "stateDir": "/Users/you/.atomic-agent", "globalSkillsDir": "/Users/you/.atomic-agent/skills", "projectSkillsDirName": ".atomic-agent/skills", "sessionsDbFile": "/Users/you/.atomic-agent/sessions.sqlite", "grammarsDir": "/Users/you/.atomic-agent/grammars", "userConfigFile": "/Users/you/.atomic-agent/config.json" }, "llama": { "url": "http://127.0.0.1:8080", "healthPath": "/health", "completionPath": "/completion" }, "browser": { "channel": "chrome", "headless": false, "cdpUrl": null }, "agent": { "tokenBudget": 3000, "maxSteps": 25, "toolTimeoutMs": 60000, "approvalLevel": 3, "approvalRequired": true }, "tools": [ { "name": "fs.read", "description": "Read a file", "readonly": true } ], "skills": [ { "name": "pdf", "description": "Work with PDF files", "source": "global" } ], "skillsOmitted": 0}The browser block reports the browser settings the runtime resolved from the ATOMIC_AGENT_BROWSER_* environment variables. It is not a config.json block: browser settings are environment variables only, and a browser key in config.json is ignored. agent.maxSteps is the leg length, and agent.toolTimeoutMs is reported for compatibility but no tool reads it. skillsOmitted counts installed skills that skills.catalogTokenBudget left out of the skills array; it is always present, 0 when nothing was dropped.
agent.approvalLevel is the live gate state, not the boot-time config snapshot — it reflects --no-approval boots and any later level change, so it is always the truth.
Integrator caveats
Three things that are not defects but will surprise you if you meet them at integration time rather than reading them here, plus one known issue.
No CORS, no OPTIONS
The server sets no Access-Control-* headers and registers no OPTIONS route. A browser app served from any other origin will fail its preflight and never reach the API — this is not something a header on your side can fix.
Front the server with a reverse proxy that adds CORS, or call it from your own backend. The design assumption is a local or server-side client, which is also the assumption that makes auth=none on 127.0.0.1 survivable.
/api/events has no keepalive
The approval SSE stream sends bytes only when there is an approval to send. It emits no periodic comment frames or heartbeats, so a connection can sit completely idle for a long time.
Many proxies and load balancers close idle connections at 30–60 seconds. If you put anything in front of /api/events, raise its idle timeout or you will lose the approval channel silently — and gated tool calls will then have nobody to ask. Clients should reconnect on drop; pending requests are replayed to a new subscriber, so a reconnect does not lose a waiting turn.
Derived session ids are content-addressed
If a request carries no explicit session_id (in the body) or X-Atomic-Session-Id (as a header), the server derives one by hashing the system prompt plus the first user message: api-<sha256[:16]>.
That is deliberate — the same client resending the same opening prompt re-hits the same session and reuses its KV-cache slot. But it also means two unrelated clients that send an identical system prompt and identical first user message land in the same session, sharing transcript and memory. With a fixed system prompt and a common opener (“hi”), collisions are not hypothetical.
If your clients are independent, pass an explicit session id. Do not rely on derivation for isolation.
PATCH /api/config rewrites the whole file
Do not call it on an install with providers, MCP servers, or an analytics opt-out you want to keep. To change one setting, run atomic-agent config set <key> <value> on the same machine instead; it leaves the rest of the file alone:
atomic-agent config set log.level debugA running serve process does not pick up a changed file until it restarts. The PATCH response body is the full config that was actually written, so if you have already called it, diff that body against a backup to see what was reset.
Approvals over HTTP
The server has no terminal to prompt on, so approvals travel over the wire instead. serve wires the runtime’s onApprovalRequest callback into an approval bus:
- A gated tool call publishes an approval request to the bus.
- Connected clients receive it on the
GET /api/eventsSSE stream. Pending requests are replayed when a client connects, so a late subscriber does not miss a turn that is already waiting. - The client answers with
POST /api/approval/resolve.
On the SSE stream each request arrives as an approval_request event. The resolve body carries its id and a decision:
curl -X POST http://127.0.0.1:8787/api/approval/resolve \ -H "Authorization: Bearer $MY_KEY" \ -H "Content-Type: application/json" \ -d '{ "approvalId": "…", "decision": "allow-once", "reason": "looks fine" }'decision is allow-once or approve to allow, deny or reject to refuse; reason is optional. A success returns { "resolved": true, approvalId, approved }. An unknown or already-answered approvalId returns 404, and a missing id or bad decision returns 400.
If nothing is listening on /api/events, gated calls have nobody to ask. Either keep a client subscribed or run the server at an approval level where the work you are sending does not stop.
Steering a running turn
A steer message is text you add to a turn that is already running, without starting a new request. The agent reads it at the next step boundary.
curl -X POST http://127.0.0.1:8787/api/sessions/s-1234/steer \ -H "Authorization: Bearer $MY_KEY" \ -H "Content-Type: application/json" \ -d '{ "text": "Skip the tests folder." }'| Status | Meaning |
|---|---|
200 { steered: true, sessionId } | Accepted. Not yet delivered: the turn reads it at its next step. |
400 | Missing session id, or text is not a non-empty string. |
409 | No turn on that session is accepting steers. Send the message as a normal POST /v1/chat/completions instead. |
429 | The session’s steering inbox is full; the running turn has not read the pending messages yet. |
A turn can end before it reads a steer (for example, when the message arrives during the final step). Such messages are parked, not dropped:
- A non-streaming completion lists them in an
undelivered_steersarray in its response body. A streaming one with extensions on sends thesteer_undeliveredevent. GET /api/sessions/{id}/steerreturns{ sessionId, undelivered: [{ seq, text, parkedAt }], discarded }. Reading does not consume them.DELETE /api/sessions/{id}/steer?through={seq}acknowledges everything up to and includingseq.discardedcounts messages lost because the per-session parking cap overflowed; acknowledge that separately with?discarded={n}.
Creating tasks
POST /api/tasks takes a JSON body:
{ "sessionId": "s-1234", "userMessage": "Summarise today's changed files", "maxAttempts": 3, "maxSteps": 10}sessionId and userMessage are both required strings — omit either and the route returns 400. maxAttempts falls back to ATOMIC_AGENT_TASKS_MAX_ATTEMPTS (default 3); maxSteps is optional. A successful create returns 201 with the persisted row.
If the task subsystem is off (ATOMIC_AGENT_TASKS_ENABLED=0), every /api/tasks route returns 404 with tasks subsystem disabled.
Webhooks
A webhook lets an external system — a GitHub hook, a CI job, a cron-like SaaS — trigger an agent turn over HTTP without writing any code. You declare bindings in the webhooks block of config.json, and each one is mounted at POST /api/webhooks/<name>.
{ "webhooks": { "github-issues": { "userMessageTemplate": "Triage this issue: {{body.issue.title}}\n\n{{body.issue.body}}", "secret": "a-long-random-string", "sessionMode": "ephemeral" } }}The name is the URL path segment and must match [a-zA-Z0-9_-]+. Anything else fails config validation at startup. The default is webhooks: {} — no bindings, so no ingress.
Binding fields
| Field | Type | Default | What it does |
|---|---|---|---|
userMessageTemplate | string | required | The user message the task is created with. Supports {{body.<json.path>}} placeholders. |
secret | string | — | When set, the request must carry a matching X-Webhook-Secret header. |
sessionMode | ephemeral | persistent | named | ephemeral | How sessions carry across repeated hits. |
sessionId | string | — | The session to use. Required when sessionMode is named. |
schedule | object | — | Optional schedule applied to the created task: { "kind": "at", "at": <epoch ms> }, { "kind": "interval", "everyMs": <ms> }, or { "kind": "cron", "expression": "<cron>", "tz": "<IANA>" } (tz optional). |
Templating the message
userMessageTemplate is rendered against the parsed JSON request body. Placeholders take the form {{body.<path>}} and walk the body by dot-separated keys:
{{body.issue.title}} → body.issue.title{{body.repository.name}} → body.repository.nameOnly paths rooted at body resolve. Anything else — and any path that does not exist in the payload — substitutes to an empty string rather than failing the request. Strings, numbers, and booleans interpolate directly; objects and arrays are JSON-stringified. To emit a literal {{, escape it with a backslash: \{{not a placeholder}}.
The templater is deliberately narrow. There is no expression language, no conditionals, and no function calls.
Session modes
| Mode | Behaviour |
|---|---|
ephemeral | No session id is passed. The task runner creates one per hit. Matches CLI one-shot behaviour. |
persistent | The first hit creates a session and records it in <stateDir>/webhook-sessions.json, keyed by webhook name. Every later hit — including after a process restart — reuses it. |
named | Uses the sessionId you configured, verbatim. Nothing is created and nothing is persisted. |
Use persistent when the caller is a recurring feed that should accumulate context, and ephemeral when each hit is independent.
Authentication
The webhook route is an authenticated route like any other, so the bearer token still applies when the server has one. secret is an additional check, not a replacement:
curl -X POST http://127.0.0.1:8787/api/webhooks/github-issues \ -H "Authorization: Bearer $MY_KEY" \ -H "X-Webhook-Secret: a-long-random-string" \ -H "Content-Type: application/json" \ -d '{ "issue": { "title": "Crash on startup", "body": "Steps to reproduce…" } }'If the binding declares no secret, the header is not checked at all.
Responses
| Status | When |
|---|---|
202 | Accepted. Body is { "taskId": "...", "sessionId": "..." }. |
400 | Missing webhook name, unparseable JSON body, or a schedule the task runner rejects. |
401 | The binding declares a secret and the X-Webhook-Secret header is missing or wrong. |
404 | Unknown webhook name, or the task subsystem is disabled. |