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)
  • --listen takes an address and port. With port 0, the OS picks a port and the startup line shows it.
  • --bearer-secret takes 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's secrets.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.

MethodPathWhat it doesSuccess
GET/v0/versionDescribe the API, the host version and the engine's document versions.200
GET/v0/fleetThe whole fleet: the list --json document.200
POST/v0/instancesRegister 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}/statusState, restart policy, restart count and failure cause.200
GET/v0/instances/{name}/capabilitiesThe agent's capability declaration for this OS.200
GET/v0/instances/{name}/configThe resolved config, secrets masked: the config get --json document.200
PUT/v0/instances/{name}/configSet one key. Body: {"key": ..., "value": ...}. Returns the key as written, plus any warnings.200
POST/v0/instances/{name}/startStart the agent.200, with the agent
POST/v0/instances/{name}/stopStop it. Optional body {"window_secs": N}, 1 to 86400; without a body, the 30-second default.200, with the agent
POST/v0/instances/{name}/pausePause it.200, with the agent
POST/v0/instances/{name}/resumeResume it.200, with the agent
GET/v0/instances/{name}/eventsOne agent's committed events after a cursor. See events.200
GET/v0/eventsLive events for all agents, as server-sent events.200, stays open
GET/v0/exportA batch from the global event journal. See export.200
POST/v0/export/ackMark 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"}}
kindStatusWhen
unauthorized401Missing, wrong or repeated bearer token.
invalid_body400Malformed 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_body415, 422No usable Content-Type (415), or JSON of the wrong shape for the route (422).
invalid_name400The agent name is not valid.
not_found404No such agent, or no such route.
method_not_allowed405The path exists, the method does not.
conflict409The action does not fit the agent's state, such as stopping a stopped agent or removing a running one.
cursor_invalid409A cursor beyond what the journal or log holds, or below what the journal still keeps. The message names both positions.
internal500Something 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, pass cursor={"seq":0}, URL-encoded.
  • limit defaults to 500 and is capped at 10,000. It must be 1 or more.
  • An empty events list means you are caught up.
  • retained_from is the oldest seq still 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_skipped and blank_lines_skipped report 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 stop with 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.