Skip to content

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.

Terminal window
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.

Terminal window
# Safe: authenticated, reachable from the local network
ATOMIC_AGENT_API_KEY="$(openssl rand -hex 32)" \
atomic-agent serve --host 0.0.0.0 --port 8787

Authenticated routes expect a standard bearer header:

Authorization: Bearer <token>

Flags

FlagDefaultDescription
--host H127.0.0.1Address to bind.
--port P8787Port to listen on. Must be an integer in 0..65535.
--cwd DIR / --working-dir DIRcurrent directoryWorking directory the OS tools and sessions resolve against.
--api-key KATOMIC_AGENT_API_KEY env varBearer token required on every route except /health and /v1/models. If both flag and env var are omitted, auth is disabled.
--no-approvaloffForce 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 & pathAuthPurpose
POST /v1/chat/completionsrequiredChat completions, streaming or sync.
POST /v1/chat/completions/{completion_id}/cancelrequiredAbort a streaming completion by id.
GET /v1/modelspublicModel catalog — a single atomic-agent entry.

A first request

Start the server, then talk to it like any OpenAI endpoint:

Terminal window
atomic-agent serve --api-key "$MY_KEY"
Terminal window
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:

Terminal window
curl http://127.0.0.1:8787/health

Custom headers

HeaderDirectionPurpose
X-Atomic-Session-Idrequest and responsePin the request to a session; returned so you can continue the conversation.
X-Atomic-Completion-IdresponseReturned on streaming and non-streaming completions; pass it to the cancel route.
X-Atomic-ExtensionsrequestOpt into named SSE events (for example tool_progress) instead of the strict OpenAI subset.
X-Webhook-SecretrequestAuthenticates 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:

EventWhenPayload
session_idOnce, at stream start{ id, object, created, model, session_id } — the session this request landed on.
tool_progressEach parsed tool call{ …, session_id, tool, label }. label is the call’s arguments as JSON, cut to 120 characters.
reasoning_progressEach <think> chunk{ …, session_id, step_index, text }: incremental reasoning text.
progress_noteA reply the model sent while still working{ …, session_id, step_index, text }. The turn continues after it.
steer_undeliveredOnce, 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.
usageOnce, before the final chunk{ …, usage: { prompt_tokens, completion_tokens, total_tokens } }.
errorOn 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.

Terminal window
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.

Terminal window
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:

Terminal window
atomic-agent config set log.level debug

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

  1. A gated tool call publishes an approval request to the bus.
  2. Connected clients receive it on the GET /api/events SSE stream. Pending requests are replayed when a client connects, so a late subscriber does not miss a turn that is already waiting.
  3. 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:

Terminal window
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.

Terminal window
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." }'
StatusMeaning
200 { steered: true, sessionId }Accepted. Not yet delivered: the turn reads it at its next step.
400Missing session id, or text is not a non-empty string.
409No turn on that session is accepting steers. Send the message as a normal POST /v1/chat/completions instead.
429The 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_steers array in its response body. A streaming one with extensions on sends the steer_undelivered event.
  • GET /api/sessions/{id}/steer returns { sessionId, undelivered: [{ seq, text, parkedAt }], discarded }. Reading does not consume them.
  • DELETE /api/sessions/{id}/steer?through={seq} acknowledges everything up to and including seq. discarded counts 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

FieldTypeDefaultWhat it does
userMessageTemplatestringrequiredThe user message the task is created with. Supports {{body.<json.path>}} placeholders.
secretstring—When set, the request must carry a matching X-Webhook-Secret header.
sessionModeephemeral | persistent | namedephemeralHow sessions carry across repeated hits.
sessionIdstring—The session to use. Required when sessionMode is named.
scheduleobject—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.name

Only 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

ModeBehaviour
ephemeralNo session id is passed. The task runner creates one per hit. Matches CLI one-shot behaviour.
persistentThe 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.
namedUses 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:

Terminal window
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

StatusWhen
202Accepted. Body is { "taskId": "...", "sessionId": "..." }.
400Missing webhook name, unparseable JSON body, or a schedule the task runner rejects.
401The binding declares a secret and the X-Webhook-Secret header is missing or wrong.
404Unknown webhook name, or the task subsystem is disabled.