Approval gates
The five-step ladder that decides which HTTP-driven tool calls stop and wait.
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).
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>| 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.
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. |
GET /api/capabilities | required | Runtime wiring summary. |
GET /api/config | required | Read the resolved user config. |
PATCH /api/config | required | Merge-write the user config file. |
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. |
| 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. |
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/health| 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 for streaming responses; 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}. |
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.
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.
With the header set you get the same content chunks plus 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 | The tool name and arguments, with session_id. |
reasoning_progress | Each <think> chunk | Incremental reasoning text, with session_id. |
usage | Once, before the final chunk | { β¦, usage: { prompt_tokens, completion_tokens, total_tokens } }. |
error | On a mid-stream failure | { error: "<message>", category?: "<failure category>" }. |
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." }] }'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": 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.
Four things that are not defects but will surprise you if you meet them at integration time rather than reading them here.
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 keepaliveThe 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.
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 restThe 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.
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:
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.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.
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.
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.
| 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 at / interval / cron schedule applied to the created task. |
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.
| 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.
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.
| 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. |
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.