HTTP API
meka serve exposes meka as an HTTP API server so other programs can drive agent turns programmatically. Where Interactive mode is for humans at a terminal and ACP is for editor integrations over stdio, the HTTP API is for service-to-service use cases:
- A Telegram or Discord bridge that connects a chat bot to an agent.
- A web or mobile UI that streams assistant responses in real time.
- A script or orchestrator that embeds meka as a sub-agent backend.
- Any cross-language client that speaks HTTP+JSON.
All three entry points (meka, meka acp, meka serve) drive the same agent core: same tools, same profiles, same session persistence. The HTTP API is a transport layer on top. A shell script that wants one turn as JSON and no server can use meka --oneshot --format json instead; see One-shot mode.
Starting the server
meka serve
The server reads the [serve] section from your config.toml (see Configuration below). At minimum you need one bearer token; bind defaults to 127.0.0.1:8080:
[serve]
bind = "127.0.0.1:8080"
[[serve.tokens]]
token = "${MEKA_API_TOKEN}"
scopes = ["sessions:r", "sessions:w"]
On startup the server logs the bind address and begins accepting requests. All endpoints (except health probes and OpenAPI docs) require a valid Authorization: Bearer <token> header.
Two flags are refused rather than ignored: -c and -r. Both name one run’s session, and the server creates one per POST /v1/sessions, each naming its own profile. Address an existing session by id under /v1/sessions/{id}, and pass profile on the create request. --profile is accepted, since it selects which configured profile a session gets when it names none, which is a property of the server rather than of one session.
TLS:
meka servespeaks plain HTTP. For production, front it with a TLS-terminating reverse proxy (nginx, Caddy, Cloudflare Tunnel).
Quick example
Blocking turn (simplest)
# Create a session
curl -s -X POST http://localhost:8080/v1/sessions \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"cwd": "/home/user/project"}' | jq .id
# → "550e8400-e29b-41d4-a716-446655440000"
# Submit a turn
curl -s -X POST http://localhost:8080/v1/sessions/550e8400-e29b-41d4-a716-446655440000/turn \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"message": "list the files in src/"}' | jq .final_text
# → "Here are the files in src/: ..."
Streaming turn
curl -N -X POST http://localhost:8080/v1/sessions/$SESSION_ID/turn \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"message": "explain this codebase", "stream": true}'
The response is a text/event-stream (SSE) that emits typed events as the agent works:
retry: 3000
event: turn.started
id: 0
data: {"turn_id":"...","session_id":"...","started_at":"2026-05-26T13:45:12Z","source":"client"}
event: assistant_text.delta
id: 1
data: {"text":"This project is ","turn_id":"...","session_id":"..."}
event: assistant_text.delta
id: 2
data: {"text":"a Rust workspace that..."}
event: tool_call.composing
id: 3
data: {"id":"tu_1","name":"read_file"}
event: tool_call.executing
id: 4
data: {"id":"tu_1","name":"read_file","input":{"path":"src/main.rs"},"display_summary":"src/main.rs"}
event: tool_call.completed
id: 5
data: {"id":"tu_1","is_error":false,"content":[{"type":"text","text":"fn main() { ... }"}]}
event: turn.finished
id: 12
data: {"turn_id":"...","session_id":"...","stop_reason":"end_turn","usage":{"input_tokens":12340,"output_tokens":567,...}}
Every payload carries turn_id and session_id; the later events above elide them.
Core concepts
Sessions
A session is a persistent conversation with its own working directory, permission level, and message history. Sessions live in the same store as REPL and ACP sessions; they’re interchangeable.
POST /v1/sessions Create a session
GET /v1/sessions List sessions (paginated)
GET /v1/sessions/{id} Get session details
PATCH /v1/sessions/{id} Update permission, approvals, cwd or profile
DELETE /v1/sessions/{id} Close and clean up
POST /v1/sessions/{id}/fork Branch a copy off a session
GET /v1/sessions is paginated. limit is how many to return (default 50, clamped to 1..200),
most recently updated first. When more remain, the response carries next_cursor; pass it back as
cursor for the next page. include_children=true lists sub-agent sessions too, and cwd=<path>
keeps only the sessions in that working directory, compared in the canonical spelling every session
records.
When creating a session, specify the working directory and optionally a permission level, the approvals switch, a profile, and capabilities:
{
"cwd": "/home/user/project",
"permission": "workspace",
"approvals": false,
"profile": "work",
"capabilities": {
"supports_reasoning_stream": false,
"supports_permission_prompts": true
}
}
approvals is whether a tool call above the level is put to the client for approval rather than
refused; see Approvals below. Omitted, it is the server’s [permissions].approvals.
Every session response echoes both permission and approvals back.
Create, get, list, fork and PATCH all answer with the same session record:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"created_at": "2026-05-26T13:45:12Z",
"updated_at": "2026-05-26T13:47:01Z",
"cwd": "/home/user/project",
"permission": "workspace",
"approvals": false,
"profile": "work",
"title": "list the files in src/",
"last_turn_at": "2026-05-26T13:47:01Z",
"capabilities": {"supports_reasoning_stream": false, "supports_permission_prompts": true},
"turn_in_flight": false
}
created_at is when the row was made; updated_at moves on any session-level change, a PATCH
included; last_turn_at is the last successful turn, and title is the first user message’s
words, whitespace collapsed and cut to 80 characters.
On every response and event this API sends, a field that has no value is omitted rather than sent
as null. On a session that means last_turn_at until a turn has run (and always on a session
this server has evicted), cwd when the row recorded none, permission when the session is not
loaded and its row records no level, and parent_id, which only a sub-agent’s session carries. The
same rule gives a turn its refusal_text only on a refusal and a tool call its display_summary
only when the tool has a label. It is also why meka session show --format json prints the same
object for the same record: the row’s fields are one shape shared by both surfaces, and
last_turn_at, capabilities and turn_in_flight are what the server adds to it.
profile names a profile in the server’s config.toml; GET /v1/profiles lists them, and a name
that is not configured is a 422 whose detail reads no profile named 'x' (configured: a, b).
Omitted, it is the server’s own default profile. The session keeps
it for the rest of its life and every session response echoes it back as profile, so a client can
confirm which account a session bills.
To move a live session onto another profile, PATCH /v1/sessions/{id} with {"profile": "other"}.
That rewrites the session’s row, so it holds for a resume from any surface rather than for this
request. Switching mid-conversation is allowed and is your call: a thinking block is tagged with the
backend that produced it and is not replayed to a different one, so from the next turn the model no
longer sees the reasoning recorded under the old profile. Like cwd, it is a 409 when a turn is
already in flight; cancel first. permission and approvals are the two fields that apply during
a turn; see Permission levels over HTTP. (One admitted between the
check and the agent swap makes the swap wait for that turn rather than fail, so the request can take
as long as the turn does. The row has already moved by then, and the agent follows when the turn
ends.)
A PATCH naming a profile moves the session to that profile, and the profile is the whole story:
the model, the account and every model-tied setting come from it, so there is nothing else on the
row to reconcile. The row is also the billing record, so a profile the store cannot record fails the
request; permission, approvals and cwd still apply to the live session when their row write
fails, and the failure is logged.
If you run more than one meka on the same store, send the PATCH to whichever process has the
session. A body naming only a profile is the one PATCH that works on a session this server has
not loaded, and it takes the session lock to do it, so a session another process is running answers
409 session-locked rather than moving a row that process would go on ignoring. Only the host
holding a session may change what it runs on.
A body naming only profile is also the rescue for a session whose profile has left
config.toml: it moves the row without building an agent, so it works on a session that cannot
currently run. Adding permission or cwd to the same body loses that, because those need a loaded
session and loading one is exactly what fails; send the profile on its own first.
The cwd field is validated on create, fork and patch, by the same rule the REPL’s /cd and ACP
apply:
- Must be an absolute path (no relative paths).
- Must exist on the server’s filesystem.
- Must be a directory (not a file, device, or socket).
- Must not contain null bytes (which cause kernel/userspace path mismatch).
- Is recorded, and echoed back, in its canonical spelling: symlinks resolved,
.and..removed. Every host records the same spelling, so acwdfilter on the listing finds a session however its directory was spelled.
If cwd is omitted, it defaults to the server process’s current working directory.
Sessions persist server-side until explicitly deleted or evicted by the idle timeout GC (see Session lifecycle).
Capabilities
| Capability | Default | Meaning |
|---|---|---|
supports_reasoning_stream | false | Include thinking.delta events in the SSE stream |
supports_permission_prompts | true | The client can answer a mid-turn permission_required event |
Enabling supports_reasoning_stream costs a streaming turn its retry on a transient provider failure: the deltas have already reached you and a second attempt would repeat them, and reasoning is the first thing a turn produces. Blocking turns on the same session are unaffected, since they carry whole blocks rather than deltas.
Set supports_permission_prompts: false if you stream but have no interface to show an approval
prompt on, which is the normal case for a service-to-service client streaming for liveness. Gated
tools are then denied immediately with an explanatory notice, the same as blocking mode. Leaving it
true means every gated call parks for 30 minutes and then denies anyway, which is hard to tell
apart from a hang. Better still, create the session with permission: "workspace" so nothing is gated.
The flag speaks for the streaming client: a feed reader that opened the stream with attend=true
is asked regardless, since attending is that declaration made per connection.
Forking a session
POST /v1/sessions/{id}/fork copies a session’s conversation into a new session and returns it with
201 and the usual session body. The copy starts with the source’s full history and is immediately
usable; the source is left untouched, and does not have to be in memory, so a GC-evicted session
forks as well as a live one. A source with a turn in flight answers 409 turn-in-flight, as
PATCH, DELETE, compact and rewind do; cancel the turn or wait for it. A source another meka
process holds answers 409 session-locked. Either copy would have ended on a prompt nothing
answered.
The body is optional and inherits everything by default. The only field is cwd, matching ACP’s
session/fork, which likewise carries a workspace but no permission or capability fields:
{ "cwd": "/home/user/other-project" }
Permission, approvals, capabilities and the profile are inherited and remain changeable afterwards via
PATCH /v1/sessions/{id}. Sub-agent child transcripts are not copied, and a fork of an ordinary
session records no link back to its source.
A sub-agent’s own id is refused with 422: the copy would keep that sub-agent’s parent and spawn terms,
so it is a sibling under the same parent rather than a session this endpoint could hand back. See
Forking a session for the full semantics.
Sub-agent sessions cannot be driven through this API
GET /v1/sessions?include_children=true lists the sessions an agent_spawn created. Those ids are
readable through every endpoint on this page (/messages, /context, /export) and
drivable through none of them: POST /v1/sessions/{id}/turn answers 422 with
/errors/session-not-drivable, as do /compact, /responses/{request_id}, /fork, /schedule,
and PATCH /v1/sessions/{id}. A sub-agent
runs under the tools, permission ceiling and profile its spawn call set, which live in its
spawn record and which only its parent can reconstruct, so the conversation is continued with the
agent_followup tool from the parent rather than over HTTP.
Two exceptions, both of which change a transcript without running anything on it. Teardown stays
open: DELETE /v1/sessions/{id} discards a sub-agent and DELETE /v1/sessions/{id}/tasks/{task_id}
stops one of its background tasks, and the parent’s own agent_delete does the same thing. So does
POST /v1/sessions/{id}/rewind, which truncates the event log the same caller can already read in
full through /export, and which meka session rewind has always allowed on a sub-agent. The line is
whether the model runs: /compact is refused because compaction is a turn.
Importing an archive
POST /v1/sessions/import recreates a session tree from a meka session export archive under fresh
ids, on the same terms as the CLI’s meka session import. An archive naming no profile takes the
server’s default, the same one POST /v1/sessions applies to a body with no profile; a
long-lived host always has one, since it refuses to start without it.
One limit is the server’s alone: an archive holding more than 1000 sessions is refused with a
422 whose detail names the count and the cap, and points at meka session import. The whole tree
is written in one transaction on the process’s single connection to the store, so a larger one would
hold every other in-flight request behind it. A one-shot meka session import restoring its own
backup has nothing to contend with and so carries no cap; it is the way to restore a tree this
large.
Everything else about the archive is honored as the CLI honors it; see Exporting a session.
Detecting an in-flight turn
Session responses carry turn_in_flight, a boolean saying whether a turn is running right now. It
exists so a client whose SSE stream dropped can tell “my turn is still running” from “my turn died”
without submitting a speculative turn and reading the 409. A dropped stream does not cancel the
turn; the work continues server-side and resubmitting would duplicate a reply the user is about to
receive. Poll GET /v1/sessions/{id} and wait for it to go false rather than retrying blind.
The same holds for a blocking turn whose client gives up: a request timeout on your side does
not stop the turn. It runs to completion, persists its messages, and fires its webhook; you just
never see the response body. Read the reply from GET /v1/sessions/{id}/messages. This is why a
client timeout shorter than your longest turn is safe, and why retrying on one duplicates work
rather than recovering it.
Turns
A turn is one round-trip: you send a user message, the agent processes it (potentially calling tools in a loop), and returns a result. Turns are ephemeral: they’re not stored as their own resource, but the messages they produce are persisted in the session’s conversation history.
POST /v1/sessions/{id}/turn Submit a turn
POST /v1/sessions/{id}/cancel Cancel an in-flight turn
One turn at a time per session. A second POST /turn while another is running returns 409 Conflict. Across sessions, turns run fully concurrently. A client that would rather hand the message over and be told when the model read it uses the inbox instead of waiting for the session to be free.
POST /cancel takes an optional body {"turn_id": "..."}. Without one it stops whatever is running, as it always did. With one it stops only that turn, and answers 409 turn-mismatch naming the turn actually in flight when it is another, so a client that watched a turn cannot stop the scheduled fire or the inbox turn that replaced it. Every turn.started on the session feed carries the id to name.
The turn request body accepts five fields:
| Field | Type | Default | Description |
|---|---|---|---|
message | string | (required) | The user message. May be empty when images is non-empty |
images | array | [] | Image attachments; see Image attachments |
stream | bool | false | false → single JSON response; true → SSE stream |
options.skill | string | null | null | When set, activates the named skill for this turn (equivalent to /skill <name> in the REPL). With an empty message the skill body runs alone, as --skill does; a turn with no text, no image and no skill is a 422 |
options.unanswered_message | string | keep | What becomes of message if the turn ends, failed or canceled, before anything from the model reached the conversation. keep leaves it in place, as the REPL does with a typed prompt. withdraw takes it back, for a client that resends a failed turn; see Resending a failed turn |
The inbox
POST /turn is synchronous: one request, one turn, the response scoped to it, and a 409 while the session is busy. The inbox is the asynchronous door beside it, for a client that lives with a session rather than driving one turn at a time: a chat bridge, a UI, a parent process with something to say while the agent works.
POST /v1/sessions/{id}/inbox Enqueue a message
GET /v1/sessions/{id}/inbox Items the model has not been shown
DELETE /v1/sessions/{id}/inbox/{item_id} Withdraw an item still waiting
The body is {"message": "...", "class": "steer" | "followup" | "interrupt", "source": "..."}. class is required and is the whole contract:
steerreaches a turn that is already running. The loop reads the inbox at every round boundary, after a round’s tool results and before the next request, and appends what it finds to that same message, so the model sees it as soon as it next asks the provider anything. This is what lets you correct or redirect the agent ten seconds into a ten-minute task, the way a person glances at a message mid-task.followupwaits for the running turn to end.interruptdoes not wait for the answer being written. While the provider is streaming, the stream is dropped within a second, the text that had arrived is kept as the answer so far, and the message follows it as the next thing the model reads; the same turn carries on, with no terminal in between, and the feed says so with anotice. A cut can land inside a thinking block or after a tool call was announced and never run: thenoticeis what closes those. While a tool runs, nothing is cut: the message is read at the round boundary after the tool’s results, exactly as asteeris. A profile that does not stream has nothing partial to keep: the reply being generated is dropped whole and the request goes again with the message. The cost is the request sent again, which the prompt cache mostly absorbs, and the part of the answer that was never written. For “stop, do this instead”; asteeris enough for “also, when you get to it”.
Every class rides the opening of the next turn when nothing is running, whoever starts that turn, and opens a turn of its own when nothing else does. A turn that has been admitted but has not yet sent its first request is not running yet in this sense: an item that lands in that moment rides its opening, whatever its class. The model reads each item under a header meka writes: [Message from <source>, arrived <time>], with while you were working added when it landed mid-turn. source defaults to the token’s description, then to client; the body is verbatim, so a client relaying text from strangers fences it itself.
The answer is 202 with the item and its state:
{"item_id": "...", "session_id": "...", "class": "steer", "state": "pending", "replayed": false}
An item is pending until its text is in the conversation, appended until the provider accepts a request carrying it, then delivered; withdrawn is a DELETE, a canceled turn, or meka giving up. Delivered means the model read it, not that it was written down: the feed’s inbox.delivered fires when the provider accepts the request, and names the turn that carried the item. A rewind that takes an appended item’s text out of the conversation before it was delivered offers the item again. GET /v1/sessions/{id} reports inbox_pending beside turn_in_flight while the session is loaded.
Idempotency-Key works here as on POST /turn: the same key with another message or class is refused with 409 idempotency. One difference matters to a bridge: the key is recorded on the row rather than in memory, so a retry that lands after a meka restart still finds its earlier item and answers it with replayed: true, and the key stays bound to that item for the session’s life, withdrawn or delivered. The item is durable before the 202; a session evicted for idleness is revived to run it, and a process that restarts finds it waiting.
A turn opened on inbox items that fails before anything from the model reached the conversation withdraws its prompt and offers the items again, waiting 10 seconds, then twice that per attempt, up to five minutes between attempts. After an hour from when an item was enqueued it is given up on: withdrawn, with inbox.failed on the feed and the webhook, so whoever is waiting is told rather than left with silence. An item a tool round already carried into the conversation is not retried: it is history, and the next turn of any kind delivers it. An item on a session another process holds (a REPL open on it, say) is asked about again every ten seconds until the holder lets go, or the holder’s own next turn carries it; those waits count toward the hour.
Only a pending item can be withdrawn. DELETE on one that is already in the conversation answers 409 inbox-appended, since only a turn can answer it now; a delivered or withdrawn one is 404. A DELETE that lands in the instant between a turn reading the item and writing it may still be read by the model. Canceling a turn the inbox opened, with POST /cancel, withdraws the items it opened on, the way a canceled client turn loses its prompt, and the feed reports each as inbox.withdrawn. The endpoint refuses a sub-agent’s session exactly as POST /turn does: a worker’s inbox is its parent’s, written with the agent_steer tool. No images in this release.
Image attachments
Each entry in images is {"media_type": "...", "data": "<base64>"}. Images are inlined rather
than referenced by path because the API is a network surface: a client on another host shares no
filesystem with the agent, so it can’t name a file for the agent to read.
curl -s -X POST http://localhost:8080/v1/sessions/$SESSION_ID/turn \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"message\": \"what does this diagram show?\",
\"images\": [{\"media_type\": \"image/png\", \"data\": \"$(base64 -w0 diagram.png)\"}]}"
- Requires vision. Attaching an image to a session whose profile has
vision = falsereturns422. The check is per session, from the profile that session recorded, so a session created withprofileor moved by aPATCHfollows that profile rather than the server default.visiononGET /v1/inforeports the process default profile’s flag, which answers for a session created without naming one. media_typeis a hint. If it doesn’t name a supported format, the payload’s magic bytes are used instead, soapplication/octet-streamstill works for a real image.- Formats. PNG, JPEG, GIF, WebP, and BMP pass through; TIFF, ICO, HDR, EXR, TGA, PNM, QOI, DDS,
and Farbfeld are converted to PNG. Anything else is a
422. - Size. Each image is capped at 3.75 MB decoded (~5 MB of base64). Note this interacts with
max_body_bytes: the 10 MiB default comfortably fits one image, but a multi-image turn may need it raised. - Errors name the offender. A bad attachment returns
422with a detail like`images[1]` is invalid: unsupported image format.
Detecting a rewritten history
GET /messages returns the materialized view: what the model can currently see. Five things rewrite it rather than appending to it (compaction, POST /rewind, a mid-turn repair of a malformed request, the redaction of an image that no longer fit the request size budget, and the withdrawal of a prompt whose turn produced nothing, for a recurring job or a turn sent with options.unanswered_message set to withdraw), and after any of them your copy is no longer a prefix of the server’s.
Two signals cover this:
revisionon the response increments on every rewrite. If it changed since your last poll, re-fetch rather than diff. This is the one to key on, because it covers all five causes.compactionon a message identifies a summary and says how many messages it replaced and which compaction it was. Only compaction leaves a message behind to carry it; a rewind removes messages with nothing in their place, which is whyrevisionexists.
total alone is not enough: a shrinking total is indistinguishable from the server losing your conversation.
Note that neither GET /context nor GET /v1/sessions/{id}/tools will load an evicted session. Reading is not permitted to take the session’s cross-process lock, which a write would hold for idle_timeout. /context answers from the store with the live counters omitted; /tools returns 409, since a catalog needs a loaded session.
Messages
Read the conversation history for a session:
GET /v1/sessions/{id}/messages?offset=0&limit=50
Returns messages with role, content blocks, timestamps and turn correlation ids, beside total
(the length of the whole conversation, not the page) and revision. limit defaults to 200 and is
capped at 1000; offset defaults to 0.
A user message carries what the user typed as a text block. Ahead of it, when meka added one,
sits a turn_context block: the permission and environment context, todo list, catalog changes,
background outcomes and resume notice meka injected for that turn, which the model saw as text ahead
of the words. It is typed so a client can show or hide it; the text blocks alone are the words.
An image block, whether an attachment on a user message or a tool result, carries its media_type
and the hash of its bytes rather than the bytes. GET /v1/sessions/{id}/blobs/{hash} returns them
under that media type, and only for a session whose messages reference the hash. An image the
request budget redacted to fit the profile’s max_request_bytes reads as a text block saying so:
the redaction is recorded on the conversation once, so what the model was last sent is what the
history shows.
Compaction, rewind and export
POST /v1/sessions/{id}/compact summarizes the conversation now. The body is optional:
| Field | Type | Default | Description |
|---|---|---|---|
instructions | string | (none) | Guidance on what to keep or drop, as /compact <instructions> in the REPL |
keep_recent | bool | (meka decides) | Whether to keep the most recent turns verbatim after the summary |
The response carries source (checkpoint, checkpoint_text or summarizer) and
memories_written, the memories the checkpoint turn wrote.
POST /v1/sessions/{id}/rewind drops trailing turns. The body is optional:
| Field | Type | Default | Description |
|---|---|---|---|
turns | integer | 1 | How many trailing turns to drop. At least 1, and no more than the conversation holds, or the request is a 422 |
The response carries turns_removed, messages_before and messages_after.
GET /v1/sessions/{id}/export?format= returns the transcript: markdown (the default) as
text/markdown, or json as the archive POST /v1/sessions/import and meka session import
accept.
Blocking response
With stream: false (the default), the server holds the connection until the turn completes, then returns a single JSON response:
{
"turn_id": "t_01J...",
"session_id": "s_01J...",
"stop_reason": "end_turn",
"final_text": "Here are the files in src/: ...",
"messages": [
{
"role": "assistant",
"content": [{"type": "text", "text": "..."}]
}
],
"tool_calls": [
{
"id": "tu_1",
"name": "read_file",
"input": {"path": "src/main.rs"},
"display_summary": "src/main.rs",
"is_error": false,
"content": [{"type": "text", "text": "..."}]
}
],
"usage": {
"input_tokens": 12340,
"output_tokens": 567,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 8000
},
"notices": []
}
Key fields:
final_text: concatenated assistant text. This is what most bots display to the user.messages: structured message array for clients that want richer rendering.tool_calls: every tool the agent called during the turn, with inputs and outputs.stop_reason:end_turn,max_tokens, orrefusal.notices: provider advisories and warnings about approvals refused without asking.refusal_text: present only whenstop_reasonis"refusal".
Streaming response
Every resident session has one event feed. Everything a turn emits goes on it, whoever started the turn: a POST /turn, a scheduled fire, a background outcome, an inbox item. POST /turn with stream: true answers with a view of that feed scoped to the one turn it started, as text/event-stream, and closes after the turn’s terminal. GET /v1/sessions/{id}/stream is the feed itself, across turns; see The session feed. Every event has a monotonic id, a named event type, and a JSON data payload, and every payload carries turn_id and session_id, so a client holding several feeds can file an event without per-connection state.
Event types
Lifecycle
| Event | Payload | When |
|---|---|---|
turn.started | turn_id, session_id, started_at, source ("client", "inbox" with item_ids, "schedule" with job_id, or "background") | Turn begins |
turn.finished | turn_id, session_id, stop_reason, usage, optional refusal_text | Turn completed successfully |
turn.failed | turn_id, session_id, error (Problem Detail shape), message_withdrawn when the turn began | Turn failed mid-stream |
turn.canceled | turn_id, session_id, reason ("client", "server_shutdown", or "sse_lag" when the only consumer fell behind and the turn was stopped for it), message_withdrawn when the turn began | Turn was canceled |
turn.finished, turn.failed, and turn.canceled are terminal for the turn: a POST /turn stream closes immediately after its own, and the feed carries on to the next turn. turn.failed and turn.canceled also carry message_withdrawn when the turn began, whether it took the message it was sent back out of the conversation; see Resending a failed turn.
Inbox
| Event | Payload | When |
|---|---|---|
inbox.delivered | item_ids, turn_id, session_id | The provider accepted a request carrying these items, so the model has read them |
inbox.failed | item_id, session_id, reason | meka gave up on the item after the retry ceiling and withdrew it |
inbox.withdrawn | item_id, session_id | A client withdrew the item with DELETE, or the turn it opened was canceled; turn_id names the turn in flight at the time, if any |
inbox.delivered arrives inside the turn that read the item, before that turn’s terminal: for a steer read at a round boundary, after the tool_call.completed of that round; for an interrupt that cut the answer, after the notice announcing the cut. See The inbox.
Content deltas
| Event | Payload | When |
|---|---|---|
assistant_text.delta | text | Each chunk of assistant text |
thinking.delta | text | A chunk of extended thinking content (only when supports_reasoning_stream: true) |
Reasoning streams in chunks, one event per chunk, the way assistant_text.delta does; concatenate them to reassemble the block. A turn the provider answered without streaming sends the block as a single delta, so a client never has to tell the two apart. The blocking response (stream: false) reports each block whole, as a thinking content block in messages, and only when the session has supports_reasoning_stream on.
Tool execution
| Event | Payload | When |
|---|---|---|
tool_call.composing | id, name | The model started writing the call’s arguments |
tool_call.executing | id, name, input, display_summary | Tool call starts |
tool_call.completed | id, is_error, content | Tool call finishes |
progress | server_name, tool_name, tool_use_id, progress, total, message | An MCP tool reported progress while running |
tool_call.output_delta | id, chunk | A running execute_command produced output; append chunk to what you show for the call |
subagent.activity | id, summary | A sub-agent under the agent_spawn call id started a tool call; summary is its rolling activity block and replaces the previous one |
progress relays an MCP server’s notifications/progress for a call that is still running: progress is the server’s counter, total its target when it gave one, message its text, and tool_use_id the tool_call.executing the update belongs to. The three optional fields are omitted when the server did not send them. Only MCP tools report progress; a built-in’s next sign of life is its tool_call.completed, except execute_command, whose output streams as tool_call.output_delta.
tool_call.output_delta and subagent.activity are progress rather than history, and the feed treats them so: they carry no id, are never replayed after a reconnect, and never displace the events a Last-Event-ID resumption depends on. Command output is coalesced to about one event per 150 ms per call, whatever is left is flushed just ahead of the call’s tool_call.completed, and that event still carries the whole output. The activity block holds the sub-agent’s last 20 tool calls. A command run with background: true is not streamed: its call returns at once with a task id, and its output arrives with the task’s outcome.
The arguments are written between tool_call.composing and tool_call.executing on the same id, which makes that interval the only thing on the stream that separates the agent writing a message from the agent doing anything else. Assistant text is usually narration around a call rather than the reply itself, and by tool_call.executing the arguments are already finished. A client drawing a typing indicator for a tool like an MCP send_message raises it on the first and drops it on the second. The payload is the id and the name because nothing else has streamed yet: which conversation a message is for is not known until tool_call.executing.
Three limits. The event exists only when meka streams from its provider, so a server started with --no-stream receives each call whole and emits tool_call.executing with nothing before it. The pairing is not guaranteed, because a turn that fails or is canceled mid-call emits tool_call.composing with nothing after it, so close per-id state on the terminal event as well. And the interval is only wide on backends that stream a call as it is written (anthropic-messages, claude-subscription, openai-responses, chatgpt-subscription); openai-chat-completions resolves each call’s name and arguments together when the stream ends, so there the two events arrive back to back.
Notices and pauses
| Event | Payload | When |
|---|---|---|
notice | level, text | Provider advisories or warnings |
permission_required | request_id, tool_name, input, expires_in_seconds | Permission approval needed (approvals on, call above the level) |
Context
| Event | Payload | When |
|---|---|---|
context.compacted | source, replaced_count, generation | The conversation was summarized and the window replaced |
context.compacted is the one event on this stream that is not additive. Everything else appends, so a client that misses one still holds a prefix of the truth; a compaction removes messages the client has already rendered. source is checkpoint, checkpoint_text, or summarizer (they differ in fidelity, not just mechanism), replaced_count is how many messages the boundary removed from the view (the whole pre-compaction window, including the tail compaction re-appends verbatim), and generation counts compactions from 1.
The same information appears on GET /messages: the summary message carries a compaction object with replaced_count and generation, and every other message omits the field. Without it a polling client sees total shrink with no explanation, which is indistinguishable from the server losing the conversation.
Heartbeats
A : keep-alive comment is sent every 20 seconds. SSE clients ignore these automatically. The stream also sends retry: 3000 as its first line, hinting clients to reconnect after 3 seconds on disconnect.
SSE lag
The server buffers up to 256 events per SSE stream. If a consumer reads too slowly and falls behind, the server closes that consumer’s stream, and what it sends first depends on whether anyone else was still reading:
- Nobody else was reading. The turn is canceled to stop burning provider tokens, and the stream ends with a terminal
turn.failedcarrying error typehttps://meka.so/errors/sse-lag. That event is the stream’s, sent before the turn has unwound, so it carries nomessage_withdrawn; the outcome recorded for a later re-attach is aturn.canceledwithreason: "sse_lag"and does. Retry by submitting a new turn. - Another consumer was keeping up. The turn keeps running for them, so nothing has failed. The lagging stream ends with a
warnnoticeexplaining the drop (the usuallevelandtext, plusturn_idandsession_id) and closes. Re-attach withLast-Event-IDrather than retrying: the turn is still in flight, so a new turn would be refused with409 turn-in-flight, and re-attaching recovers the dropped events instead of redoing the work.
Turn events are broadcast, so a re-attached client or a second consumer counts as a separate reader. Use GET /messages to inspect what the agent completed either way. A reader of the session feed that falls behind gets the notice and keeps its connection; a turn is never canceled for a feed reader, because the turn was not run for it.
The session feed
GET /v1/sessions/{id}/stream is the session’s feed: every event of every turn, across turns, for as long as the connection is held. It is how a client sees the turns nobody asked for over HTTP, a scheduled job firing at three in the morning or a background task reporting back, and it is where a client that submits through the inbox learns what became of its items. Subscribe once, and file events by the turn_id they carry. Loading the session is part of opening the feed, so a bridge can subscribe before it has anything to submit, and a reconnect to an evicted session gets its feed back rather than a 404.
Send the last id you received as a Last-Event-ID header (browser EventSource does this automatically) or as a ?last_event_id= query parameter, and the server replays what you missed before following the live feed.
Add ?attend=true to say that this reader shows approval prompts and answers them. It needs sessions:w, and while at least one attending reader is connected a gated call on any turn parks as permission_required instead of being refused without asking; when the last one disconnects, a parked prompt is canceled. See Approvals. A reader that attends also counts as a renderer of reasoning deltas, so on a session with supports_reasoning_stream its turns lose their retry the way a streaming client’s do.
curl -N -H "Authorization: Bearer $TOKEN" \
-H "Last-Event-ID: 42" \
"http://localhost:8080/v1/sessions/$SESSION/stream"
Ids run across the whole session and the ring spans turns, so an id from an earlier turn is an ordinary position: everything after it replays, the later turns’ terminals included. When a turn is in flight as you attach, the feed opens with a turn.started carrying "resumed": true and the turn_id you joined, which is how to tell “my stream resumed” from “a newer turn started while I was away”; that event is synthesized rather than replayed, so it carries no id: and no started_at, but it names the turn’s source (with its item_ids or job_id), and everything after it is the real thing. With no turn in flight there is nothing to re-issue, and the most recent turn’s terminal is handed over when the ring no longer holds it, so a client that reconnects late still learns the outcome.
The feed does not end with a turn. A client that wants one turn’s outcome stops reading at that turn’s terminal. The old contract, a stream that closed after the turn it rejoined, is what POST /turn with stream: true still gives.
Three limits, all deliberate:
- The replay buffer is bounded by
[serve] stream_replay_events(default 256). If yourLast-Event-IDis older than the oldest retained event, you get anoticesaying the replay has a hole rather than a transcript that silently skips. ReadGET /messagesto fill it. - Only the most recent turn’s terminal is retained past the ring. Everything else a late client needs is in
GET /messages. - A turn opened by
POST /turnwithstream: trueis not canceled immediately when its client disconnects. It keeps running for[serve] stream_reattach_grace(default 30s) waiting for the client to come back; after that the agent loop stops, since nobody is listening. Set"0s"to restore the older behavior where a dropped stream cancels the turn at once. The rule is only for turns a streaming client opened: a turn the server started for a fire, an outcome or an inbox item runs for the session and is never stopped for want of a reader.
A session with a live feed subscriber is not idle, so the idle sweep leaves it resident. Opening the feed loads the session if it was not, exactly as submitting a turn does, so a sessions:r token can bring one into memory and keep it there, and the route answers 409 session-locked and 422 session-not-drivable where POST /turn would.
Webhooks
meka serve can POST to configured endpoints when something happens that no client is necessarily waiting on: a scheduled job firing overnight, a background task finishing long after the turn that started it.
[[serve.webhooks]]
url = "https://bridge.example/meka-hook"
secret = "${MEKA_WEBHOOK_SECRET}" # or secret_file = "/etc/meka/hook.secret"
events = ["turn.finished", "turn.failed", "task.finished", "schedule.fired", "inbox.delivered", "inbox.failed"]
timeout = "10s" # per attempt, default 10s; "0s" is refused at startup
max_retries = 3 # after the first attempt, default 3, at most 10
events is required and every name must be recognized: an endpoint whose only subscription is a typo would be silently never called, so an unknown event is a startup error rather than a warning.
turn.finished and turn.failed cover turns submitted through POST /turn. A scheduled job’s turn fires schedule.fired (which carries its own status) instead, so no turn produces two deliveries. A turn the server runs purely to report a background outcome fires neither: the news is the task’s, and task.finished has already carried it.
task.finished is not a turn event. It fires when a background task reaches a terminal state, whether or not any turn reports it: a canceled task fires it with no turn at all, and its outcome then rides whichever turn the session takes next. Expect it alongside a turn.finished when a client’s own POST /turn is what carries the outcome, and expect it on its own for a task interrupted by a host that died, which no turn ever ran.
inbox.delivered and inbox.failed are the inbox’s two outcomes: the model read an item, or meka gave up on it. A turn the server runs on inbox items posts turn.finished or turn.failed like a client’s, since nothing else carries it.
A client that wants to know about everything the agent did should subscribe to all six.
Payloads
Every delivery carries delivery_id, event, timestamp, and event-specific identifiers:
{
"delivery_id": "6c1f...",
"event": "schedule.fired",
"timestamp": "2026-02-01T03:00:00Z",
"job_id": "9f2c...",
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "completed"
}
A schedule.fired status is completed, failed, or canceled when the turn was stopped before it finished, as happens when the server drains during it.
Payloads never carry message content. A webhook URL is a string in a config file: it can be mistyped, it can outlive whatever owned it, and anything that learns it can reach it. So a delivery tells you what happened to which session, and you fetch the conversation with your own bearer token over the API you already authenticate against. A compromised endpoint learns that a session was active, not what was said in it.
Verifying a delivery
When secret is set, each request carries X-Meka-Signature: sha256=<hex>, an HMAC-SHA256 over <timestamp>.<body> keyed with the secret. The timestamp is inside the signed material, so a captured delivery cannot be replayed forever: reject anything whose X-Meka-Timestamp is too old and the window closes.
Each attempt carries its own timestamp and signature. A retry can land minutes after the first attempt, so re-sending the original stamp would have it rejected by that very window. Deduplicate on X-Meka-Delivery, which stays constant across a delivery’s attempts.
import hmac, hashlib
def verify(secret: str, timestamp: str, body: bytes, signature: str) -> bool:
expected = "sha256=" + hmac.new(
secret.encode(), timestamp.encode() + b"." + body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
Deliveries also carry X-Meka-Event, X-Meka-Delivery (unique per delivery, for deduplicating retries), and X-Meka-Timestamp.
X-Meka-Timestamp and the body’s timestamp field differ on a retry, deliberately. The header is when this attempt was sent, re-stamped each time, because that is what your replay window is checking; a retry carrying the original time would be rejected as stale by the very check the header exists for. The body’s field is when the event happened and stays fixed across attempts, so ordering and deduplication see one event rather than several.
Omitting secret is allowed for loopback receivers and logs a startup warning; no signature header is sent, rather than one computed over an empty key.
Delivery semantics
Deliveries are notifications, not a durable queue. They are not persisted, not retried across a restart, and outstanding attempts are abandoned when the process exits: a delivery in flight during a SIGTERM is lost. That is the trade for never blocking the work that triggered it. Anything you cannot afford to miss should be reconciled by polling (GET /v1/schedule, GET /v1/sessions/{id}/tasks), with the webhook as the fast path rather than the only one.
Delivery is fire-and-forget on a detached task, so a slow or dead receiver never wedges the scheduler behind it. A 5xx or a transport error is retried with exponential backoff (1s, 2s, 4s, capped at 30s) up to max_retries. A 4xx is not, since retrying cannot fix a request the receiver considers malformed, with two exceptions: 429 Too Many Requests and 408 Request Timeout say “not now” rather than “not ever” and are retried like a 5xx. That matters because several jobs sharing a cron minute deliver as a burst, which is exactly when a receiver rate-limits. Retry-After is not honored; the backoff above is used regardless. After the last attempt meka logs one warn and gives up. Turn cancellations are not delivered: the client that canceled already knows.
Permission levels over HTTP
The same four permission levels apply: none, read, workspace, unrestricted. Set the level at session creation or update it via PATCH /v1/sessions/{id}; approvals sits beside it on both, and {"approvals": true} in a PATCH body turns it on for a live session.
Both apply during a turn: the next tool call is checked against the new level and the new switch, as the REPL’s Shift+Tab and ACP’s session/set_mode do, so dropping a running session to read is a brake and not a request to stop. cwd and profile wait for the turn to end, and a body naming either answers 409 turn-in-flight meanwhile.
Approvals
With approvals: true, a tool call above the session’s level parks as a permission_required SSE event whenever someone is there to answer it: the client of a POST /turn with stream: true, or a feed reader that opened GET /v1/sessions/{id}/stream?attend=true (which needs sessions:w, the scope that answers). Any turn qualifies, an inbox turn, a scheduled fire or a background outcome included, so a UI that submits through the inbox and watches the feed is asked like a streaming client is. With nobody attending, the call is refused without asking and a notice says so. The stream stays open while waiting. Your client resolves it by POSTing to the responses endpoint:
POST /v1/sessions/{id}/responses/{request_id}
Content-Type: application/json
{"outcome": "allow"}
Possible outcomes:
| Outcome | Effect |
|---|---|
allow | Run this tool call |
deny | Refuse this tool call |
allow_always | Allow this and all future calls to this tool (session-scoped) |
deny_always | Deny this and all future calls to this tool (session-scoped) |
input is every argument the call was made with, and a prompt should show it: tool_name alone asks you to approve a write without showing what is written. If no response arrives within 30 minutes the request is denied; expires_in_seconds on the event carries that figure, and it is the same backstop an ACP client’s prompt gets. When the last client that could answer disconnects, the request is canceled at once rather than left to that timeout. An approved call still runs at the session’s level: approval never widens reach, so an approved write at read lands only under the session’s cwd.
Approvals with blocking turns
When stream: false and approvals are on, and no feed reader is attending, there is no channel for permission prompts. Every call that would need approval is refused without asking; each refused tool appends a notice to the response saying so and pointing at stream: true and attend=true.
MCP elicitations (interactive form prompts from MCP servers) are always auto-declined over HTTP; there is no channel for interactive input. A notice event is emitted when this happens.
Recommendation: non-interactive callers (bots, bridges, scripts) should leave approvals off and create sessions at the level they need, so nothing is refused without asking. Use stream: true, or attend the feed, with approvals on if you need approval flow.
Authentication
Every request requires Authorization: Bearer <token>, except the two health probes and, when [serve].docs is enabled, /v1/openapi.json and /v1/docs. Both of those are off by default, so on a default deployment they answer 404 rather than serving anything unauthenticated. A 401 carries WWW-Authenticate: Bearer realm="meka", as RFC 9110 requires. A browser’s CORS preflight is the one other exception, and only where cors_allowed_origins is set.
Scopes
Each token carries a set of scopes that control what it can access:
| Scope | Permits |
|---|---|
sessions:r | List sessions, get details, read messages, context occupancy, export, tools, background tasks, the inbox, the session feed |
sessions:w | Create, modify, delete sessions; submit and cancel turns; enqueue and withdraw inbox items; compact, rewind, import; respond to permission prompts; cancel background tasks |
skills:r | Read installed skills, including bodies |
skills:w | Create, update, delete skills |
memory:r | Read the memory store |
memory:w | Create, update, delete memories |
schedule:r | List scheduled jobs. GET /v1/schedule is server-wide and returns each job’s full prompt, so this reads instruction text and not just schedules. A gate’s check is withheld unless the token also holds sessions:r |
schedule:w | Create and cancel scheduled jobs. A job’s prompt runs a full turn with tools, so this is deferred turn execution, not just bookkeeping. A job’s optional gate runs a shell command or a read-only tool call and additionally requires sessions:w (see below) |
mcp:r | Read MCP server status and advertised tools |
mcp:w | Reconnect an MCP server |
Discovery endpoints (/v1/info, /v1/skills, /v1/mcp, /v1/profiles) accept any token with at least one read scope. Two deliberately do not: GET /v1/skills/{name} needs skills:r and GET /v1/instructions needs sessions:r, because both return instruction text rather than a listing.
Scopes are flat: memory:r does not imply memory:w, and neither implies the other. Operations on a conversation stay under sessions:*, because the thing being read or changed is one session. The process-wide stores carry their own scopes so a bridge token that runs turns cannot also empty the memory store or plant an unattended scheduled job.
An unrecognized scope logs a warning at startup and grants nothing, so a typo like sessions:write is visible rather than silently inert.
Note:
[skills] agent_managedand[memory] enabledgovern what the model may do on its own initiative. They do not gate these endpoints. A token is the operator acting remotely, equivalent to runningmeka skill addin a shell, so askills:wtoken writes skills even whenagent_managed = false.
Token configuration
Tokens are configured under [[serve.tokens]] in your config. Three forms are supported:
# Inline plaintext, development only (a startup warning is logged)
[[serve.tokens]]
token = "sk_dev_test123"
scopes = ["sessions:r", "sessions:w"]
# Environment variable substitution, recommended for CI/containers
[[serve.tokens]]
token = "${MEKA_BRIDGE_TOKEN}"
description = "telegram bridge"
scopes = ["sessions:r", "sessions:w"]
# File-based, recommended for production (chmod 0600)
[[serve.tokens]]
token_file = "/etc/meka/bridge.token"
description = "telegram bridge"
scopes = ["sessions:r", "sessions:w"]
Token comparison uses constant-time equality to prevent timing side-channel attacks. Tokens never appear in logs; only a truncated SHA-256 fingerprint is used for diagnostics.
Browser clients
A web application served from another origin, a static site or a development server, calls the API directly from the browser once [serve].cors_allowed_origins lists its origin, or *. The reference page covers the setting; this is what the grant covers.
- Preflights need no token. The browser’s
OPTIONSrequest is answered ahead of authentication and runs nothing: it loads no session, takes no lock and enqueues nothing. The real request that follows needs the same bearer token and scopes as ever. - Request headers.
Authorization,Content-Type,Idempotency-KeyandLast-Event-IDare granted by name, on top of the headers a browser may always send.Authorizationhas to be named because a wildcard grant never covers it. - Methods.
GET,HEAD,POST,PUT,PATCHandDELETE. A method a route does not implement is still a405. - Response headers.
Retry-AfterandWWW-Authenticateare exposed to page script. Errors carry the grant like successes do, so a401, a403, a413or a429is a Problem Detail the page can read rather than an opaque failure. - No cookies.
Access-Control-Allow-Credentialsis never sent. Send the bearer header on every request and usecredentials: "omit". - Streams. The feed and a streaming turn are granted like any other response and are not buffered. A native
EventSourcecannot send a header, so read SSE withfetchand a streaming parser, and sendLast-Event-IDyourself on a reconnect. Fetch an image or an export the same way and hand the bytes to an object URL.
CORS is the browser’s policy on sharing a response, not authorization: an allowed origin still needs a token, and a refused origin only stops the page from reading the answer. It does not make an endpoint reachable, provide TLS, or bypass a browser’s local-network permission; a remote deployment is exposed through HTTPS as before. Set the policy in one place: a reverse proxy that adds CORS headers of its own on top of meka’s gives the browser two grants, and it refuses both.
Idempotency
Blocking turn submissions (stream: false) support Stripe-style idempotency via the Idempotency-Key header:
curl -X POST http://localhost:8080/v1/sessions/$ID/turn \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 7f8a9b0c-1234-5678-abcd-ef0123456789" \
-d '{"message": "deploy to staging"}'
If the same key is replayed, the server returns the cached response. If the same key is sent with a different body, it returns 409 Conflict. A key must be non-empty ASCII of at most 255 characters; anything else is a 422 invalid-body. While a request carrying a key is still running, a second request with the same key answers 409 idempotency rather than waiting; retry it once the first completes.
Keys are scoped per-token and per-session, and expire after 24 hours. The session is part of the scope because an Idempotency-Key names your unit of work: sending the same key to two sessions is a reasonable thing to do, and it now runs both turns instead of answering the second with the first’s transcript.
A turn that was canceled is not cached, so the retry the cancellation invites can actually run. Neither is a 5xx, for the same reason. Either retry re-executes the turn, and unless the turn was sent with options.unanswered_message set to withdraw it runs above the message the failed one left behind; see Resending a failed turn.
The cache is bounded per token by both entry count and total bytes; a response too large to keep is not cached, and its retry re-executes.
Idempotency keys are ignored for streaming responses; streaming clients should reconnect by submitting a new turn.
Which endpoints are safe to retry
Idempotency-Key covers blocking turns only. For everything else, know what a blind retry does before you configure one:
| Endpoint | Retry-safe | On a duplicate |
|---|---|---|
POST /turn (blocking, with a key) | yes | cached response returned |
POST /v1/sessions/{id}/inbox (with a key) | yes | the earlier item is answered, replayed: true; the key is on the row, so this holds across a restart |
POST /cancel, DELETE /v1/sessions/{id}, DELETE /v1/sessions/{id}/tasks/{task_id}, DELETE /v1/sessions/{id}/inbox/{item_id} | yes | already-done is the same state; a withdrawn item answers 404 on the retry |
DELETE /v1/skills/{name}, /v1/memory/{name}, /v1/schedule/{job_id} | yes, but | the resource is gone, so the retry answers 404. Expected, not a failure; treat it as success if you are retrying blind |
PUT /v1/skills/{name}, PUT /v1/memory/{name} | yes | same body writes the same skill file or memory row |
POST /compact | mostly | a second compaction summarizes the summary; fidelity drops, nothing is lost |
POST /rewind | no | drops another turn. A client that retries on a connection error loses conversation |
POST /sessions/import | no | creates a second copy of the tree under new ids |
POST /sessions/{id}/schedule | no | creates a second job |
The three marked no are administrative operations meant to be driven deliberately. If your HTTP stack retries failed POSTs by default, exclude them, or check the outcome first: POST /rewind returns messages_before and messages_after, and GET /messages returns a revision that increments on every rewrite.
Error handling
All HTTP error responses use RFC 9457 Problem Details with Content-Type: application/problem+json:
{
"type": "https://meka.so/errors/session-not-found",
"title": "Session not found",
"status": 404,
"detail": "session '2f0c9b6e-4d1a-4c0e-9b7f-3a5d8e1c2b4f' not found",
"instance": "/v1/sessions/s_xyz/turn"
}
The type URI is the stable, machine-readable error code. Route error handling on type, not on status or detail.
A parse failure names the field. A body that does not parse is a
422invalid-bodywhosedetailreadsinvalid <endpoint> request body: <what the parser found>, naming the field at fault: an unknown one, a missing one, or one of the wrong type. Every request type denies unknown fields, so a typo is refused rather than ignored. The names it cites are the wire schema, which the OpenAPI spec defines; nothing internal is in them. Validation that runs after parsing, such as a profile that is not configured or acwdthat does not exist, names the field the same way.A
502on a turn carries the provider’s own response text in aprovider_responsemember when[serve] relay_provider_errorsis on, which is the default; a deployment that has turned it off omits the member entirely. It exists because the upstream’s error type is the actionable part and a client that cannot see it is left guessing.detailstays meka’s own sentence either way, so nothing is traded for it, and the relayed text is capped at 4 KiB with the cut marked. The full text goes to the server log regardless.
provider_responseis readable atsessions:r. Submitting a turn takessessions:w, but the failure also rides the terminalturn.failedevent, whichGET /v1/sessions/{id}/streamreplays to any reader. Since an upstream refusal can name the operator’s account with the provider and its rate-limit posture, set[serve] relay_provider_errors = falsewhere read-only tokens go to people who may watch a session but are not entitled to the account behind it.A turn that failed or was canceled after it began carries
message_withdrawn, whether the message it was sent is still in the conversation. It istrueonly for a turn sent withoptions.unanswered_messageset towithdrawthat ended before anything from the model reached the conversation. A turn refused before it began, such as the503for a required MCP server, never added the message and carries no such member; see Resending a failed turn.The
503a turn gets when a required MCP server is down is not covered by that key and never relays: the server names travel, the connector’s reason does not, since it is meka’s own subprocess text and has carried a command line and its path. The endpoints under/v1/mcpdo relay their reason, since a caller naming one server and asking why it will not connect is asking for it.An installation fault is a
500, not a422. A[web]client meka cannot build from itsproxyorca_cert_file, or abase_urlshape a backend refuses, is the operator’s to fix and names a path or an endpoint out of theirconfig.toml; the body says only “internal server error; consult server logs” and the sentence goes to the log. The common case does not reach a request at all: the web client is built at startup, so a server with a bad[web]block fails to start rather than answering turns.
Error types
| Type | Status | Meaning |
|---|---|---|
/errors/auth | 401 | Missing or invalid bearer token |
/errors/auth-scope | 403 | Token lacks the required scope |
/errors/session-permission | 403 | The token is fine; the session sits too low. Raise it with PATCH /v1/sessions/{id}; a better token will not help |
/errors/session-not-found | 404 | Unknown session id |
/errors/not-found | 404 | Unknown skill, memory, MCP server, background task, scheduled job, image blob, or inbox item; also a skill or memory store that is disabled, or a server with [schedule] enabled = false, since there is nowhere to write |
/errors/session-not-loaded | 409 | The session exists but is not in memory; submit a turn to load it. Do not retry POST /cancel: there is no turn to cancel |
/errors/session-locked | 409 | Another meka process holds the session’s lock (e.g. two meka serve instances sharing one store); wait or restart the other process |
/errors/turn-in-flight | 409 | A turn is already running on this session within this process; cancel it via POST /cancel first |
/errors/turn-canceled | 409 | Turn was canceled |
/errors/turn-mismatch | 409 | POST /cancel named a turn that is not the one in flight; the turn_id member names the one that is. Nothing was canceled |
/errors/inbox-appended | 409 | The inbox item is already in the conversation, so only a turn can answer it now; nothing to withdraw |
/errors/store-read-only | 409 | The skill lives under a [skills] extra_paths root; meka reads those but never writes to them, so writing here would shadow the file rather than change it |
/errors/session-not-drivable | 422 | The id names a sub-agent’s conversation, which only its parent drives. Reading it is unaffected; the message names the parent and what to do there: agent_followup for a turn or a fork, POST /v1/sessions/{parent}/responses/{request_id} for an approval, and the parent itself for a scheduled job. Do not retry with a corrected payload: no body addressed at this id is accepted |
/errors/request-not-found | 404 | Unknown or expired request_id |
/errors/idempotency | 409/429 | Key conflict (body mismatch: 409; cache cap: 429) |
/errors/invalid-body | 400/422 | Request body validation failed (422), or a path/query parameter the router rejected (400) |
/errors/request-too-large | 422 | meka refused to send the turn: the conversation is still over the profile’s max_request_bytes after redacting older tool-result images. meka’s own ceiling, so no provider judged it and no provider_response rides along; detail names the size, the limit and the remedy, /compact. Do not retry unchanged: POST /compact first |
/errors/payload-too-large | 413 | Body exceeds max_body_bytes, meka’s limit on the HTTP request itself. Unrelated to request-too-large, which is about what meka may send onward |
/errors/concurrency-limit | 429 | Process-wide turn limit reached (Retry-After header included) |
/errors/sse-lag | 500 | SSE consumer fell behind; stream terminated (see SSE lag) |
/errors/stream-detached | 500 | SSE-only. A re-attached stream ended with no recorded outcome because the turn’s task died; read GET /messages for what completed |
/errors/provider | 502 | An upstream call failed for a reason meka could not classify as transient. Usually permanent (a revoked credential, a base_url that is not the API), but it is a catch-all, so treat it as “no reason to expect a retry to help” rather than “a retry cannot help” |
/errors/provider-unavailable | 502 | The upstream failed in a way meka’s classifier had already labeled transient. Worth one backed-off resend. Carries a Retry-After when the upstream gave one, which most of the time it did not |
/errors/context-overflow | 502 | The conversation exceeds the model’s context window, and auto-compaction was off, already spent, or could not shorten it enough. Do not retry unchanged; POST /compact or send less first. Carries provider_response like the two above, since the upstream is what refused it |
/errors/mcp-unavailable | 503 | An MCP server marked required was not connected, so the turn was refused before reaching the provider. The servers extension names them; each one’s reason is in the server log |
/errors/internal | 500 | Unhandled server error |
Streaming turns that fail mid-stream emit a turn.failed SSE event with the same error shape, then close the connection.
The three 502s are the ones worth branching on.
/errors/provider-unavailableis the positive signal: meka’s classifier recognized the failure as transient, which covers an overload, a 5xx, a dropped connection and a stalled stream. Resend it after a pause; Resending a failed turn says what the failed turn leaves behind./errors/context-overflowis the flat refusal: the request no longer fits and will not fit next time either, so retrying it unchanged loops until your client gives up; shorten the conversation withPOST /v1/sessions/{id}/compactor send less.
/errors/provideris the absence of the first signal, not the opposite of it. It is a catch-all covering everything meka could not place, so a revoked credential lands there and so does a 408, a truncated response body, and any mid-stream error type meka does not yet recognize. Most of the time it is permanent and worth surfacing to a human rather than retrying, but do not build a client that will never retry it: one unhurried resend is reasonable, an unbounded loop is not.Branch on
type, not onRetry-After. ARetry-Afteris present only when the upstream volunteered one in delta-seconds form, which most transient failures do not: a dropped connection never produced a response to carry a header, a mid-streamoverloaded_errorhas no headers at all, and an upstream answering with an HTTP date sends none meka can read. Treating its absence as “permanent” discards turns a second attempt would have completed, which is the reason these two types exist separately.Neither provider type says how many attempts meka made first. It declines to retry at all once any output has reached the stream or its retry budget is spent, and a canceled turn abandons the sequence wherever it stands, so one of these can reach you after three attempts or after none.
/errors/provider-unavailableclaims a failure class, not that your next attempt will succeed.A
Retry-Afteron a/errors/provider-unavailableresponse is the upstream’s own, relayed up to an hour. Honor it in preference to your own backoff. The other two never carry one.
Resending a failed turn
A failed turn keeps the message you sent, in the conversation and on disk. That is the REPL’s
behavior too: the person who typed the prompt can see it and expects the agent to have it, and a
turn that got as far as a partial answer or a tool call has work behind it that refers to the
message. For a client that answers a 502 by resending the same message it is the wrong default,
because the resend appends a second copy of the message after the first, and the model is then shown
the same request twice with nothing between them for the life of the session.
Say so instead. A turn sent with options.unanswered_message set to withdraw takes its message
back when the turn ends, failed or canceled, before anything from the model reached the
conversation, so the resend is the only copy. A turn that got a partial answer or a tool call into
the conversation keeps its message either way, so a resend after one is a new turn rather than a
replay. Send the option on every turn you would resend; it is per turn, not per session, and it
never touches a background outcome that was riding on the message, whose row is already spent.
The response says which happened. A turn that failed or was canceled after it began carries
message_withdrawn, in the Problem Detail body of a blocking turn and on the turn.failed and
turn.canceled events of a streaming one. Branch on it rather than on what you saw arrive: thinking
and a half-composed tool call both look like output on the stream, and neither reaches the
conversation, while a completed reply of nothing but thinking does and is invisible to a blocking
client. true means the conversation no longer holds the message and your resend will be the only
copy. false means it does, and a resend appends a second one after whatever the turn produced. A
turn refused before it began, such as the 503 for a required MCP server or any 4xx, never added
the message and carries no message_withdrawn; a resend is the first copy.
Discovery endpoints
These endpoints help clients inspect the server’s capabilities at runtime.
| Endpoint | Auth | Description |
|---|---|---|
GET /v1/health/live | None | Liveness probe: 200 if the process is up |
GET /v1/health/ready | None | Readiness probe: 200 if the store is healthy, at least one profile is configured, and no required MCP server has failed. A failed optional server doesn’t affect readiness, since it can’t stop a turn either. Returns status, session_db, profile_configured, and mcp_servers_healthy (boolean, no server names). profile_configured means a profile exists in config.toml, not that it has a usable credential: a profile’s credential is checked when a session first needs it, so a server can be ready and still answer 422 to POST /v1/sessions. |
GET /v1/profiles | Any read scope | Configured profiles, as {"profiles": [...]}. Each carries name, account, backend (omitted when the profile names an account that is not configured), model (omitted when the profile names none) and active: true on the one a session gets when it names none. Read-only; profiles come from config.toml |
GET /v1/info | Any read scope | Server version and permission surface, and scopes, the ones the calling token holds, so a client can show only the controls it may use. vision reports whether the default profile accepts image attachments; a session on another profile follows that one. Carries no profile or model: GET /v1/profiles reports both per profile and marks the default with active |
GET /v1/skills | Any read scope | Installed skills |
GET /v1/mcp | Any read scope | MCP server connection status |
GET /v1/openapi.json | None, and off unless [serve].docs is set | OpenAPI 3 spec |
GET /v1/docs | None, and off unless [serve].docs is set | Swagger UI |
Session lifecycle
Idle timeout and GC
A background garbage collector scans in-memory sessions and evicts those that have been idle longer than idle_timeout; "0s" disables it, and gc_scan_interval = "0s" is refused:
[serve]
idle_timeout = "24h"
gc_scan_interval = "5m"
Eviction drops the in-memory state (agent runtime, conversation buffer, cancellation tokens) but keeps the SQLite row. A later request with the same session id transparently re-attaches and continues the conversation.
To also remove the row on eviction:
[serve]
delete_on_idle = true
A session is never evicted while a turn is in flight, while a scheduled fire or a compaction holds its runtime, or while one of its background tasks is still running.
Graceful shutdown
meka serve handles SIGTERM / SIGINT with a controlled drain:
- Stop accepting new connections.
- Cancel all in-flight turns (same mechanism as
POST /cancel). - Emit
turn.canceledwithreason: "server_shutdown"on open SSE streams. - Wait up to
shutdown_drain_timeoutfor every turn to finish unwinding, including scheduled fires, background-outcome deliveries, and turns whose client has already disconnected. Canceling a turn is not the same as waiting for one: what follows the cancellation is the commit of the partial reply and of whatever the round already produced. - Exit
0. A drain that hits the timeout instead logs a warning, abandons what is still running, and exits1, so a supervisor can tell the two apart.
[serve]
shutdown_drain_timeout = "30s"
Concurrency
- Per session: one turn at a time. A second
POST /turnreturns 409; a message that should not wait for the session to be free goes through the inbox, which a running turn reads at its next round boundary. - Across sessions: fully concurrent. Multiple sessions can run turns in parallel.
- Process-wide cap (optional): set
max_concurrent_turnsto limit total in-flight turns. Exceeding the cap returns 429 with aRetry-Afterheader.
Configuration
All settings live under [serve] in your config.toml. See the [serve] section of the config file reference for the full field list.
Minimal example:
[serve]
bind = "127.0.0.1:8080"
[[serve.tokens]]
token = "${MEKA_API_TOKEN}"
scopes = ["sessions:r", "sessions:w"]
Full example:
[serve]
bind = "0.0.0.0:8080"
cors_allowed_origins = ["https://owner.github.io"] # a browser UI's origin; omit for none
max_body_bytes = 10485760 # 10 MiB (default)
max_concurrent_turns = 20
idle_timeout = "24h"
gc_scan_interval = "5m"
delete_on_idle = false
shutdown_drain_timeout = "30s"
# Bridge token, env var substitution
[[serve.tokens]]
token = "${BRIDGE_TOKEN}"
description = "telegram bridge"
scopes = ["sessions:r", "sessions:w"]
# Admin token, file-based
[[serve.tokens]]
token_file = "/etc/meka/admin.token"
description = "operator debugging"
scopes = ["sessions:r", "sessions:w", "mcp:r", "skills:r"]
Client recipes
Telegram bridge (Python)
A bridge lives with the session, so it submits through the inbox and watches the feed rather than holding a turn open per message. The agent’s replies go out through whatever tool the bridge exposes to it; the feed tells the bridge when each message was read.
import httpx
MEKA_URL = "http://localhost:8080"
MEKA_TOKEN = os.environ["MEKA_TOKEN"]
HEADERS = {"Authorization": f"Bearer {MEKA_TOKEN}"}
async def handle_message(chat_id: str, message_id: str, text: str):
session_id = await get_or_create_session(chat_id)
resp = await httpx.AsyncClient().post(
f"{MEKA_URL}/v1/sessions/{session_id}/inbox",
headers={**HEADERS, "Idempotency-Key": f"{chat_id}:{message_id}"},
json={"message": text, "class": "steer", "source": "telegram"},
)
resp.raise_for_status()
return resp.json()["item_id"] # 202: durable, read at the next boundary
async def follow(session_id: str):
async with httpx.AsyncClient(timeout=None).stream(
"GET", f"{MEKA_URL}/v1/sessions/{session_id}/stream", headers=HEADERS
) as feed:
async for event in parse_sse(feed):
if event.name == "inbox.delivered":
mark_read(event.data["item_ids"])
elif event.name == "inbox.failed":
tell_chat(event.data["item_id"], event.data["reason"])
Web UI (TypeScript, streaming)
A UI that renders the whole session subscribes to the feed once and files events by turn_id, so it also shows the turns it did not start: a scheduled fire, a background task reporting, a message the user typed while the agent was working and the agent answering it in place. The feed is read with fetch and an SSE parser rather than a native EventSource, which cannot send the bearer header; a UI served from another origin also needs cors_allowed_origins to name it.
const feed = await fetch(`${MEKA_URL}/v1/sessions/${sessionId}/stream`, {
headers: { Authorization: `Bearer ${token}` },
});
for await (const event of parseSse(feed.body)) { // any SSE parser over a ReadableStream
const data = JSON.parse(event.data);
if (event.event === "turn.started") openTurn(data);
if (event.event === "assistant_text.delta") append(data);
if (event.event === "turn.finished") closeTurn(data);
}
async function send(input: string) {
await fetch(`${MEKA_URL}/v1/sessions/${sessionId}/inbox`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ message: input, class: "steer" }),
});
}
A UI that wants one turn per request, with the response scoped to it, keeps POST /turn with stream: true; that stream still closes at its own terminal.
Shell script
#!/usr/bin/env bash
set -euo pipefail
TOKEN="sk_..."
BASE="http://localhost:8080"
# Create a session
SESSION=$(curl -sf -X POST "$BASE/v1/sessions" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"cwd\": \"$(pwd)\"}" | jq -r .id)
# Run a turn
RESULT=$(curl -sf -X POST "$BASE/v1/sessions/$SESSION/turn" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"message": "summarize this project"}')
echo "$RESULT" | jq .final_text
# Clean up
curl -sf -X DELETE "$BASE/v1/sessions/$SESSION" \
-H "Authorization: Bearer $TOKEN"
Scheduled jobs
meka serve is the durable host for scheduled wakeups. It fires every job in the store, reviving evicted sessions on demand, so jobs keep running whether or not a client is connected and survive a restart of the server.
An agent-initiated turn has no HTTP request to respond to, so its output is persisted to the session like any other turn. Read it back with GET /v1/sessions/{id}/messages.
POST /v1/sessions/{id}/schedule plants a job on a session. Scheduling must be enabled on the
server ([schedule] enabled), or the request is a 404 not-found: there is nowhere for the job
to go, the same answer a disabled skill or memory store gives. Listing and canceling stay open, so
jobs left from before the flag was flipped can still be cleared out.
| Field | Type | Default | Description |
|---|---|---|---|
prompt | string | (required) | What the agent is asked to do when the job fires; must not be blank |
at | string | One-shot: an RFC 3339 instant, or a duration from now ("20m", "1h 30m") | |
every | string | Recurring interval ("30m", "6h") | |
cron | string | 5-field cron pattern, evaluated in the host’s local time | |
gate | object | Guard the job on a probe; see below |
Exactly one of at, every and cron is required. A gate is {"check": ..., "when": ...}:
check is {"command": "..."} for a shell command or {"tool": "...", "arguments": {...}} for a
read-only tool call, and when is "changed" (the default), "succeeded", {"matches": "<regex>"}
or {"at": "<json pointer>", "is": "not_empty" | "empty" | "changed"}. What a gate requires of the
token and the session is under Endpoint reference.
Reverse proxy setup
For production deployments behind nginx:
location /v1/ {
proxy_pass http://127.0.0.1:8080;
proxy_buffering off;
proxy_cache off;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_read_timeout 600s;
}
Key points:
- Disable buffering: SSE events must not be buffered. Every SSE response carries
X-Accel-Buffering: noandCache-Control: no-cache, no-transform, which switch nginx’s buffering off per response; theproxy_buffering offabove covers proxies that do not honor the header. - Extend read timeout: turns can take minutes; the default 60s is too short.
- Do not compress: gzip/brotli on SSE responses swallow events. Exclude the
/turnroute from compression middleware.
Endpoint reference
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /v1/health/live | None | Liveness probe |
| GET | /v1/health/ready | None | Readiness probe |
| GET | /v1/info | read | Server version, permission surface, and the caller’s scopes |
| GET | /v1/skills | read | Installed skills |
| GET | /v1/mcp | read | MCP server status |
| POST | /v1/sessions | sessions:w | Create session |
| GET | /v1/sessions | sessions:r | List sessions |
| GET | /v1/sessions/{id} | sessions:r | Get session |
| PATCH | /v1/sessions/{id} | sessions:w | Update session |
| DELETE | /v1/sessions/{id} | sessions:w | Delete session |
| POST | /v1/sessions/{id}/fork | sessions:w | Fork session |
| GET | /v1/sessions/{id}/messages | sessions:r | List messages |
| GET | /v1/sessions/{id}/blobs/{hash} | sessions:r | Image bytes behind a content block |
| POST | /v1/sessions/{id}/turn | sessions:w | Submit turn |
| POST | /v1/sessions/{id}/cancel | sessions:w | Cancel turn (optionally one named turn_id) |
| POST | /v1/sessions/{id}/inbox | sessions:w | Enqueue a message: steer reaches the running turn, interrupt cuts its answer, followup waits |
| GET | /v1/sessions/{id}/inbox | sessions:r | Inbox items the model has not been shown |
| DELETE | /v1/sessions/{id}/inbox/{item_id} | sessions:w | Withdraw an item still waiting |
| POST | /v1/sessions/{id}/responses/{request_id} | sessions:w | Resolve permission prompt |
| GET | /v1/sessions/{id}/stream | sessions:r | The session’s event feed, across turns; ?attend=true (needs sessions:w) to be asked to approve gated calls |
| POST | /v1/sessions/{id}/compact | sessions:w | Summarize the conversation now |
| GET | /v1/sessions/{id}/context | sessions:r | Context occupancy and cumulative usage |
| POST | /v1/sessions/{id}/rewind | sessions:w | Drop trailing turns |
| GET | /v1/sessions/{id}/export | sessions:r | Full transcript (?format=markdown|md|json) |
| POST | /v1/sessions/import | sessions:w | Recreate a session tree from an export |
| GET | /v1/sessions/{id}/tools | sessions:r | Tool catalog for this session (409 if not loaded) |
| GET | /v1/sessions/{id}/tasks | sessions:r | Background tasks |
| DELETE | /v1/sessions/{id}/tasks/{task_id} | sessions:w | Cancel a background task |
| GET | /v1/schedule | schedule:r | All scheduled jobs |
| GET | /v1/sessions/{id}/schedule | schedule:r | Scheduled jobs for one session |
| POST | /v1/sessions/{id}/schedule | schedule:w (+ sessions:w for a gate) | Create a scheduled job |
| DELETE | /v1/schedule/{job_id} | schedule:w | Cancel a scheduled job |
| GET | /v1/skills/{name} | skills:r | One skill, with its body |
| PUT | /v1/skills/{name} | skills:w | Create or update a skill |
| DELETE | /v1/skills/{name} | skills:w | Delete a skill |
| GET | /v1/memory | memory:r | Memory index |
| GET | /v1/memory/{name} | memory:r | One memory, with its body |
| PUT | /v1/memory/{name} | memory:w | Create or update a memory |
| DELETE | /v1/memory/{name} | memory:w | Delete a memory |
| GET | /v1/mcp/{name}/tools | mcp:r | Tools one MCP server advertises, each with its resolved permission, which step of the resolution chain decided it, whether config lets the agent see it, and whether a readOnlyHint the server sent was declined by trust_read_only_hint = false |
| POST | /v1/mcp/{name}/reconnect | mcp:w | Reconnect an MCP server |
| GET | /v1/instructions | sessions:r | Resolved standing instructions |
| GET | /v1/profiles | read | Configured profiles |
| GET | /v1/openapi.json | None, and off unless [serve].docs is set | OpenAPI spec |
| GET | /v1/docs | None, and off unless [serve].docs is set | Swagger UI |
GET /v1/sessions is paginated by limit and cursor (see Sessions), and takes include_children=true to list sub-agent sessions alongside root ones, and cwd=<path> to filter by working directory. A sub-agent’s session record carries parent_id, which is what reconnects it to the session that dispatched it.
A memory record carries both updated_at (when the row last changed) and created_at (when the memory was made, stamped once at creation), plus its tags and its read_count, how many times the agent has recalled it through memory_read. The two timestamps are deliberately separate: a description edit moves updated_at without the note saying anything new, and it is created_at that the model is shown as an age. PUT /v1/memory/{name} accepts tags with the same omit-to-keep rule as body: omit to leave an existing memory’s labels alone, send [] to clear them.
GET /v1/memory/{name} answers 404 for a name that is not stored, with no 422 case: a memory is a row, so there is no file to be present but unparseable. Reading through this endpoint deliberately does not increment the memory’s read count: an operator is not the agent recalling anything, and the count feeds search ranking.
Descriptions and bodies are returned exactly as stored, not as they are rendered into a model’s context: this endpoint is a backup and inspection door, like meka memory export, and stripping characters out of a note on the way through would make a restore lossy. JSON escaping keeps that safe in transit, but a client that decodes and prints the text to a terminal should neutralize it, as meka does at its own render boundaries.
These four endpoints are not gated by [memory] enabled. That switch decides whether an agent keeps memories; a token is the operator, so it reaches a store that already exists exactly as meka memory list does in a shell.
A scheduled job’s optional gate is the sharpest grant on this API, and how sharp depends on what it checks. It requires sessions:w in addition to schedule:w either way.
A shell gate ("check": {"command": "…"}) runs through sh -c as the user running meka serve, on a timer, before the turn and independently of it, so it needs no working provider and no model to execute. The session must be at unrestricted. This is the one grant workspace does not carry: the command runs outside the turn, so nothing confines it to the workspace roots, and the API’s own 403 says unrestricted.
A tool gate ("check": {"tool": "…", "arguments": {…}}) is not held to that bar. It may only name a tool meka resolves to read, and the session need only be at read. Both facts are re-checked on every fire, so a tool that resolves higher after a config change stops being a gate.
execute_command is one such tool wherever a sandbox backend is usable, so a read session can plant an arbitrary command on a timer through the tool form. That is deliberate and it is not the same grant as the shell form: a gate dispatches at read, the level meka sandboxes, so the command runs read-only-confined rather than as a bare sh -c, and where no sandbox is available the tool resolves above read and the gate is refused instead. The confinement blocks writes, not the network. See Scheduled jobs for the longer version.
No job of any kind can be created on a session at none, gated or not: no tool runs there, so the turn could neither act on the job nor cancel it, and POST /v1/sessions/{id}/schedule answers 403 session-permission rather than creating a row that can never run. A job whose session drops to none afterwards keeps its row and reports itself: every job view carries a withheld field, present only when something is holding the job back. With sessions:r it is the same sentence the agent is given; a schedule:r-only token gets a fixed sentence saying the reason needs sessions:r, because the reason can name the session’s level, a gate’s tool, or the first line of a check’s output. It is computed per request from the session’s current level, so it tracks a PATCH /v1/sessions/{id} without the job being rewritten.
A schedule:*-only token can still plant ordinary prompt-only jobs; it cannot reach a gate at all. Scope a bridge accordingly, and note that GET /v1/schedule is server-wide, so schedule:r alone lists every session id in the store.
DELETE /v1/schedule/{job_id} and DELETE /v1/sessions/{id}/tasks/{task_id} both accept a unique id prefix as well as the full id, matching meka schedule cancel and the schedule_cancel / task_cancel tools: the 8-character short form those surfaces print is enough. An id matching nothing is a 404 and one matching several is a 422, so a typo is never reported as a cancellation. A job that a scheduler sweep retired between the lookup and the delete is a 404 as well, for the same reason: 204 means this request canceled the job, not merely that it is gone.
Canceling a background task records the cancellation and signals the running task, but only meka serve can signal work meka serve started. If the session is open in another process (a meka -r REPL, say), the row is marked canceled and the command keeps running there until it ends on its own; its result is then discarded, because the row is no longer running.
POST /v1/mcp/{name}/reconnect answers 200 with where the server now stands, which is not the same as “it worked”: read state, not the status code. An attempt that ran and failed is a 200 carrying state: "failed", not a 502. A server the startup sweep is still connecting comes back as state: "pending" with no attempt made, so a dashboard polling GET /v1/mcp during startup does not mistake “still coming up” for “down”. The two non-200s are narrow: 422 when the server is disabled in config, and 502 when an already-connected server’s transport could not be re-established within [mcp] connect_timeout.
MCP OAuth login and logout are deliberately absent: the flow prints a URL for a person to open in a browser and waits for the callback, which does not belong on a service-to-service surface. Use meka mcp login on the host. /v1/profiles is read-only for the same reason profile selection has no environment tier: an ambient value must never silently rebind which account a named profile bills.
For full request/response schemas, see /v1/openapi.json on a running server, or browse it interactively at /v1/docs (Swagger UI).
Both are off unless you set [serve].docs, and both are unauthenticated when on, so CI pipelines and code generators can fetch the spec without a token. That combination is what makes them opt-in: they take no token and they publish the shape of every endpoint the deployment exposes, which is useful on a workstation and reconnaissance anywhere else.
Exporting the spec
Save a local copy for offline use or code generation:
curl -s http://localhost:8080/v1/openapi.json -o openapi.json
Code generation
Generate a typed client from the exported spec:
# Python (openapi-python-client)
openapi-python-client generate --path openapi.json
# TypeScript (openapi-typescript)
npx openapi-typescript openapi.json -o src/api.d.ts
# Go (oapi-codegen)
oapi-codegen -package api openapi.json > api/api.gen.go
# Rust (progenitor)
cargo progenitor-client openapi.json
Import into tools
- Postman / Insomnia: Import → URL →
http://localhost:8080/v1/openapi.json - Bruno: Create collection from OpenAPI → paste the URL or a saved file.
- Swagger Editor: File → Import URL →
http://localhost:8080/v1/openapi.json