docs · reference
Control API#
Unreleased: after 0.11.0. The Control API is part of
hekma serve, which is on Hekma's main branch and ships in the next release; Hekma 0.11.0 does not have it.
hekma serve can expose a small HTTP and JSON API for the agents it supervises: register and remove agents, start, stop, pause and resume them, read and set config, read the fleet, and receive events live or from a durable journal. It is off unless you turn it on, every route needs a bearer token, and it is version v0, marked unstable, so fields and messages can still change. There is no route to send input to an agent.
Turn it on#
Pass both --listen and --bearer-secret to hekma serve (or hekma-host):
export CONTROL_BEARER="use-a-long-random-value"
hekma serve --listen 127.0.0.1:8477 --bearer-secret CONTROL_BEARER[hekma] host started; state root /Users/you/Library/Application Support/ktesio — supervising agents until SIGTERM/SIGINT
[hekma] control api listening on 127.0.0.1:8477 (v0, unstable — bearer required)--listentakes an address and port. With port0, the OS picks a port and the startup line shows it.--bearer-secrettakes the name of a secret, not the token itself. Hekma reads the environment variable with that name first, then the same key in the state directory'ssecrets.toml, like any secret.
One flag without the other is a usage error (exit code 2). A name that resolves to nothing, or to an empty value, stops the host before it binds anything:
✗ the host could not start: the control API's bearer secret could not be resolved: secret 'CONTROL_BEARER' could not be resolved (tried: environment, secrets file); set the environment variable CONTROL_BEARER, or add CONTROL_BEARER to the engine secrets file (mode 0600)Binding an address other than loopback works, and the host warns about it: the API has no TLS. Keep it on loopback, or on a network you trust. Run Hekma as a service shows a container setup.
Authentication#
Every route, /v0/version included, needs Authorization: Bearer <token>. A missing or wrong token gets 401 with a WWW-Authenticate: Bearer header, before any handler runs:
curl -s http://127.0.0.1:8477/v0/version{"schema_version":1,"error":{"kind":"unauthorized","message":"missing or invalid bearer token"}}The token is compared in constant time and never echoed. A request with more than one Authorization header is refused. Secret config values are masked in every response, as they are in the CLI; the API has no way to reveal them.
Anyone with the token can make the host run programs: registering an agent from a manifest path and starting it launches whatever that manifest names. Treat the token like a login to the machine.
Routes#
All paths start with /v0. {name} is an agent's name.
| Method | Path | What it does | Success |
|---|---|---|---|
| GET | /v0/version | Describe the API, the host version and the engine's document versions. | 200 |
| GET | /v0/fleet | The whole fleet: the list --json document. | 200 |
| POST | /v0/instances | Register an agent. Body: {"name": ..., "kind": ...} or {"name": ..., "manifest": "/absolute/path"}, never both. | 201, with Location and the new agent |
| GET | /v0/instances/{name} | One agent: the show --json document. | 200 |
| DELETE | /v0/instances/{name} | Remove a stopped agent. A running one is a 409. | 204 |
| GET | /v0/instances/{name}/status | State, restart policy, restart count and failure cause. | 200 |
| GET | /v0/instances/{name}/capabilities | The agent's capability declaration for this OS. | 200 |
| GET | /v0/instances/{name}/config | The resolved config, secrets masked: the config get --json document. | 200 |
| PUT | /v0/instances/{name}/config | Set one key. Body: {"key": ..., "value": ...}. Returns the key as written, plus any warnings. | 200 |
| POST | /v0/instances/{name}/start | Start the agent. | 200, with the agent |
| POST | /v0/instances/{name}/stop | Stop it. Optional body {"window_secs": N}, 1 to 86400; without a body, the 30-second default. | 200, with the agent |
| POST | /v0/instances/{name}/pause | Pause it. | 200, with the agent |
| POST | /v0/instances/{name}/resume | Resume it. | 200, with the agent |
| GET | /v0/instances/{name}/events | One agent's committed events after a cursor. See events. | 200 |
| GET | /v0/events | Live events for all agents, as server-sent events. | 200, stays open |
| GET | /v0/export | A batch from the global event journal. See export. | 200 |
| POST | /v0/export/ack | Mark the journal consumed up to a position. Body: {"seq": N}. | 200 |
There is no send route. hekma agent send and ACP prompt turns need a program that embeds the engine.
Starting an agent and reading it back:
curl -s -X POST -H "Authorization: Bearer $CONTROL_BEARER" \
http://127.0.0.1:8477/v0/instances/worker/start{"schema_version":2,"instance":{"name":"worker","kind":"ticker","state":"running","restart_count":0,"restart_policy":"on-failure","budget":{"cumulative_limit":6000,"cumulative_remaining":4800,"breach_action":"pause"},"usage":{"cumulative_input_tokens":1000,"cumulative_output_tokens":200,"current_run_input_tokens":1000,"current_run_output_tokens":200,"cumulative_cached_tokens":0,"current_run_cached_tokens":0},"metering_source":"self-reported","supervision":"session","agent_home":"/Users/you/Library/Application Support/ktesio/agents/worker"}}Each lifecycle route answers with the agent as it is after the action, so you see what happened without a second request. An agent started through the API belongs to the host and is supervised until the host stops. Budgets are ordinary config keys, so you set them with PUT .../config and read their state in the fleet documents; when this agent crossed its 6000-token budget, the host paused it at once, and the next read showed "state":"paused".
Documents and versions#
Every response body is JSON with a schema_version. Responses that carry an engine document (the fleet, one agent, config, events) use the same document and version as the CLI's --json output, so one parser serves both. Documents that exist only in the API (version, status, capabilities, errors, export) are version 1 of their own family.
GET /v0/version tells a client what it is talking to:
{"api":"hekma-control-api","version":"v0","stability":"unstable","schema_version":1,"host_version":"0.1.0","engine":{"event":1,"fleet":2,"usage":1,"budget":1,"log":1},"log_capture":"full"}stability stays unstable for v0: fields are added, not removed, but names and messages may still change before the API is declared stable. log_capture is the host's log capture mode, which tells you whether agent output can be read.
Errors#
Every error, including an unknown path or a wrong method, is a JSON body with a kind and a message:
{"schema_version":1,"error":{"kind":"conflict","message":"Agent Instance 'worker' is running; stop it first or pass --force"}}kind | Status | When |
|---|---|---|
unauthorized | 401 | Missing, wrong or repeated bearer token. |
invalid_body | 400 | Malformed JSON, both kind and manifest, a relative manifest path, window_secs out of range, an unknown config key or bad value, a malformed cursor, limit=0. |
invalid_body | 415, 422 | No usable Content-Type (415), or JSON of the wrong shape for the route (422). |
invalid_name | 400 | The agent name is not valid. |
not_found | 404 | No such agent, or no such route. |
method_not_allowed | 405 | The path exists, the method does not. |
conflict | 409 | The action does not fit the agent's state, such as stopping a stopped agent or removing a running one. |
cursor_invalid | 409 | A cursor beyond what the journal or log holds, or below what the journal still keeps. The message names both positions. |
internal | 500 | Something failed in the host; the message says what. |
Events#
Two routes deliver events. Use them together: subscribe to the live stream first, then pull from the durable record to fill any gap. This is the pattern on Events, resync and diagnostics.
Live: GET /v0/events. A server-sent event stream for all agents. Each event is one data: line holding the engine's own event JSON (see JSON output):
data: {"kind":"transition","schema_version":1,"instance":"worker","prior_state":"paused","new_state":"running","cause":{"kind":"command","command":"resume"},"at":"2026-10-22T19:40:10Z"}
data: {"kind":"usage_update","schema_version":1,"event":{"instance":"worker","run_id":"run-1792697966380900000-0","input_tokens":1000,"output_tokens":200,"cached_tokens":0,"metering_source":"self-reported","sequence":8,"occurred_at":"2026-10-22T19:40:10Z"}}If a client falls more than 1024 events behind, it gets {"kind":"lagged","skipped":N} and the stream continues with newer events. The stream closes when the host shuts down; reconnect when it is back.
Durable: GET /v0/instances/{name}/events?cursor=.... One agent's committed events after a cursor, exactly as resync_events returns them: transitions, then breaches, then usage, with the cursor for the next call and torn_tail_skipped. Leave out cursor to start from the beginning; pass the returned cursor, URL-encoded, to continue:
curl -s -H "Authorization: Bearer $CONTROL_BEARER" \
"http://127.0.0.1:8477/v0/instances/worker/events?cursor=%7B%22transitions%22%3A2%2C%22breaches%22%3A0%2C%22usage%22%3A1%7D"Repeating a cursor returns the same events, never more. A cursor beyond what the logs hold is a 409 cursor_invalid.
Export#
The export journal is one durable list of every event across all agents, in commit order, for a client that must not miss anything even across host restarts. The host appends each event to outbox.jsonl in the state directory before publishing it, and numbers it with a seq that only grows. You pull batches and acknowledge what you have processed; the acknowledgement is saved in outbox-ack.json.
Pull: GET /v0/export?cursor=...&limit=....
{
"schema_version": 1,
"events": [
{"schema_version": 1, "seq": 1, "event": {"kind": "transition", "schema_version": 1, "instance": "worker", "prior_state": "registered", "new_state": "starting", "cause": {"kind": "command", "command": "start"}, "at": "2026-10-22T19:39:26Z"}},
{"schema_version": 1, "seq": 2, "event": {"kind": "transition", "schema_version": 1, "instance": "worker", "prior_state": "starting", "new_state": "running", "cause": {"kind": "adapter-ready"}, "at": "2026-10-22T19:39:26Z"}}
],
"cursor": {"seq": 2},
"torn_tail_skipped": false,
"blank_lines_skipped": 0,
"retained_from": 1
}- Without
cursor, you get everything after your last acknowledgement. To read from the start, passcursor={"seq":0}, URL-encoded. limitdefaults to 500 and is capped at 10,000. It must be 1 or more.- An empty
eventslist means you are caught up. retained_fromis the oldestseqstill in the journal. The journal is kept to a bounded size: acknowledged entries are dropped first, and unacknowledged ones only if it is still too big, with a note on the host's stderr. Pull and acknowledge regularly.torn_tail_skippedandblank_lines_skippedreport a crash-cut last line and blank lines that were skipped.
Acknowledge: POST /v0/export/ack with {"seq": N} marks everything up to N as processed and returns the saved position, {"schema_version":1,"seq":N}. Acknowledging a lower number than before changes nothing. Acknowledging past the newest entry, or pulling with a cursor below retained_from or past the newest entry, is a 409 cursor_invalid.
Run only one engine process against a state directory whose journal you export (see one engine per state directory). A separate long-running command on the same directory numbers its own events and can write duplicate seq values; the export then refuses to read the journal rather than return them twice. If the journal is ever damaged, stop the host, rename outbox.jsonl aside, and use the per-agent events route to recover.
Limits#
The API runs inside the host process, next to supervision. It is meant for a trusted operator or a local tool on the same machine:
- No TLS, no rate limiting and no CORS headers. A browser page on another origin cannot call it; put it behind a proxy on the same origin.
- A slow client holds a request on the host. A
stopwith a long window keeps its connection open until the stop finishes. - On shutdown, the API stops first and open event streams close; requests still open after one second are dropped, with a note on stderr.
- There is no health route; every route needs the token.
Read next#
- Run Hekma as a service: the host the API runs in.
- JSON output: the documents the API returns.
- Events, resync and diagnostics: the same events from inside a Rust program.