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
- Host and origin. On a loopback bind only the loopback names (
127.0.0.1,localhost,::1) and the--allow-hostnames are answered; on another bindHostis checked only against--allow-hostnames, 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"}. - 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. - 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_usdmust be a number above 0 (not a bool or a string), here and in aconfigsignal; anything else is400. 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 intobypassPermissionsunless the server runs with--allow-bypass-permissions. Acwdmust 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:403with 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 path | Body / query | Answers |
|---|---|---|
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=N | the task's events after seq N, as Recorded objects (seq, at, event) |
GET /tasks/{id}/watch | ?after=N | text/event-stream, below |
inputis 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_keymakes the create idempotent: a repeat returns the first task's id.history_from/history_throughstart the task from another task's conversation up to that task's eventhistory_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'sowner,owner_epochandlease_until, and the inbox countersinbox_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 path | Body | Answers |
|---|---|---|
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}/pending | approvals 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 path | Answers |
|---|---|
GET /tasks/{id}/usage | the task tree's tasks, cost_usd, api_seconds, usage, and by_model (usage, calls, cost_usd, api_seconds per model) |
GET /tasks/{id}/context | what 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}/rewind | the 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 path | Query / body | Answers |
|---|---|---|
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 /models | the 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.json | this 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
A server-sent event stream (text/event-stream, cache-control: no-cache) of the task's events after ?after=N:
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: {}eventmessages are recorded events, in order, each exactly once fromafteron.deltamessages are live output that is never stored: model text (kind: "text"), thinking (thinking), or a running tool's latest output (tool, withcall_id,tool,elapsed).call_seqandattemptname theModelCalleda fragment belongs to; a retry starts over, so drop fragments of an earlier attempt. The recordedModelRepliedis 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-workerthere are none.- A comment line
: keep-aliveis sent after 15 seconds without a message. endcloses 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.
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
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:
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__)