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

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 for streaming responses; 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: five extra named events

With the header set you get the same content chunks plus named events:

EventWhenPayload
session_idOnce, at stream start{ id, object, created, model, session_id } β€” the session this request landed on.
tool_progressEach parsed tool callThe tool name and arguments, with session_id.
reasoning_progressEach <think> chunkIncremental reasoning text, with session_id.
usageOnce, before the final chunk{ …, usage: { prompt_tokens, completion_tokens, total_tokens } }.
errorOn a mid-stream failure{ error: "<message>", category?: "<failure category>" }.
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": 32000,
"maxSteps": 30,
"toolTimeoutMs": 120000,
"approvalLevel": 3,
"approvalRequired": true
},
"tools": [
{ "name": "fs.read", "description": "Read a file", "readonly": true }
],
"skills": [
{ "name": "pdf", "description": "Work with PDF files", "source": "global" }
]
}

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

Four things that are not defects but will surprise you if you meet them at integration time rather than reading them here.

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 merges three keys and ignores the rest

The merge is shallow and covers exactly localModels, log, and agent, plus version carried over from the current file. Any other top-level key in your patch β€” browser, tasks, webhooks, mcp, memory β€” is silently dropped. You get a 200 and a config that does not contain your change.

The response body is the full config that was actually written, so diff against it rather than assuming the patch applied.

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.

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.

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 config.tasks.maxAttempts; maxSteps is optional. A successful create returns 201 with the persisted row.

If the task subsystem is off (tasks.enabled: false), 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 at / interval / cron schedule applied to the created task.

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.

Approval gates

The five-step ladder that decides which HTTP-driven tool calls stop and wait.

CLI Reference

Every command and flag, including serve alongside run and tui.

Scheduling

Deferred and recurring tasks, which the CLI creates and the HTTP API runs.

Traces and replay

serve traces by default β€” where the files land and how to read them.

Sidecar protocol

The NDJSON alternative to HTTP, for desktop apps that embed the agent as a child process.