Skip to content

HTTP / WebSocket API ​

Every endpoint noeta serve answers, its authentication, and the two streaming formats: the event stream of /tasks/{id}/watch and Claude Code's headless protocol at /stream-json. The code is noeta/host/http.py, _http_routes.py, _http_stream.py and _http_guard.py; GET /openapi.json returns the same API as an OpenAPI 3.1 document. Running the server is covered in Run it as a server.

The API is the client API on the wire: create, send, answer, signal, cancel, read and watch. noeta.RemoteClient (and noeta.connect("https://…")) calls it with the same methods as noeta.Client, and the terminal UI (noeta --server URL), noeta acp --server URL, the web app and the desktop app all use it.

Authentication and the checks every request passes ​

Request HTTP or WebSocket Host and origin loopback names or --allow-host own page or --allow-origin Bearer token Authorization: Bearer … ?token= on a WebSocket only 403 cross-site 401 unauthorized Handler on a guarded client config keys: model, effort, cwd, … signals: the six front ends send bypassPermissions: only if the server allows cwd: server's directory or --allow-root 403 refused 200 · 201 → store 404 unknown task · 400 bad body a refused WebSocket: handshake answered 403
  1. Host and origin. On a loopback bind only the loopback names (127.0.0.1, localhost, ::1) and the --allow-host names are answered; on another bind Host is checked only against --allow-host names, when given. A request a browser sends from another origin is refused unless that origin is the server's own page or an --allow-origin. Refused: 403 {"error": "cross-site request refused"}.
  2. Bearer token. Authorization: Bearer <token> on every request. A WebSocket may pass it as ?token= instead, because a browser cannot set the header there; no other route takes the query parameter, and the server keeps ?token= values out of its logs. Missing or wrong: 401 {"error": "unauthorized"}. noeta serve --no-token (loopback only) turns this off.
  3. What a request may ask for (GuardedClient). A request may set only these task config keys when it creates a task: model, effort, max_turns, cwd, permission_mode, output_schema, timezone, interactive, permission_prompt_tool, ultracode, max_budget_usd, continued_as, bash_mode. max_budget_usd must be a number above 0 (not a bool or a string), here and in a config signal; anything else is 400. Keys the server runs with (--effort, --permission-mode) are laid under the request's, which win. It may post only the signals front ends send: permission_mode, config, resumed, restore_files, rename, archive. It may not start or switch a task into bypassPermissions unless the server runs with --allow-bypass-permissions. A cwd must lie in the server's directory or an --allow-root (a relative one is taken from the server's directory and stored absolute); a task created without one runs in the server's directory. Refused: 403 with the reason.

Other errors: an unknown task is 404 {"error": "unknown task …"}; a malformed body or query is 400 with the reason. Every error body is JSON {"error": …}; a path not listed here is a plain 404 (unless the server serves the web app's files, below).

The token is NOETA_SERVER_TOKEN, else one made on the server's first start and printed once; ~/.noeta/server-token keeps only its SHA-256 (ADR-013). Host code that builds the app itself calls noeta.host.http.create_app(client, token=…) or token_sha256=…; auth=False is the only way to serve without one.

Tasks ​

Method and pathBody / queryAnswers
POST /tasks{agent, input?, config?, queue?, dedupe_key?, history_from?, history_through?}201 {"task_id": …}
GET /tasks?parent_id=&status=&limit= (status: ready, running, waiting, ended; limit default 100)a list of task rows
GET /tasks/{id}one task row
GET /summaries?limit=&archived= (limit default 50; archived=true / false keeps only those / the others)the root tasks, most recently updated first, each {task_id, agent, status, waiting_on, end_status, created_at, updated_at, title, cwd, prompt, archived} (prompt: the first one, 200 characters)
GET /tasks/{id}/events?after=Nthe task's events after seq N, as Recorded objects (seq, at, event)
GET /tasks/{id}/watch?after=Ntext/event-stream, below
  • input is a string, or a user message {"role": "user", "content": [...]} holding only text and image blocks; it is always the person's (a client never writes another role or origin).
  • dedupe_key makes the create idempotent: a repeat returns the first task's id.
  • history_from / history_through start the task from another task's conversation up to that task's event history_through (a fork).
  • A task row has task_id, root_id, parent_id, agent, queue, status, waiting_on, end_status, last_seq, created_at, updated_at, ready_at, the claim's owner, owner_epoch and lease_until, and the inbox counters inbox_seq / inbox_read_seq. A field still at its default (null, 0) is left out of the JSON.
  • The ten event types and their fields are in Events.

Talking to a task ​

Method and pathBodyAnswers
POST /tasks/{id}/messages{input, when?, dedupe_key?} (when: next_turn, the default, or now){"inbox_seq": n}; for a local slash command {"output": text} and nothing is sent
GET /tasks/{id}/pendingapprovals and questions waiting on a person, the task's sub-agents' included
POST /tasks/{id}/answers{request_id, decision?, always?, data?}{"ok": true, "inbox_seq": n}; 409 when the task is not waiting on request_id
POST /tasks/{id}/signals{name, data?, dedupe_key?, wake?}{"ok": true, "inbox_seq": n}
POST /tasks/{id}/cancel{reason?, turn_only?} or no body{"ok": true}

Messages. A text starting with /name args runs the task agent's slash command of that name first: a prompt command's text is what gets sent; a local command answers {output}. A text that names no command is sent as it is.

Pending. Each item has task_id (the task that asks, maybe a sub-agent), request_id, kind (approval or question), tool, input, prompt, detail, agent, a title for a card ("Bash command · from the Explore agent") and, for an approval, choices ([{id, label}]: yes, the "don't ask again" rule, a mode switch, no).

Answers. For an approval, decision is allow or deny, and always: true also remembers the rule the card offered. data adds: {"mode": m} picks a card's mode choice, {"message": t} adds the reason for a no or words with a yes. For a question, data maps each question id to {"selected": [labels]} or {"other": "text"}. A request_id the task is not waiting on (unknown, or answered and already taken) is 409, so a typo or a stale card is not answered silently; a request stays open until its answer is taken, so a retried answer still succeeds, and a second answer to the same request is ignored.

Signals. permission_mode with {mode} switches the permission mode; config with any of model, effort, max_turns, output_schema, interactive, permission_prompt_tool, ultracode, max_budget_usd changes the task's config (a null value removes the key; a request may only remove client_hooks, never set it, and max_budget_usd must be a number above 0); resumed, restore_files, rename{title} and archive {archived} are what front ends send when a person reopens, rewinds, renames or archives. wake: false leaves a waiting task waiting. A model's route (which provider serves it) is recorded by the server, never taken from a request.

Cancel. Ends the task, or with turn_only: true stops only the current turn.

Reading more about a task ​

Method and pathAnswers
GET /tasks/{id}/usagethe task tree's tasks, cost_usd, api_seconds, usage, and by_model (usage, calls, cost_usd, api_seconds per model)
GET /tasks/{id}/contextwhat fills the next request: {model, window, total, categories, deferred_tools, tools}, categories being estimated tokens per part (System prompt, System tools, MCP tools, Messages, Free space, Claude Code's /context order). Only when a worker in this server process runs the task's agent (not with --no-worker); else 501
GET /tasks/{id}/rewindthe person's prompts, each {index, task_id, seq, at, text, files} (the files changed since)
POST /tasks/{id}/rewind{index, conversation?, files?} (both default true): go back to before prompt index, restoring the conversation (in a new task), the files, or both. Answers {task_id, prompt, files}

Commands, agents, models, files ​

Method and pathQuery / bodyAnswers
GET /commands?agent=&task_id=&cwd=the slash commands an agent's plugins offer: [{name, description, argument_hint, local}]
POST /commands/run{text, agent?, task_id?, cwd?}{text, local} for a /name args text, or null when it names no command; nothing is sent
GET /agents[{name, description, model, one_shot}]
GET /modelsthe model catalog: [{id, context_window, max_output_tokens, aliases}]
GET /files?q=&task_id=&cwd=&limit=paths under a task's (or a cwd's, else the server's) directory holding q, for a prompt's @ mentions (limit default 30)
GET /openapi.jsonthis API as an OpenAPI 3.1 document

With a task_id, commands are listed and run in that task's own agent and directory; with a cwd, the directory is checked as a new task's would be.

The event stream: GET /tasks/{id}/watch ​

Store recorded events, seq > after Worker in this process LocalApp.deltas Workers on other machines Postgres notifications, best effort No message for 15 s Task ended and every event sent noeta serve · GET /tasks/{id}/watch?after=N event: event data: Recorded (seq, at, event) event: delta data: Delta (kind, text, call_seq …) : keep-alive a comment line event: end data: {} or {"error": …} Front end RemoteClient.watch terminal UI --server web app text/event-stream · reconnect with after = the last seq seen

A server-sent event stream (text/event-stream, cache-control: no-cache) of the task's events after ?after=N:

text
event: event
data: {"seq":42,"at":1793872072.95,"event":{"type":"ModelCalled","model":"claude-sonnet-5-5",…}}

event: delta
data: {"kind":"text","text":"Reading the","call_seq":42,"attempt":1,…}

: keep-alive

event: end
data: {}
  • event messages are recorded events, in order, each exactly once from after on.
  • delta messages are live output that is never stored: model text (kind: "text"), thinking (thinking), or a running tool's latest output (tool, with call_id, tool, elapsed). call_seq and attempt name the ModelCalled a fragment belongs to; a retry starts over, so drop fragments of an earlier attempt. The recorded ModelReplied is the truth. Deltas come from workers in the server's own process, and, on a Postgres store, from workers on other machines through Postgres notifications (best effort). On sqlite with --no-worker there are none.
  • A comment line : keep-alive is sent after 15 seconds without a message.
  • end closes the stream when the task has ended and every event was sent; {"error": …} when following the task failed.

To resume after a dropped connection, reconnect with after set to the last seq you saw. RemoteClient.watch does this by itself (up to five reconnects in a row without an event).

Claude Code's headless protocol: /stream-json ​

The same protocol as noeta -p --input-format stream-json --output-format stream-json (noeta/host/stream_json.py), so a program written for Claude Code's Agent SDK can drive a remote noeta.

WebSocket GET /stream-json (upgrade): one JSON message per frame, both ways. The client sends user messages and control_requests; the server sends system / init, assistant and user messages, stream_events with partial messages on, system notes, a result per prompt and control_requests of its own (can_use_tool: the client answers permission prompts on this socket). Query parameters: resume (a task id), fork, agent, model, cwd, permission_mode, max_turns, include_partial_messages, replay_user_messages, and token. A request from another site's page or to a rebound Host name is refused in the handshake (HTTP 403), so it gets no connection. A client of this server that is refused is accepted and then closed with a code it can read: 4401 (reason unauthorized) without a valid token, 4403 with the refusal as the reason (cut to 123 bytes) for a mode or cwd the server does not allow. An unknown resume task sends {"type": "error", …} and closes.

POST /stream-json: the same, one way. The request body is JSON lines in; the response is JSON lines out (application/x-ndjson) until the work is done. Nothing can answer a permission prompt on a POST, so anything that would ask is refused and the turn goes on. A mode or cwd the server does not allow is a 403 before anything runs.

bash
printf '%s\n' '{"type":"user","message":{"role":"user","content":"what is in this folder?"}}' |
  curl -sN -X POST "http://127.0.0.1:8642/stream-json?permission_mode=default" \
       -H "Authorization: Bearer $NOETA_SERVER_TOKEN" --data-binary @-

The control requests and message kinds are the ones noeta -p handles (The noeta command).

The web app's files ​

With noeta serve --web DIR (the built web app, web/dist) every path not listed above serves that directory's files without a token: they hold none. They go out with a content security policy (the page's scripts, styles and requests are its own server's; images also data: and blob:), X-Frame-Options: DENY and X-Content-Type-Options: nosniff. The page asks for the token once, or takes it from the address's #token=…, which never reaches the server.

Examples ​

bash
export NOETA_SERVER_TOKEN=…        # the token the server printed
H="Authorization: Bearer $NOETA_SERVER_TOKEN"
S=http://127.0.0.1:8642

# start a task
curl -s -X POST $S/tasks -H "$H" -H 'content-type: application/json' \
  -d '{"agent":"main","input":"list the TODOs","config":{"permission_mode":"default"}}'
# → {"task_id":"40efba61…"}

# follow it
curl -sN $S/tasks/40efba61…/watch -H "$H"

# answer what it waits on
curl -s $S/tasks/40efba61…/pending -H "$H"
curl -s -X POST $S/tasks/40efba61…/answers -H "$H" -H 'content-type: application/json' \
  -d '{"request_id":"toolu_…","decision":"allow"}'

# say more, then stop the turn
curl -s -X POST $S/tasks/40efba61…/messages -H "$H" -H 'content-type: application/json' \
  -d '{"input":"now fix the first one"}'
curl -s -X POST $S/tasks/40efba61…/cancel -H "$H" -H 'content-type: application/json' \
  -d '{"turn_only":true}'

The same from Python:

python
import os
import noeta
client = noeta.connect("http://127.0.0.1:8642", token=os.environ["NOETA_SERVER_TOKEN"])
task = await client.create("main", "list the TODOs", config={"permission_mode": "default"})
async for rec in client.watch(task):
    print(rec.seq, type(rec.event).__name__)