Introduction
meka is a general-purpose AI agent harness, the layer that wraps a large language model with everything it needs to act as an autonomous agent: a tool set, working memory, context management, persistent sessions, a permission model, and several ways to drive it. You bring a model (Claude or OpenAI, API key or subscription); meka turns it into an agent that can read and edit files, run commands, fetch web pages, call MCP servers, and delegate to sub-agents to get real work done.
The name reflects the design: the model is the pilot, and meka is the mech it operates. The pilot (the model and the backend serving it) is swappable; the harness around it stays the same.
> find all Rust files in this project and count the lines of code
You describe the goal in natural language and the agent decides which tools to use to reach it.
Use it however you work
The same agent core is exposed through four front-ends:
- Interactive: a permission-gated screen for conversational work in your terminal
- One-shot:
meka --oneshot -p "..."for scripts, pipelines, and CI - Editor (ACP): run as an Agent Client Protocol agent inside editors like Zed
- HTTP service:
meka serveexposes the agent over HTTP+JSON for bots, web UIs, and other programs
What the harness provides
- Built-in tools: file read/write/edit, glob search, regex content search (ripgrep), web fetch and shell command execution
- Pluggable backends:
anthropic-messages,claude-subscription,openai-chat-completions,openai-responses,chatgpt-subscription, and any endpoint serving one of those protocols - MCP support: extend the agent with tools, resources, and prompts from external MCP servers
- Permission model: control what the agent can do (none/read/workspace/unrestricted), switchable mid-session
- Sessions: conversations persisted in SQLite; resume, export, or compact any session
- Working memory: a session-scoped scratchpad for intermediate results that stays out of the context window
- Sub-agents: delegate research or analysis to sub-agents that can orchestrate their own sub-agent teams and be run at a restricted permission level
- Skills: load reusable, user-authored instruction packages on demand
- Context management: automatic compaction keeps long sessions under the model’s context limit
- Extended thinking:
anthropic-messagesandclaude-subscriptionsupport extended thinking for complex reasoning
How it works
- You give meka a goal in natural language, interactively, one-shot, over ACP, or over HTTP.
- meka sends it to the configured model along with a system prompt, the tool schemas, and a context block describing the current permission level, working directory, tools, and skills.
- The model decides which tools to call (if any) and returns text and/or tool calls.
- meka enforces the current permission level, executes the tool calls, and feeds the results back to the model.
- The loop repeats until the model is done; the final response is returned (streamed as Markdown in the terminal).
Installation
meka is written in Rust and builds as a single binary.
Pre-built binaries
Download the latest release for your platform from the GitHub Releases page.
| Platform | Archive |
|---|---|
| Linux (x86_64) | meka-linux-amd64.tar.gz |
| macOS (Apple Silicon) | meka-macos-arm64.tar.gz |
| Windows (x86_64) | meka-windows-amd64.zip |
Extract the binary and place it somewhere on your $PATH:
# Linux/macOS
tar -xzf meka-*.tar.gz
cp meka ~/.local/bin/
mekabox
scripts/mekabox
runs the meka installed on the host inside a stock archlinux:latest container, with no image of
its own to build or pull: the binary is bind-mounted in, ~/.config/meka read-only, and
~/.local/share/meka writable, since that is where meka.db keeps every session and credential.
The agent starts at unrestricted with instructions saying it may install whatever the task needs,
and that it must not create memories or scheduled jobs unless asked, since the store it writes is
the host’s. It is the answer to “let it do anything, just not to my machine”: the container is
disposable and the host config cannot be written. The store is the one host file the box shares,
and nothing sandboxes a command at unrestricted, so a command in the box can read every
credential in it: the boundary is around your filesystem, not your accounts. It picks podman over
docker when both are present.
Cargo install
If you have Rust installed, you can install meka directly from the Git repository:
cargo install --locked --git https://github.com/k4yt3x/meka.git
This builds the latest version from source and installs it to ~/.cargo/bin/.
Building from source
Prerequisites
- Rust 1.95 or newer, the version
Cargo.tomldeclares - A C compiler (for the bundled SQLite)
Build
git clone https://github.com/k4yt3x/meka.git
cd meka
cargo build --release
The binary will be at target/release/meka. Copy it somewhere on your $PATH:
cp target/release/meka ~/.local/bin/
Verify
meka --version
meka --help
Quick start
1. Add an account and a profile
Before the first run, configure an account and a profile on it. meka account add runs the right
credential flow (OAuth login or API-key prompt) and writes the account to
~/.config/meka/config.toml; meka profile add names the model to ask it for:
# Claude Code subscription (OAuth)
meka account add claude --backend claude-subscription
meka profile add work --account claude --model claude-opus-5-5
# or a Claude API key
meka account add anthropic --backend anthropic-messages
meka profile add work --account anthropic --model claude-opus-5-5
# or OpenAI
meka account add openai --backend openai-chat-completions
meka profile add work --account openai --model gpt-6-astra
account add prompts for the backend you omit and acquires the secret (browser OAuth for
claude-subscription / chatgpt-subscription, an API-key prompt otherwise), keeping it in the
store. profile add prompts for the account and model you omit. A sole profile is the default;
add more later and switch with meka profile use <name> or the per-run --profile <name> flag.
If you launch
mekawith no profile configured, it errors and tells you to runmeka account addandmeka profile add. See Configuration for all options and the fullmeka account/meka profilereference.
2. Start using meka
After setup, run meka. The screen opens on the conversation, with a composer at the bottom, a bar down its left edge in the permission level’s color, and a status row under it that opens with the level: read by default. The agent can read files, search, and run shell commands in a sandbox that blocks writes. It cannot modify your files.
3. Ask it something
> what files are in the current directory?
The agent will use the file_find tool to list files and describe them.
4. Enable the workspace level
Press Shift+Tab to cycle the permission to workspace, where the agent may write inside your working directory. Now it can modify files too, and its shell may write inside the same boundary:
> create a file called hello.txt with the text "hello world"
5. One-shot mode
For quick tasks without opening the screen:
meka --oneshot -p "what is my current working directory?"
The process exits after the agent responds. Without --oneshot the same prompt runs as the first
turn and then drops you into the interactive shell.
6. Continue a previous session
To pick up where you left off, continue the last session:
meka -c
Or resume a specific session by its id:
meka -r 550e8400-e29b-41d4-a716-446655440000
See Sessions for more details.
Upgrading
Most upgrades are a binary swap: stop every meka process on the store (a meka serve unit, an open screen, an editor’s meka acp), replace the executable, and carry on. An upgrade that changes the store’s schema migrates it on the first open, and refuses while another meka process of 0.87 or later has the store open, since that process would go on writing the shape the migration replaced; an older process goes unseen, which is why it is stopped first. This page covers the upgrades that are more than a swap.
Copying a store
The store under MEKA_DATA_DIR runs in WAL mode, so the most recent writes, including a schema
migration, can sit in meka.db-wal beside meka.db until SQLite checkpoints them. A copy that
takes meka.db alone can therefore carry a schema version its tables have not caught up with.
meka checks for that on open and refuses the store rather than running against it. Copy the -wal
and -shm companions with the file.
0.87 to 0.88
Nothing in config.toml is renamed, but display.status_row and display.turn_end_summary
refuse a fact listed twice: name it once. The store moves forward on its first open, and 0.87
refuses a store 0.88 has opened; downgrading means restoring the backup the upgrade took. The
conversions need nothing from you:
- Every turn records the permission level it ran under. A turn written before takes the level
the first of its own messages that names one told the model, its opening in a turn meka wrote;
one where none does takes its session’s level, or
readon a session at a level meka does not know. - A tool result written before records nothing of how the screen draws it, so a resumed session
draws those calls as 0.87 did: an edit’s diff from its arguments, a write with none, and a
failed command as
failed, its exit code in its output. - A compaction written before records no checkpoint turn, so its row opens to the memories its checkpoint wrote, by name, and its summary. The summary is read out of the message the compaction wrote, up to the first section meka adds after it, so a summary that held one of those headings itself shows only what came before it.
Session archives are format_version 21. A 0.87 archive imports as it is, its turns and
compactions converted the same way.
An ACP client that did not advertise session.compaction is shown each compaction as a
tool_call of kind think, closed under the sentence for what set it going, where 0.87 told it
in a [meka] chunk or a notice. A client that matched those for a compaction should match the
card, or advertise the capability and read compaction_update.
0.86 to 0.87
Nothing in config.toml is renamed. The store moves forward on its first open, and 0.86 refuses a
store 0.87 has opened; downgrading means restoring the backup the upgrade took. The conversions
need nothing from you:
- A cron job written before names no time zone and keeps running in the machine’s.
- Reasoning a
chatgpt-subscriptionprofile sealed names that endpoint, and is sent back to it alone; reasoning from anywhere else names none and is sent nowhere. Naming it readsconfig.toml’s profiles, so an upgrade of a store holding sealed reasoning refuses while the file cannot be read. - The copies of the recent turns earlier compactions kept are linked to the rows they copy, so history shows each such turn once; a copy whose row a turn sweep has already taken, or that no row matches, stays in history as the record of what it says.
- The session search index is rebuilt once.
Session archives are format_version 18. A 0.86 archive imports as it is: its kept copies are
linked the same way, and its sealed reasoning names no endpoint, since the archive does not say
which.
Two environment values, a handful of flags, two OpenAI request shapes, two HTTP shapes, an ACP field and a tool’s argument types are refused or read differently.
openai-responses asks for encrypted reasoning, a reasoning summary and a cache key. Every
request carries include: ["reasoning.encrypted_content"], reasoning.summary and a
prompt_cache_key naming the session, as OpenAI’s own client sends them. A model that does not
reason refuses the first two, on OpenAI’s own API as well (gpt-4.1, gpt-4o): set
encrypted_reasoning = false and reasoning_summary = false on its profile (meka profile set).
An endpoint behind base_url that refuses one is answered by turning that one off the same way,
and prompt_cache_key = false in the account’s table of config.toml.
openai-chat-completions asks for usage on every streamed request
(stream_options.include_usage), where it asked only some endpoints. An endpoint that refuses the
field is answered by stream_usage = false in the account’s table of config.toml.
A relative MEKA_CONFIG_DIR or MEKA_DATA_DIR is refused. meka stops at startup naming the
variable, where it used to fall back to the platform directory with a warning and so ran against
the real store. Set each to an absolute path.
meka serve and meka acp refuse the flags that belong to a run. Both refuse --prompt,
--skill and --oneshot, and meka serve refuses the directory flags too, where they were
silently ignored. Remove them from the unit file or the editor’s agent command.
meka profile add and meka profile set refuse a flag the account’s backend never sends, as
meka account add already did, where they wrote a key nothing reads. Drop the flag; nothing is
written when it is refused.
Feed event ids start from the clock. Each meka serve process issues ids above the clock’s
microseconds at its start, so a Last-Event-ID saved before a restart is read as a gap (a
feed.gap with no count) rather than as a position in the new process’s feed. A client that
handles feed.gap by re-reading GET /messages needs no change; one that compared ids against
small numbers should treat them as opaque, increasing integers.
GET /v1/instructions shows path and source to a token with config:r or config:w
alone. A token with sessions:r still reads the text. Grant config:r to a client that needs
to know where the file is.
ACP: a prompt response’s usage is the turn’s, as the protocol defines it, where it was the
session’s running total. An editor that showed it as the session’s cost should sum it across
turns.
A tool’s boolean, number and object arguments are read strictly. A boolean is true or
false, a number an integer (2.0 counts as 2, as JSON Schema has it), an object an object
(web_fetch’s headers an object of strings), or the field is left out. Anything else, a string
such as "true" or "3", a fraction, a negative where a count is asked for, or an object spelled
as a JSON string, is refused naming the field, so a model corrects the call rather than having it
read as false, as the default or as absent. memory_search read a quoted limit as the number
it spelled, and now refuses it.
0.85 to 0.86
Nothing is renamed and no key is refused; one archive version moves.
Session archives are format_version 17. A 0.85 archive imports as it is; a 0.86 archive can
record where a session stopped sending Claude its earlier thinking, which 0.85 refuses to read by
its version.
0.84 to 0.85
One config key is retired, and the store gains a table and a column, which the first run adds on its own; the HTTP API grows a surface and four of its shapes change, a start refuses a profile that cannot run, two shell commands refuse where they warned, and an ACP client sees two things differently.
The installation is managed over HTTP, under two new scopes. /v1/accounts, /v1/profiles,
/v1/mcp and PUT /v1/instructions do what meka account, meka profile, meka mcp and the
instructions file do in a shell, and a change made through them is served at once; an edit made
in a shell is served by the next of them, or by POST /v1/config/reload. They sit behind
config:r and config:w, which no existing token holds: grant them to the token that administers
the installation and to no other, since config:w is shell on the host. GET /v1/config names
where the installation is (config_path, data_dir), when config.toml was last read and
written, and config_changed, whether an edit made in a shell awaits POST /v1/config/reload,
which reads the file again; every write of the server’s own ends with the same whole read. The
server now starts with no profile configured, so an installation can be set up over the API; the
refusal moved to the session that names none, and over HTTP it names POST /v1/accounts and
POST /v1/profiles rather than the shell’s commands.
Adding a second profile names the one that was alone as default_profile, in a shell as over
HTTP, where it used to leave the installation with no default; and removing the default while two
or more profiles would remain is refused, in a shell as over HTTP, where meka profile remove
used to warn and unset it. Point default_profile elsewhere first, with meka profile use or
PATCH /v1/profiles/{name} and "default": true. meka account add refuses --client-id and
--oauth-token-url on a backend that never reads them, where it dropped them with a warning, as
it already refused --interleaved-thinking.
Every profile must be able to run. A start, a reload and every write of the installation
refuse a config.toml holding a profile whose account is not configured, that names no model, or
whose max_output_tokens does not exceed its thinking budget, where only the default was held to
it. The refusal names the profile; repair it with meka profile set or remove it.
An MCP credential is bound to the endpoint it was stored for. A connect presents OAuth tokens
to the exact url they were issued for alone, and a bearer or a client secret to the scheme, host
and port of the url it was stored with, so a credential never follows a server to another host.
The first run binds every credential already stored to its server’s url as config.toml states
it then, which is where it has been presented all along, so nothing changes for a server that
stays put; it stops, naming the file, while a credential is stored and config.toml cannot be
read, and naming the server while a credential is stored for one whose url holds a ${VAR} the
environment leaves unset, since a binding made from the literal would hold the credential for a
URL nothing reaches. The binding is made from that first command’s environment: a variable it
holds differently from the server’s own runs, or a ${VAR:-default} it leaves to its default,
binds the credential where its expansion points, and the server’s connect then refuses it, naming
the origin it was stored for. Run the first command after the upgrade with the environment the
server runs under. Storing a secret for such a server, with meka mcp add, meka mcp login --auth-token-stdin or --client-secret-stdin, or over the API, is refused the same way where
the variable is unset. A server whose url moves to another host afterwards fails to connect,
saying so, until it is logged in again or its secret stored again. meka mcp get and GET /v1/mcp/{name} list as credentials the kinds presented to the
url, and the kinds held for another endpoint under credentials_for_another_endpoint, with the
origin each was stored for; credential_origin and credential_origin_matches_url are gone.
resource is retired from [mcp.servers.auth]. A client_credentials or
client_credentials_jwt token request names the server’s own url as its resource, as the MCP
authorization spec requires, so the key would only restate it or contradict it; a file that still
carries it is refused at startup, naming the key and its line. Delete the line.
GET /v1/mcp is an object, and a server’s auth is the block. GET /v1/mcp answers
{"servers": [...]}, with orphaned_credentials beside it when a credential is stored under a
name no entry claims; a client that read the bare list reads servers. GET /v1/mcp/{name} and
meka mcp get --format json carry every key the entry states, and auth is the block as written,
{"type": "oauth", ...}, where it was the type string alone; required and deferral are what
the server runs under, and inherited names those the entry leaves to [mcp].
OpenAPI: the responses body’s outcome schema is ResponseOutcome. It takes the three
answers an elicitation takes (accept, decline, cancel) beside the four an approval takes. A
client generated from the spec regenerates; one that sends the approval answers is unaffected.
context.measured on the session feed. After every provider round, and after a compaction or
rewind, the feed says what is in context (used, and window once known), the figure
GET /v1/sessions/{id}/context answers with, so a client that polled it for a gauge can follow the
event instead. A client that ignores events it does not know needs no change.
An ACP client’s gauge moves per round, and a background report is a notice. usage_update
arrives after every provider round, and once on session/new, session/load, session/resume
and session/fork with what the session’s row recorded, rather than at a turn’s end. Finished
background work is no longer pushed as a user_message_chunk carrying the report the model read:
a client that advertised session.notices gets a notice titled in the screen’s words, with the
task’s label as its description and the tasks under _meta.background; one that did not gets
the same words as a parenthesized agent_message_chunk, which is also what a session/load
replays where the report landed. A client that matched on the [Background task header reads the
notice or the chunk instead. A scheduled job’s prompt is still a user_message_chunk.
0.83 to 0.84
Nothing is renamed; one value is refused where it never applied, the store is migrated on first open, and one wire shape grows.
[shell].sandbox_backend takes this platform’s values only. A value from another platform was
ignored on macOS and Windows; it is now refused: shell_execute at read fails and startup warns,
naming the values this platform takes. Linux takes bubblewrap, bubblewrap-landlock and
landlock, Windows takes appcontainer and restricted-token, and macOS takes none. A
config.toml shared across machines leaves the key unset, or sets it per machine through
MEKA_SANDBOX_BACKEND. Windows now chooses its backend by probe: a build without the AppContainer
API, or whose API speaks schema 1.0, warns once at startup that the restricted token is in use and
what it cannot hold; pinning sandbox_backend = "restricted-token" accepts that and silences the
warning, as pinning landlock does on Linux, and on a schema 1.0 build pinning
sandbox_backend = "appcontainer" takes the AppContainer with network = "host" held as remote.
A Windows 11 build with the process security environment API runs commands in an AppContainer.
On 26H2, and on 24H2 and 25H2 from the August 2026 update as Microsoft lists them, read and
workspace now confine a command through the OS’s own environment: meka’s config directory and
store are unreadable, confine_reads and network hold for the shell as they do for the file
tools, and no ACE is written to your directories. Four limits of the mechanism come with it: cd
into a read-only directory fails (git’s -C and Python’s os.chdir included; the initial
directory, PowerShell’s Set-Location and cd into a workspace directory work), msys2 bash.exe
cannot start, a removable FAT32 volume and a mapped network drive are out of reach, and an app
execution alias such as the Python install manager’s python.exe does not start, while the real
executable runs by its path. Pin sandbox_backend = "restricted-token" to keep the previous
mechanism. On both backends a confined command can no longer read or set the clipboard, drive
other windows or inject input.
Every compaction records what it was. The boundary a compaction writes now carries what set it
going (reactive, proactive, manual, requested, emergency or retention), which strategy
wrote the summary, the memories the checkpoint wrote and whether the recent turns were kept, so
the screen’s row, the editor’s entry and the HTTP marker say the same thing live and on a replay of
the log. A boundary written before 0.84 recorded none of that, and nothing in the store says, so
the migration gives every one of them the commonest case: an automatic compaction at the ceiling
(reactive), written by the checkpoint, with no memories written and the recent turns kept. A
resumed older session therefore replays each earlier compaction as Autocompacted the conversation after context usage crossed the ceiling., whatever set it going at the time.
Session archives are format_version 16. An archive written by 0.79 through 0.83 is brought
forward on import, its boundaries given the same record.
context.compacted says more, and has company. The stream event and the compaction object
on GET /messages carry trigger, memories_written and kept_recent beside source,
replaced_count and generation; the event also names its compaction_id. context.compacting
opens a compaction under that id before any request is made, and context.compaction_failed
closes one that did not land. The one-shot JSON report lists the turn’s compactions under
compactions.
An ACP client sees the compaction it asked to see. One that advertised session.compaction
gets compaction_update, in_progress then completed with the summary, in place of the
[meka] chunk; one that did not gets the advisory, a notice if it advertised session.notices
and the chunk otherwise, now in the screen’s words (The agent compacted the conversation. rather
than Compacted the conversation: 545 messages replaced by a summary (checkpoint, compaction 8)).
A client matching on the old sentence should match on [meka] alone or advertise a capability.
0.82 to 0.83
Nothing is renamed and no key is refused; one thing changes what a memory does.
load decides how a memory reaches the context; priority is importance alone. A memory stored
at priority 0 before 0.83 is migrated to load: inline, so what sat in every turn’s context stays
there. A memory written from now on is index unless the write says inline or search, whatever
its priority: memory_write’s load, meka memory add --load, or load on
PUT /v1/memory/{name}. Every existing memory is marked verified as of its creation, since nothing
has verified it since; memory_search’s unverified filter and --unverified count from there.
0.81 to 0.82
The Swagger UI is gone. /v1/docs answers 404 whatever [serve].docs says, and the key still
serves /v1/openapi.json. To browse the API, import that URL into one of the
tools the HTTP API page lists.
0.80 to 0.81
Two keys move to the section that governs them. config.toml refuses the old key, naming its
line; move it.
| Surface | 0.80 | 0.81 |
|---|---|---|
config.toml | [session].subagent_max_depth | [subagents].max_depth |
config.toml | [session].send_class | [display].send_class |
A profile’s keys are ordered by how many backends read them. meka profile add and
meka profile set write max_request_bytes after max_output_tokens and deferred_tools after
vision, and the next write to a profile puts an existing one in that order. Nothing is renamed
and no value changes.
0.78 to 0.79
Nothing is renamed and no key is refused; two things change what a running deployment does.
Every MCP connect opens with a protocol probe. A server that answers it is spoken to as MCP
2026-07-28; one that does not gets the initialize handshake it had. A stdio server from before
the probe existed may swallow it and answer nothing afterwards, so such a server is given up to
ten seconds, or half of [mcp].connect_timeout
when that is less, on its first connect in a meka process before a fresh child gets initialize;
the verdict is kept for the process, so later connects pay nothing. See
Connection lifecycle.
Session archives are format_version 15. A 0.78 archive imports as it is; a 0.79 archive
carries the allow-list keys on every sub-agent spec, which 0.78 refuses to read by its version.
0.77 to 0.78
A blocking POST /turn holds its turn. A request with stream: false whose client disconnects
now stops the turn at its next round boundary once the connection has been gone for
[serve] stream_reattach_grace, the
way a dropped stream always did, and the turn ends with reason: "abandoned". Until 0.77 such a
turn ran to its end and the client read the reply out of GET /messages. A client that cannot hold
a connection for the length of a turn sends detach: true and reads the turn from the resource the
202 names, or follows the session feed; see Holding a turn.
A feed subscriber counts as a listener, so a client that keeps the feed open is unaffected.
The message a turn opens with is on the feed as message.appended. A scheduled fire’s prompt
and a background report that opened a turn used to reach the session feed as an info notice
holding the text; they now arrive as message.appended, the message as GET /messages types it,
under the turn’s id, and so does every other turn’s opening. A client that displayed those notices
reads the scheduled and background_report blocks of the new event instead; a client that logs
notices stops seeing prompt bodies in them. See the session feed.
A blocking POST /turn answers with the opening message first. messages begins with the
user-role message the turn opened with, as the history reads it back, where it used to begin with
the assistant’s first message. A client that read messages[0] as the reply filters by role.
Session archives are format_version 14. A 0.77 archive imports as it is; a 0.78 archive
names why a canceled turn stopped and which fallback profile served a turn, which 0.77 refuses to
read.
0.76 to 0.77
Nothing is renamed and no key is refused; three things change what a running deployment does.
Deferred tools are declared natively on the official endpoints. A profile on
claude-subscription, chatgpt-subscription, or anthropic-messages and openai-responses
without a base_url now declares every deferred tool from the first request with defer_loading,
and a load no longer rewrites the prompt cache. Set
deferred_tools = "append" on a profile to keep
the 0.76 shape. A profile behind a base_url keeps append unless it states native.
The first request of every live session rewrites its prompt cache once. The tools array and the shape of a successful tool result both change, so a session resumed on 0.77 reads nothing back on its first request and everything from the second.
A claude-subscription session has no MCP meta-tools. The mcp_resource_* and
mcp_prompt_* tools leave the request and the catalog on that backend, because its endpoint refuses
their names; see Deferred tools. They are back the moment the
session moves to another profile.
0.75 to 0.76
A sub-agent’s scope is three fields. agent_spawn’s writable_roots and readable_roots
are gone. A call names working_directory, where the sub-agent works, and may add
additional_workspace_directories, more of its workspace with the working directory’s rights, and
additional_readable_directories, what it reads beyond its workspace. A call naming none of the
three gives the sub-agent the parent’s whole scope, as before; a list needs working_directory
beside it, and "." is the parent’s own directory. Stored specs are converted when the store is
opened: the first writable root becomes the working directory and the rest the workspace list, and
the readable roots become the readable list beside the directory the sub-agent was spawned in. A
sub-agent that had only readable roots no longer follows the parent’s other directories. The common
calls, before and after:
| 0.75 | 0.76 |
|---|---|
{"writable_roots": ["/a"]} | {"working_directory": "/a"} |
{"writable_roots": ["/a", "/b"]} | {"working_directory": "/a", "additional_workspace_directories": ["/b"]} |
{"readable_roots": ["/b"]} | {"working_directory": ".", "additional_readable_directories": ["/b"]} |
{"writable_roots": ["/a"], "readable_roots": ["/b"]} | {"working_directory": "/a", "additional_readable_directories": ["/b"]} |
A list no longer runs the sub-agent at workspace on its own: it runs at the parent’s level unless
permission lowers it, and a list beside unrestricted is refused. A permission above the
parent’s is refused rather than clamped. The result of a spawn and of a follow-up opens with
Agent:, Permission: and the scope the sub-agent holds, in the lines the environment block uses;
a script that read agent: from the first line reads Agent:.
A sub-agent’s reads follow its spawn. [permissions].confine_reads governs root sessions
alone. A sub-agent reads what its spawn call stated, or what its parent reads when it stated
nothing, on every installation. A parent whose reads are not confined hands reads anywhere only
by stating no scope.
Every surface says directory. One table of the renames:
| Surface | 0.75 | 0.76 |
|---|---|---|
| Run flag | --writable-root <PATH> | --additional-workspace-directory <DIR> |
| Run flag | none | --additional-readable-directory <DIR> |
memory export | --dir | --directory |
config.toml | [shell].readable_paths | [shell].additional_readable_directories |
config.toml | [skills].extra_paths | [skills].additional_directories |
| Session JSON, HTTP API | cwd | working_directory |
| Export archive | additional_roots | additional_workspace_directories |
GET /v1/sessions filter | cwd=<path> | working_directory=<path> |
| Export archive | format_version 9 | format_version 11 |
| Store | sessions.cwd, sessions.additional_roots_json | working_directory, additional_workspace_directories_json |
What meka relays into a user message has its own block. An inbox item, a scheduled job’s fire
and a background report were text blocks, or for a report a section of the turn_context
block; they are inbox, scheduled and background_report blocks now, on GET /messages, in a
blocking turn’s messages and in an archive, each carrying text as the model read it and the
facts a client labels it by. The store converts what it holds on the first open, and a resumed
screen shows an item as the labeled prompt it was and a fire or a report as the stage direction
the live screen said, where it used to show meka’s header as something you typed.
The account views take the account. meka account usage, whoami and stats take the
account by name, as login does, and lose --profile; named nothing, they ask the account the
default profile bills, or the one account. An account with no profile answers. profile leaves
their output, plain and JSON.
The two config keys are refused by name, with their line, until renamed. An archive of version 10
or older is converted on import. The store’s columns are renamed on the first open, behind the usual
backup; a hand-written query names the new ones. ACP’s additionalDirectories keeps its protocol
name, and /cd keeps its verb.
0.73 to 0.74
A one-shot run prints the answer alone, as markdown. meka --oneshot writes the last text
block the model wrote to stdout when the turn ends, as source rather than rendered, terminal or
pipe alike, and nothing else: no tool indicators, no thinking, no session id line and no usage
line, on either stream. --format json is unchanged and carries the session
id, the calls and the usage; -v logs the process. A script that read the session id from stderr
reads it from the JSON report instead.
Three keys are gone, and the file refuses them by name. [display].tool_params and
[thinking].show_content chose what a tool call or a thinking block showed before you expanded it.
The screen expands and collapses either on a click or a key, so a call starts with its summary and
a thinking block collapsed, and a one-shot run shows neither. [display].max_width capped the width
meka laid text out at; the screen uses its window and a one-shot run its terminal. Delete the three
from config.toml.
[display].show_token_usage is a fact of turn_end_summary. The screen now closes every turn
with a row saying when it ended and how long it took, and the tokens used are one of the facts
that row can state. show_token_usage = true becomes
turn_end_summary = ["time", "duration", "usage"]; the old key is refused by name.
0.72 to 0.73
meka session delete is meka session remove. The session command now uses the verb every
other object command uses; its flags are unchanged. A script or a cron line that runs
meka session delete --older-than-days <DAYS> changes the one word. Tool names such as
agent_delete, the HTTP DELETE routes and [serve].delete_on_idle keep theirs.
0.71 to 0.72
The interactive terminal is a screen, not a line editor. meka without --oneshot now takes
over the terminal the way an editor does: the conversation fills the screen, the composer stays at
the bottom, and you can type while the agent works. What that changes for an existing setup:
- Six
[display]keys are gone, and the file refuses them by name.render_mode,input_style,newline_before_promptandnewline_after_promptdescribed a stream the screen no longer writes. Delete them fromconfig.toml.--render-modeandMEKA_RENDER_MODEare gone with the first; a one-shot run now renders markdown when stdout is a terminal and writes the source as written when it is a pipe, so a script gets the words without asking.show_path_in_promptandshow_context_in_promptare replaced bystatus_row, a list of what the status row shows in order. The default shows the path; to have the gauge back, list it:status_row = ["path", "profile", "context", "tasks", "subagents"]. - Answers no longer go to stdout in interactive mode. The screen draws on one stream.
meka --oneshotand the HTTP API are the doors for a script, and both keep their contracts. - A terminal is required. A pipe on stdin or stdout is refused with a line naming
--oneshot. A harness that drove the old prompt through pipes drives--oneshotor the HTTP API instead. !commandoutput is captured into the transcript rather than the child taking the terminal. A command that needs a terminal of its own runs in a shell beside meka.- The syntect and raw views are gone. The screen renders markdown; a thinking block or a tool call expands to its whole content on a click or a key, so the source views have nothing left to show that the screen cannot.
/historyand/clearare gone, anddisplay.resume_show_recentwith them; the file refuses the key by name. The screen opens on the last page of the session’s log and reads older turns as you scroll up, however long the session is, so there is nothing left for either command to do. Deleteresume_show_recentfromconfig.toml.- A slash command’s answer is a panel, not rows of the transcript.
/help,/status,/usageand every listing open in place of the composer until Esc closes them; a bare/permission,/approvalsor/profileis a picker. A command is no longer echoed as a prompt. - The approval prompt is a picker. The four answers are picked with the cursor, their keys
(
y,a,n) or their numbers;alwaysandneverare no longer typed. Esc denies. - Every row has a glyph.
[tool name(...)],Thinking...,Error:,(interrupted)and the bracketed usage line are replaced on the screen by one register:•,!and■in column 0 and the text at column 2. A one-shot run keeps the old spellings on its stream, with one change: consecutive tool indicators at the summary level now have a blank line between them, as every other pair of blocks does.
Two keys are new: display.mouse, on by default,
and session.send_class, steer by default.
Interactive mode describes the screen.
0.70 to 0.71
A hole in a feed is a feed.gap event, no longer a notice. A client that resumed a session
feed, a sub-agent’s feed or the server feed from a Last-Event-ID the replay ring no longer
reached was told so by a warn notice, and so was one that fell behind while connected. The
first case is now a feed.gap event, with session_id on a session’s feed and dropped when the
server can count the hole; a client that matched the notice’s text, or treated a notice without a
turn_id as a hole, switches on the event name instead. The second case no longer reaches a
client: a reader that falls behind is caught up from the ring, and only a POST /turn stream
whose client was the sole reader still ends, with the sse-lag failure as before. notice now
carries only what the agent or a provider said. See
Falling behind.
0.69 to 0.70
The HTTP API changes shape in the places below. The store gains a table of turns and a column naming each message’s turn, the three todo tools become the checklist tools, every compaction boundary takes a checklist, and the message meka writes to send the model back to work (after a reply with no visible text, or with checklist items open) becomes a block of its own; the migration runs on open and needs nothing from you, and an older archive converts on import.
todo_write, todo_edit and todo_read are checklist_add, checklist_edit and
checklist_read, and the list is binding. A turn can no longer end while an item on the list
is pending or in progress: the model is sent back to its open items, up to three times in a row
without a tool call, before the turn is allowed to end with them open. There is no title and no
replace-all; items have ids, checklist_edit changes one at a time, and completed and
canceled take an item off the list, the latter with a reason. Checklist
has the rule. The old names in a session’s history, in a sub-agent’s denied tools and in an
archive are renamed on open and on import; a list from before 0.70 is not recovered, since it
never survived a restart anyway. A [tools] filter or a skill that names a todo_* tool needs
the new name.
The per-turn context no longer carries the list. The model sees it in every checklist_*
result, in the nudge, and through checklist_read; compaction copies the open list into the
summary.
A blocking turn’s response carries the turn’s messages, and final_text and tool_calls are
gone. messages now holds what the turn added to the conversation, in the shape
GET /v1/sessions/{id}/messages reads it back: the assistant’s messages with their text, thinking
and tool calls, and the tool-result messages that answered them. The reply a bot shows is the text
blocks of the assistant messages, joined; a tool call is a tool_use block and its result the
tool_result block on the user-role message after it.
last_turn_at is gone from the session record; last_turn replaces it. The old field named
the last turn of any outcome, only while this server held the session. last_turn is the
session’s latest turn, recorded for every host’s turns, and says how it ended: status,
ended_at, and a stop_reason or an error. GET /v1/sessions/{id}/turns lists them all.
turn_id on a message is the turn’s id; the positional label is turn_label. A message’s
turn_id was a dense label (t_0001, …) derived from its position; it is now the id of the turn
that added the message, the one turn.started announced and GET /v1/sessions/{id}/turns lists,
and the label moved to turn_label unchanged. A client that groups by the label reads
turn_label; one that joins the feed to the history reads turn_id.
used_percent is gone from GET /v1/sessions/{id}/context. Divide used by window; both
are present exactly when the percentage was.
vision left GET /v1/info for GET /v1/profiles. Read it on the entry of the profile a
session runs on; the entry marked active is the one a session gets when it names none.
GET /v1/sessions/{id}/schedule is gone. List one session’s jobs with
GET /v1/schedule?session=<id>; creating a job keeps its route.
DELETE /v1/schedule/{job_id} and DELETE /v1/sessions/{id}/tasks/{task_id} take the full
id. A prefix matches nothing and answers 404. meka schedule cancel and the task_cancel tool
keep taking the short form.
subagent.activity is gone from the HTTP feed. A sub-agent’s tool calls are no longer
summarized under the parent’s agent_spawn call; while the parent runs a sub-agent, the
sub-agent’s own id answers GET /v1/sessions/{id}/stream with its feed, every call in full. The
permission_required a sub-agent’s call parks still arrives on the parent’s feed, now naming
the sub-agent in subagent_id. ACP clients are unaffected.
GET /v1/sessions/{id}/stream on a sub-agent’s id answers 409, not 422. While the parent
runs the sub-agent it is the sub-agent’s own feed; otherwise it is 409 subagent-not-running,
a type of its own, where 0.69 answered 422 session-not-drivable. A client that branched on
the 422 to recognize a sub-agent reads parent_id on the record instead.
Session archives are format_version 9. Each session carries its turns, each event names
the turn that added it in turn_id, so an import keeps a session’s turns under ids of its own,
each compaction boundary carries the checklist as it stood, and a nudge is a block of its own. An
archive of version 3 through 8 still imports, with no turns and no turn named where it had none,
an empty checklist on its boundaries, and every nudge it wrote as text taken as the block.
0.67 to 0.68
headers_helper is a path beside config.toml, or absolute. A relative name is resolved
against the directory config.toml is in and nowhere else; it is no longer tried against the
working directory meka was launched from, and a bare name is no longer searched on PATH. A
helper that lived beside the config keeps working; one that relied on the launch directory or on
PATH needs its path written out.
An MCP server whose url or args name an unset ${VAR} is refused. The literal used to be
sent to the other side; a key in a query string or a token on a command line is a credential, so
these fields now fail closed as env and headers always did. Set the variable, or give it a
default with ${VAR:-default}. A reference the grammar does not accept (an unclosed ${, an empty
name, a default that opens another reference) is refused the same way in every field that reaches
the other side. To hand a child shell a variable it expands itself, write it without braces
(args = ["-c", "srv --token $MY_TOKEN"]); meka leaves that alone.
[serve] values take ${VAR:-default} and no longer take $$. The one ${VAR} grammar
serves every field of config.toml; $$ in a [serve] value is now two dollar signs, and
$${VAR} is a $ followed by the expansion.
meka mcp add, remove, enable and disable refuse to write a config.toml that would not
read back, as account and profile always have. A file broken elsewhere is fixed by hand
first; an entry whose own keys broke the file is still removable.
[background] max_tasks = 0 is refused at startup while enabled = true. It offered
background to the model and refused every call; set it to 1 or more, or turn the switch off.
A session expires as a tree. The retention sweep and meka session delete --older-than-days
judge a root by its own age and take its sub-agents with it, and keep every sub-agent of a root
that is pinned or owns a scheduled job. Before, an old sub-agent under a kept root was deleted on
its own and agent_followup on it then failed. Every delete now locks the session with its
sub-agents: meka session delete <id>, --all and the servers’ deletes refuse while another
process is running a sub-agent under it, where they took the sub-agent from under that process.
GET /v1/sessions/{id}/stream loads an evicted session only for sessions:w. A token with
sessions:r alone attaches to a session the server has loaded and is answered 409
(/errors/session-not-loaded) otherwise, where it used to revive the session and hold it, under
its cross-process lock, for as long as the stream stayed open. A bridge that subscribes before it
submits already holds sessions:w; a dashboard that only reads keeps reading resident sessions.
Below unrestricted, the in-process readers refuse a process’s own /proc entries.
file_read and file_search refuse /proc/<pid>, /proc/self and /proc/thread-self, whose
environ, cmdline, fd and mem carry what a process was given; the system-wide files
(/proc/meminfo, /proc/cpuinfo, /proc/mounts) stay readable.
A skill’s bundled files in meka’s own store are readable at every level. file_read and
file_search reach skills/ under the config directory below unrestricted, where they refused
it; the rest of the directory stays private, and the shell sandbox still masks all of it, which the
header a skill is served with now says.
interleaved_thinking on an account. anthropic-messages still sends the
interleaved-thinking beta whenever thinking is on; [accounts.<name>] interleaved_thinking = false,
or meka account add --interleaved-thinking false, withholds it for an endpoint that refuses the
header. Nothing changes for an account that does not set it.
Wire changes. schedule.fired carries status: "not_run" for a job whose session could not
be opened for it, where it said "failed"; turn.started carries source: "compaction" for the
checkpoint turn POST /compact runs, with a turn.finished that closes it; meka account whoami --format json carries auth.expires_at as RFC 3339 rather than Unix seconds;
DELETE /v1/sessions/{id} answers 409 while a scheduled or background turn is taking the
session, as fork and PATCH do. A script that matched refusals from meka mcp, schedule,
background, memory or skill on configuration error: will not match them any more: they
print what happened, without that prefix. meka session list -n 0 and meka session search -n 0
show every session where they showed none, and meka --format json <subcommand> is refused where
the flag was ignored.
0.66 to 0.67
Session archives are format_version 6. Each session’s stats carries a turn_position,
the pair the Claude subscription wire reports, or null for a session that numbers nothing. An
archive written by 0.59 through 0.66 still imports, brought forward at the door. A session that
already had turns before this release never numbers them, on the store and in an archive alike,
because nobody numbered those; every session created from now on does.
0.65 to 0.66
A shell under Landlock alone has no temporary directory. 0.65 gave each Landlock-confined
command a private directory under /tmp, named by TMPDIR. That was the one thing meka wrote
outside its own store below unrestricted, and it is gone: mktemp, Python’s tempfile, gcc
and patch fail at read under Landlock as they did through 0.64. Install bubblewrap, whose
private /tmp is a tmpfs inside the sandbox and touches nothing real, or add the directory a tool
needs as a writable root at workspace.
Command output is bounded at 64 MiB and never spooled to disk. A command that prints more
than that across stdout and stderr is stopped, and the result carries everything it printed up to
the stop. The capture files that used to hold an overflowing stream, under the platform cache
directory or MEKA_DATA_DIR/command-output, are no longer written; any left from earlier releases
can be deleted. A command killed at its timeout now keeps what it printed before the kill.
0.64 to 0.65
Landlock on its own needs ABI v9 (kernel 7.1). Below that the kernel cannot refuse a
connect() to a Unix socket on disk, so a shell at read could reach D-Bus and systemd-run --user and have them act on its behalf. meka now reports the backend unusable on such a kernel
instead of warning, and shell commands at read fail until a usable backend exists. Install
bubblewrap, which works on any kernel with user namespaces; on a kernel of 6.12 or newer the
command also runs inside meka’s Landlock ruleset there. Affected as of this release: Debian 13,
RHEL 10, Ubuntu 24.04 and 26.04 LTS without Bubblewrap.
Session archives are format_version 5. Every field is written, and the ones that used to
default when absent are now required: blobs on the archive, and approvals, additional_roots
and profile on each session. An archive written by 0.59 through 0.64 still imports, brought
forward at the door, so only an archive written by hand has to change.
The checkpoint_text compaction source is gone. POST /v1/sessions/{id}/compact and the
context.compacted stream event report source as checkpoint or summarizer only. A
checkpoint turn that ends without calling context_replace now falls back to the summarizer
instead of having its closing text taken as the summary. A client matching on the old value should
treat it as summarizer, which is what those compactions now produce.
file_read and scratchpad_read take start and end. Both name the first and last line to
return, counted from 1 and inclusive, the way a regex match reports its line; offset and
limit are gone. file_read counted offset from 0, and scratchpad_read took both as bytes. A
call that still passes the old names reads from the top. A scratchpad_read reply now ends on a
whole line and names the line to continue from rather than a byte offset, and returns as much as
fits by default. A client that read file_read’s offset to show a location should read start,
which needs no adjustment.
0.63 to 0.64
The first open builds the session search index. meka session search reads a full-text index
over the words of every conversation, and the first launch of 0.64 fills it over every session in
the store, which takes a moment on a large one and is logged at info. The index is a few percent
of the store’s size. Nothing else changes about the store’s contents.
A title or a pin does not move updated_at. PATCH /v1/sessions/{id} used to move
updated_at for any field; title and pinned leave it alone, so renaming a session does not
reorder the listing or change which session meka -c continues. A client using updated_at to
detect changes sees a title change only in title.
Pinned sessions are listed first. GET /v1/sessions, ACP session/list and meka session list put pinned sessions ahead of the rest, newest pin on top; the unpinned sessions keep their
order. A next_cursor taken from a 0.63 server is refused by 0.64 as invalid; start the listing
again.
0.62 to 0.63
An inbox item’s source is optional. An item posted to POST /v1/sessions/{id}/inbox without
a source used to be given one by meka: the token’s description, else client. It now has none.
GET /v1/sessions/{id}/inbox omits the field for such an item, so a client that reads source as
required must treat it as optional; a client that sends one sees no change. The model reads such an
item under [Message arrived <time>] rather than [Message from <source>, arrived <time>], and
the notice an interrupt raises says “Interrupted the answer to read a message.” with no name.
Send a source if you want the model to know who is writing: who sent a message is the client’s to
say.
The system prompt states how the harness works and nothing else. It no longer tells the model what it is (“a general-purpose agent”), what to do (“complete the user’s task”), or how to conduct itself. The conduct sentences that left, for an instructions file that wants any of them back, where they were always yours to set:
- “Explain consequential or destructive actions before proceeding.”
- “Recover from routine tool errors when possible; report unresolved failures and their impact.”
- “Lead with the outcome and relevant evidence; keep progress updates brief.”
- “Batch independent work; sequence dependencies and changes to shared state.”
- “Apply relevant skills and server guidance within these constraints.”
- “Do not poll or duplicate running work.” and “Keep work needed for your answer in the foreground.”
- “Prefer to finish or checkpoint work before then.” and “save essential state with the available tools before another pass” (the context window)
- “Check here before creating one, so you do not duplicate.” (scheduled jobs)
- For a sub-agent: “Complete the assigned task without follow-up questions. Report the outcome, relevant evidence or verification, and unresolved limitations.”
What stays is what the model needs to use the harness: the permission levels, how tools, background work, scheduling, memory and the context window behave, and that replies are rendered as Markdown.
0.59 to 0.60
Every built-in tool is named <noun>_<verb>. The noun is the class a tool belongs to and the
verb its one operation, so the tools that act on one thing sort as one block. Nine names change:
| 0.59 | 0.60 |
|---|---|
read_file | file_read |
write_file | file_write |
edit_file | file_edit |
find_files | file_find |
search_contents | file_search |
execute_command | shell_execute |
fetch_url | web_fetch |
render_image | image_render |
load_tool | tool_load |
todo is three tools. todo_write takes title and items and replaces the list,
todo_edit takes set and updates statuses by task number, todo_read takes nothing. The single
tool’s “items then set in one call” is two calls now.
tool_search is new: keyword search over every registered tool, deferred ones included, that
says for each whether the current permission level allows a call.
What is converted for you, the first time the store is opened and behind the automatic backup: the
calls in every stored session (so a resumed session keeps the tools it had loaded and its history
shows one set of names), the denied_tools of every sub-agent, the tool a finished background
task reports, and the tool a scheduled job’s gate calls. A session archive written by 0.59
(format_version 3) still imports; new archives carry format_version 4.
One narrow case to know about: the conversion reads every stored message that can hold a call, and a prompt you typed that was, in its entirety, the JSON of a tool call under an old name is converted like a call. Prose around such JSON leaves it alone.
What is not: a [tools] or [subagents] list, a tool_permissions key, a skill, standing
instructions, or a client that matches on a tool name must be edited. The startup warning and the
model’s unknown-tool reply suggest the new name when the old one is a reordering of it
(read_file, write_file, edit_file, render_image, load_tool) or a family’s bare noun
(todo); the other four are named only here.
0.58 to 0.59
meka is licensed under the GNU Affero General Public License, version 3 or later. Releases up to 0.58.0 were published under MIT and remain so. From 0.59.0 on, conveying meka or a modified copy, and offering a modified copy to users over a network, carries the AGPL’s obligation to make the corresponding source available.
The problem type URIs moved to meka.run. Every error document meka returns now names a
type under https://meka.run/errors/ where it used to sit under https://meka.so/errors/. The
identifier after the last slash is unchanged, as are the HTTP status, the title and every other
member of the document. A client that matches the whole URI must update its table; one that reads
only the final segment is unaffected.
0.54 to 0.55
GET /v1/sessions/{id}/stream is the session’s event feed and no longer ends with a turn. It
used to rejoin the current turn and close after that turn’s terminal; it now carries every turn on
the session, whoever started it, for as long as the connection is held, and opens the feed of a
session that has not streamed yet instead of answering 404. A client that read the stream to its
end must stop at the terminal it was waiting for. POST /turn with stream: true is unchanged and
still closes after its own terminal. The replay ring spans turns, so a Last-Event-ID from an
earlier turn resumes across them rather than reporting a gap.
Every SSE event carries turn_id and session_id, and turn.started carries source. A
client that compared an event’s data whole sees two extra members; one that read named fields is
unaffected.
Scheduled fires and background-outcome turns are on the feed. They used to be invisible over
HTTP until GET /messages. Nothing changes on the webhooks: a fire still posts schedule.fired
and nothing else.
agent_steer is a new built-in tool. A session that lists tools by name, or a skill that
denies agent_spawn to take the lifecycle tools with it, now covers five names rather than four.
0.53 to 0.54
The store renames its tables and columns in place. The upgrade runs on the first open, behind
the usual pre-migration backup, and needs nothing from you. A tool that reads meka.db directly
sees scratchpad_entries for tool_outputs, messages.kind for messages.role,
background_tasks.tool and scratchpad_entry for tool_name and scratchpad_name,
mcp_credentials.server for server_name, blobs.size_bytes for size, and the session counters
without their stat_ prefix; the provider_credentials view and memories.last_read_at are gone.
Sessions has the full layout.
Three surfaces say the new names too. GET /v1/sessions/{id}/tasks and the task.finished
webhook carry tool and scratchpad_entry in place of tool_name and scratchpad_name. A
memory’s creation stamp is created_at on GET /v1/memory and in --format json, and created
in a file meka memory export writes. A session archive holds its scratchpad entries under
scratchpad_entries rather than tool_outputs and is format_version 3; meka session import
refuses an older archive, so export it again from the meka that wrote it, or rename the key and set
the version in the file by hand.
0.52 to 0.53
[session].context_messages is gone. Delete the key from config.toml; a file still carrying
it is refused at startup, naming the key and the line. It cut every request to the newest 200
messages, and a conversation that outgrew it lost its oldest messages from the request with no
summary in their place: context usage fell instead of climbing, and the agent forgot what it had
been told before the cut. Every request now carries the whole conversation, and the context
ceiling ([session].context_ceiling_percent, 90% of the profile’s context_window by default)
with auto-compaction is the only bound. A long session reaches the ceiling and compacts where it
used to slide; with auto_compact = false it grows until the provider rejects a request.
Nothing in the store changes. A session already past 200 messages resumes with all of them in the request, so its first turn back may compact.
The container image is gone. ghcr.io/k4yt3x/meka receives no new tags, and the Dockerfile
went with it. What the image was for, running the agent unrestricted against a disposable
filesystem, is what mekabox does with the meka installed on the
host, inside a stock archlinux:latest container with your config mounted read-only; install the
binary from the release archive or with Cargo and run the wrapper. The wrapper itself moves from
contrib/container/mekabox to scripts/mekabox, so a link or PATH entry that named the old
path needs the new one.
0.49 to 0.50
meka tools is meka tool. Every top-level command names the object it manages in the
singular, and this was the one that did not. meka tools list is meka tool list; the flags and
the JSON envelope are unchanged. meka mcp tools <name> keeps its name, since it lists the tools
of a server rather than managing tools.
/tasks is /task in the REPL, for the same reason: /skill, /memory and /schedule are
singular. /task, /task show <id>, /task cancel <id> and /task cancel --all do what the
plural did. The HTTP route /v1/sessions/{id}/tasks is unchanged.
0.46 to 0.47
search_web is gone. It scraped DuckDuckGo’s HTML and was turned away by the bot detection more
often than not. Web search now comes from an MCP server, which packages one engine’s key, quota and
response shape without meka embedding any of them:
meka mcp add exa https://mcp.exa.ai/mcp
A [tools] list that still names search_web warns at startup and otherwise works. Existing sessions
that called it resume unchanged: their tool results are text, and nothing re-runs them.
0.45 to 0.46
The store migrates itself, as every release since 0.43 has. config.toml does not, and this
release changes its shape: a [providers.<name>] profile is now an [accounts.<name>] table plus
a [profiles.<name>] table, default_provider is default_profile, and the ask permission
level is gone. A config in the old shape is refused at startup, naming the first key meka does not
know, rather than read with a guess at what it meant. The conversion is a one-shot script,
migrate-0.45-to-0.46.py, attached as an asset to the 0.46 release.
Beyond the config shape, this release renames several tool parameters, changes a handful of HTTP
fields and status codes, and makes ACP answer InvalidParams where it answered InternalError.
Everything a client, a skill or a script could depend on is listed below under “What else changed”
and “Tool parameters”, each with its remedy. Run the script, launch once, and work down those two
lists for anything you automated.
Order
-
Run the script against your config, first as a dry run, then with
--apply. It needs Python 3.11 and thetomlkitpackage (pip install tomlkit), which is what lets it keep every comment and the order of everything it does not touch.python3 migrate-0.45-to-0.46.py # prints a diff; writes nothing python3 migrate-0.45-to-0.46.py --apply # rewrites config.toml in placeIt finds meka’s config the way meka does, honoring
MEKA_CONFIG_DIR;--config PATHpoints it at a copy instead.--self-testchecks the script against its own fixture and exits. -
Install 0.46 and launch it once. The store migrates on that open, behind an automatic copy beside it named for the schema version it came from (
meka.db.v9.bakfor a store 0.45 left), in ten ledger steps. The first creates the REPL’sprompt_historytable where a store lacks one, a no-op otherwise. The other nine:sessions.providerbecomessessions.profile, andprovider_credentialsbecomesaccount_credentials, keyed by account, both renames of what was always there; anapprovalscolumn is added, everyasksession becomesnonewith it on, and a root session that never recorded a level (one an ACP client created) adopts[permissions].default; each stored turn’s inline<context>preamble becomes its ownturn_contextblock; every image’s bytes move out of its message row intoblobs, leaving a reference; every column and index takes one naming rule, with the JSON in two of them following suit; arepairrow’s own thinking blocks take that rule’s tag too, which the step before it passed over; a root session still without a level after all that takes[permissions].default, and a launch that cannot read the file refuses here rather than skipping the stamp; and a stopped task’s stored status is spelledcanceled. Four of these walk every message row, so a store with years of image-heavy sessions takes a moment on that first launch and grows a copy of the same size beside it. A store restored from a.dumpreplays the whole ledger, and if no default profile can be resolved when it does, the frozen 0.44 step warns with its old--provideradvice; read it as--profile.
Run the script before you launch 0.46, not after. A meka launched against an unconverted config
warns that it cannot read the file and then refuses whatever needed it; only the commands that
edit it through toml_edit (meka account remove, meka profile remove, meka mcp remove) and
the ones that read the store alone still run. The store migrates on that launch only if no step
needs the file. A root session that never recorded a level needs [permissions].default from it,
and a migration that cannot read the file refuses and rolls back rather than stamping nothing, so
the store keeps its 0.45 shape until the launch after the script has run.
What the script converts
| Before | After |
|---|---|
default_provider = "work" | default_profile = "work" |
[providers.work] with type, base_url, client_id, oauth_token_url, device_id | [accounts.work] with backend in place of type, and the other four unchanged |
[providers.work] with model, context_window, max_output_tokens, effort, vision, thinking, thinking_budget, max_request_bytes, redact_thinking | [profiles.work] with account = "work" and eight keys unchanged; redact_thinking = true becomes thinking_display = "redacted" and false becomes "summarized" |
[permissions].enabled containing "ask" | "none" in its place |
[permissions].default = "ask" | default = "none" and approvals = true |
[web].request_timeout_seconds = 30, connect_timeout_seconds, read_timeout_seconds | request_timeout = "30s", connect_timeout, read_timeout, by value; a 0, which meant the default, is removed |
[mcp].grace_seconds = 3, connect_timeout_seconds = 30 | grace = "3s", connect_timeout = "30s", by value; a 0 becomes "0s", which grace accepts and connect_timeout refuses at startup |
[mcp].strict | default_required, same meaning |
[session].retention_days = 30 | retention = "30d", by value |
[thinking].budget_tokens | budget, same value |
Every profile becomes one account and one profile of the same name, so nothing you named changes
its name and every session still resolves. Two old profiles on one login stay two accounts with two
copies of the credential; merge them by hand if you like, by pointing both profiles’ account at
one and running meka account remove on the other once nothing names it. A key the script does not
know is carried into the profile table and reported, where meka will refuse it by name; a duration
key whose value is not a whole number is left under its old name and reported, with the same result.
What else changed
- The
meka providersuite is gone.meka account add/login/list/removemanage accounts and their credentials;meka profile add/set/use/list/removemanage profiles.meka account usage/whoami/statsare where they were, and take--profile <name>instead of a positional name.account addtakes--backendwhereprovider addtook--type. - The prompt is a flag.
meka "text"ismeka -p "text", and-p -reads the prompt from stdin. There is no positional prompt, someka unknowncommandis an error rather than a session. --provideris--profile, long form only:-pis the prompt.--format jsonon a--oneshotrun prints one object for the turn; see One-shot mode.- HTTP API: the
providerfield onPOST /v1/sessions,PATCH /v1/sessions/{id}and every session response isprofile, andGET /v1/providersisGET /v1/profiles, whose rows carryaccountandbackendin place oftype. A session export archive’sproviderfield isprofile, and itsformat_versionis 2, so an archive written by 0.45 is refused by version; re-export it from a migrated store. - ACP: the
configOptionsentryproviderisprofile. - REPL:
/provideris/profile, and/statusshows the profile with its account. - One spelling per value.
--permissionandMEKA_PERMISSIONtake a level’s full name (n,r,w,uare gone), and--render-mode,MEKA_RENDER_MODEand[display].render_modetaketermimad,syntectorraw(richis gone). The flags and the config key refuse anything else; the two variables warn and fall through to the next source, as they always have. The undocumentedtextspelling of--format plainis gone too. - A
[permissions].defaultorenabledentry naming a level meka does not have is refused at startup, with the line, the way an unknown key is, instead of being dropped with a warning. The script rewritesask; anything else you spelled yourself. - The
askpermission level is gone, replaced by theapprovalsswitch beside the level: a call above the level is refused, or put to you when the switch is on./approvals on|offin the REPL,approvalsonPOST /v1/sessionsandPATCH /v1/sessions/{id}, and the ACP config option of the same name set it;[permissions].approvalsis what a new session starts with. The store migration turns anasksession intononewith approvals on, which asks about every call asaskdid. An approved call now runs at the session’s level, so an approved write atreadlands only under the workspace roots whereaskwrote anywhere; raise the level if an approved call needs the reach. See Permissions. - A user message is two blocks. What meka injects ahead of the words for a turn (permission
and environment context, todos, catalog changes, background outcomes, the resume notice) is its
own
turn_contextcontent block, first, and the words are atextblock.GET /v1/sessions/{id}/messagesreturns the block typed, so a client readingcontent[0].textas the prompt now reads the context; take thetextblocks. A migration splits every stored turn once. - Image bytes live in a
blobstable. The migration moves every inline image out of its message row and leaves a reference by content hash, so a screenshot read twice is stored once. A session export carries ablobslist with the bytes its sessions reference, and an archive that references a blob neither it nor the store holds is refused. Over HTTP an image block reportsmedia_typeandhash, andGET /v1/sessions/{id}/blobs/{hash}serves the bytes. - A scheduled job runs at its session’s recorded level and nothing else. The polling process’s
own
--permissionno longer stands in for a session row that records no level; every surface records one at creation, and the migration stamps the configured default on any older root row that never got one. A sub-agent’s row now records its level too. - Every config duration is a humantime string.
[web].request_timeout,connect_timeoutandread_timeout,[mcp].graceandconnect_timeout,[session].retention("30d"); the script converts the_secondsand_dayskeys by value, and"0s"is refused where zero is meaningless.[mcp].strictisdefault_requiredand[thinking].budget_tokensisbudget, both converted.MEKA_MCP_STDIO_CONCURRENCYandMEKA_MCP_HTTP_CONCURRENCYare gone: set[mcp].stdio_concurrencyandhttp_concurrency(3 and 20 by default, zero refused).MEKA_MCP_TOOL_TIMEOUTtakes a duration such as10m, not milliseconds; a bare number is ignored with a warning and the default of ten minutes applies. - One exact spelling per value, everywhere.
--permission,--render-mode,--sandbox-backend,--formatandmcp add --transportrefuse case variants (Read,JSON),session exportandGET /v1/sessions/{id}/exportdrop themdalias ofmarkdown,mcp add --authtakesoauth,client_credentialsorclient_credentials_jwtas the[auth]block spells them (the hyphenated forms are gone), and[mcp].default_permission, a server’spermissionandtool_permissions, and[tools].tool_permissionsrefuse a level meka does not have at startup, naming the line, where they warned and ignored it. - HTTP API: an unloaded session whose row records no level omits
permission(it sent""); every optional field is omitted rather thannull,display_summaryincluded;GET /v1/health/readyreportsprofile_configured(wasprovider_configured); thepermission_requiredevent carriesinputand stays answerable for 30 minutes (was 60 seconds); a body that fails to parse says which field on every endpoint;POST /v1/sessions/{id}/responses/{request_id}at a sub-agent’s id answers 422session-not-drivable; a fork of a session another meka process holds answers 409session-locked; a session’stitleis the first user words with whitespace collapsed, cut at 80 characters, andmeka session showlabels ittitle(wasopening). Four status codes move: a[web]orbase_urlmisconfiguration is a sanitized 500 (was a 422 naming the operator’s path), and a session lock meka cannot open is 500 (was 409session-locked); meka’s own request-ceiling refusal is 422 with the newtyperequest-too-large(was 502provider);GETandPATCH /v1/sessions/{id}answer 404 or 500 for a row they cannot read (was 200 withprofile: ""); andPOST /v1/sessions/{id}/schedulewith scheduling disabled is 404 (was 422). - ACP: a locked session, a sub-agent’s id, a profile the config no longer has and every other
refusal the caller can act on answer
InvalidParams(wasInternalError);session/set_config_optionrefuses a profile switch while a turn is in flight instead of writing the row and deferring;session/new,load,resumeandforkrefuse acwdthat is not an existing directory and record it canonically; tool-call and permission titles read<tool_name> <argument>(read_file src/x) and permission requests carryrawInputwith a JSON content block. - Terminal output: every timestamp is local time with its UTC offset (
2026-09-07 14:03 +02:00), sizes print as MiB, KiB or B, and every listing command takes--format json, printing the HTTP API’s record shapes. Tool-call indicators and the approval prompt show a tool’s real name (read_file, notReadFile); the prompt is headed[approval]and takesalwaysandnever. - Skills you wrote that name a renamed tool parameter (next table) or the old
[ask]prompt must be edited by hand; meka does not rewrite skill files. - A gate’s pointer test is
not_empty(wasnot-empty) inschedule_create,POST /v1/sessions/{id}/scheduleandmeka schedule add; stored jobs are converted by the store. canceled, onel, on every wire meka owns. Matchturn.canceledas the SSE terminal event,https://meka.run/errors/turn-canceledas the problemtype, andstatus == "canceled"in task views (GET /v1/sessions/{id}/tasks,DELETE .../tasks/{task_id}),task_listoutput andschedule.firedwebhook bodies; thereasonvalues are unchanged. The store rewrites its stored task rows on first open (the tenth ledger step). ACP’sstopReason: "cancelled"and MCP’snotifications/cancelledare those protocols’ own spellings and stay.
Tool parameters
Six built-in tool parameters are renamed so that one name means one thing across the catalog:
is_regex for a boolean, glob for a glob, limit for a result cap, id for an identifier.
| Tool | Before | After |
|---|---|---|
conversation_search | regex (boolean) | is_regex |
conversation_read | count | limit |
find_files | pattern | glob |
fetch_url | max_length | limit |
agent_followup | agent | id |
agent_delete | agent | id |
A call spelling the old name is missing its required parameter (glob, id) or, where the
parameter was optional, has it ignored in favor of the default. search_contents gains a limit
(1 to 100, default 100) beside its unchanged pattern.
meka does not rewrite what names these. A skill under the skills directory
(~/.config/meka/skills/<name>/SKILL.md) that spells out a find_files or agent_followup call
must be edited by hand, and a scheduled job whose gate calls one of these tools with the old
argument must be recreated. Past calls in a session’s history keep the old names, which is
harmless: the model reads the current schema on its next turn.
The sections below predate 0.46 and use its old names: --provider is --profile, meka provider … is meka account … and meka profile …, the positional prompt is -p, and the ask level is
none with approvals on.
0.43 to 0.44
A binary swap, and the store migrates itself as promised below, unless you authenticate an MCP
server with auth_token or client_secret, which are no longer config keys. Read the next
section first if you do; meka will refuse to start otherwise. Then the behavior changes below,
worth reading before you resume an existing session or run a scripted meka, several of which apply
only if you run meka serve or meka acp.
MCP secrets moved out of config.toml. auth_token on a server, and client_secret in a
[mcp.servers.auth] block, are gone. Both were secrets sitting in a plaintext file people commit
and sync; they now live in the store beside the OAuth tokens, which is where the login
credentials have always been.
meka cannot move them for you. The store migrates itself because it has a ledger recording what it
has already done; config.toml has none and may be older or newer than the binary at any moment, so
a key left behind is a parse error naming the key and the line rather than a value silently ignored:
$ meka mcp list
Error: database error: schema migration 3 ('sessions_name_their_provider') failed: Invalid
parameter name: cannot record a provider for 4 carried-forward session(s) while config.toml
cannot be read; fix the file and start meka again. The store is unchanged
The parse error itself is a warning just above it, naming the key and the line:
WARN meka: failed to read config.toml, so no profile can be adopted for older sessions:
configuration error: failed to parse …/config.toml: TOML parse error at line 12, column 1
|
12 | auth_token = "…"
| ^^^^^^^^^^
unknown field `auth_token`, expected one of `name`, `transport`, …
Two messages because two things are stuck: the file will not parse, and the migration that has to name a profile for your existing sessions cannot ask it which one. Fixing the file fixes both, and nothing has been written in the meantime: “The store is unchanged” is literal, and the copy taken before the attempt is still beside your store. (On an installation with no sessions to carry forward, only the parse error appears.)
For each server, delete the line and store the secret instead. Which command depends on which key
you deleted, and the two are alternatives, not a sequence: a bearer belongs to a server with no
[auth] block, a client secret to one that has it.
$ # for a server whose `auth_token` you deleted (no [auth] block):
$ pass show api-token | meka mcp login api --auth-token-stdin
$ # for a server whose [auth] block's `client_secret` you deleted:
$ pass show acme-secret | meka mcp login acme --client-secret-stdin
meka mcp get <name> then lists the kinds it holds without printing any of them. --auth-token and
--client-secret are gone from meka mcp add for the same reason: an argument is visible in ps
output and in the shell history of every user on the machine. Use the -stdin forms, which add
also takes.
If you were using auth_token = "${API_TOKEN}" to keep the token out of the file, a header does the
same job and still expands: headers = { Authorization = "Bearer ${API_TOKEN}" }. Storing it is the
better answer, since it survives without the variable being set.
Nothing else about a server moves. env, args and headers stay in config.toml with ${VAR}
expansion, because they configure a process or a request and merely may contain a secret.
isolated scheduled jobs are gone; every job fires in the session that created it. The mode ran
a job’s turn in a fresh session rather than the conversation that made it, to avoid replaying that
conversation’s history. Only meka serve ever honored it: the REPL and ACP already ran such a job
in the open conversation, with a warning, so for two of the three hosts nothing changes at all.
Existing jobs are not deleted and do not need touching. The store drops the column and the job keeps its schedule and its prompt, firing into the session it belongs to from then on.
What it cost is why it went. The fire inherited the creating session’s authority (its permission
level, its working directory, its profile, its MCP servers) and dropped the conversation,
which is where anything you told the agent that never reached a memory or an instructions file
lives. Its result landed in a session nothing linked to, and the turn could not even cancel its own
job, because schedule_cancel resolves against the session it is running in.
meka acp and meka serve clients: POST /v1/sessions/{id}/schedule now refuses isolated with a
422 naming the field, rather than accepting and ignoring it. GET /v1/schedule and the
schedule.fired webhook no longer carry it either.
If you were relying on the mode, an external timer does the same job with the level and profile stated outright instead of inherited (0.44 syntax):
meka --oneshot --permission read --provider work "summarize today's alerts"
Often a gate is the better answer: it means a frequent job takes no turn at all on the ticks where nothing happened, which saves more than skipping the history did.
A session another one spawned is driven only by its parent. POST /v1/sessions/{id}/turn
answers 422 for a sub-agent’s id, meka -r <sub-agent-id> refuses by name, and a scheduled fire aimed
at one does the same. Both agent builders now check, rather than the scheduling door alone.
What this closes is that a sub-agent’s restrictions live in its spawn record, which those builders
never read: the [subagents] denials it was created under, its memory and instruction grants, and
the permission ceiling its spawn call set. Driving one from a host therefore ran a conversation that
was deliberately given narrow tools with the full built-in set at the host’s level. agent_followup
was and remains the door that reconstructs those terms, so nothing meka does for you changes.
Reading a sub-agent is untouched: meka session export, GET /v1/sessions/{id}/messages and
meka session list --include-children all still serve it.
Forking one does not promote it, and that is the other half of the change. A fork of a sub-agent
now carries parent_session_id and the spawn terms, so the copy is a sibling under the same parent
rather than a new root; without that, POST /v1/sessions/{id}/fork was a one-call way around the
refusal above, handing back a live session over a sub-agent’s whole conversation with none of the terms
it was spawned under. The two doors that have to hand back a live session therefore refuse a
sub-agent’s id up front: POST /v1/sessions/{id}/fork answers 422, and ACP’s session/fork answers
InvalidParams. meka session fork still makes the copy: it takes no runtime, and the copy is
readable like any other sub-agent. Forking an ordinary session is unchanged. If you want a sub-agent’s conversation as a root session of your own, copy
the text out rather than expecting a command to promote it.
meka session list --long is gone, along with the columns it showed. If a script parses that
output, it needs updating; the default columns are unchanged.
/cd with no argument returns to the directory meka was launched from, not $HOME. /cd ~
still goes home. The old behavior made a bare /cd a surprising way to leave the project you were
working in.
render_mode = "silent" is gone, as are --render-mode silent and MEKA_RENDER_MODE=silent.
Delete the setting: termimad is the default.
A config still carrying it fails to parse, naming the value and the line, and --render-mode silent
is refused by clap. MEKA_RENDER_MODE=silent is the quiet one: an unreadable value there has always
been dropped in favor of the next source, so it falls through to your config file or the default
rather than saying anything.
It never did what it says. It suppressed the model’s answer and nothing else, so a run under it
printed the session id, the reasoning line, tool indicators, todo lists, notices and token usage,
and dropped the one thing you were waiting for. Both things it might plausibly have meant are
shell redirections that already work, and work the right way round: meka … 2>/dev/null keeps the
answer and drops the chrome, meka … >/dev/null 2>&1 drops both.
SSE thinking.delta now carries one chunk of reasoning per event. It used to send one event per
completed block, so a client that opted into supports_reasoning_stream and rendered each event as a
whole block will now show fragments. Concatenate the deltas to rebuild the block, exactly as you
already do for assistant_text.delta. A client that concatenated needs no change, and one that never
set the capability sees nothing new. A turn the provider answered without streaming still arrives as
a single delta, so there are no two shapes to tell apart, and stream: false still reports each block
whole in thinking.
One consequence worth planning for: a session receiving reasoning gives up its retry on a transient
provider failure, because the deltas have already reached you and a second attempt would repeat them.
Leave supports_reasoning_stream off if you would rather have the retry.
meka session delete refuses ids given alongside --all. It used to take both and quietly do
the wider thing, so meka session delete "$ID" --all with $ID unset deleted every session and
then reported the empty id as a failure: a complete wipe reported as an error. Naming sessions and
asking for all of them are two different requests; say one or the other. --older-than-days has
conflicted with both for the same reason since 0.44.
Every command taking a session, job or task id now accepts a unique prefix of one, which is what
the listings print. Full ids still work, so nothing that already worked stops. An ambiguous prefix
is refused with the candidates named, and an empty one matches nothing rather than the only row:
meka schedule cancel "$JOB" with $JOB unset used to cancel whatever job was alone.
meka mcp logout <name> clears every credential that server holds, not only its OAuth tokens.
If you were using it to drop a stale token from a server that also has a stored bearer or client
secret, you will now need to store that again with meka mcp login.
A scheduled job is refused on a sub-agent session. POST /v1/sessions/{id}/schedule answers 422
if the session was spawned by another. Sub-agents never had the schedule_* tools, so no job meka
created can be affected; what this closes is a client planting one directly, which would have woken
the sub-agent without the tool restrictions or memory grants it was spawned under.
ACP session/load and session/resume refuse a sub-agent’s id. Both used to take the session’s
lock, rewrite its cwd, retire its background work and replace its roots before failing with
Internal error; both now decline with InvalidParams before touching anything, naming the parent
to use agent_followup from. An editor that stored a sub-agent’s id from session/list gets a clear
refusal instead of a mutated row and an opaque failure. Over HTTP the same holds for every write-side
endpoint: POST /v1/sessions/{id}/turn and its neighbors refuse before taking the sub-agent’s lock or
marking its background tasks interrupted.
A session that carries spawn terms is refused even when its parent is not in the store. That
shape has one source, and it is a pair of documented commands: meka session export <sub-agent> --format json followed by meka session import. The archive’s parent_id points outside it, so the
import re-roots the row while copying the spawn terms faithfully. The result reads as a sub-agent’s
conversation to every door, so meka -r on it, and POST /turn, /fork, /schedule and PATCH
against it over HTTP, all refuse. meka -c skips it and meka session list shows it only under
--include-children, both so nothing offers you a session it will then decline. If you were using
export-then-import to promote a sub-agent into a standalone session, that no longer works; there is
no supported replacement, because the tools and permission ceiling a sub-agent ran under live in the
terms its parent set and nothing outside that parent can reconstruct them. The conversation itself
stays fully readable, and importing a whole tree (the root and its sub-agents together) is
unaffected, since each child keeps its parent.
This upgrade deletes the pre-migration copy 0.43 left, and keeps one from now on. Before it
migrates, meka copies the store aside; until now nothing removed those, so a full duplicate of your
whole history accumulated per schema-changing release. From 0.44 a fresh copy supersedes the one
before it. In practice that means meka.db.v1.bak in your data directory, the copy of your
pre-0.43 store, is removed on this upgrade and replaced by a copy of your pre-0.44 one. If you
want the older file, move it somewhere else before upgrading.
Two things worth knowing about what is kept. Peak disk during an upgrade is higher than the steady state, because the new copy is written before the old ones go: budget for the store plus every copy already beside it plus one more, and expect to settle back at twice the store. And the copy is taken per schema-changing upgrade, not per release, so one file can span several versions if you skip some.
What keeping only the newest copy costs, stated plainly. The copy you hold is of the store after the migration before this one. So it undoes the most recent conversion and nothing earlier: if a migration converts something wrongly, you do not notice, and you then take another schema-changing upgrade, the only copy predating the fault is gone. That is a real limitation rather than a technicality, and it is the reason to move a copy of your own aside if a particular upgrade worries you. It is accepted because the alternative was an unbounded pile of full-size duplicates, whose cost is certain where this one is conditional on a bug outliving a release.
A resume now starts at the level the session recorded. Both CLI hosts do this: the REPL and
meka --oneshot -c / -r. The scripted one is where a silent change matters most, since a
--oneshot run that passes no --permission used to start at the config default and now starts at
whatever the session was last set to. A session you created with --permission unrestricted comes
back at unrestricted without the flag. Before, the row said one thing and the run did another;
every other surface already read the row, and these two were the ones that did not. Pass
--permission on the resume to move it. A level that is no longer in [permissions].enabled is not
granted: the session drops to the configured default with a warning.
A session now runs on the profile it was created with. Every existing session is
recorded as running on your current default profile, which is what they were in fact running on, so
nothing moves. From here meka -p openai then meka -c stays on openai. If nothing could be
resolved when the migration ran (no profile configured yet), sessions are left without one and say
so; resume such a session once with --provider <name> to record it. The migration says which
profile it recorded and on how many sessions; run once with -v if you want to see it.
A 502 from meka serve now carries the provider’s own response text, as a provider_response
member on the Problem Detail. It used to be withheld and written only to the server log.
The reason for the change is that the redaction defended less than it appeared to: meka acp has
always handed the same text to its client, so withholding it on HTTP left the text just as public
while making the one surface quieter. What it cost was the upstream’s error type, which is the one
part of a failed turn a client can act on.
Know who can read it before you leave it on. An upstream refusal can name your account with the provider,
your organization, and your rate-limit posture. Submitting a turn takes sessions:w, but the
failure is also carried by the terminal turn.failed event, and re-attaching to a stream takes only
sessions:r, so a read-only token sees it too. If you issue read-only tokens to people who may
observe a session but are not entitled to the account behind it, set [serve] relay_provider_errors = false. Nothing else changes: detail carries the same sentence either way,
and with the key off the member is simply absent.
The 503 for a required MCP server that is down is not affected and still reports only the server
names. That reason is meka’s own subprocess text and has carried a command line and its filesystem
path, which is a different disclosure and not one this key governs.
GET /v1/info no longer returns provider or model. Read them from GET /v1/providers
instead, which lists every configured profile with its name, its type (the backend), its
model, and active: true on the one a session gets when it names none. The old fields held the
default profile’s backend under the name provider, while provider on POST /v1/sessions names
a profile, so a client that read one and posted it to the other got a 422. They were duplicates of
the active row besides.
If you ran a 0.44 development build, your store repairs itself on the next run. One such build
removed a migration from the middle of the ledger instead of appending its reversal. user_version
is a positional index, so that renumbered every later step, and a store sitting between the hole and
the new head skipped a step it had never run while stamping itself current. The symptom was every
MCP connection failing with no such table: mcp_credentials after a migration that reported
success.
Nothing is needed from you: an appended step recreates the table and carries the old MCP credentials into it, because a store already stamped past the missed step is only reachable by appending. Released 0.43 stores were never affected; they sit at the baseline and migrate straight through.
--model, --base-url, --thinking and --thinking-budget are gone. A profile is an
indivisible bundle: the backend, the endpoint, the credential keyed to it, the model, and every
model-tied setting. A session selects one by name and records that name. A flag that moved one field
of the bundle left the rest behind, so --model against a profile stating context_window = 1000000
ran a 200K model while gauging its context against a 1M window, and never auto-compacted.
Change a setting on the profile (0.44 syntax):
meka provider set work model claude-opus-5
meka provider set work effort --unset
Or make a second profile and select it with --provider, which is now the only provider flag on a
run. meka provider add has a flag for every profile field except device_id, which meka resolves
and persists itself, so one command creates a whole profile (0.44 syntax):
printf '%s' "$ANTHROPIC_API_KEY" | meka provider add fast \
--type anthropic-messages --model claude-haiku-4-5 \
--context-window 200000 --api-key-stdin
The thinking budget is per profile. [providers.<name>].thinking_budget takes precedence over
[thinking].budget_tokens, which stays as the installation-wide fallback and needs no edit. The
global was previously cross-checked against a per-profile max_output_tokens, so a profile could
be refused over a number stated nowhere in it, and told to fix it by lowering a value every other
profile also read.
meka acp and meka serve refuse -c and -r. Both name one run’s session, and a long-lived
host has no such thing: it creates one per session/new or per POST /v1/sessions, each naming its
own profile. They used to be accepted and quietly misapplied: -c / -r switched off the
default-profile check a host with no default needs most. Over HTTP, name a provider on POST /v1/sessions; under ACP, session/new creates on the host’s default and session/set_config_option
moves it.
--provider is still accepted, because it selects which configured profile the host defaults to,
which is a property of the host rather than of one session.
Also on the HTTP side, and not a break: PATCH /v1/sessions/{id} with a body naming only a provider
now works on a session that is not loaded, which is how you move one whose profile has left
config.toml. It takes the session lock to do it, so if you run more than one meka on the same
store, send it to whichever process has the session; another one answers 409 session-locked
rather than moving a row the running host would ignore.
0.42 to 0.43
Nothing to do. Start 0.43 and it brings the store forward itself, on the first open, before anything reads it.
This is the first release that migrates its own store, and from here on that is the rule: upgrades from 0.43 onward are a binary swap, whatever the schema does.
What it changes, if you want to know what happened. A scheduled job’s gate used to be two columns, gate_command and gate_fire; it is now gate_kind plus a JSON gate_spec, which is what lets a gate call a read-only tool instead of a shell command. And a due job is now claimed by leasing it rather than by consuming its row, which adds claimed_by, claimed_until and attempts, so a host that crashes mid-delivery no longer loses the occurrence, or for a one-shot the whole job. Each gate’s stored baseline is preserved, so a changed gate does not fire spuriously on its first evaluation afterwards.
Before it writes anything, meka copies the store to meka.db.v1.bak beside it. That doubles the space the store takes until you delete it, which is worth knowing if yours is large. Start with -v once if you want the exact path in the log; the copy is otherwise silent. It records the version it was taken at, so if you ever restore it, the next start migrates it again correctly rather than mistaking it for a store that is already current.
The whole thing is one transaction, so an interruption leaves the store exactly as it was rather than half-converted. Running two hosts at once is fine: the first takes the schema lock and the second waits, then finds nothing to do.
Coming from 0.41 or older, run migrate-0.41-to-0.42.py once first, as described below. 0.43 recognizes a 0.41-shaped store and refuses it by name rather than converting it into something still unreadable, and it changes nothing when it does.
A gate that cannot be read
Rare, and worth knowing the shape of. If a job’s gate was already unreadable under 0.42 (a hand-edited row, or a gate_fire value meka never wrote), it cannot be converted, because there is nothing to convert it from. Such a job never fired under 0.42, and it does not fire under 0.43 either: the migration leaves it in the same refused state rather than guessing at what it meant or deleting it. It is logged once, by id, at warn.
The consequence is that the row stays inert and invisible, as it already was: it will not appear in meka schedule list and meka schedule cancel cannot reach it. Recreate the job if you still want it. The original row is in the backup 0.43 took, meka.db.v1.bak. Note that from 0.44 a later schema-changing upgrade deletes that file, so put a copy somewhere of your own if you want to keep it.
0.41 to 0.42
A store written by 0.41 needs five conversions before 0.42 reads all of it. They are performed by migrate-0.41-to-0.42.py, a one-shot script attached as an asset to the 0.42 release. Download it, run it once, and you are done with it.
This one stays a script, and 0.43’s own store migration does not replace it: 0.42 carried no migration code to reach back with, and conversion B below has to guess. 0.41 recorded nothing about which provider a thinking block came from, so the script tells them apart by the shape of the blob, and it reports what it read before it writes. A guess wants a human reading the counts, which is the one thing a migration that runs on every start cannot offer.
Order
- Run 0.41 once, before you replace it. It brings a store from an older release fully up to date; 0.42 carries no migration code and cannot.
- Install 0.42 and launch it once. This is what creates the tables the script writes into, so it is not an arbitrary step you can move: run the script against a store that predates 0.42 and it stops with an explanation rather than guessing.
- Run the script, first as a dry run, then with
--apply.
python3 migrate-0.41-to-0.42.py # reports what it would change; writes nothing
python3 migrate-0.41-to-0.42.py --apply # does it
Read the dry run before you apply it. Conversion B in particular reports how many thinking blocks it read as Claude’s and how many as OpenAI’s, and 0.41 did not record which was which. If those counts do not match the providers you actually used, stop: the blocks it could not place are left alone, but the ones it places wrongly are not recoverable from the row afterwards.
The dry run is the only place to read that. Its per-class counts and its warning about a session holding both kinds describe the write it is about to do, so once the blocks are converted a later run has nothing left to report about them.
Between steps 2 and 3 the store is live but incomplete: memories are absent from the agent’s index, and any session affected by conversion E below is already broken. Step 3 is part of the upgrade rather than cleanup to get to later.
The script finds meka’s own directories by default, honoring MEKA_CONFIG_DIR and MEKA_DATA_DIR; --root, --skills-root and --database point it at a copy instead. --self-test checks the script against its own fixtures and exits, touching nothing of yours.
What it converts
| Conversion | What it changes | If you skip it |
|---|---|---|
| A. Memories | The Markdown files under <config>/memory/ become rows in the store’s memories table, which is where 0.42 reads memories from. The files are read, never written or deleted. | The memories are simply not there. The files are untouched on disk, so nothing is lost and the import still works whenever you get to it. |
| B. Thinking blocks | A stored block’s bare signature becomes an opaque object naming which provider it belongs to: signed for a Claude signature, sealed for OpenAI’s encrypted reasoning. 0.41 wrote both to the same field and recorded nothing about which was which, so the script tells them apart by the shape of the blob and reports the counts before it writes. A blob it does not recognize is left exactly as it is. | The block loses its opaque half, so that reasoning stops being replayed to the provider. The session still loads and still runs; it just resumes without the chain of thought behind those turns. |
C. A skill’s version: / author: | Both move from the top level of a SKILL.md’s frontmatter under metadata:, keeping their names, which is where the Agent Skills spec puts them. | Nothing. meka reads a top-level version: and author: permanently, because Claude Code’s plugin skills declare version: there. This conversion is cosmetic. |
D. A skill’s priority: | Moves under metadata: and is renamed to meka-priority:. | The skill silently drops to the default rank of 5. A rank is read from metadata.meka-priority and nowhere else, so the [Skills] index comes out in a different order and its cap drops different skills. Nothing warns. |
E. A stored tool_result | Content held as a bare JSON string becomes a list of typed blocks, [{"type": "text", "text": ...}]. | The affected session breaks. The row will not deserialize, so it is dropped as the session loads, which orphans the tool_use it answered, and the provider then refuses the next turn. |
The two that matter
A and B announce themselves: a memory you saved is missing from the index, or a thinking block is not replayed. Both are recoverable by running the script later.
D and E are the ones that damage silently. D changes which skills the [Skills] index shows first and which its cap drops, with nothing on screen to say the rank it used was not the one in your file. E can leave a session unusable: it loads cleanly, and then the next turn is refused by the provider because a tool_use in the history has no matching result. See Sessions if you have already met that error.
Configuration overview
meka is configured with named accounts and profiles in a config file at
~/.config/meka/config.toml, plus secrets kept in the store. An account is where a request
goes and who meka is when it arrives: a backend, an endpoint, and the credential a login produced. A
profile is what meka asks that account for: a model and every model-tied setting. The quickest way
to get started is to let the two command suites write both for you:
$ meka account add claude --backend claude-subscription
$ meka profile add work --account claude --model claude-opus-5-5
The first command writes an [accounts.claude] table to the config file, runs the OAuth login
(or prompts for an API key, depending on the backend), and saves the secret to the store. The
second writes a [profiles.work] table naming that account. With one profile configured, it is the
default. The resulting config looks like:
default_profile = "work"
[accounts.claude]
backend = "claude-subscription"
[profiles.work]
account = "claude"
model = "claude-opus-5-5"
See Config file for the full reference and the
meka account and
meka profile command suites.
Required settings
To run a turn, meka needs an active profile that names an account and a model, and a stored
credential for that account. If no profile can be selected, or the active profile’s account has no
credential, meka prints an error pointing at meka profile add / meka account login.
| Setting | Source | Named on the command line |
|---|---|---|
| Profile for an existing session | The session’s own row | --profile <name>, which repins the row |
| Profile for a new session | default_profile in config, or the sole profile | --profile <name> |
| Profile for a sub-agent | The parent’s, or the profile its agent_spawn call named when [subagents].agent_chosen_profile is on | none |
| Account | [profiles.<name>].account | none |
| Backend, endpoint, OAuth settings | [accounts.<name>].* | none |
| Model and every model-tied setting | [profiles.<name>].* | none |
| Credential (API key / OAuth) | The store, via meka account add / login | none |
A profile is indivisible
A profile is a named bundle: the account it bills, the model, the profiles that stand in for it,
and every model-tied setting (context_window, max_output_tokens, max_request_bytes, effort,
vision, deferred_tools, thinking, thinking_budget, cache_control, thinking_display). A
session selects one by name and records that name.
Nothing overrides a field inside one.
There is deliberately no --model, --base-url, --thinking or --thinking-budget. A flag that
moved one field of the bundle left the rest behind, so a session could run a 200K model while
gauging its context against the 1M window its profile still stated, and never auto-compact.
To change a setting, edit the profile:
meka profile set work model claude-opus-5-5
To run something different, make a second profile on the same account and select it:
meka profile add fast --account claude --model claude-haiku-5-5 --context-window 200000
meka --profile fast -p "quick question"
Override layers
Profile selection is layered as follows; higher-priority layers override lower ones:
- The session’s own row: the profile it was created with. A session that exists runs on what
its row says, whatever
default_profilelater becomes. --profile <name>: on a new session this chooses what the row records; on a resume it rewrites the row, so the change holds for every later turn and from every surface. See what a resume restores.- Config file: persistent accounts and profiles in
~/.config/meka/config.toml. - Built-in defaults: permission defaults to
read, streaming defaults to on.
There is no environment-variable tier for accounts or profiles; an ambient OPENAI_API_KEY or
MEKA_PROFILE has no effect (see Environment variables).
Credential resolution
The credential for a session’s profile is loaded from the store, keyed by the profile’s account name. It is acquired interactively:
meka account add <name>runs the OAuth login (claude-subscription,chatgpt-subscription) or prompts for the API key (anthropic-messages,openai-chat-completions,openai-responses) when the account is created.meka account login <name>re-acquires it for an existing account (rotate an API key, recover from a dead OAuth refresh token), keeping every setting on the account and every profile on it. Add--api-key-stdinto pipe the key in for scripted rotation.meka account remove <name>deletes the stored credential and the account, once no profile names it.
Because secrets are keyed per account, two accounts on the same backend (for example, two Claude subscriptions) keep independent credentials, and every profile on one account shares its login.
Deleting an [accounts.<name>] block by hand removes the settings but not the secret, which stays in
the store under that name. meka account list names any credential left that way, and meka account remove <name> deletes it; see Leftover
credentials.
Why some settings have no config key
A few things are deliberately CLI-only, with no config.toml key and no environment variable.
--additional-workspace-directory is the current example: which directories a run works in is
a per-run scope, like the working directory itself, not a preference worth persisting. Writing it
into a file would make the boundary depend on where the file lives rather than on what you asked for
this time.
This is the same reasoning that keeps the working directory out of config, and it is the exception to “config.toml is the complete source of truth”: that rule covers persistent settings, and a per-run scope is not one.
When edits take effect
meka in the terminal reads config.toml and your instructions files at startup, so anything you
change applies from the next command. A long-lived host is different: meka serve and meka acp
read both once, when the process starts, and keep what they read for as long as they run.
Two consequences worth knowing:
meka profile addormeka account addwhile a server is running does not reach it. The new entry is on disk and the listings show it, butPOST /v1/sessionsand ACP’s profile picker answer “not configured” until the server is restarted. The same applies to editing an existing profile or account. The one exception is a writemeka servemade itself: an account or profile added, changed or removed through its own API is served from the next session on, because the server reads the whole file again after each write of its own, which also serves an edit made in a shell before it.- Editing your instructions files does not reach it either. They are read once and go into the
cached prompt prefix that every session shares. The same exception applies:
instructions.mdwritten throughPUT /v1/instructionsis read again by the server that wrote it, for the sessions that open from then on.
Restart the server to pick either up, or ask meka serve to read the file again with
POST /v1/config/reload, which applies the
accounts, profiles, MCP server entries and instructions.md as they stand, with [thinking] and
[session].context_window, which complete the profiles, an MCP server’s credentials a shell
stored, and [[serve.tokens]], and nothing else: not the [mcp] table’s own keys, nor any other
section. A token removed or rescoped there applies to the next request, and a stream it holds
open ends once the token no longer grants what the stream reads. Its answer, and GET /v1/config, name in restart_required the sections the file has
changed since the server started that only a restart applies, and a reload refuses a file the
server could not start on, naming the setting. Everything else follows a live source and needs
no restart: skills are re-read per turn, memories per turn, and MCP tool lists follow the server.
A rotated credential sits between the two, and the distinction matters if you are rotating
because a key leaked. meka account login <name> from a second process is picked up without a
restart by anything that builds a provider after it: newly created sessions, ones the server
re-attaches after eviction, and ones explicitly repinned by PATCH /v1/sessions/{id},
session/set_config_option or /profile.
A session already resident in memory holds the provider it was built with. For an API-key
account that means it keeps presenting the old key until it is evicted ([serve] idle_timeout, 24
hours by default) or the server restarts. For the OAuth backends (claude-subscription,
chatgpt-subscription) the live provider re-reads the stored bundle when it next refreshes its
token, so a rotation is usually adopted sooner, but nothing makes that happen on demand.
To be certain a revoked credential is out of use, restart the host.
Config file
meka looks for a TOML configuration file at a platform-specific location:
| Platform | Path |
|---|---|
| Linux | ~/.config/meka/config.toml ($XDG_CONFIG_HOME/meka/config.toml) |
| macOS | ~/Library/Application Support/meka/config.toml |
| Windows | %APPDATA%\meka\config.toml |
The config file is optional. If it does not exist, meka silently skips it.
meka refuses unknown keys: a typo (contex_window) or a removed key (reasoning_effort) fails the load with an error naming the offending key, rather than being silently ignored. Fix or remove the key to continue.
The commands that edit the file are exempt, so a broken config can still be repaired from the CLI: meka mcp add / remove / enable / disable, meka account remove / rename and meka profile remove / rename work on the raw document and don’t care about an unknown key elsewhere in it. Everything that reads config fails instead of answering from empty defaults, because “No MCP servers.” over a file full of them is indistinguishable from the truth.
Those editors only reach the keys they own, so a bad key anywhere else ([session], [permissions], a top-level typo, a raw syntax error) has to be fixed in an editor. The error names the file, line, column, and offending key.
Set the MEKA_CONFIG_DIR environment variable to override the default location entirely. The value points at the meka directory itself (contains config.toml and skills/). Useful for tests, portable installs, and isolating a per-project config from your global one.
The directory holds two more things that are not config keys. Standing instructions live at a conventional path beside the file, because prose long enough to be worth writing is miserable to maintain inside a TOML string, and skills have a directory of their own:
~/.config/meka/
├── config.toml
├── instructions.md # or instructions/*.md
└── skills/
Write instructions.md, or split a large set across instructions/*.md, and meka reads it at startup into the ## Standing instructions section of the system prompt; see Instructions. To pass the text as a string instead (containers, CI), use MEKA_INSTRUCTIONS, MEKA_INSTRUCTIONS_FILE, or --instructions. Files beyond that path, a project’s AGENTS.md for one, are named under [instructions].
Everything in that directory is content you put there, so it is safe to keep under version control. Commands that edit the config take a cross-process lock on the directory itself, as does claiming a skill store, so neither leaves a lock file behind; a write is published by renaming a short-lived config.toml.<pid>.<seq>.tmp over the target, so that name can appear for the duration of one write. If you are upgrading from a version that wrote .config.toml.lock, or .meka-store.lock inside a skill store, delete them: nothing reads or writes them any more.
Windows still writes both, because its file locks are mandatory rather than advisory: a lock held on config.toml would make the file unreadable to the command holding it, and LockFileEx refuses a directory handle outright. If you keep a meka config directory under version control on Windows, ignore .config.toml.lock and skills/.meka-store.lock.
The sections below are in the order a file is best written in: the blocks with many entries first (default_profile, accounts, profiles, MCP servers), then each single table from the most to the least consequential, and [serve] last.
Accounts and profiles
Where a request goes and what it asks for are configured separately. An account is a backend,
an endpoint and the credential a login produced: [accounts.<name>] in config.toml, with the
secret in the store under the same name. A profile is an account plus a model and every
model-tied setting: [profiles.<name>]. A session records the profile it runs on; the profile
names its account. Two profiles on one account share one login; one account on two endpoints is two
accounts.
Secrets are never stored in the config file. API keys and OAuth token bundles live in the
store, keyed by account name, and are acquired through the meka account
command suite (meka account add runs the API-key prompt or the OAuth login for you). The config
file holds only the non-secret settings shown below.
default_profile = "work"
[accounts.claude]
backend = "claude-subscription"
[accounts.ollama]
backend = "openai-chat-completions"
base_url = "http://localhost:11434/v1"
[profiles.work]
account = "claude"
model = "claude-opus-5-5"
[profiles.fast]
account = "claude"
model = "claude-haiku-5-5"
context_window = 200000
[profiles.local]
account = "ollama"
model = "llama3"
Selecting the active profile
For each run meka picks one profile using this precedence:
--profile <name>CLI flag.default_profilein the config file.- The sole profile, if exactly one is configured.
If none of these resolve (no profiles configured, or more than one with no default_profile /
--profile), meka errors and points you at meka profile add / meka profile use. Resuming a
session is the exception: it runs on the profile it recorded and never consults this, so an
ambiguous default does not block meka -c. meka serve is the other: it starts, and refuses the
POST /v1/sessions that names no profile with the same sentence, since each of its sessions may
name its own. There is no environment-variable tier for profile selection; the config file (plus
the per-run CLI flag) is the source of truth.
Timeouts
Every backend connects with a 30-second handshake deadline and fails a stream that produces nothing for five minutes, which surfaces as a retryable error rather than a hung turn.
Neither is a limit on the turn. There is deliberately no cap on how long a turn may run, how many tool calls it may make, or how many tokens it may spend: those ceilings belong to your API key and your provider plan, not to the harness. What these bound is silence. A model that is still thinking is still sending, so a stream that goes quiet for five minutes has died, and waiting on it forever is not patience.
A whole reply (--no-stream, and the summary a compaction asks for) is silent until it is finished,
so it gets no clock at all: a reply may take as long as it takes. A connection whose peer has gone
is found by TCP and HTTP/2 keepalives instead, which a busy server answers and a vanished one does
not, so a dropped route still surfaces as an error within a minute or two. A peer that is up and
never answers is not found this way: such a request waits until it is canceled, which is the trade
a base_url behind a gateway with no timeout of its own makes. A cancel drops a pending reply at
once, in either mode.
default_profile
Top-level field naming the profile to use when --profile isn’t passed. Set it with
meka profile use <name>. A sole profile is the default by the selection rule, so the first
meka profile add leaves the key unset; the second names the profile that was alone, so the
default stays where it was.
Account fields
backend
The driver the account uses (required).
| Value | Protocol | Auth |
|---|---|---|
anthropic-messages | Anthropic Messages, POST {base}/v1/messages | API key (x-api-key) |
claude-subscription | Anthropic Messages, against api.anthropic.com | Claude subscription OAuth (fingerprinting + attestation) |
openai-chat-completions | OpenAI Chat Completions, POST {base}/chat/completions | API key |
openai-responses | OpenAI Responses, POST {base}/responses | API key |
chatgpt-subscription | OpenAI Responses, against chatgpt.com/backend-api/codex | ChatGPT subscription OAuth |
An API-key backend is named for the protocol it speaks, because base_url decides the endpoint and
the same protocol is served by many vendors. A subscription backend is named for the product,
because the endpoint is fixed and what the account holds is a billing relationship. See Providers
overview for which servers implement which protocol.
base_url
Custom API base URL. Useful for:
- Self-hosted models via Ollama (
http://localhost:11434/v1) - OpenRouter (
https://openrouter.ai/api/v1) - Other OpenAI-compatible API providers
If not set, defaults to:
https://api.openai.com/v1for theopenai-chat-completionsandopenai-responsesbackendshttps://chatgpt.comfor thechatgpt-subscriptionbackend (request path is/backend-api/codex/responses)https://api.anthropic.comfor theanthropic-messagesandclaude-subscriptionbackends
Set it with meka account add <name> --base-url <url>, or edit the account table by hand.
The URL is used as given. An endpoint that answers with a redirect is refused, naming the location it pointed to, rather than followed, so a credential never leaves the host you configured; set the final URL.
The two API families end their base URL in different places, and that is not meka’s choice. An
OpenAI-compatible base includes the version segment, which is why every provider documents one
ending in /v1 and why meka appends only /chat/completions. A Claude base is the host root,
because meka reaches two different roots off it: /v1/messages for the turn, and /api/oauth/...
for the subscription usage and profile endpoints. A base ending at /v1 could not reach the second
set. The official SDKs draw the line the same way.
A gateway that fronts both APIs therefore publishes two URLs, and its Anthropic one is often written
with the /v1 its OpenAI sibling needs (https://api.synthetic.new/anthropic/v1). Paste it as-is:
for an anthropic-messages or claude-subscription account meka drops a trailing /v1, since it
re-adds that segment on every request and the alternative is a request to /v1/v1/messages. Only a
trailing one goes, so a base whose path legitimately contains /v1 earlier
(https://gateway.ai.cloudflare.com/v1/{account}/{gateway}/anthropic) is left alone. Trailing
slashes are trimmed for every backend.
The reverse is not inferred: an openai-chat-completions base is used exactly as written, because a
gateway serving /chat/completions at its root is legitimate and meka cannot tell that apart from a
missing /v1. If an OpenAI-compatible endpoint 404s, check that the base carries the version segment
its documentation shows.
oauth_token_url
The OAuth token endpoint meka posts to, for the initial code exchange at meka account add /
login and for every refresh thereafter. Both, not just refreshes: it overrides a constant, so it
overrides it everywhere that constant is used. Defaults:
https://platform.claude.com/v1/oauth/tokenforclaude-subscriptionhttps://auth.openai.com/oauth/tokenforchatgpt-subscription
It exists because that endpoint is the provider’s fact, not meka’s, and a value baked into the
binary either goes stale or sits on the far side of a proxy your network makes you use. Set it with
client_id when your route out needs both.
There is deliberately no authorize_url to go with it, and the asymmetry is not an oversight: meka
never requests the authorization URL, it hands it to your browser, so an egress proxy is never in
that path. The two legs meka makes itself are the code exchange and the refresh, and this covers
both.
client_id
OAuth client id override (advanced; claude-subscription / chatgpt-subscription only). Leave unset to use meka’s built-in default client ids.
interleaved_thinking
Whether the endpoint takes the interleaved-thinking beta header, which anthropic-messages
sends whenever thinking is on. Unset is true, which is what Anthropic’s own API needs to think
between tool calls; an endpoint that refuses the header sets it false. The other backends never
read it, and meka account add --interleaved-thinking refuses it for them. The other switch an
endpoint decides, the cache breakpoint, is a profile field, because its answer varies by model on
a gateway; see cache_control.
[accounts.gateway]
backend = "anthropic-messages"
base_url = "https://gateway.example.test"
interleaved_thinking = false
prompt_cache_key
openai-responses only. Whether the request carries prompt_cache_key, OpenAI’s hint for routing
one conversation’s requests to the cache holding its prefix; meka sends the session’s id, as
chatgpt-subscription does. Unset is true, which is what OpenAI’s own API takes. An endpoint
that refuses the field sets it false; most servers ignore a field they do not know.
stream_usage
openai-chat-completions only. Whether a streamed request asks for its usage with
stream_options.include_usage, without which OpenAI’s own API reports none and the context gauge
and automatic compaction read zero. Unset is true. An endpoint that refuses the field sets it
false, and its usage is then whatever it reports unasked (OpenRouter reports it either way).
[accounts.strict]
backend = "openai-chat-completions"
base_url = "https://llm.example.test/v1"
stream_usage = false
max_tool_name_bytes
anthropic-messages, openai-responses and openai-chat-completions only. The longest tool name
the endpoint takes, in bytes. Unset is the wire’s own limit as its owner documents it: 128 on the
Messages and Responses APIs, 64 on Chat Completions. A namespaced MCP tool name longer than this is
sent cut to it, ending in a hash of the whole name, and read back as the whole name. A gateway that
holds names to less states what it holds them to; at least 16, which a cut name needs.
device_id
claude-subscription only. Stable per-device identifier embedded in metadata.user_id to mirror Claude Code’s ~/.claude.json device id (getOrCreateUserID in utils/config.ts).
If unset, meka first tries to adopt userID from ~/.claude.json (so meka and Claude Code on the same machine look like the same device). If that file is missing or has no userID, meka generates a 64-character hex string. Either way, the resolved value is persisted back to the account under [accounts.<name>].device_id, the first time a process reads the account: at startup, or when meka serve serves the file after a write or a reload. This file write only happens for the claude-subscription backend; other backends don’t need a device id.
You can supply your own value if you want to control attribution explicitly:
[accounts.work]
backend = "claude-subscription"
device_id = "your-stable-id-here"
Profile fields
account
The account the profile bills (required). Must name an [accounts.<name>] table; a profile whose
account is missing is refused by name when a session tries to run on it, and meka profile list
says so.
model
The model identifier to send to the provider, forwarded verbatim. Optional in the file, but a session cannot run without one: a profile that names no model is refused by name when a session tries to run on it. meka does not gate which strings are valid, so an OpenAI-compatible endpoint accepts whatever that server exposes.
meka profile add suggests claude-opus-5-5 for a profile on a Claude account and gpt-6-astra for one on an OpenAI account. For the current line-ups, see Anthropic’s models overview and OpenAI’s models overview; naming them here would go stale on someone else’s schedule.
Change it with meka profile set <name> model <value>.
fallbacks
Profiles to serve a turn from when this one cannot, in order of preference: list the profile you would rather run on first, since every turn starts on the first profile, this one included, whose upstream is not known to be down, and a switch rebuilds the prompt cache at the new profile once. A turn falls back when the profile’s upstream is still failing once the retry budget is spent (an outage, a rate limit, a dropped stream), or when it refuses for quota or billing by a signal it documents (a 402, a quota code on a 429, the Claude subscription’s exhausted window). It does not fall back on a context overflow, an invalid request, a tool refusal or an authentication failure, which another profile would not fix. The whole profile is swapped, so no setting of this one is ever mixed with another’s. Each fallback must be able to carry the sessions of the profile it backs: the same vision, and a context_window and max_request_bytes no smaller, each resolved as a session resolves it. A larger window or cap on a fallback is never drawn on: while a fallback serves, the session keeps gauging against its own profile’s window, so when it compacts does not depend on who answered.
The session keeps naming this profile. A fallback serves one turn, the rest of that turn stays on it, and the next turn starts here again, or on the first fallback whose upstream is not known to be down: a profile that failed over is skipped for five minutes, or until the upstream’s stated reset when that is sooner, never longer. Once a turn is running, a failure moves it to the next profile in the list it has not tried, so it fails only when every one has refused it. What served a turn is on its record (profile on GET /v1/sessions/{id}/turns and on turn.finished) and in a notice on the feed and the screen. See Fallback profiles.
Each entry must name another configured profile, once. meka profile remove refuses a profile another’s fallbacks names, and meka profile rename moves the name.
[profiles.main]
account = "anthropic"
model = "claude-opus-5-5"
fallbacks = ["backup"]
[profiles.backup]
account = "openrouter"
model = "anthropic/claude-sonnet-4-5"
Change it with meka profile set <name> fallbacks backup,last-resort (comma-separated), or --unset to remove it. meka profile add takes --fallback <name>, repeatable.
context_window
The model’s context window (total tokens it can hold), used for the /status gauge and auto-compaction. Takes precedence over [session].context_window; when neither is set, meka assumes 1000000.
meka never infers this from the model name and never asks the provider for it, so this is where a model smaller than the default gets stated. It is a local budgeting number that is never sent on the wire, so a wrong value can’t fail a request, but leaving it at 1M for a smaller model means planned compaction never fires, and every compaction instead happens after the provider rejects the request as too large, costing a wasted round trip each time.
The window belongs to the session, not to the process: each session is measured against the profile it recorded, so two sessions in one meka serve can sit on profiles with different windows.
[profiles.work]
account = "openai"
model = "my-128k-model"
context_window = 131072
max_output_tokens
Override the per-request output (completion) token cap. When unset, each backend keeps its built-in default:
| Backend | Default when unset |
|---|---|
Claude, thinking adaptive | 128000 |
Claude, budgeted | twice the resolved budget, or 32000, whichever is larger |
Claude, off | 32000 |
| Every other backend | the endpoint’s own |
The Claude figures are meka’s own defaults for the two Anthropic backends. The adaptive one is what Claude Code 2.1.310 sends for Opus 5.5, the model meka profile add suggests, and the most any model that takes adaptive thinking accepts; Claude Code’s per-model catalog says the same for Sonnet 5.5 and Haiku 5.5 and 64000 for the rest of the line-up, and stating a figure here replaces the default. The OpenAI backends send no cap unless the profile states one, because each reaches whatever base_url names and the endpoint’s default is that endpoint’s fact.
Under thinking = "budgeted" the value must exceed the profile’s resolved thinking budget (thinking_budget, else [thinking].budget, else 16000). meka profile add and meka profile set both refuse a profile that fails this, and it is validated again at startup.
[profiles.work]
account = "anthropic"
max_output_tokens = 16000
max_request_bytes
Largest request body, in bytes, before the oldest tool-result images are redacted to fit; a body
that still does not fit is refused, and the turn retries without its newest attachments. Unset, the
Anthropic backends use 31457280 (30 MiB), which is Anthropic’s 32 MiB cap less headroom, and the
OpenAI backends apply no ceiling until one is stated: their endpoints’ caps are the endpoints’ own
facts. An account reaches whatever its base_url names, so a profile on an endpoint with a smaller
cap states it here. Redaction removes tool-result images, oldest first; an image attached to the
newest message is never removed, and a body that still does not fit is refused so the turn can
degrade its own attachments instead. openai-chat-completions never sends tool-result images (that
API’s tool messages are text), so there the ceiling only refuses. The Anthropic backends also send
at most 100 images in one request, the Messages API’s own limit, redacting the oldest past it the
same way.
[profiles.local]
account = "gateway"
max_request_bytes = 8388608
effort
One knob for reasoning effort across every backend: Claude sends it as output_config.effort (claude-subscription under the effort-2025-11-24 beta, anthropic-messages directly), openai-chat-completions as reasoning_effort (with max_completion_tokens for the output cap), and the two Responses backends as reasoning.effort (with max_output_tokens).
When unset the field is omitted, and the provider applies its own default. claude-subscription is the exception: it sends medium, Claude Code’s default for Opus 5.5. That is the point of leaving it unset: effort is a request parameter the provider owns, and omitting it is how you ask for whatever that provider considers right. meka picks no tier of its own, because it cannot know which tiers a given endpoint implements: anthropic-messages and openai-chat-completions reach any compatible server, including local ones serving weights that never had a reasoning knob, and a tier the backend doesn’t implement is a rejected request rather than a graceful ignore.
An explicit value is absolute: sent verbatim (trimmed and lowercased), with no validation or clamping, whatever model it is aimed at. You own correctness for your model and endpoint; an invalid value is rejected by the API. A blank value reads as unset.
Typical values: low, medium, high, xhigh, max.
[profiles.work]
account = "anthropic"
effort = "xhigh"
vision
Whether this profile’s model accepts image input. Defaults to true. Set false for a text-only model so attachments are refused rather than sent to a model that cannot read them. A turn on such a profile is refused, before anything is sent, while the session’s history holds an image: switch the session to a profile with vision. A profile’s fallbacks share its vision, so a fallback never meets that refusal.
Refusal is per session, from the profile that session recorded, on both ACP and POST /v1/sessions/{id}/turn. What ACP advertises in promptCapabilities.image is necessarily per connection: initialize is answered before any session exists, so it reports the default profile’s flag. A client on a vision-capable connection can still have its attachment refused by a session pinned to a text-only profile. See ACP.
[profiles.local]
account = "ollama"
model = "llama-3-8b"
vision = false
deferred_tools
How a request carries the tools the agent has not loaded: native or append. Every backend but
openai-chat-completions reads it. Under append a deferred tool is absent from the request until
tool_load adds its schema to the tools array; the array heads the cached prefix, so the request
after a load rewrites the whole cache once. Under native every deferred tool is declared from the
first request with defer_loading, the array never changes, and tool_load and tool_search
answer with a reference the endpoint expands in place: Anthropic’s tool_reference block on
anthropic-messages and claude-subscription; on openai-responses and chatgpt-subscription
the endpoint’s own tool_search item, which replaces the two discovery tools there and takes a
query to find or a list of names to load. Unset is native on the two subscription backends
and on anthropic-messages and openai-responses without a base_url, and append elsewhere: a
gateway that ignores defer_loading accepts it silently and shows the model every deferred schema
from the first request, which costs more than append and is invisible. Whether a gateway honors
it varies by model and by the upstream it routes a request to, so check a profile before setting
native on it: a deferred tool called with the right arguments and no load means the schema was
shown. See
Deferred tools for the cost model and for deferral, the
registry setting that decides which tools are deferred at all.
[profiles.claude-via-openrouter]
account = "openrouter"
model = "anthropic/claude-opus-5-5"
deferred_tools = "native"
thinking
Claude-only. How the request encodes extended thinking, and whether it asks for it at all:
| Value | Wire shape |
|---|---|
adaptive (default) | thinking: {"type": "adaptive"}: the model sets its own budget. Claude 4.6+ |
budgeted | thinking: {"type": "enabled", "budget_tokens": N}, with N from thinking_budget, else [thinking].budget, else 16000. Required by pre-4.6 Claude, and the form most third-party Anthropic-compatible servers implement |
off | No thinking field |
One knob rather than two: it replaces both the old on/off switch and the encoding meka used to infer from the model name. The right value depends on the model and on what the endpoint implements, which meka can’t determine, so the profile states it, and a profile whose model later changes is yours to keep correct.
[profiles.local]
account = "gateway"
thinking = "budgeted"
thinking_budget
Tokens the model may spend thinking. Read only under thinking = "budgeted"; the other two settings send no budget at all. A profile that states none falls back to [thinking].budget, and then to 16000.
Per profile because it is a parameter of thinking, and thinking is per profile. It was one installation-wide value until 0.44, which meant a profile could be refused over a number stated nowhere in it, and told to fix it by lowering a global every other profile was also budgeting against. Under thinking = "budgeted" this profile’s max_output_tokens must exceed the resolved budget, and the remedy now names this profile’s own keys.
[profiles.work]
account = "anthropic"
thinking = "budgeted"
thinking_budget = 20000
cache_control
anthropic-messages only. Whether each request carries the moving cache_control breakpoint on
the last block it sends. Unset is true, which is what Anthropic’s own API needs to cache the
prompt incrementally: the breakpoint moves with the conversation, and each turn is billed a write
for the new suffix alone. What an endpoint does with the breakpoint is the endpoint’s fact, and on
a gateway it varies by model, which is why this sits on the profile rather than beside
interleaved_thinking on the account. OpenRouter turns it into Gemini
explicit caching, which bills a write of the whole prompt on every turn the breakpoint moves, so a
Gemini profile there pays more with the breakpoint than without it. The symptom in the usage is a
cache write equal to the cache read on every turn with no live input, and a /status gauge at
twice the real occupancy, since the two tiers are counted as separate tokens. Such a profile sets
false, which sends no breakpoint at all and leaves the endpoint’s own implicit caching to work; a
Claude model on the same account keeps the default. The other backends never read it:
claude-subscription pins its own breakpoint and the OpenAI wires have none, so meka profile add --cache-control is dropped for them and meka profile set refuses it.
[profiles.gemini]
account = "openrouter"
model = "google/gemini-3.8-flash"
cache_control = false
thinking_display
claude-subscription only. How the model’s thinking is presented, one of Claude Code’s three
display modes:
summarized(the default): the server streams a short summary of the reasoning, shown as thinking text (a dimmed preview row, the whole summary when expanded). Sent asthinking.display = "summarized", which is what Claude Code sends with itsshowThinkingSummariessetting on.updates(Claude Code’s own default): the server streams a running token count in place of the text, and the screen draws• Thinking · 150 tokensfrom it, redrawn as the count climbs and left on screen when the phase ends. Sent asthinking.display = "updates"under thethinking-display-updates-2026-08-18beta.redacted: the server withholds the text and may return opaqueredacted_thinkingblocks. Sent as theredact-thinking-2026-02-12beta with no display field.
Every mode returns signed thinking blocks, which meka stores and replays verbatim, so multi-turn
continuity holds in all three. With thinking off there is nothing to display, and meka sends the
redaction beta as Claude Code does.
[profiles.work]
account = "anthropic"
thinking_display = "updates"
encrypted_reasoning
openai-responses only. Whether the request asks for the model’s reasoning sealed, with include: ["reasoning.encrypted_content"], and sends it back on the turn’s later requests, so the model
reasons on between its own tool calls rather than starting over at each. Defaults to true, what
OpenAI’s own API carries for a reasoning model. A model or endpoint that refuses the field sets it
false: OpenAI’s API refuses it for a model that does not reason, such as gpt-4.1 or gpt-4o,
with Encrypted content is not supported with this model naming include. Reasoning goes back only to the endpoint that sealed it: a session that ran on another
endpoint, chatgpt-subscription included, keeps its reasoning on that endpoint.
reasoning_summary
openai-responses only. Whether the request asks for reasoning.summary, the digest of the
model’s reasoning meka shows as its thinking. Defaults to true. A model or endpoint that refuses
the field sets it false, as a model that does not reason does on OpenAI’s API, and the thinking
then shows only what the endpoint streams unasked (vLLM and Ollama stream their reasoning text
either way).
[profiles.gpt41]
account = "openai"
model = "gpt-4.1"
encrypted_reasoning = false
reasoning_summary = false
[mcp]
Which MCP servers to connect to, and what their tools are allowed to do. The MCP
page covers the rest: the meka mcp command suite, where a server’s secrets live, the OAuth flows,
the connection lifecycle, and the resource and prompt tools.
[mcp] top-level table
| Field | Purpose |
|---|---|
default_permission | Fallback permission for MCP tools whose server didn’t advertise readOnlyHint and doesn’t have a permission override. Accepts "none", "read", "workspace", or "unrestricted"; any other value is refused at startup, naming the line. If unset the hardcoded fallback is "unrestricted" (strict). It stays there deliberately: an MCP server runs unsandboxed, so workspace cannot confine it. |
deferral | Default for every server’s deferral: deferred (the default), eager or hidden. A server’s own deferral wins, a name in its eager_load_tools ships eager under every value, and the MCP resource and prompt tools follow this setting. |
default_required | Default for every server’s required flag. When true, all enabled servers gate the turn; when false (the default) only servers with required = true do. An unavailable optional server doesn’t stop the turn; its failure is logged once when it happens, and its live state is shown by /mcp list on the screen or probed with meka mcp reconnect <name>. |
grace | Per-turn cap on how long to wait for still-Pending servers to connect before deciding. A duration string; default "3s". "0s" skips the wait, for scripts that want to fail fast. |
connect_timeout | Per-server timeout for connect + the protocol probe or initialize + list_tools. A hung stdio spawn or slow HTTPS handshake can’t stall the whole fleet past this bound. The server/discover probe a stdio server gets waits up to ten seconds, or half of this value when that is less, before the server is taken for legacy; an HTTP server’s probe waits up to ten seconds whatever this value is, so a value at or under ten seconds leaves an HTTP server that ignores the probe no time for the fallback. A duration string; default "30s". "0s" is refused at startup. |
stdio_concurrency | How many stdio servers connect at once at startup. Each is a process launch, so raising it trades startup latency for load; lower it on a machine where several heavy servers starting together is the problem. Default 3; 0 is refused at startup. |
http_concurrency | How many HTTP servers connect at once at startup. Higher than the stdio limit because a connect is a request rather than a process. Default 20; 0 is refused at startup. |
[[mcp.servers]]
An array of MCP server configurations. Each entry defines a server to connect to at startup.
| Field | Required | Description |
|---|---|---|
name | Yes | Unique name for this server. Used as namespace prefix for tools (name__tool). Must match [A-Za-z0-9_-]+, must not contain __, and must not be meka, ide, or start with mcp_. |
transport | Yes | Transport type: "stdio" (spawn subprocess) or "http" (streamable HTTP). |
command | Stdio only | Path or name of the executable to spawn. On Windows, npx / .cmd / .bat / .ps1 are auto-wrapped in cmd /c. |
args | No | Arguments to pass to the command. |
env | No | Environment variables to set for the spawned process (stdio only). The child does not inherit meka’s environment; see below. |
url | HTTP only | URL of the MCP server endpoint. |
headers | No | Custom HTTP headers to include with every request (HTTP only). |
headers_helper | No | Path to an executable whose stdout (Name: Value\n lines) is merged over headers on every connect (HTTP only), so a request the server rejects as unauthorized reconnects once to fetch them again. Absolute, or relative to the directory config.toml is in; a bare name is not searched on PATH. Executed with MEKA_MCP_SERVER_NAME / MEKA_MCP_SERVER_URL in env; 15 s timeout. |
auth | No | OAuth authentication configuration (see below). Mutually exclusive with a stored bearer token. |
permission | No | Server-wide permission override: none, read, workspace or unrestricted. Applies to every tool on this server, beating the readOnlyHint the server advertises and the [mcp].default_permission global fallback. Any other value is refused at startup, naming the line, the way an unknown key is. See Permission resolution below. |
allowed_tools | No | Optional allow-list of raw tool names (the form the server advertises, not the server__tool namespaced form). When set and non-empty, only these tools are registered; all others from this server are ignored. |
disabled_tools | No | Optional block-list of raw tool names. Applied after allowed_tools; tools listed here are never registered. Both lists can coexist; the net set is allowed_tools \ disabled_tools. |
eager_load_tools | No | Raw tool names that ship eager-loaded whatever deferral says. Listed tools skip the tool_load round-trip and sit in the cacheable tools-array prefix from turn 1. Use this for tools the agent invokes constantly (search, fetch, …); leave others deferred so the tools array stays lean. |
deferral | No | How this server’s other tools ship: deferred (listed under [Tool discovery], schema on tool_load), eager (every tool declared from the first request) or hidden (not registered at all: not in the catalog, the request or the context gauge). Unset inherits [mcp].deferral. See Deferred tools for what each costs. |
tool_permissions | No | Per-tool permission overrides keyed by raw tool name, same values as permission. Beats the server-level permission and the server’s readOnlyHint when resolving a tool’s required permission. A level meka does not have is refused at startup, naming the line. |
trust_read_only_hint | No | Whether this server’s readOnlyHint: true may classify a tool as read. Defaults to true. Set false for a server you have not audited: its hints become advisory for display only, so its tools fall through to the strict unrestricted fallback, skipping [mcp].default_permission (a global convenience must not re-grant what a per-server audit decision refused). A readOnlyHint: false is still honored either way, since it only raises the requirement. See Permission resolution below. |
disabled | No | When true, the server is skipped entirely at startup: no process is spawned, no HTTP connect is attempted. Flip it back with meka mcp enable <name> or by editing the config. Unset means false. |
required | No | When true, a turn is refused while this enabled server is not Connected (a disabled server is never started, so it never gates). Over the HTTP API that refusal is a 503 /errors/mcp-unavailable naming the servers. When false, the session runs without it and its tools are simply absent. Unset inherits [mcp].default_required (itself false), so servers are optional unless they opt in. |
Permission resolution
Every MCP tool’s required permission is resolved through a five-step chain; the first match wins:
server.tool_permissions[<raw-tool>]: explicit per-tool override.server.permission: explicit server-level override. Applies to every tool on that server regardless of what the server advertises.tool.annotations.readOnlyHintfrom the server:true→Read,false→Unrestricted. Thetruehalf is skipped when the server setstrust_read_only_hint = false, and a hint skipped that way also bypasses step 4, landing on step 5.[mcp].default_permission: global fallback. Not consulted for a hint that step 3 refused.- Hardcoded
Unrestricted: strict ultimate fallback.
User-supplied config (1, 2, 4) always beats the server’s self-classification; if a server lies about a tool, you can override. But when no user config says anything, the server’s hint is trusted for that specific tool so readOnlyHint = false destructive tools don’t silently become Read-accessible just because the user opted into a lenient global default.
Hint spoofing: readOnlyHint is asserted by the server and not verified by meka, and MCP tools run in the server’s own process with no sandbox. A server that claims readOnlyHint = true for a tool that in fact writes therefore gets to write your tree while meka sits at read: MCP tools are outside the read filesystem boundary that covers meka’s built-ins (see Permissions).
Three defenses, in increasing order of bluntness:
tool_permissionson the specific tools you want pinned (step 1 wins).trust_read_only_hint = falseon the server, which makes its hints advisory for display only. A refused hint drops straight to the strictunrestrictedfallback, deliberately skipping[mcp].default_permission: that key is a global default, and letting it answer would meandefault_permission = "read"silently re-granting exactly what the per-server flag refused. None of that server’s hinted tools is reachable atreadwithout an explicit override.server.permission = "unrestricted"on the whole server (step 2 wins), ordisabled_toolsto remove the tool entirely.
The hint is trusted by default because most servers annotate honestly and requiring per-tool config for every server would make read impractical. trust_read_only_hint is the switch for a server you have not audited.
Stale config: entries in allowed_tools / disabled_tools / eager_load_tools / tool_permissions that don’t match any advertised tool get a warn! line at connect time. The server still connects; you just see a heads-up so you can clean up after the server renames a tool. A name that appears in both eager_load_tools and disabled_tools also warns: the disabled filter wins, so eager-loading the disabled tool is a no-op.
Visibility across levels: the resolved permission doesn’t hide a tool from the agent. Every registered tool is listed in the per-turn context with its required level noted inline, and a [Permission context] section names the current level and whether approvals are on (what each level allows is stated once in the system prompt, and the per-tool levels are in the catalog above it). The agent can still reason about an inaccessible tool and suggest /permission <level> to enable it; the permission gate is enforced at dispatch time. Keeping the tool catalog visible across levels is also what lets the Claude prompt cache survive mid-session permission toggles.
The stdio server’s environment
A stdio server is a child process that talks to the network, and it does not inherit meka’s
environment. It receives the same curated base a shell at read gets (PATH so it can resolve its
own binaries, HOME, locale, TMPDIR), plus whatever the server’s own env table sets.
Configuring a server is a decision to run its code, not a decision to hand it every credential on
the machine: without this, ANTHROPIC_API_KEY, AWS_* and GITHUB_TOKEN all rode along into every
server you had ever added.
The base also carries the machine’s network configuration (HTTP_PROXY, HTTPS_PROXY, NO_PROXY,
SSL_CERT_FILE, SSL_CERT_DIR, NODE_EXTRA_CA_CERTS and the usual siblings), because a server
that cannot see them connects to nothing behind a corporate proxy and fails every call with an
error naming none of the cause. Those say where to go and whom to trust; they grant nothing.
Three families are deliberately left out and have to be requested per server: SSH_AUTH_SOCK, which
is a live credential agent; NODE_OPTIONS, which takes --require and therefore arbitrary code;
and the import paths PYTHONPATH / NODE_PATH / VIRTUAL_ENV, which change what a program loads.
A server that genuinely needs one takes it explicitly:
[[mcp.servers]]
name = "tooling"
transport = "stdio"
command = "my-tooling-server"
env = { PYTHONPATH = "${PYTHONPATH}" }
A server that genuinely needs a secret asks for it by name, and ${VAR} still reads meka’s
environment at connect time:
[[mcp.servers]]
name = "github"
transport = "stdio"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"]
env = { GITHUB_PERSONAL_ACCESS_TOKEN = "${GITHUB_TOKEN}" }
Examples
Exa: web search, which meka has no built-in tool for. The free tier works without an API key; paste a key into the headers table for the paid tier:
# Free tier, no key required
meka mcp add exa https://mcp.exa.ai/mcp
# Paid tier, expands from EXA_API_KEY at connect time
meka mcp add exa https://mcp.exa.ai/mcp --header "x-api-key=${EXA_API_KEY}"
Well-annotated server: no config needed. Every tool is classified by its own readOnlyHint (read tools Read, write tools Write):
[[mcp.servers]]
name = "notion"
transport = "http"
url = "https://mcp.notion.com/mcp"
User-declared trust on an unannotated server (all tools accessible in Read):
[[mcp.servers]]
name = "internal"
transport = "http"
url = "https://mcp.internal/…"
permission = "read"
Overriding a mis-annotated or distrusted tool (one specific tool requires unrestricted):
[[mcp.servers]]
name = "notion"
transport = "http"
url = "https://mcp.notion.com/mcp"
[mcp.servers.tool_permissions]
"notion-do-something-scary" = "unrestricted"
Subset of a server’s tools (only query registers, all others are ignored):
[[mcp.servers]]
name = "pg"
transport = "stdio"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-postgres"]
allowed_tools = ["query"]
Block-list with a narrow exception (all fs tools are Read-accessible except the two destructive ones, which are never registered):
[[mcp.servers]]
name = "filesystem"
transport = "stdio"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem"]
permission = "read"
disabled_tools = ["delete_file", "move_file"]
MCP tools are registered with namespaced names in the format servername__toolname to prevent collisions with built-in tools or between servers.
Tool and resource descriptions returned from MCP servers are truncated at 2048 characters to keep the rendered catalog bounded.
Environment variable substitution
Every string field listed above (command, args, env values, url, headers values, headers_helper) supports ${VAR} and ${VAR:-default} expansion from the process environment. A missing variable with no default is logged at startup. In command or headers_helper it is left literal, naming a program that will not be found; in args, env, url or headers, which reach the other side, it fails closed instead: the server is marked failed and never connected, so a literal Bearer ${TOKEN} or ?key=${KEY} is not sent to anyone. A reference the grammar does not accept (an unclosed ${, an empty name, a default that opens another reference) is treated the same way. A $NAME without braces is not a reference and is passed through, for a child shell to expand. Use this to avoid committing secrets:
[[mcp.servers]]
name = "github"
transport = "http"
url = "https://mcp.github.com"
headers = { X-Api-Key = "${GITHUB_MCP_TOKEN}" }
env, args and headers may contain a secret, but they are not one: env sets a subprocess’s whole environment, args carries connection strings, and headers carries X-Tenant-Id as readily as X-Api-Key. meka cannot tell which is which, so they stay in config.toml and ${VAR} is how you keep a value out of it.
A bearer token and an OAuth client secret are unambiguously secrets, so they are not config at all. They live in the store and are set with meka mcp add --auth-token-stdin / --client-secret-stdin, or afterwards with meka mcp login. See Credentials.
A stored credential is bound to the url it was stored for, ${VAR} expanded, and presented there alone: an OAuth login’s tokens to that exact URL, the resource they were issued for, and a bearer or a client secret anywhere on its scheme, host and port. Point url at another host and the server stops presenting what it holds, and fails to connect saying so, until it is logged in or given its secret again; meka mcp get lists what it holds for another endpoint. A ${VAR} in url that the environment leaves unset binds nothing: storing a secret for the server is refused where it is unset.
[mcp.servers.auth]
OAuth authentication for HTTP MCP servers. Set type to choose the authentication method. This is mutually exclusive with a stored bearer token. Every token request names the server’s own url as its resource (RFC 8707), as the MCP authorization spec requires, so no key states it.
The client secret is not a field here. It is a secret, so it lives in the store: set it with meka mcp add --client-secret-stdin or meka mcp login <name> --client-secret-stdin. See Credentials.
| Field | Required | Description |
|---|---|---|
type | Yes | Auth method: "client_credentials", "client_credentials_jwt", or "oauth" |
client_id | Varies | OAuth client id (required for client_credentials/jwt, optional for oauth with dynamic registration) |
scopes | No | OAuth scopes to request |
signing_key_path | JWT only | Path to PEM private key file |
signing_algorithm | No | JWT signing algorithm: RS256 (default), RS384, RS512, ES256, ES384 |
redirect_port | No | Local port for OAuth authorization code callback. When omitted, meka binds to a random ephemeral port (recommended). oauth only. |
Examples
Stdio server
[[mcp.servers]]
name = "postgres"
transport = "stdio"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-postgres", "postgresql://localhost/mydb"]
permission = "unrestricted"
HTTP server
[[mcp.servers]]
name = "web-tools"
transport = "http"
url = "http://localhost:8080/mcp"
permission = "read"
HTTP server with authentication
The bearer token is not in the file. Store it once with meka mcp add api https://api.example.com/mcp --auth-token-stdin, or meka mcp login api --auth-token-stdin for a server that already exists.
[[mcp.servers]]
name = "api"
transport = "http"
url = "https://api.example.com/mcp"
permission = "unrestricted"
[mcp.servers.headers]
X-Custom-Header = "value"
Stdio server with environment variables
[[mcp.servers]]
name = "github"
transport = "stdio"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"]
permission = "read"
[mcp.servers.env]
GITHUB_TOKEN = "ghp_..."
Multiple servers
[[mcp.servers]]
name = "filesystem"
transport = "stdio"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/projects"]
permission = "read"
[[mcp.servers]]
name = "github"
transport = "stdio"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"]
permission = "unrestricted"
HTTP server with OAuth client credentials
[[mcp.servers]]
name = "api"
transport = "http"
url = "https://api.example.com/mcp"
permission = "unrestricted"
[mcp.servers.auth]
type = "client_credentials"
client_id = "my-client-id"
scopes = ["read", "write"]
client_credentials needs a client secret, which is stored rather than written here: pass --client-secret-stdin to meka mcp add, or meka mcp login api --client-secret-stdin afterwards.
HTTP server with JWT client credentials
[[mcp.servers]]
name = "api"
transport = "http"
url = "https://api.example.com/mcp"
[mcp.servers.auth]
type = "client_credentials_jwt"
client_id = "my-client-id"
signing_key_path = "/path/to/private-key.pem"
signing_algorithm = "RS256"
scopes = ["admin"]
HTTP server with OAuth authorization code flow
On first connection, meka opens a browser for authorization and stores the token for future use.
[[mcp.servers]]
name = "github-mcp"
transport = "http"
url = "https://mcp.example.com"
[mcp.servers.auth]
type = "oauth"
client_id = "my-app-id"
scopes = ["repo", "user"]
redirect_port = 8400
If client_id is omitted, meka attempts dynamic client registration with the server.
[permissions]
Controls which permission levels are reachable at runtime and which level the session starts at. See the Permissions page for what each level does.
| Field | Required | Description |
|---|---|---|
default | No | Level the session starts at. One of "none", "read", "workspace", "unrestricted". Default "read". Overridden by --permission and MEKA_PERMISSION. |
enabled | No | List of levels that can be reached at runtime via /permission and Shift+Tab. Default ["none", "read", "workspace", "unrestricted"]. Disabled levels are skipped during Shift+Tab cycling and refused by /permission with an error. |
approvals | No | Whether a new session starts with approvals on: a tool call needing more than the session’s level is put to you rather than refused. Default false. A session records its own switch afterwards, moved by /approvals, PATCH /v1/sessions/{id} or the ACP approvals config option. |
confine_reads | No | Whether a root session’s reads below unrestricted are confined to its workspace, its additional readable directories and the directories a command needs to run, the way writes are confined to the workspace at workspace. Default false. Instance-wide, read at startup: every root session this process runs is bound by it, and unrestricted is the per-session escape. A sub-agent’s reads are confined to its scope whatever this says. See The read boundary. |
network | No | What a confined command and web_fetch may reach: "host", the machine’s network; "remote", other machines but not this one; or "none". Default "host". web_fetch, which runs in the meka process where no sandbox reaches, holds every value itself: under "none" it is withheld from every session and listed by meka tool list as disabled; under "remote" it refuses this machine’s own addresses (loopback, unspecified, and any address an interface here holds) at the URL, at every redirect and at the resolver, while a machine on the LAN stays reachable; with a proxy configured the proxy resolves the target and only the proxy’s own name is judged, as under [web].refuse_private_destinations. A command holds what its backend can. Under "none", Bubblewrap unshares the network namespace, Landlock refuses every TCP bind and connect and, from ABI v10 (kernel 7.2), every UDP bind and send, sandbox-exec withholds its outbound rule, and the Windows AppContainer grants no network capability. A value a backend cannot hold as such runs as the next narrower one it can, and startup says so once with the value to set that silences the warning: Bubblewrap, Landlock and sandbox-exec hold "remote" as "none", and the AppContainer on schema 1.0 holds "host" as "remote". The Windows restricted token has no say over a command’s network, so a command under it reaches the host at every setting, which startup says once; web_fetch holds the setting there too. An MCP server is a separate process outside the sandbox and keeps its own network: a session that must not reach the network is also given no server that can, through [[mcp.servers]] or a sub-agent’s allow_servers. unrestricted runs the shell unsandboxed, so the setting holds web_fetch alone there, and the scope lines say Network: none (web_fetch). A sub-agent holds its parent’s setting unless its spawn narrows it with network, and the scope lines name what the shell and web_fetch each reach when either is narrowed. A boundary like confine_reads, reaching the in-process tool and the sandboxed shell alike, which is why it lives here and not under [shell]. |
A level meka does not have is refused at parse, with the line. An enabled list that names nothing falls back to read alone, with a warning, rather than to the default set, so an empty list cannot widen authority. If default is not in enabled, meka logs a warning and falls back to read if it’s enabled, otherwise the lowest enabled level (in none → read → workspace → unrestricted order). Same behavior if --permission or MEKA_PERMISSION selects a disabled level: meka warns and starts at the configured default rather than refusing to launch.
[permissions]
default = "read"
enabled = ["none", "read", "workspace", "unrestricted"]
approvals = true # ask me about anything above the level instead of refusing it
confine_reads = true # reads stay within the roots, as writes do at workspace
network = "none" # cut off the network; "remote" keeps other machines reachable
[shell]
Settings for shell command execution.
shell.sandbox
Whether to enable read-only filesystem sandboxing for shell commands at read. When enabled (default), shell commands can be executed at read and workspace but with the filesystem write-protected outside the workspace roots. When disabled, shell commands require unrestricted.
Default: true
[shell]
sandbox = false # disable the sandboxed shell at read
The sandbox uses Bubblewrap or Landlock on Linux, sandbox-exec on macOS, and an AppContainer or a restricted token on Windows, chosen by shell.sandbox_backend where the platform has a choice. On platforms where no backend is usable, shell commands always require unrestricted regardless of this setting.
shell.sandbox_backend
The mechanism a confined command runs under. Each platform takes its own values, and a value from another platform is refused where it would be used: shell_execute at read fails and startup warns, naming the values this platform takes. A config.toml shared across machines leaves the key unset or sets it per machine through MEKA_SANDBOX_BACKEND. macOS has one backend, sandbox-exec, and takes no value.
Linux takes "bubblewrap", "bubblewrap-landlock" and "landlock":
- Bubblewrap (
"bubblewrap") wraps the command inbwrapwith read-only bind of/, tmpfs masks over/run//tmp//var/tmp/$XDG_RUNTIME_DIR, and--unshare-user --unshare-pid --unshare-uts --unshare-ipc. The tmpfs masks hide the dbus session bus and the systemd-user socket, so state-changing IPC calls likesystemctl --user startanddbus-sendfail. Network is intentionally not unshared socurl http://x | pdftotextstill works. On a kernel with Landlock ABI v6 (6.12) or newer the command also runs inside meka’s Landlock ruleset, enacted bymeka confineafter the mounts, which closes the abstract namespace, signals and device ioctls, and from kernel 7.1 the sockets the masks do not cover. Requires thebubblewrappackage and a kernel with user-namespace creation enabled. - Bubblewrap with Landlock (
"bubblewrap-landlock") is Bubblewrap with the Landlock layer inside required rather than added where the kernel allows: on a kernel below 6.12 the backend is unusable andshell_executeatreadfails, so a pinned value guarantees the layer is applied. What the layer closes still follows the kernel: the abstract namespace, signals and device ioctls from 6.12, sockets on disk outside the masks from 7.1. Auto-detection never picks it; pin it on a host where that guarantee matters more than a working shell. - Landlock (
"landlock") uses the Landlock LSM to block filesystem writes, and requires ABI v9 (kernel 7.1+): the first ABI that can refuseconnect()to Unix sockets on disk, which is the dbus / systemd-user route out of the sandbox. On an older kernel meka reports the backend unusable rather than leave that door open; install Bubblewrap there. Closing it also costs socket-based clients likedockerandpsqlatread. meka’s own directories are hidden by per-sibling read grants, and each command gets a private temporary directory named byTMPDIR. No ABI mediates file metadata, sochmod,chown,touchandsetxattrstill succeed atread; that is what the startup warning names.
Windows takes "appcontainer" and "restricted-token":
- AppContainer (
"appcontainer") runs the command inside a process security environment the OS enforces, on a Windows 11 build that has the API: 26H2, where meka measured it, and 24H2 and 25H2 from the August 2026 update, where Microsoft lists it. meka’s config directory and store are denied, the read boundary holds for the shell as it does for the file tools,network = "none"cuts a command off, and nothing is written to your directories’ ACLs. See Shell for what it covers and its limits. - Restricted token (
"restricted-token") runs the command under a token derived from meka’s own: Low integrity atread, write-restricted to the workspace roots atworkspace. It confines writes only: a command can read meka’s store,confine_readsholds for the file tools alone, andnetwork = "none"forweb_fetchalone. That is what the startup warning names when it is the automatic choice.
When omitted, meka probes the platform’s stronger backend once at startup, Bubblewrap on Linux and AppContainer on Windows, and auto-picks it when usable; AppContainer counts as usable at schema 1.1, and a build whose API speaks 1.0 is named in the warning, since pinning "appcontainer" takes it there with network = "host" held as "remote". Otherwise it auto-picks the fallback, Landlock or the restricted token, and emits a one-shot warning naming what the fallback gives up. Set the field explicitly, to the fallback included, to suppress that warning. No command writes this field; leave it unset to keep auto-detection.
If the configured backend can’t be used at runtime (bwrap not installed, user namespaces denied, the AppContainer API missing on this build, a value from another platform), shell_execute at read hard-errors with a message naming the configured backend, the specific failure reason and the key to fix. read is not blocked for other tools; only shell_execute requires a usable sandbox.
Overridable for one run with meka --sandbox-backend <value>, and for a whole environment with
MEKA_SANDBOX_BACKEND. Precedence is flag, then environment, then this field.
Default: unset (auto-detect).
[shell]
sandbox = true
sandbox_backend = "bubblewrap" # Linux; "appcontainer" or "restricted-token" on Windows
shell.additional_readable_directories
Directories every confined command may read beyond the system directories, wherever reads are confined: with permissions.confine_reads on, and for every sub-agent, whose reads its scope confines. Additional beyond the system directories, as the flag --additional-readable-directory is per run. The system directories are where the operating system keeps the programs, libraries and configuration a command needs to run at all, and the list is deliberately system-level: on Linux /bin, /sbin, /usr, /lib, /lib32, /lib64, /etc, /proc, /dev, /nix/store and /run/current-system/sw; on macOS /bin, /sbin, /usr, /etc, /System, /Library/Apple, /Library/Preferences, /Library/Filesystems/NetFSPlugins, /var/db, /opt/homebrew, /Library/Developer and /dev, plus the active developer directory xcode-select records, since the developer commands in /usr/bin are stubs that run the real binaries from there. Each is taken only where it exists. The skill directories [skills].additional_directories names are readable too. Add here what else this machine keeps system software under: /opt, /snap, a toolchain outside the home directory. An entry is warned about and dropped when it does not resolve, when it is relative, or when it is a directory the sandbox masks (/tmp, /run, the runtime directory, the filesystem root, meka’s own directories), since Bubblewrap binds the readable directories after its masks and /run named here would hand the session bus back to a confined command. On Windows the system directories are the Windows directory, Program Files, Program Files (x86) and ProgramData, located by the environment; under the restricted token the shell’s reads are not confined, so the list reaches the file tools alone there.
Default: []
[shell]
additional_readable_directories = ["/opt", "/snap"]
[tools]: built-in tool filters
The three knobs [[mcp.servers]] exposes for MCP tools also apply to meka’s built-in tools (file_read, file_write, shell_execute, etc.) via a top-level [tools] table. MCP per-server filtering is separate from this and keeps its own namespaces; this block only affects the built-ins.
| Key | Purpose |
|---|---|
allowed_tools | Optional allow-list of built-in tool names. When set and non-empty, only these built-ins register, with two exceptions: the seven MCP meta-tools register regardless, because they are how the agent reaches a configured server’s resources and prompts at all, and so does context_replace, compaction’s own plumbing, which no list reaches and which is refused outside a checkpoint. Naming a meta-tool here is inert and warns at startup; use disabled_tools to remove one. Use meka tool list to see the canonical names. |
disabled_tools | Block-list of built-in tool names. Applied after allowed_tools; a tool here is never registered even if it also appears in the allow-list. |
tool_permissions | Per-tool required-permission override keyed by built-in name. Beats the hardcoded required level from the tool’s impl. Levels: none, read, workspace, unrestricted; any other value is refused at startup, naming the line. |
Stale entries (a name that matches no built-in) emit a warn! at startup. meka still starts; the warning just flags a likely typo or a tool the binary renamed.
Restrict a session to read-only inspection:
[tools]
allowed_tools = ["file_read", "file_find", "file_search", "web_fetch"]
Force shell_execute to need unrestricted, so a session below that with approvals on prompts for every shell call:
[tools.tool_permissions]
shell_execute = "unrestricted"
Disable web access entirely in a locked-down environment:
[tools]
disabled_tools = ["web_fetch"]
Sub-agents spawned via agent_spawn inherit the same filter; a disabled built-in is disabled everywhere. To take something away from sub-agents only, use [subagents]. Run meka tool list to see every built-in’s effective required permission, whether a [tools.tool_permissions] override is in effect, and whether the current config enables it.
[subagents]
How deep sub-agents may nest, what one may never hold, and the one choice its parent may make for it. Where [tools] restricts everyone, this block applies only to sub-agents.
| Key | Type | Default | Description |
|---|---|---|---|
max_depth | int | 3 | How deep sub-agents may nest; 1 lets only the root spawn, 0 withholds agent_spawn |
disabled_servers | list | [] | MCP servers a sub-agent cannot see at all |
disabled_tools | list | [] | Individual tool names a sub-agent cannot see |
agent_chosen_profile | bool | false | Let the spawning agent choose the profile a sub-agent runs on |
[subagents]
max_depth = 2
disabled_servers = ["mekabridge"]
disabled_tools = ["mcp__notion__create_page"]
max_depth bounds nesting through agent_spawn. The root agent spawns at depth 1, its sub-agents at depth 2, and so on; each level below the cap is granted its own agent_spawn. 1 lets the root spawn and nothing below it; 0 withholds agent_spawn from everyone, and the four lifecycle tools with it. An agent can narrow a subtree with the tool’s max_depth parameter, never widen it, and a built-in absolute cap bounds real nesting whatever either says.
disabled_servers is the one that matters. Naming a server removes everything it offers from every sub-agent: its tools, its resources, and its prompts. Reach for it when a server exists to talk to you or to act on your behalf. The motivating case is a server that can message the user: without this, a sub-agent three levels down can send a message the user has no way to distinguish from the one they are actually talking to.
disabled_tools takes names as they appear in the tool list, so built-ins (file_write) and namespaced MCP tools (mcp__notion__create_page) share one namespace. For a whole server, prefer disabled_servers: it covers the resource and prompt surfaces that a tool-name list cannot reach.
An entry matching nothing emits a warn! at startup, the same way [tools] does. A typo here denies nothing while reading as a restriction, which is worse than writing no config at all.
These are floors. An orchestrator can restrict a particular sub-agent further with agent_spawn’s allow_servers, allow_tools, deny_servers and deny_tools parameters, and each level of nesting inherits everything above it, but nothing can grant back what this block took away: a call-site allow-list only narrows, and a tool named here is withheld whatever an allow-list says.
agent_chosen_profile = true lets an orchestrator run a sub-agent on a profile other than its own: agent_spawn gains a profile parameter whose choices are every configured profile, so a mid-tier model can dispatch hard tasks to an expensive one and trivial ones to a cheap one. It is off by default because a sub-agent then bills whatever account the chosen profile names, and that is a decision to make once, in the config, rather than one the model makes on every spawn. The agent only sees profile names, so say in your standing instructions what each profile is for. A sub-agent spawned with a profile keeps it on every follow-up; one spawned without follows its parent’s profile.
Why memory and instructions are not configured here
Two things a sub-agent might inherit are deliberately absent: the memory store and the instructions file. Both are granted per call by agent_spawn and default to nothing.
The distinction is what config can actually enforce. A capability can be withheld: a tool the registry never registered cannot be reached, however the parent phrases the task. Context cannot. An agent holding the instructions has them verbatim in its own system prompt, and one with memory_read can read any memory, so either can be copied into a sub-agent’s prompt whatever config says. A [subagents].memory = "none" key would look like a boundary while stopping only the sub-agent’s own browsing, not the content reaching it, and a control that reads as a guarantee but isn’t one is worse than none.
The other half of the argument is that the config guardrail existed for a failure mode that no longer applies. It was there because the parent might forget, which only matters for things that are on by default. Both of these now default to off, so forgetting produces a clean sub-agent.
[instructions]
Where the standing instructions are read from beyond the conventional path. See Reading a project’s AGENTS.md in the Instructions guide.
| Key | Type | Default | Description |
|---|---|---|---|
files | array | [] | Files, or directories of files, read into the standing instructions when a session opens |
[instructions]
files = ["AGENTS.md", "~/notes/meka-site.md"]
A relative entry resolves against the session’s working directory, which is how AGENTS.md names whichever project the session opens in; an absolute one is read as given, and a leading ~ is expanded. The text follows the standing instructions under the same heading. It is read when the session opens rather than at startup, so a meka -c or a new serve session sees an edit. An entry that is not there is skipped without a word; one that cannot be read is skipped with a warning.
Empty by default, and deliberately so: a relative entry lets whatever sits in a session’s directory speak with the operator’s authority, and under meka serve the client chooses the directory. Name one only where every directory a session may open in is yours to trust.
[skills]
Controls the skill store. See the Skills guide.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Register skill_read / skill_search and render the skills index |
agent_managed | bool | false | Additionally register skill_write, skill_edit and skill_delete |
additional_directories | array | [] | Directories to scan beyond meka’s own store, read-only |
[skills]
enabled = false
Setting enabled = false keeps every skill tool’s schema out of every request and renders no skills section. Files already in ~/.config/meka/skills/ are left untouched.
agent_managed = true lets the agent author its own skills. It is off by default because you normally curate that store yourself; it exists for a long-running agent that dispatches sub-agents, where a skill is the only artifact that both survives the session and can be handed to a sub-agent as its task. Sub-agents never receive the authoring tools whatever this is set to. See Letting the agent manage skills.
additional_directories adds directories to the scan beyond meka’s own store. They are strictly read-only: meka never creates them and never writes into them, so an entry that does not exist is simply skipped and leaves nothing behind. A leading ~ is expanded. Where reads are confined, these directories stay readable, as meka’s own skills store does, so a skill’s bundled files can be read; see the read boundary.
[skills]
additional_directories = ["~/.agents/skills"]
~/.agents/skills is the cross-client convention, so pointing at it makes skills installed by other Agent Skills clients visible here. It is not a default: reading a directory outside meka’s own namespace is your call. meka’s own store is searched first and wins a name collision. There is no automatic project-level scan, for the same reason meka reads nothing from the working directory that config does not name, the files under [instructions] included; name the path here if you want a project’s skills read. See Reading skills from other directories.
An entry that repeats an earlier one, or that names meka’s own skills directory, is dropped with a warning: it would otherwise be scanned twice and every skill in it reported as shadowed by itself. An empty string is dropped too, since it would expand to your home directory.
[memory]
Controls the agent’s durable note store. See the Memory guide.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Register the memory_* tools and render the memory index |
inline_max_bytes | integer | 32768 | Bytes the bodies of the in-force inline memories may total; a write past it is refused |
[memory]
enabled = false
Setting enabled = false keeps the five memory_* tool schemas out of every request and renders no memory section, which is worth doing for lean sessions that will never use it. Memories already stored are left untouched, and meka memory still reaches them.
inline_max_bytes bounds what the agent carries whole in its context. The inline band is rendered into the opening message once per session and again after each compaction, so a larger budget costs prefix tokens the cache holds rather than tokens on every turn. A write that would take the inline tier past it is refused at every door, the operator’s included, and a store already past it (the key lowered, or rows written past meka’s doors) shows the bodies that fit and lists the rest by description until they are trimmed; see The inline budget. Zero is refused at startup.
There is deliberately no environment variable and no CLI flag here: whether an agent keeps memories is a property of the installation, not something to vary per run.
[schedule]
Controls the wakeups the agent schedules for itself. See the Scheduling guide.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Register the schedule_* tools and run the scheduler |
poll_interval | duration | "10s" | How often due jobs are checked; "0s" is refused at startup |
missed_grace | duration | "24h" | How late a one-shot job may be and still fire after downtime |
gate_timeout | duration | "30s" | Wall-clock budget for a gate probe; "0s" is refused at startup |
claim_lease | duration | "1h" | How long a host’s claim on a due occurrence is good for |
max_jobs | int | 50 | Per-session ceiling, refused at schedule_create; 0 is refused at startup |
max_consecutive_fires | int | 5 | Per-session ceiling on turns spent in one sweep |
[schedule]
enabled = true
poll_interval = "10s"
missed_grace = "24h"
gate_timeout = "30s"
claim_lease = "1h"
max_jobs = 50
max_consecutive_fires = 5
poll_interval is the real resolution floor: a job whose interval is shorter than the tick fires once per tick, not once per interval.
missed_grace applies only to one-shot jobs. Recurring jobs need no equivalent, because their occurrences are one period apart, so the most recent missed one is always less than a period old; the scheduler coalesces the rest into a single catch-up fire.
claim_lease is how long a crashed host’s occurrence stays unavailable before another host takes it. A due job is leased rather than consumed, so the row survives until the turn is delivered and a host that dies mid-delivery costs a retry rather than the occurrence. Raise it only if a gate probe plus a turn could plausibly exceed an hour; lowering it below that risks a second host taking an occurrence the first is still running, which the session lock catches at the cost of a deferral and a re-run gate probe. A host refuses to start on a value at or under gate_timeout, since a lease that cannot outlast the host’s own probe is never right; that check does not cover the turn after the probe, which is unbounded, so leave headroom on top of it.
max_consecutive_fires interleaves sessions: without it, one session’s whole backlog runs to completion before another session’s single due job is reached. Jobs past the budget keep their occurrence, run no gate, and are taken by the next sweep most-overdue first. It bounds a batch rather than a rate: sweeps do not overlap and the next starts as soon as the last ends, so a backlog still produces one turn per job, just in interleaved groups. 0 is refused, since it would hold every job over forever; use enabled = false to turn scheduling off.
Setting enabled = false keeps the three schedule_* tool schemas out of every request and leaves existing jobs on disk without firing.
As with [skills] and [memory], there is no environment variable and no CLI flag: whether an agent may schedule its own turns is a property of the installation.
[background]
Controls tool calls the agent starts and does not wait for. See the Background tasks guide.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Offer the background parameter and register the task_* tools |
max_tasks | int | 10 | Concurrent tasks per session, refused at dispatch; 0 is refused at startup while enabled |
[background]
enabled = true
max_tasks = 10
Alone among the capability blocks, this one is off by default. [schedule], [skills], and [memory] add capability without changing when a turn ends; this changes the contract of the primary interaction into “you asked, it answered, and something else may interrupt you later”. That is right for an unattended assistant and wrong for someone using the screen as a command line. A scheduled job also takes an explicit act to create, whereas background is reachable from any tool call, so an agent will reach for it unprompted.
Setting enabled = false keeps the background property out of every tool schema and the two task_* tools out of every request, rather than advertising a parameter that would only ever be refused.
Outcome delivery shares [schedule].poll_interval, so that key sets how long a finished task waits before it is reported, whether or not scheduling itself is enabled.
Config-only, like the blocks above: no environment variable, no CLI flag.
[session]
Settings for session history retention and context window management.
session.retention
Delete sessions not updated for longer than this, at startup and then hourly for as long as meka serve, meka acp or the screen stays up. A duration string like "30d" or "12h". Uses updated_at, so an actively-resumed session is preserved even if created long ago. Deletions are reported at warn level.
The screen’s input history, the lines Up and Ctrl+R recall, is swept with it: an entry older than this window, or than turn_retention when that is the shorter, is deleted, since what a person typed must not outlive the conversation it went into.
Three kinds of session are spared whatever their timestamp says. A session another meka process has open is skipped, and the sweep reports how many: only turns bump updated_at, and resuming does not, so an interactive session left open past the window looks expired while somebody is in front of it. A pinned session is never expired, nor is any parent of one: a pin is you saying keep this. And a session with a scheduled job still ahead of it is never expired, nor is any parent of one: a gated watcher that evaluates every tick and rarely fires looks untouched for exactly as long as it is working, and deleting it would take the schedule with it.
Default: unset, meaning nothing is deleted. "0s" is refused at startup, since it would delete everything on every launch. Conversation history isn’t reproducible, so meka keeps it until told otherwise. Use meka session remove --older-than-days <DAYS> to prune manually instead.
[session]
retention = "30d"
session.turn_retention
Delete the turns of every session that ended longer ago than this, once a compaction has summarized them. A duration string like "90d" or "36h". The sweep runs at startup, at the start of each turn for that turn’s session, and hourly for as long as meka serve, meka acp or the screen stays up.
Only turns before a session’s latest compaction can go, since the summary is what stands in for them: the turns the model still works from are never deleted out from under it. When one of those is older than the window, the next turn compacts first, keeping no rounds verbatim and quoting none, and the sweep takes it afterward. Needs auto_compact = true for that reason. A turn goes whole: its messages, its record, its words in the search index and the images only it carried. Inbox items and background outcomes go once they have been delivered or withdrawn for longer than the window. Scratchpad entries and memories are the agent’s own and stay.
The model is told the window in its system prompt, and every turn’s context block names the date nothing precedes, so it knows how far back conversation_search reaches and to keep in memory what must outlive the window. See What the agent sees.
Default: unset, meaning every turn is kept for the life of its session. "0s" is refused at startup, since it would compact and delete the conversation on every turn. Deletions are reported at warn level.
[session]
turn_retention = "90d"
session.auto_compact
Automatically compact the conversation once it is past context_ceiling_percent of the context window, between turns or between two tool rounds of one turn. Compaction summarizes older messages and preserves recent ones, the open checklist, and scratchpad entries. Off changes nothing else: a whole scratchpad_read still stops at the ceiling, and a request past the window fails the turn.
Default: true
[session]
auto_compact = false
session.context_ceiling_percent
The share of the context window meka lets the conversation fill on its own. Two things happen at the line: with auto_compact on, the conversation is compacted once past it; and a whole scratchpad_read that would carry the context past it is cut there and says where to continue, whether or not compaction is on. Refused outside 1 through 100.
What is left above the line has to hold the reply and one round’s growth past it, so keep at least your output budget plus a round free: the API refuses a request whose input and output cap together exceed the window. The default leaves 150k tokens on a 1M window against a Claude reply budget of 128000; on a small window it needs lowering, or max_output_tokens does.
Default: 85
[session]
context_ceiling_percent = 70
session.compact_checkpoint
Run a checkpoint turn before each compaction, in which the agent saves anything that must outlive the window and writes the replacement summary itself. See Compacting a session.
Costs one extra model call per compaction. Turning it off falls back to a standalone summarizer that has no tools and none of the standing instructions, so it cannot save to memory and cannot apply any judgment about what this particular deployment is for.
Note that this applies to automatic compactions too, so an unattended checkpoint can write memory with nobody watching.
Default: true
[session]
compact_checkpoint = false
session.context_window
Override the model’s context window size (in tokens). Used for the context ceiling. A per-profile [profiles.<name>].context_window takes precedence over this.
When neither is set, meka assumes 1000000. It does not infer the window from the model name, query the provider’s models API, or cache anything: the window is a local budgeting number that is never sent on the wire, so a wrong value can’t fail a request, and the user is the one who knows the truth.
1M suits the current flagship models and overshoots the smaller and older ones. Overshooting is survivable rather than free: planned compaction never fires, so those sessions compact only after the provider rejects an over-long request, paying a wasted round trip each time. Set the real window on any profile whose model is smaller.
[session]
context_window = 200000
[thinking]
Presentation and budget settings for extended thinking (anthropic-messages and claude-subscription backends). Whether thinking is on, and which wire encoding it uses, is the per-profile thinking key, not a setting here.
While the model is thinking, the screen draws a live • Thinking row so a long pause reads as work rather than as a hang. On claude-subscription it carries the server’s own running estimate (• Thinking · 150 tokens), redrawn in place as the count climbs; anthropic-messages does not report one, so the row stays bare. The count is coarse: a progress signal, not an accounting figure.
When the block ends the line stays on screen as a record that the phase happened; if the model returned readable reasoning, that text replaces the line instead.
thinking.budget
Maximum number of tokens the model can use for thinking. Read only under thinking = "budgeted"; the adaptive encoding lets the model set its own budget and sends no cap. A per-profile [profiles.<name>].thinking_budget takes precedence over this.
Default: 16000
[web]
Settings for the HTTP client web_fetch uses. All keys are optional; unset fields use the defaults shown below.
| Key | Type | Default | Purpose |
|---|---|---|---|
user_agent | string | Real Chrome UA | Some sites block non-browser UAs. Override if you need a specific identifier. |
request_timeout | duration | "30s" | Total request budget (connect + TLS + read). "0s" is refused at startup. |
connect_timeout | duration | unset | Separate cap on TCP + TLS handshake. Fail fast on unreachable hosts without shortening the whole request budget. "0s" is refused at startup. |
read_timeout | duration | unset | Per-chunk idle timeout. Catches bodies that stall mid-stream. "0s" is refused at startup. |
max_redirects | int | 10 | Cap on 3xx hops. 0 means no redirects are followed: a 3xx is returned as the response. |
proxy | string | unset (honors HTTP_PROXY / HTTPS_PROXY / ALL_PROXY env) | Proxy URL. Schemes: http://, https://, socks5://, socks5h://, socks4://. The literal string "none" explicitly disables env-var auto-detection. |
ca_cert_file | path | unset | Extra PEM bundle to trust on top of the system store. Useful for corporate MITM proxies or self-signed internal services. Accepts single-cert and multi-cert files. |
https_only | bool | false | Refuse plain http:// URLs. |
min_tls_version | string | unset (reqwest default) | Minimum TLS version. Accepts "1.0", "1.1", "1.2", "1.3". Unknown values log a warning and fall through. Note: the bundled rustls backend supports only TLS 1.2 and 1.3; "1.0" / "1.1" will surface a build error. |
danger_accept_invalid_certs | bool | false | DANGEROUS. Disable TLS certificate validation entirely. Emits a warn! on every startup when enabled. Only use against trusted local dev servers. |
danger_accept_invalid_hostnames | bool | false | DANGEROUS. Accept certificates whose hostname doesn’t match. Emits a warn! on every startup when enabled. Only use against trusted local dev servers. |
refuse_private_destinations | bool | false | Refuse a fetch whose destination is a loopback, private, link-local, carrier-grade NAT or unspecified address, judged on every connection: the URL as given, every redirect hop, and every address a host name resolves to, so a name that answers differently twice cannot slip past. Off by default, since a local service is often exactly what a fetch is for. With proxy set, the proxy resolves the target and the guard sees only the proxy’s own name, which it judges too: a proxy on this machine is named by its address, 127.0.0.1, not localhost. |
Example: corporate proxy with a private CA
[web]
proxy = "http://corp-proxy.internal:3128"
ca_cert_file = "/etc/ssl/corp-root-ca.pem"
min_tls_version = "1.2"
request_timeout = "60s"
Example: local testing against self-signed certs
[web]
# Route everything through a local SOCKS proxy you control.
proxy = "socks5h://127.0.0.1:1080"
# Accept self-signed certs on dev.local, KEEP THIS OFF IN PROD.
danger_accept_invalid_certs = true
Example: fail-fast timeouts
[web]
request_timeout = "5s"
connect_timeout = "2s"
max_redirects = 0
[display]
Settings for the interactive screen: the two rows it composes, whether finished steps fold, the
session id lines, what Enter does while a turn runs, the mouse, and stream, which also decides how
a one-shot run asks the provider.
display.status_row
Which facts the status row under the composer shows, each in its place and, within it, in the order listed. On the left, after the permission level, which always opens the row, the profile and the model say what the next prompt goes under, and the path and the project where the session works; the context gauge and the running work stand against the right edge.
path: the session’s working directory, shortened with~project: the project the session is on, by name, while it is on oneprofile: the profile the session runs on, by namemodel: the model the profile runs, when the config names one; off by default, since a profile named for its model would show it twicecontext: the live context-window gauge, e.g.128.4k / 1.0M (13%)(tokens in context / model window, and the share used), the same figures/statusshows on itsContext:line; shown from the start at0 / <window> (0%), omitted only while the window is unknowntasks:2 background tasks, while any runsubagents:1 sub-agent, while any run
A hint (Copied 3 rows, The question was withdrawn) takes the status row’s right side while it
stands, whatever the list says. Where the row is narrow, the level stays whole and the readings give
way to it, each shown whole or not at all, from the last, since a reading cut short reads as another
number; the facts after the level give way from the last too: the one that no longer fits is cut
(a path from its start), or dropped when too little of it would show. A word that is not one of
these, or one listed twice, is refused by name.
[display]
status_row = ["profile", "context"]
Default: ["profile", "path", "project", "context", "tasks", "subagents"]
display.turn_end_summary
What the row that closes a turn states, in the order listed:
time: when the turn ended, as a time of day today and with the date otherwiseduration: how long it ranusage: the tokens it used, with the share of the input that came from the cache
The row reads as one sentence whose first word says how the turn ended:
• Finished at 19:28 after 19m 19s
• Interrupted at 19:30 after 2m 3s
• Finished at 19:28 after 19m 19s with 12.3k tokens in, 1.2k out, 96% cached
A fact with nothing to say is left out. An empty list draws no row. A word that is not one of
these, or one listed twice, is refused by name. On a resumed session every recorded turn closes with the same row, read
from the store, so a scrollback says when each turn ended. A one-shot run prints none; --format json carries the turn’s usage.
The usage’s in count is the total of all three Anthropic input tiers (live, cache-write,
cache-read); the cached share is cache_read / in. The /status slash command surfaces the
session’s cumulative numbers in the same vein.
[display]
turn_end_summary = ["time", "duration", "usage"]
Default: ["time", "duration"]
display.fold_steps
Whether the interactive screen folds a run of thinking and tool calls into one row, such as
▸ Thought twice, ran 3 commands, read a file, once the agent writes something after it or its turn
ends. Changes to files, memories and skills, and agent_spawn and agent_followup calls, never
fold. Ctrl+O opens every run, with every call expanded, until it is pressed again. With false,
every run starts open and still folds when you click its row. See
Folded steps.
Default: true
[display]
fold_steps = false
display.show_session_id_on_create
Whether the screen shows the session id when a new session is created.
Default: false
display.show_session_id_on_resume
Whether the screen shows the session id when a session is resumed with -c, -r or /resume.
Default: false
display.show_session_id_on_exit
Whether the screen shows the session id when meka exits.
Default: true
A one-shot run prints no session id whatever these say: --format json carries it as
session_id, and -v logs it.
[display]
show_session_id_on_create = true
show_session_id_on_resume = true
show_session_id_on_exit = false
display.send_class
What Enter does on the interactive screen while a turn is running: steer posts the message to the
session’s inbox, where the agent reads it at its next round; followup holds it and sends it as the
next turn once this one ends; interrupt cuts the answer being streamed and hands the agent the
message at once. Alt+S cycles the class for one run. See
While a turn runs.
Default: steer
[display]
send_class = "followup"
display.mouse
Whether the interactive screen captures the mouse. With it on, the wheel scrolls the transcript and a click expands or collapses a block; the terminal’s own click-and-drag then needs its modifier key (Shift in most terminals, Option in iTerm2) to select text. With it off the mouse is the terminal’s, and the keyboard still scrolls and toggles. See Scrolling and the mouse.
Default: true
[display]
mouse = false
display.wheel_rows
How many rows the transcript, or a panel under the pointer, moves for each wheel event the terminal
sends. The terminal already decides how many events one notch of the wheel sends: Ghostty sends
three, a trackpad sends one per few pixels, and VS Code’s terminal sends one, so one row per event
follows what the terminal meant. Raise it if your terminal sends one event per notch and scrolling
feels slow. 0 is refused at startup.
Default: 1
[display]
wheel_rows = 3
display.stream
Whether the provider is asked to stream its reply or to return it whole. On the screen a streamed
answer is drawn as it arrives and a whole one lands when the turn ends; a one-shot run prints its
answer when the turn ends either way. The --no-stream flag turns streaming off for one run; this
key is the standing preference, and it applies to sub-agents as well.
Default: true
[display]
stream = false
[serve]
Configuration for meka serve, the HTTP API server. See the HTTP API usage guide for a full walkthrough.
A ${VAR} in a [serve] value (a token, a webhook URL) is expanded from the process environment by the grammar the MCP fields use, ${VAR:-default} included. A name the environment does not supply and no default covers refuses the server at startup, since a token left reading ${VAR} would be an auth-bypass-shaped configuration; so does a reference the grammar does not accept (an unclosed ${, an empty name, a default that opens another reference), which would otherwise become the token as written.
serve.bind
Address and port the HTTP server listens on.
| Type | Default |
|---|---|
string | "127.0.0.1:8080" |
[serve]
bind = "0.0.0.0:8080"
Security: Binding to
0.0.0.0exposes the server on all interfaces. In production, keep127.0.0.1and front with a TLS-terminating reverse proxy.
serve.cors_allowed_origins
Browser origins allowed to call the API cross-origin, for a web application served from somewhere other than meka itself. Omitted or empty, the server sends no CORS headers at all, and a browser refuses every cross-origin call.
| Type | Default |
|---|---|
array of string | [] (cross-origin access off) |
[serve]
cors_allowed_origins = [
"https://owner.github.io",
"http://localhost:5173",
]
An origin is a scheme, a host and a port, and nothing else: https://owner.github.io/mekaweb/ has the origin https://owner.github.io, and a path cannot narrow the grant. Each entry is normalized at startup to what a browser sends in Origin (lowercase host, default port dropped, a trailing root slash tolerated), and a request is granted only when its origin matches an entry exactly: another scheme, port or subdomain is another origin. An entry with a path, query, fragment or credentials, a non-HTTP scheme, null, or a pattern such as https://*.example.com is refused at startup.
["*"], alone, grants any origin. That is safe here because the API authenticates with a bearer header the page sets itself and never with a cookie: a page without the token gets a 401 from any origin, and a page holding it can use it from anywhere regardless. The allowlist guards only what needs no token: the two health probes, the opt-in OpenAPI document and the body of a 401. Name your origins where you can; use * for a LAN deployment reached from several device addresses. Browser clients describes what the grant covers.
serve.max_body_bytes
Maximum request body size in bytes. Requests exceeding this limit are refused with 413 Payload Too Large. 0 is refused at startup; omit the field for the default.
| Type | Default |
|---|---|
integer | 10485760 (10 MiB) |
serve.relay_provider_errors
Whether a 502’s payload carries the provider’s own response text, as a provider_response member
alongside detail.
On by default. The upstream’s error type is the actionable part of a failed turn, and “consult the
server log” is no answer to anyone driving a meka they do not operate. meka acp honors this key too: the same policy decides what a failed turn’s error.data carries.
What it can expose is usually the upstream’s response body, which can name the operator’s account with the provider and its rate-limit posture: a fact about your billing relationship rather than about the caller or the conversation, which is why this is a switch rather than a decision meka makes for you. Not always, though. The member carries the failing call’s error message, and for some failures that is meka’s own sentence about the call rather than anything the provider sent.
It reaches sessions:r, not only sessions:w. Submitting a turn takes the write scope, but the
failure also rides the terminal turn.failed event, and GET /v1/sessions/{id}/stream replays that
to any reader. Turn this off where read-only tokens go to people who may watch a session but are not
entitled to the account behind it.
[serve]
relay_provider_errors = false
detail is unchanged either way, so a client reading only that sees the same sentence and a
context overflow keeps its “shorten it before retrying” remedy. Off, the member is simply absent
and the text goes to the server log alone.
Bounded to the provider’s own response. A required MCP server that is down still reports only the
server names under /errors/mcp-unavailable, because that reason is meka’s own subprocess text and
has carried a command line and its filesystem path. This key does not turn that on.
| Type | Default |
|---|---|
boolean | true |
serve.docs
Whether to serve the OpenAPI document at /v1/openapi.json.
Off by default. It is the only route on the surface that takes no bearer token and describes the
deployment rather than reports on it: what it publishes is the shape of every endpoint you expose.
That is exactly what you want while building a client against a local meka serve, and exactly
what you do not want reachable from anywhere else. Turn it on deliberately.
[serve]
docs = true
| Type | Default |
|---|---|
boolean | false |
serve.max_concurrent_turns
Process-wide cap on in-flight turns across all sessions, counting every turn: one a client submits and one the server starts on its own for an inbox item, a scheduled job or a finished background task. When the cap is reached, a new turn submission returns 429 Too Many Requests with a Retry-After header, and an autonomous turn waits for a free slot (the next turn’s end or the next tick) rather than running past the cap. Leave it unset for no limit; 0 is refused at startup, because a cap of zero would 429 every turn rather than mean “unlimited”.
| Type | Default |
|---|---|
integer | unbounded |
serve.stream_replay_events
How many of a session feed’s latest SSE events to retain, across its turns, so a client reconnecting to GET /v1/sessions/{id}/stream with Last-Event-ID can replay what it missed. The events retained also stay within 16 MiB of payload, the oldest leaving first, since one file edit’s event carries the file before and after.
| Type | Default |
|---|---|
integer | 256 |
Matches the live broadcast channel’s capacity: retaining more than the channel can buffer would let a reconnecting client replay events a connected consumer would have been dropped for missing. Raising it buys a longer reconnect window at the cost of memory for every resident session. 0 switches replay off, so a reconnect receives only what happens from then on and is told its replay is incomplete rather than being handed a silently truncated one.
serve.stream_reattach_grace
How long a held turn keeps running after its request’s client disconnects, waiting for a listener to come back, and how long a parked approval prompt waits after its last attending reader disconnects before it is canceled. Accepts duration strings.
| Type | Default |
|---|---|
string (duration) | "30s" |
Zero subscribers means nobody is listening, and a turn with no audience is spending provider tokens for nothing. That is the right instinct and the wrong deadline: a client whose connection just dropped and one that is never coming back are the same observation until the window expires. Set "0s" to stop a turn at the first round boundary after its request drops, which spends less on abandoned work and makes re-attach useful only for turns that already finished. A turn a request opened with detach: true, and one the server opened, is never stopped for want of a reader; see Holding a turn.
serve.inbox_retry_ceiling
How long a queued inbox item is retried for after a turn on it failed, from when it was enqueued, before it is given up on with inbox.failed. Accepts duration strings.
| Type | Default |
|---|---|
string (duration) | "1h" |
The ceiling bounds the whole wait: a retry never lands past it, whatever the upstream said about when it would be back, so a deployment states its own patience here, and every item shows the deadline it sets as retry_until. Set "0s" to give an item up on its first failure, for a client that owns redelivery itself. A failure no retry would change, a rejected credential or a request the upstream judged wrong, gives the item up at once regardless; one waiting can change, the upstream unavailable, a required MCP server not ready, meka’s own store or disk refusing, is retried within the ceiling. See The inbox.
serve.idle_timeout
How long a session can sit idle (no turns submitted) before the GC evicts it from memory. Accepts duration strings like "24h", "30m", "7d". "0s" turns idle GC off: nothing is ever evicted for being idle.
| Type | Default |
|---|---|
string (duration) | "24h" |
Eviction drops the in-memory runtime but preserves the SQLite row; a later request transparently re-attaches. See delete_on_idle to also remove the row. An open feed keeps its session loaded when its token holds sessions:w, the scope that could load it; a sessions:r reader keeps nothing loaded.
serve.gc_scan_interval
How often the background GC scanner runs. Accepts duration strings; "0s" is refused at startup, since the scanner would then never run.
| Type | Default |
|---|---|
string (duration) | "5m" |
serve.delete_on_idle
When true, idle-evicted sessions also have their SQLite row deleted, unless a pin or a scheduled job anywhere in the session’s tree keeps it, as [session].retention spares one. When false (default), only the in-memory state is dropped and the session can be re-attached later.
| Type | Default |
|---|---|
bool | false |
serve.shutdown_drain_timeout
Maximum time to wait for in-flight turns and tasks to finish during graceful shutdown (SIGTERM / SIGINT). After this timeout, remaining tasks are aborted and the process exits.
| Type | Default |
|---|---|
string (duration) | "30s" |
[[serve.tokens]]
An array of bearer tokens for API authentication. At least one token is required.
| Key | Required | Description |
|---|---|---|
token | Yes* | The bearer token value. Supports ${ENV_VAR} substitution. Mutually exclusive with token_file. |
token_file | Yes* | Path to a file containing the token (one line, trimmed). Mutually exclusive with token. A startup warning is logged if the file is world-readable. |
description | No | Human-readable label for this token (appears in logs). |
scopes | Yes | Array of scope strings. One :r and one :w per subsystem: sessions, skills, memory, schedule, mcp, config. |
* Exactly one of token or token_file must be set.
Inline plaintext tokens log a startup warning; use ${ENV_VAR} or token_file for production.
Examples
Development token (inline):
[[serve.tokens]]
token = "sk_dev_test123"
scopes = ["sessions:r", "sessions:w"]
Production token (environment variable):
[[serve.tokens]]
token = "${MEKA_BRIDGE_TOKEN}"
description = "telegram bridge"
scopes = ["sessions:r", "sessions:w"]
Production token (file-based):
[[serve.tokens]]
token_file = "/etc/meka/bridge.token"
description = "telegram bridge"
scopes = ["sessions:r", "sessions:w"]
Admin token with every scope:
[[serve.tokens]]
token = "${MEKA_ADMIN_TOKEN}"
description = "operator"
scopes = [
"sessions:r", "sessions:w",
"skills:r", "skills:w",
"memory:r", "memory:w",
"schedule:r", "schedule:w",
"mcp:r", "mcp:w",
"config:r", "config:w",
]
Scopes are flat: memory:r does not imply memory:w, and neither implies the other. See the HTTP API scope table for what each permits. An unrecognized scope logs a warning at startup and grants nothing, so a typo like sessions:write is visible rather than silently inert.
[[serve.webhooks]]
Outbound endpoints meka POSTs to when something happens that no client is waiting on: a scheduled job firing, a background task finishing. Omit the block entirely and meka never makes an outbound request.
[[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"]
timeout = "10s"
max_retries = 3
| Key | Type | Default | Notes |
|---|---|---|---|
url | string | required | https:// or http://; supports ${ENV_VAR} |
secret | string | none | HMAC key for X-Meka-Signature; supports ${ENV_VAR} |
secret_file | path | none | Mutually exclusive with secret; chmod 0600 |
events | array | required | One or more of turn.finished, turn.failed, task.finished, schedule.fired, inbox.deferred, inbox.delivered, inbox.failed |
timeout | duration | "10s" | Per attempt; "0s" is refused at startup |
max_retries | integer | 3 | Retries after the first attempt, capped at 10 |
events is required and every name must be recognized. An unknown event is a startup error, not a warning, unlike an unknown token scope: a scope that grants nothing leaves the token working for whatever else it holds, whereas an endpoint whose only subscription is a typo is silently never called at all.
Payloads carry identifiers and metadata, never message content. Omitting secret sends unsigned deliveries and logs a warning. See Webhooks for the payload shape and the signature-verification recipe.
Environment variables
The config file is the recommended way to configure meka. Environment variables are useful for operational overrides; for example, in CI pipelines, containers, or to isolate a per-project config and data directory.
These operational variables override config file values but are overridden by CLI flags.
Accounts and profiles are not configurable via the environment. Profile selection comes from the config file and
--profile; the model and every other model-tied setting come from the selected profile, the endpoint from its account; secrets come from the store viameka account. There are no account or profile env vars. This is deliberate: an ambientOPENAI_API_KEYorMEKA_PROFILEleft in the environment must never silently rebind which account a named profile bills.
meka-specific variables
| Variable | Description | Example |
|---|---|---|
MEKA_PERMISSION | Default permission level | none, read, workspace, unrestricted |
MEKA_INSTRUCTIONS | Standing instructions as a string, overriding the instructions.md file; files named under [instructions] still apply. Equivalent to --instructions. Used by the mekabox container wrapper, which mounts the config directory read-only and so cannot supply a file. | Be terse. |
MEKA_INSTRUCTIONS_FILE | Standing instructions read from this path (a file, or a directory of *.md); files named under [instructions] still apply. For a file you did not choose the location of, such as a Kubernetes ConfigMap. Conflicts with MEKA_INSTRUCTIONS. | /run/secrets/meka-instructions |
MEKA_CONFIG_DIR | Override the default config directory. Points at the meka directory itself (contains config.toml and skills/). The only isolation knob that works on every platform: dirs::config_dir() ignores $XDG_CONFIG_HOME on macOS/Windows. Must be absolute: a relative value is refused at startup, since falling back to the default would act on the real configuration and resolving it against the working directory would load whatever ./config.toml is there. An empty value is unset. | /tmp/meka-test/meka |
MEKA_DATA_DIR | Override the default data directory (where meka.db lives). Same cross-platform escape hatch: dirs::data_dir() ignores $XDG_DATA_HOME on macOS/Windows. Useful for tests, portable installs, and per-project session isolation. Must be absolute, refused at startup otherwise, for the same reason as above and more sharply: meka.db holds every account credential. An empty value is unset. | /tmp/meka-test/data/meka |
MEKA_SANDBOX_BACKEND | Override [shell].sandbox_backend, with the same values per platform. Pinning a value also suppresses the warning the auto-pick prints when it falls back. Used by the mekabox wrapper to pin Landlock in the container without editing the read-only host config. | landlock, appcontainer |
MCP variables
| Variable | Description | Default |
|---|---|---|
MEKA_MCP_TOOL_TIMEOUT | Per-call timeout for MCP tools, as a duration string such as 10m or 90s. Applies to every remote tool invocation; on timeout meka cancels the request and returns an error to the model. A value that does not parse, or a zero, is warned about and ignored. | 10m |
How many servers connect at once at startup is a setting, not a variable: [mcp].stdio_concurrency and [mcp].http_concurrency.
Editor
| Variable | Description | Example |
|---|---|---|
VISUAL, then EDITOR | The editor meka memory edit and meka skill add --edit open. VISUAL is tried first, then EDITOR; with neither set, meka memory edit fails naming both, and meka skill add --edit writes the skill and warns that it skipped the editor. The value is tried as a program name first and split on whitespace only if nothing is there, so code --wait works, and it is never run through a shell. | nvim |
Logging
meka uses the tracing framework. The log level can be controlled with:
| Variable | Description | Example |
|---|---|---|
RUST_LOG | Standard Rust log filter | meka=debug, meka=trace |
If RUST_LOG is not set, the verbosity flag (-v, -vv, -vvv) controls the level:
| Flag | Level |
|---|---|
| (none) | warn |
-v | info |
-vv | debug |
-vvv | trace |
Logs are written to stderr so they do not interfere with agent output.
CLI options
meka [OPTIONS] [COMMAND]
Commands
account
Manage accounts and their credentials. meka account add writes an [accounts.<name>] table to
~/.config/meka/config.toml and keeps its secret in the store; usage, whoami and stats
are the read-only views described under Account info.
meka account add claude --backend claude-subscription
meka account list [--format <FORMAT>]
meka account login claude
meka account remove claude
meka account rename claude personal
meka account usage [NAME] [--format <FORMAT>] # session / weekly windows
meka account whoami [NAME] [--format <FORMAT>] # plan, tier, org, role and local auth status
meka account stats [NAME] [--format <FORMAT>] # lifetime tokens, streaks, per-day counts
See the meka account CLI reference for the full flag list.
profile
Manage profiles: an account plus a model and every model-tied setting. meka profile add writes a
[profiles.<name>] table; nothing here touches a credential.
meka profile add work --account anthropic --model claude-opus-5-5
meka profile list [--format <FORMAT>]
meka profile set work model claude-opus-5-5
meka profile set work effort --unset # back to the default
meka profile use work
meka profile remove work
meka profile rename work daily
See the meka profile CLI reference for the full flag list.
project
Manage projects: the names a session is on, each a memory tier of its own, kept as rows of the store. See Projects.
meka project add webdev
meka project list [--format <FORMAT>] # name, when it was added, memories, sessions
meka project remove webdev # refused while anything is on it
meka project rename webdev web # moves its sessions and memories with it
session
Manage stored sessions: list or search them, show one in full, export one as Markdown or JSON, import a JSON export, fork, rewind, title or pin one, or delete them.
Every <SESSION_ID> is a full id or any unique prefix of one, which is what the listings print.
meka session list [-n <LIMIT>] [--include-children] [--project <NAME>] [--format <FORMAT>] # default limit: 20; sub-agent sessions hidden unless asked
meka session show <SESSION_ID> [--format <FORMAT>] # full id, profile, project, directory, permission, title
meka session export <SESSION_ID> [-o <OUTPUT>] [--format <FORMAT>] # markdown (default) or json; -o - prints to stdout
meka session import <INPUT> # a JSON export; - reads stdin
meka session fork <SESSION_ID> # prints the copy's id
meka session rewind <SESSION_ID> [-n <TURNS>] # default 1
meka session set <SESSION_ID> title|pinned <VALUE> # a title, or true/false
meka session search <QUERY> [-n <LIMIT>] [--include-children] [--format <FORMAT>]
meka session remove <SESSION_IDS>...
meka session remove --older-than-days <DAYS>
meka session remove --all
See Sessions for details.
history
View or clear the input history that powers Up-arrow / Ctrl+R recall (distinct from saved sessions).
meka history list [-n <LIMIT>] [--format <FORMAT>] # default 50; -n 0 shows all
meka history clear
mcp
Manage MCP servers in config.toml and their stored credentials.
meka mcp list [--format <FORMAT>]
meka mcp get <NAME> [--format <FORMAT>]
meka mcp add <NAME> [LOCATION] [ARGS]... [flags] # a URL (HTTP) or an executable (stdio)
meka mcp remove <NAME>
meka mcp disable <NAME>
meka mcp enable <NAME>
meka mcp reconnect <NAME>
meka mcp tools <NAME> [--format <FORMAT>]
meka mcp login <NAME> [--auth-token-stdin | --client-secret-stdin]
meka mcp logout <NAME>
See MCP for the add flags and what each command does.
tool
Inspect built-in tools. meka tool list prints every built-in with its Permission,
the Source of that requirement (builtin, or override from [tools]), its Status
(enabled, deferred, or disabled), and the start of its description. The levels are the ones
a session on this machine would enforce: shell_execute is read where the shell sandbox is on
and a backend is usable, and unrestricted otherwise, so the listing probes the sandbox the way a
session start does and gives the same warning when none is usable.
meka tool show <name> prints one built-in as the model receives it: the same fields, the whole
description and the parameters schema. Under json, the object is the listing’s entry plus
parameters.
meka tool list [--format <FORMAT>]
meka tool show <NAME> [--format <FORMAT>]
skill
Manage the skills under ~/.config/meka/skills/.
meka skill list [--paths] [--format <FORMAT>]
meka skill get <NAME> [--format <FORMAT>] # frontmatter and on-disk paths
meka skill show <NAME> [--format <FORMAT>] # the rendered body
meka skill add <NAME> [flags]
meka skill remove <NAME>
See Skills for the add flags.
memory
Manage the memories the agent keeps.
meka memory list [--project <NAME>] [--format <FORMAT>]
meka memory get <NAME> [--project <NAME>] [--format <FORMAT>] # every stored field
meka memory show <NAME> [--project <NAME>] [--format <FORMAT>] # the body
meka memory add <NAME> --description <DESCRIPTION> [--project <NAME>] [flags]
meka memory edit <NAME> [--project <NAME>] # the body, in $VISUAL, then $EDITOR
meka memory remove <NAME> [--project <NAME>]
meka memory verify [--rebuild]
meka memory export [--directory <DIR>] [--project <NAME>] # default: ./meka-memory-export
meka memory import <DIR> [--force] # the reverse; --force overwrites a memory that exists
--project names the tier a command works in: a project’s, or the
global tier for an empty value. Without it a listing covers every tier, and a name that is in
several projects is refused until one is named. See Memory for the add
flags.
instructions
Show the standing instructions the agent is given.
meka instructions show # the resolved text and where it came from
meka instructions path # the paths checked, and whether each exists
See Instructions.
schedule
Inspect and cancel the wakeups the agent scheduled for itself. There is no create: a job needs a
session for its turn to run in, and the agent creates one through schedule_create.
Every <ID> is a full id or any unique prefix of one, which is what list prints.
meka schedule list [--session <SESSION>] [--format <FORMAT>] # every session's jobs, or one session's by id or prefix
meka schedule show <ID> [--format <FORMAT>] # full prompt, gate command, session, withheld reason
meka schedule cancel <ID>
See Scheduling for details.
acp
Run meka as an ACP agent over stdio. Takes no flags of its own; -c, -r and
--project are refused, and --profile selects the profile a new session starts on.
meka acp
serve
Run meka as a long-lived HTTP service. --bind <ADDR> overrides
[serve].bind. Like acp, it refuses -c, -r and --project: the host creates a session per
request.
meka serve
meka serve --bind 0.0.0.0:8080
Options
-p, --prompt <TEXT>
Run the agent’s first turn immediately with this text as the user message, then stay on the interactive screen for follow-up. Pair with --oneshot to exit after the first turn instead of opening the screen. -p - reads the prompt from stdin, to end of input.
meka -p "list all files larger than 1MB in the current directory" # first turn, then the screen
meka --oneshot -p "list all files larger than 1MB" # first turn, then exit
git diff | meka --oneshot -p - # the prompt is the diff
When omitted, meka opens the screen with no initial input. There is no bare positional prompt, so a mistyped subcommand is an error rather than a session opened with the typo as its first turn.
--skill <NAME>
Invoke a skill as the first turn. Mirrors the slash command /skill <name> [extra...]. -p, if given, is prepended to the rendered skill body as additional context. Pair with --oneshot to exit after the turn instead of opening the screen.
meka --skill download-videos -p "https://example.com/video" # first turn, then the screen
meka --skill download-videos --oneshot -p "https://example.com/video" # first turn, then exit
Errors out with a clean message if the skill name is unknown.
-c, --continue
Continue the most recent session. Takes no value.
meka -c # pick up where you left off
meka -c -p "and now add tests" # …with an opening prompt
Starting fresh when there is no session yet is not an error; meka just begins a new one. With
--project <name>, the most recent session on that project, or a new one on it.
-r, --resume [SESSION]
Resume a specific session, or pick one. The value is the full id or any unique leading prefix;
with nothing after it, the screen opens on a picker: the fifty newest sessions, filtered after /,
Enter resuming the pick and Esc leaving you in a new session.
meka -r # pick from a list
meka -r 550e8400-e29b-41d4-a716-446655440000 # full id
meka -r 550e # prefix; works if unique
meka -r 550e -p "and now add tests" # …with an opening prompt
The value can be left off because nothing on the root command is positional: the prompt is -p,
so meka -r -p "fix it" reads as a pick with an opening prompt. A scripted run has no screen to
pick on, so --oneshot -r needs the session named. With --project <name>, a bare -r picks among
that project’s sessions, and -r <id> moves the named session onto the project.
Errors if the session does not exist, the prefix matches multiple sessions (with the matching ids listed for disambiguation), or the session is locked by another meka instance.
-c and -r are mutually exclusive. Both work with --oneshot, which runs a single turn against the session and exits:
meka --oneshot -r 550e -p "summarize what we decided"
--profile <NAME>
Select which configured profile a session runs on. Takes the name of a profile from
[profiles.<name>], overriding default_profile in the config file. The choice outlives the run:
on a new session it is what gets recorded, and on a resume it rewrites the row.
meka --profile work
The value is a profile name (e.g. work, personal), not an account or a backend. List configured profiles with meka profile list. There is no short form: -p is the prompt.
A new session records the profile it runs on, so meka -c later comes back on it rather than on
default_profile. Passing --profile alongside -c / -r repins the session: the row is
rewritten and it keeps that profile from then on. See
what a resume restores.
There is no
--model,--base-url,--thinkingor--thinking-budget. A profile is an indivisible bundle of an account, a model and every model-tied setting, and a session records which one it runs on rather than a rewritten copy. To change a setting, edit the profile withmeka profile set; to run something different, make a second profile and select it with--profile.
--project <NAME>
Open the session on a project, one meka project add created; see
Projects. A new session records it, the way it records its profile, and
sees the project’s memories beside the global ones.
meka --project webdev
meka -c --project webdev # the latest session on webdev, or a new one there
meka -r --project webdev # the picker over webdev's sessions
meka -r 550e --project webdev # moves session 550e onto webdev
Beside -c and a bare -r, the flag filters the session meka selects; beside -r <SESSION>
it moves the session you named, rewriting its row. A name no project has is refused before
anything is touched. There is no value for “no project”: a session with nothing named is
on none, and /project close takes a session off one.
--permission <LEVEL>
Set the initial permission level. Accepts none, read, workspace or unrestricted.
meka --permission workspace
There is no flag for approvals: a new session takes [permissions].approvals from the config file, and /approvals on moves it afterwards.
Default: read.
Recorded on the session, so a resume comes back at the level the session was last at rather than at
the default. Passing --permission alongside -c / -r repins it, the way --profile does. A
level that is no longer in [permissions].enabled is not granted on resume: the session drops to
the configured default with a warning.
--instructions <STRING>
Standing instructions for this run, replacing the instructions.md file and both MEKA_INSTRUCTIONS* environment variables. Takes the text itself, not a path; use "$(cat file.md)" to read one.
meka --instructions "Be terse. No code fences in answers."
--additional-workspace-directory <DIR>
Add a directory to the workspace beside the working directory, with the working directory’s
rights: writable at workspace, read-only at read, searched with it by the search tools.
Repeatable. The working directory is always part of the workspace; this adds to it.
meka --permission workspace --additional-workspace-directory ../shared-assets --additional-workspace-directory /srv/build
Deliberately a flag rather than a config key: which directories this run works in is a per-run scope, like the working directory itself, not a preference to persist.
A path that does not resolve at startup is reported as a warning and kept, so a build directory that does not exist yet joins the workspace the moment it does. A path that is not a directory, or a system directory the sandbox masks, is refused with a warning: neither can be expressed as a boundary by every backend.
The masked set is the filesystem root itself plus /proc, /dev, /sys, /run, /tmp and
/var/tmp, and /run/user and $XDG_RUNTIME_DIR as whole subtrees. A directory is refused when
it is one of these and when it is an ancestor of one, but not when it is merely underneath:
a directory under one of these is usually fine and refusing it would be a real loss, since
/run/media/$USER/drive is an ordinary external disk. The ancestor half is why
--additional-workspace-directory /var is refused, and it is also why
--additional-workspace-directory ~ is refused on WSL and on minimal window managers, where
$XDG_RUNTIME_DIR lives under $HOME and so binding $HOME would hand the session bus back.
/tmp and /var/tmp are in the set because Bubblewrap masks them with a tmpfs and then binds the
requested directory back over it, last mount winning: binding /tmp/work restores just that
directory, but binding /tmp restores the entire host /tmp including every X11, D-Bus and tmux
socket in it, which is a route straight back out of the sandbox. The cost is that a session
started with cd /tmp has no write boundary, which is the safe direction to fail.
The flag reaches the interactive screen, one-shot runs, and ACP sessions. It does not reach
sessions created through POST /v1/sessions, which are single-directory by design, and therefore
does not reach a scheduled turn under meka serve either: those run in the session the job belongs
to, which the HTTP API created.
--additional-readable-directory <DIR>
Add a directory this run may read beyond its workspace and never write, which the search tools
cover only when named. Repeatable. In effect where reads are confined, by
[permissions].confine_reads; the per-run form of
[shell].additional_readable_directories,
held by the session rather than the installation, so a sub-agent spawned with nothing named
inherits it and one given a scope of its own does not.
meka --permission read --additional-readable-directory ~/reference
A path that does not resolve is reported as a warning and kept, as the workspace flag’s is. The flag reaches the same hosts the workspace flag does, and is likewise never recorded on the session.
--sandbox-backend <BACKEND>
Pick the sandbox backend for this run: landlock, bubblewrap or bubblewrap-landlock on Linux,
appcontainer or restricted-token on Windows. A value from another platform is refused, and
macOS has one backend and takes no value. Wins over MEKA_SANDBOX_BACKEND and
[shell].sandbox_backend, and like either of those,
pinning a value suppresses the warning the auto-pick prints when it falls back.
meka --sandbox-backend landlock
--eager-load-tool <SERVER:TOOL>
Eager-load a specific MCP tool for this session, bypassing the tool_load round-trip. The tool’s schema ships in the cacheable tools-array prefix from turn 1 instead of being deferred. Mirrors the per-server eager_load_tools config field: repeatable, raw tool names (the server-advertised form, not mcp__<server>__<tool>).
Particularly useful for scripted runs that know up front which tools they’ll need. The flag appends to whatever eager_load_tools lists in config.toml for that server; it doesn’t replace existing entries. A server name the file does not hold logs a warning and is kept, for a server of that name added while the process runs.
meka --eager-load-tool notion:search --eager-load-tool github:create_issue \
--oneshot -p "search Notion for the deploy runbook and open a GitHub issue"
--no-stream
Disable streaming for this run. The agent waits for the complete response before displaying it. By default, responses are streamed token-by-token; display.stream is the persistent form. Applies to sub-agents as well.
meka --no-stream
--format <FORMAT>
What a --oneshot run writes to stdout: plain (the default) streams the answer as text; json prints nothing during the turn and one object when it ends. See One-shot mode for the object’s fields. The flag applies to --oneshot alone; a run without it is refused. meka account usage, whoami and stats take the same flag and values, and meka session export --format takes markdown or json.
meka --oneshot -p "what changed?" --format json | jq -r .text
Every listing and show command takes the same --format plain|json: session list|show|search,
account list|usage|whoami|stats, profile list, project list, mcp list|get|tools,
schedule list|show, memory list|get|show, tool list|show, history list and skill list|get|show.
Under json, a show prints one object and a listing prints {"<nouns>": [...]} (sessions,
accounts, profiles, projects, servers, jobs, memories, tools, history, skills), with the field names the
HTTP API uses for the same object where it has one. An empty listing is the envelope around an empty array
and nothing on stderr; in plain, it is a No <nouns>. note on stderr and nothing on stdout.
Warnings and hints stay on stderr under either format, so meka … --format json 2>/dev/null | jq
sees only the document.
meka session list --format json | jq -r '.sessions[].id'
meka mcp get notion --format json | jq .url
--oneshot
Exit after the first turn finishes. Requires either -p or --skill <NAME>; without one of those, meka has nothing to do. Useful for scripts and CI invocations.
meka --oneshot -p "summarize the last commit"
meka --oneshot --skill deploy -p "to staging"
-v, --verbose
Increase log verbosity. Can be repeated up to three times.
meka -v # info
meka -vv # debug
meka -vvv # trace
-h, --help
Print help. -h is the summary; --help adds the longer prose under the flags that have it.
-V, --version
Print version.
Interactive mode
Start meka without --oneshot to open the interactive screen:
meka
The screen takes over the terminal, the way an editor does. A new session opens on meka’s mark,
the M of its icon in the terminal’s cyan, two columns in, beside three lines: the version, with
the commit the binary was built from (-dirty for a tree with uncommitted changes) and debug
for an unoptimized build beside it when they say more than the version does; then what the agent
carries for the session, the MCP servers and skills on one line, the memories it sees and the
standing instructions in effect on the next, each file counted once and a flag or variable as
one. It stands while nothing but a warning
stands with it, and goes with the first prompt. The
conversation fills the screen from the top;
the composer sits at the bottom, a gray block with a bar of the permission level’s color down its
left edge and a row of the gray above and below the text. The status row under the composer says,
on the left, what the next prompt goes under, the permission level in its color and the profile the
session runs on, then the session’s working directory (shortened with ~) and the
project it is on, while it is on one; against the right edge it shows the context
gauge (128.4k / 1.0M (13%): tokens in context, the model’s window, the share used) and
2 background tasks and 1 sub-agent while any run.
display.status_row picks the facts and
their order on each side, and can add the profile’s model. Type and press Enter. What you sent stays on the transcript in the same gray
block, in white, behind the composer’s bar in the color of the permission level its turn ran under
(red for a !command, gray while it waits for its turn to open, and for a prompt the log holds no turn for), and the answer streams in under it as it arrives, word by word, as rendered
markdown: a list item hangs under its marker, and a table’s columns fit the window.
While the agent works, a row of its own appears above the composer, under the blank line that
always keeps the conversation off it: a spinner in column 0, then what the agent is doing
(Working, Thinking, Answering or Using a tool), how many seconds the turn has run, and
Esc to stop. The spinner stands still as • while the turn waits on you, for an approval or a
question, and the row goes when the turn ends. A !command has the same row, as Running command.
Everything on the transcript shares one left edge. Column 0 holds a glyph that says what a row is:
▎ a bar down what you sent, in the color of the permission level it ran under (red for a
!command, gray while it waits for its turn), • something the agent or meka did (a tool call, a
thinking block, a notice, a command’s outcome), ▸ a folded run of steps or a closed compaction
and ▾ an open one, ! a warning, ■ an error, and nothing for the answer itself. No glyph is
an emoji, so none is drawn as a picture. The text of every row starts in column 2. Color is spent
sparingly: an answer’s emphasis is weight (bold is bold, headings are bold), the terminal’s cyan
names things (inline code, a tool call, the checklist), structure (bullets, rules, the quote bar)
is gray, red and yellow mean an error and a warning, and the permission level’s color marks what
runs under it. A diff tints the lines it removes red and the lines it adds green. A code block
sits on a gray shade of its own, and fenced code is highlighted from the same palette: comments
gray, keywords blue, strings green, numbers yellow, names of functions and types cyan. A code line
wider than the block folds at its edge, and a tab is drawn as four columns. The text is drawn in
the terminal’s own colors, so the screen follows your terminal’s theme. Fixed are the shades behind
the composer, a prompt, an open run and a code block, a diff’s tints and the marks on its lines, the
white of a prompt’s words and of the profile on the status row, the gray of dimmed text on a shade,
and a picture’s pixels.
A turn, its finished run of steps opened:
▎
▎ summarize the project
▎
▾ Thought once, ran a command, read a file, used a tool 1 failed
• Thinking · The user wants a summary of the project state. I should look at…
• shell_execute ls -la && cat notes.txt
└ total 12
drwxr-xr-x 2 k4yt3x k4yt3x 4096 Oct 11 09:55 .
drwxr-xr-x 9 k4yt3x k4yt3x 4096 Oct 11 09:41 ..
… +3 lines
• file_read missing.txt failed
└ failed to resolve path 'missing.txt'
• checklist_add Review the conflict section, Write the summary
• Checklist
└ › Review the conflict section
□ Write the summary
Project state
Here is a quick overview of where things stand.
The transcript is drawn from what meka knows, not written once and scrolled away, so what is on screen can be changed after the fact: a thinking block or a tool call is a block that expands and collapses, the whole transcript re-wraps when the window is resized, older turns load as you scroll up, and you can type while the agent works.
While a turn runs
Enter does not wait for the turn to end. What it does is the session’s send class. While a turn
runs the empty composer names it: Steer the agent, Queue a followup or Interrupt the agent.
| Class | What Enter does |
|---|---|
steer (default) | Posts the message to the session’s inbox; the agent reads it at its next round, between two tool calls or after its reply |
followup | Holds the message and sends it as the next turn once this one ends |
interrupt | Cuts the answer being streamed and hands the agent the message at once |
Alt+S cycles the class for this run;
display.send_class sets where it starts. The
prompt appears in the transcript with its class and reads waiting until the agent has read it.
A slash command that would have to wait on the turn is refused while one runs; see
Slash commands.
The steer and interrupt classes are the inbox the HTTP API exposes; see the inbox for what each one does inside the turn.
When the turn ends, a row closes it, in the same dim register as the other stage directions:
• Finished at 19:28 after 19m 19s
The opening words say how the turn ended (Finished, Interrupted, Failed, or Hit the output limit and Declined by the model for an answer that stopped short of being one), and
display.turn_end_summary picks the
facts after it: the time, the duration, the tokens used. A resumed session closes every recorded
turn the same way, so the scrollback says when each one ended.
Expanding and collapsing
Reasoning shows as one dimmed • Thinking · … row: the first words of the block once it has
ended, its latest line while it still streams, or the token count when the provider reports only
that.
A tool call is its name and the one argument that identifies it, • file_read notes.txt. A path
is shown as the status row shows the directory: relative to the working directory under it, with
~ for your home directory. An MCP tool’s mcp__server__ prefix is dimmed, and it is shown by the
first of the call’s arguments that is text, else the first that has a short form (a number, a
flag). A shell command is highlighted as code: its first line on the row, the
next two on a │ bar under it, then a count of the rest, or the one line left where only one is. A leading cd into the working directory, which does
nothing there, is dimmed. A call that failed says so on its row in red: exit 2 for a command, with
the code it exited with, failed for anything else, a command stopped before it exited among them.
Under the call, behind a └, is the opening of what it produced: the lines up to the first blank
one, at most three, each cut at the edge, and a count of the rest, or the one line left where only
one is. A listing shows its header and
first row, and a file its first lines; what a tool wrote for the model after its payload is counted
rather than shown, and the directions meka writes into a result for the model are left out. What a
tool said of a failure is red; a command’s output is its output, however it exited, unless it was
stopped before it exited.
A call that changed a file, a memory or a skill (file_write, file_edit, memory_write,
memory_edit, skill_write, skill_edit and the two deletes) draws the change as a diff in place
of the words the tool wrote. The line before, the line taken out on a red tint with its old
number, the line put in on a green tint with its new number, the line after, and … between
hunks; a tinted line’s number and sign are in the tint’s light shade. Its row
counts the change, +1 -1, and the arguments the diff already shows are left out of its expanded
block. A memory’s diff is over the text meka memory export writes, so a reworded description is
one changed line of frontmatter, and a delete is every line on red. Collapsed, a change gets
twenty lines rather than the three of plain output, so a small edit shows whole and a rewrite shows
its first lines and a count. A result keeps about 30 KB of a change’s lines, and counts the rest.
Either expands to the whole thing: the full reasoning, or every argument of the call wrapped in full
and all of its output. The row drops its summary once the arguments are under it; they hang on a
│ bar from the row down to the └ of the result, a shell command first, behind $, highlighted
and wrapped whole. Nothing in an expanded block is cut.
A resumed session draws every call as it was drawn live: the diff, the code a command exited with and the checklist are recorded with each result.
- Click a block to toggle it. A block lights up under the mouse, which is the hint that it opens.
- Ctrl+T shows or hides every thinking block at once; Ctrl+O expands or collapses every tool call, and opens or folds every run of steps with them.
- Alt+Up and Alt+Down pick a block out from the keyboard, newest first; Enter or Space toggles the picked one, and Esc lets go of it.
Everything else on the transcript is what it was when it happened: the prompts, the checklist as
it stood (the item in progress marked ›, the rest □, a deferred one » with its reason),
the notices, the stage directions, and ! Interrupted where a turn was stopped. A slash command’s
answer is not on the transcript at all; see Slash commands.
Folded steps
The thinking and tool calls the agent works through show as they happen. Once it writes something after them, or its turn ends, each run of them folds into one row that says what it did, in the order it first did each:
▸ Thought 3 times, ran 5 commands, read 2 files 1 failed
Everything works from the new location. Now a top-level README so the layout is self-explanatory.
▸ Ran 2 commands, used a tool
How many of its calls failed follows in red. A run of one step stays as it is, and a change to a
file, a memory or a skill, or an agent_spawn or agent_followup call, never folds: it stands
between the runs around it. A change that failed changed nothing, and folds. A run whose call is
still at work stays open, and a run that finishes while you are scrolled up is left as it is until
you are back at the bottom, so what you are reading does not move.
Click a run’s row, or pick it with Alt+Up and press Enter, to open it under its row, and
again to fold it. A folded run’s row starts with ▸, an open one’s with ▾, and an open run sits
on a shade of its own from its row through its last step, so what it holds is told from what
stands around it. A resumed session opens with every finished run folded.
display.fold_steps = false leaves every
run open until you fold it.
Images
An image goes with the next prompt. Paste or drag a path to an image file (png, jpg, gif,
webp or bmp) onto the composer and the file is attached, or press Ctrl+V to attach the image
on the clipboard. meka reads that from the operating system, since a terminal pastes text alone, so
it needs a display on the machine meka runs on and does not work over SSH; a path does. Each
attachment is a chip above the text, in the order attached, marked reading while the image is
read beside the screen; Backspace on an empty composer drops the last one. Enter sends them
with the words once every one is read; pressed before then, it sends nothing and the status row
says an image is still being read. A photo is drawn the way up it was taken, and so is a copy meka
makes of it for the model, shrunk or converted; one sent as it is keeps the orientation it records,
for the provider to read. A skill or an MCP prompt typed with them takes them, and gives
them back when it cannot run; any other command is refused while they are attached. The profile has
to accept images ([profiles.<name>].vision), the limits are the HTTP API’s, and an image waits for
the turn to end rather than riding on a steer.
The transcript draws each image sent, live and on a resume, in colored half-block characters, two
pixels to a row: scaled down to the text width and at most 20 rows, its proportions kept, and
transparent pixels left as the terminal’s background. It works in any terminal with 24-bit color,
over SSH and inside tmux, at the resolution the cells allow, which shows an image’s layout and
colors but not its small text. An image whose pixels do not decode, or with nothing in it opaque
enough to draw, is the row [image 1280x720], with the size its header states, or [image].
Scrolling and the mouse
The wheel scrolls the transcript, a row for each event your terminal sends
(display.wheel_rows moves it more); PgUp and PgDn scroll it half a screen, Ctrl+Home jumps
to the top of what is loaded and Ctrl+End to the end. The view follows new output while it is
at the bottom. Scrolling up pauses that, and ↓ back to bottom appears in the row above the
composer, on the working row while the agent works, until you return: click it (it turns white
under the mouse), press Esc, or send a prompt.
Once an answer has pushed your prompt off the top, the prompt’s first line sticks there as a bar. Click it and the prompt comes to the top of the view, with the prompt before it stuck in the bar’s place, so clicking on walks back through the conversation one prompt at a time.
The screen opens on the last page of the session’s log and reads the page above it whenever you scroll near the top, so a resumed session shows its last turns at once and the first scroll up reaches the ones before, however far back the session goes. What is held is bounded: scrolling far up lets the newest pages go, and scrolling back down reads them again; returning to the bottom always shows the end of the log with this run’s rows after it. Older history shows the conversation the log records, with a row for each compaction that opens to what it did; the turns a rewind dropped are left out, and one row in their place says how many went.
The screen captures the mouse so that the wheel, a click and a drag reach it. Dragging over the
transcript selects the text it covers, and releasing copies it: to the terminal’s own clipboard
through the OSC 52 sequence, which works over SSH and inside tmux where they allow it, and to the
system clipboard where meka can reach one. The status row says how many rows went. What is copied
is the text, not the screen: each row from the text edge, without the gutter’s marks, and a code
block’s code as it was written, a line folded at the edge coming back as one line, its indentation
its own, and the block’s shading adding nothing. The terminal’s own selection still works with its modifier held (Shift; Option in iTerm2, Fn in
Terminal.app). Set display.mouse = false to
leave the mouse to the terminal entirely; the keyboard still scrolls and toggles.
Inside tmux, the wheel needs tmux’s own mouse mode (set -g mouse on).
Keys
| Key | Action |
|---|---|
| Enter | Send, or post to the inbox while a turn runs |
| Alt+Enter, Ctrl+J, Shift+Enter | Insert a newline; Shift+Enter where the terminal speaks the kitty keyboard protocol |
| Shift+Tab | Cycle the permission level, skipping any not in [permissions].enabled (by default none → read → workspace → unrestricted → none) |
| Alt+S | Cycle the send class |
| Tab | Fill in the picked completion and keep typing; open the list again after Esc |
| Up, Down | Move the pick while the completion list is open; otherwise recall what you sent before, at the first and last line of the composer |
| Ctrl+R | Search what you sent before; Ctrl+R again steps to the next match |
| Ctrl+V | Attach the image on the clipboard to the next prompt |
| Ctrl+T, Ctrl+O | Show or hide every thinking block; expand or collapse every tool call and run |
| Alt+Up, Alt+Down | Pick a block; Enter or Space toggles it, Esc lets go |
| PgUp, PgDn, Ctrl+Home, Ctrl+End | Scroll the transcript; older turns load as you reach the top |
| Esc | Close the completion list or a panel (q closes a panel too); stop the turn, then the background tasks, then leave (see Interrupting the agent); when nothing runs, back to the bottom, let go of a picked block |
| Ctrl+C | The same ladder as Esc while the agent works; clear the composer when idle, or climb the ladder from an empty one |
| Ctrl+D | Leave, when the composer is empty |
| Ctrl+L | Redraw the screen |
The composer takes the usual editing keys: Ctrl+A and Ctrl+E for the ends of the line, Ctrl+K and Ctrl+U to kill to either end, Ctrl+W to kill a word, Alt+F and Alt+B to move by word, and the arrow keys. It grows as you type, up to a third of the screen, and scrolls inside after that.
Input history
What you send is saved to the store, so Up, Down and Ctrl+R recall what you typed in any
previous run. A brand-new meka, a resumed meka -c, and the current session all share one
history. Multi-line prompts are kept whole, and only the most recent entries are kept (older ones
are pruned). This input history is separate from the conversation itself.
Slash commands
Commands start with /. /help shows this table, with the /mcp subcommands beneath /mcp and
the shortcuts below it. A command is not echoed on the transcript. What a command has to show
(/help, /status, a listing) opens in a panel in place of the composer: Up, Down, PgUp,
PgDn and the wheel scroll it, Esc or Enter closes it, and nothing of it stays on the
transcript. What the screen holds the answer to works at any time, a turn running or not: the
listings and pickers (/skill, /memory, /schedule, /task, /session, /help, /log), the
settings (/permission, /approvals, /cd), /task cancel and /schedule cancel, and /exit.
A command that needs the agent’s turn to be over (/compact, /rewind, /fork, /export,
/title, /status, /usage, every /mcp verb and prompt, /skill <name>,
/profile <name>, /project open <name> and /project close) is refused while a turn runs, in a panel naming it: stop the turn with Esc
first, or wait for it to end. What a command does (set a level, change directory,
cancel a job) is one dimmed • row on the transcript. What a command has to say back instead, a
refusal, a failure or an empty list, is a panel as well, closed with Esc, and leaves no row.
/log opens the last few hundred log lines at every level, newest last, whatever -v lets onto
the transcript: a warning is a row as it happens, and the debug lines around it are there to read
without restarting.
A bare /permission, /approvals, /profile, /project or /resume is a picker: the choices in a panel with the
cursor on the current one, Up and Down (or j and k) to move it, PgUp and PgDn to move it by
a page, Ctrl+Home and Ctrl+End to the ends, Enter to apply, Esc to leave it as it was. In /resume’s list, / starts typing a filter that keeps only the rows
matching the text; Esc stops typing and keeps the filter, and a second Esc closes the list. With an argument each sets the value directly. A bare /skill, /memory, /task or
/schedule is a picker too: the skills and memories by name and description, the tasks and jobs by
id with what each is doing (a job that cannot fire is marked held), and Enter runs the picked
skill or shows the picked memory, task or job in full. A bare /mcp lists the configured servers
with their state, and Enter offers reconnect, login and logout for the picked one.
| Command | Description |
|---|---|
/help (or /?) | Show this help message |
/exit (or /quit) | Leave |
/session | Show the current session id |
/title [text] | Show this session’s title, or set it |
/permission [level] | Pick or set the permission level |
/approvals [on|off] | Pick or set whether calls above the level are submitted for approval |
/profile [name] | Pick or set the profile this session runs on |
/project [open <name>|close] | Pick, open or close the project this session is on |
/resume [session] | Pick a session to switch to, or switch to one by id or unique prefix |
/compact [instructions] | Summarize and compact the session, optionally saying what to keep |
/rewind [N] | Drop the last N turns from the conversation (default 1) |
/export | Export the current session as Markdown |
/fork | Fork this session and continue in the copy |
/cd [path] | Change working directory (bare: back to where meka started) |
/skill [name] [extra] | Pick a skill to run, or run one by name with extra context |
/memory [name] | Pick a memory to show, or show one by name |
/schedule [show|cancel <id>] | Pick a scheduled job to show; show or cancel one by id |
/task [show|cancel <id>] | Pick a background task to show; show or cancel one by id, or all |
/mcp <subcommand> | Manage MCP servers and prompts |
/mcp list | Pick a server to reconnect, log in or log out |
/mcp reconnect <server> | Open a new connection to a server |
/mcp login <server> | Run the OAuth flow for a server |
/mcp logout <server> | Clear stored credentials for a server |
/mcp <server>:<prompt> [args] | Render an MCP prompt as the next turn |
/status | Show the profile, model, context use and cumulative session stats |
/log | Show the recent log lines, at every level |
/usage | Show account rate-limit usage (subscription backends) |
Some of the grammar the table compresses: a bare /mcp is /mcp list, and the picker shows each
server’s live state (pending / connected / failed / disabled); /task cancel all is
accepted for --all; /schedule cancel, /task cancel and the two shows take an id or any
unique prefix; /cd ~ still goes home; /export writes session-<id>.md in the working directory
and prints where it landed; /skill <name> prepends anything typed after the name to the skill body,
and the transcript shows Running skill '<name>' with those words under it, live and on a resume,
as it does Running prompt '<server>:<prompt>' for an MCP prompt;
/memory lists memories most important first; /mcp login <server> puts the authorization URL
as a question in the composer’s place, with one field for the callback URL pasted from the
browser’s address bar when the browser cannot reach meka, and Esc on it ends the login.
Typing / opens the list of commands above the composer, each with its description, and the list
narrows as you type. Up and Down move the pick, Enter runs the picked command, Tab
fills it in so you can go on to an argument, and Esc closes the list until you type again. An
argument’s list opens the same way: permission levels for /permission, on/off for
/approvals, configured profile names for /profile, open and close then the project names
for /project, installed skill names for /skill, the
subcommands and configured servers for /mcp, and directory paths for /cd (Tab after a completed
directory drills into its subdirectories).
/status
Shows the session’s resolved model parameters followed by its cumulative counters, in a panel:
/status
Profile: work
Account: claude (claude-subscription)
Model: claude-opus-5-5
Context: 128.4k / 1.0M (13% used, 871.6k left)
Effort: xhigh
Thinking: adaptive
Turns: 23
Compactions: 2
Input tokens: 234.5k (cache hit: 92%)
Output tokens: 12.1k
Redactions: 2 (12 images, ~38.0 MiB freed)
Messages: 47
The top block reports what the session actually resolved to, in the order
[profiles.<name>] declares the same
fields, so the two can be read side by side: the Profile with its account and backend, the Model, the
Context window, the reasoning Effort sent on the wire (omitted when nothing is sent, so the
provider applies its own default; claude-subscription sends high when the profile sets none),
and the Thinking mode. The rest are cumulative counters for the session.
Context is the live context-window occupancy: the total tokens of the most recent exchange (all input tiers plus output, i.e. what the next request re-sends minus your new prompt), against the active model’s context window, with the percent used and tokens remaining. Use it to decide whether to /compact before continuing; after /compact it drops to the compacted size immediately. It reflects this session only; sub-agents spawned via agent_spawn have their own context and are not counted (a sub-agent’s returned result is counted only once it lands in this session as a tool result). It is shown from the start, at 0 / <window> before the first turn, since the window is your context_window setting (or the documented default) and this is where you confirm it took effect; it is omitted only when the window is unknown. The status row shows the same gauge at its right edge; display.status_row drops it or moves it among the readings there.
Input tokens (and the other cumulative counters) is the total billed across every turn of the whole session. These totals are persisted, so resuming a session with meka -c continues them rather than restarting at zero.
Compactions is how many times the conversation has been summarized, whether automatically, by /compact or at the agent’s request. It is counted from the session’s history, so it survives a resume, and each one puts the earliest detail one more summary away from the original.
cache hit is the share of input tokens served from the prompt cache rather than re-sent at full price, on every backend that reports one (Anthropic’s cache tiers, OpenAI’s cached_tokens). It should climb and stay high: meka keeps everything that changes mid-session out of the cached prefix, so a steady session re-reads the cache instead of rewriting it. The figure is arithmetic as much as it is health: every new token (a tool result, the model’s own reply) is written to the cache once and read on each later request, so the session-wide share starts at zero, passes 90% around the fifteenth request (each tool call is a request) and keeps climbing from there; a low figure on a short session is not a miss. Expect it to drop once after a /compact (which rewrites the head of the conversation) and to recover on the following turns. A profile with cache_control = false on an endpoint that caches only what is marked reads zero here by design.
Redactions reports any times an Anthropic backend had to drop the oldest tool-result image blocks because the request body would have exceeded the profile’s max_request_bytes (30 MiB by default, held under Anthropic’s own 32 MiB limit). A non-zero count indicates the cache prefix was invalidated for the redacted messages.
/usage
Fetches the account’s current rate-limit usage from the account the session’s profile bills and shows each rolling window with its percentage used and reset time, in a panel:
/usage
5-hour (session) [#---------] 8% used (resets in 4h 12m, 2026-07-02 02:10 +02:00)
Weekly [----------] 2% used (resets in 22h 50m, 2026-07-02 13:00 +02:00)
This is distinct from /status, which reports this session’s own token counters. /usage queries the upstream service for your whole-account subscription limits. It works only for the two subscription backends, which expose a usage endpoint (claude-subscription’s 5-hour and weekly windows; chatgpt-subscription’s primary/secondary windows plus plan and credit balance). For an API-key backend it prints a short note that usage is not available there instead. The same command is available under ACP.
/compact
The /compact command asks the LLM to summarize the entire conversation, then replaces the messages the model sees with a single summary message followed by the recent tail. This is useful for long sessions that are approaching the context window limit or becoming expensive.
After compacting, the session continues with the summary as context. The pre-compaction messages are never deleted: they stay in the underlying event log on disk (the model just no longer sees them). meka session export walks that full log, so an export always contains the entire conversation including the compacted-away turns, with a marker at each compaction point.
Every compaction, this command’s and the automatic ones, leaves a boundary on the transcript: a dim rule across the screen labeled →← Context compacted. While one runs, the working row reads Compacting the conversation. The rule starts with ▸: click it, or pick it with Alt+Up and press Enter, to open what the compaction did on a shade under it. First comes what set it going (Compacted the conversation on user request, Autocompacted the conversation after context usage crossed the ceiling, The agent compacted the conversation), then what departed from the ordinary case, when anything did: that no recent turns were kept after the summary, so the summary is all the agent has of the conversation, or that the standalone summarizer wrote it rather than the agent. Then the checkpoint turn’s thinking and calls (the memories it wrote, with their changes), each of which opens on its own as any call does, and last the summary the agent now reads in place of the conversation. A compaction before 0.88 recorded only the names of the memories its checkpoint wrote, so it opens to those and its summary. A resumed session shows the same rows, read back from the log.
/rewind
/rewind drops the most recent turn from the conversation, so the model no longer sees it or your prompt that started it. /rewind N drops the last N. The cut always lands on a turn boundary, so a tool call is never separated from its result. A compaction summary opens a turn of its own, so rewinding past everything after a compaction takes the summary with it and leaves the conversation empty; the turns it replaced stay behind the boundary on disk, and leave the screen with the summary that stood for them.
The dropped turns leave the screen too, now and whenever the session is resumed, and one row in their place says how many went. Nothing is deleted, though: like /compact, the turns stay in the event log on disk, and meka session export still shows them with a marker where the rewind happened.
Use it to take back a prompt that sent the agent down the wrong path without paying for a summary, or to recover a session the provider has started rejecting. meka repairs a rejection it causes itself (see below), but content that entered the conversation earlier is out of its reach; rewinding past it is the way back. meka session rewind <id> does the same to a session you are not currently in.
Recovering from a rejected message
Providers validate the whole conversation on every request, so one piece of content they reject would otherwise fail every later turn as well, permanently. When that happens, meka strips the offending content from what it added this turn, retries once, and hands the model the provider’s own complaint as a failed tool result so it can adapt rather than silently losing the data. If the retry is rejected too, the original content goes back untouched and the turn reports the provider’s error.
A mislabeled image already committed to the session is repaired when you resume it, without a provider round trip. For anything further back, use /rewind.
Recovering from a call that got no answer
A refusal is one thing the provider says; a request that never got a usable reply at all is another. A connection that fails or is reset while the request is going out is retried with backoff (up to twice, waiting 1s then 2s), and so is a response body that could not be read back. The turn continues as if the failed attempt had not happened, and nothing about it enters the conversation. Only when the retries run out does the turn report the error.
Worth knowing what a retry can cost. When the failure was a body that could not be read, the provider had already generated the response and billed you for it, so the retry pays a second time. meka does it anyway, because the alternative is losing the turn for content you have already been charged for once, but it is not free.
One failure is not retried, because the next attempt is known not to be worth making: a request meka could not build, which points at whatever went into it, most often a base_url that is not a URL or a stored credential carrying a character that cannot go in a header. A redirect is never followed at all: the endpoint’s answer is reported with where it pointed, and the fix is to set base_url to that endpoint. A reply, or one streamed event, larger than 64 MiB is refused for the same reason: an endpoint answering with something that size is not answering the request.
Retrying is bounded by time as well as by count, and the time bound is the one that usually decides. A failure that takes the full idle timeout to arrive costs five minutes, which spends the whole budget, so a stream that hung is reported rather than tried again: retrying is for a failure that was cheap, and a provider that went silent for five minutes has already taken more of your turn than a second silence is worth. Without the bound at all, three slow failures would be fifteen minutes of waiting on a turn that fails anyway.
The bound stops a new attempt starting rather than capping the total, so the worst case is a failure arriving just under the five minutes and permitting one more full-length attempt after it, for about ten in total.
/fork
/fork copies the current session and switches you into the copy, printing its id. Your conversation carries over untouched, so the branch happens exactly where you are; the original stops there and keeps everything up to that point.
Use it before trying a direction you might want to back out of, or before /compact if you’d rather keep the uncompacted conversation around. To go back, exit and resume the original with meka -r <old-id>.
The copy is a fully independent session with no link back to its source. (/fork only ever runs
against the session you are in, which is never a sub-agent, so the sub-agent case below cannot arise
here.) See Forking a session for exactly what it carries.
Shell escape
Type ! into the empty composer and it enters shell mode: the composer’s bar turns red, the
status row reads Run command in shell in red in place of the level and the facts after it, and what you type runs as a shell
command on Enter, bypassing the model entirely. Backspace on the empty composer leaves the mode, and a recalled
!cmd comes back in it. On the transcript the command keeps its !:
▎
▎ !ls -la
▎
total 32
drwxr-xr-x 5 user user 4096 Mar 4 10:00 .
...
The command runs in the session’s working directory, so it follows /cd. Its output is captured
and shown in the transcript, colors included, with a line naming the exit status when it failed.
It runs beside the screen, which keeps drawing and counts the seconds on the working row; Esc or
Ctrl+C kills it, with everything it started, and marks it ! Interrupted; so does leaving
meka, a hangup or SIGTERM included. (On Windows the shell alone is killed.) Its output keeps its
last 1 MiB and 10,000 lines, and a row says when more was let go. It reads nothing from the keyboard, and a command that needs the terminal itself (an editor, a pager) belongs in a
shell beside meka.
Exiting
You can leave meka in any of these ways:
- Type
/exitor/quit - Type
exitorquit - Press Ctrl+D on an empty composer
Leaving while the agent is working stops the turn first, so nothing keeps running once the screen
is gone; a !command still running is killed, and background tasks are stopped on the way out,
with how many said.
The screen hands the terminal back as it found it. The session’s id is printed on the way out
when display.show_session_id_on_exit
says so; the conversation itself stays in the store, and meka -c reopens it.
Interrupting the agent
Press Esc or Ctrl+C while the agent is running to interrupt it. The two keys count on one ladder, and presses escalate:
- The first press cancels the current turn: the request in flight is dropped and any shell command the turn spawned is killed. Background tasks keep running, because a keystroke aimed at the answer on screen should not lose a twenty-minute build.
- A second press during the same turn stops every running background task, records each as canceled, and says how many it stopped.
- A third press leaves, the way
/exitdoes: what was canceled is given its moment to unwind, the background tasks are stopped, and the exit status is 130.
The count starts over with each new turn, so the first press of the next turn cancels that turn whatever happened during the last one, and it ends with the turn: a press during a wait that is not a turn (/usage, a stuck /mcp reconnect) starts a count of its own from the first step. When nothing is running, Esc goes back to the bottom of the transcript, and Ctrl+C clears a composer with text in it; on an empty one it counts on the ladder, so a second press stops the background tasks and a third leaves.
A question the agent asks replaces the composer until it is answered. An approval prompt is a
panel titled with the call, its arguments under it and four answers to pick from: Allow (y),
Allow for the session (a), Deny (n), Deny for the session, each also reachable by
its number; Esc denies. A server that asks for a URL to be opened asks the same way, with
Open in the browser (y), Skip (s) and Decline (n) to pick from. An MCP form
takes typed answers, one field at a time; Esc declines. While any of these is open, Esc answers
it rather than stopping the turn; Ctrl+C stops the turn, which withdraws the question. Questions
are put one at a time: when two tool calls dispatched together both ask, the second waits until
the first is answered.
One-shot mode
One-shot mode runs a single prompt and exits, similar to bash -c. It takes --oneshot and the
prompt through -p:
meka --oneshot -p "your prompt here"
git diff | meka --oneshot -p - # `-p -` reads the prompt from stdin
The agent works through the prompt, tool calls included, and when the turn ends prints its answer: the last text block the model wrote, as markdown source, terminal or pipe alike, since the output is for the next tool rather than for reading. Nothing else is printed. The thinking, the tool calls and the rest of the process are logged, so -v shows them. A run interrupted with Ctrl+C exits 130, whatever it had printed by then.
A prompt without --oneshot is not a one-shot run: it seeds the first turn and then leaves you on the screen, which is the right default when you are working interactively and the first thing you want is already in your shell history.
--oneshot requires something to do, so it needs -p or --skill.
An empty or whitespace-only prompt is refused rather than sent.
Approvals have nothing to ask from here: there is no prompt to answer, so with the switch on every tool that needs approval is refused. meka says so once at startup and names each tool as it is refused, but the run is still less useful than it looks. Give a non-interactive run the level it needs with --permission, or use meka serve if you need a human in the loop over an API.
Examples
# Simple question
meka --oneshot -p "what is my current working directory?"
# File operations (requires workspace permission)
meka --oneshot --permission workspace -p "create a file called notes.txt with today's date"
# Search
meka --oneshot -p "find all TODO comments in this project"
# Web page
meka --oneshot -p "summarize https://blog.rust-lang.org"
Combining with other flags
All configuration flags work in one-shot mode:
# Use a specific profile
meka --oneshot --profile work -p "explain this codebase"
# With workspace permission
meka --oneshot --permission workspace -p "run 'cargo test' and summarize the results"
# Ask the provider for a whole reply rather than a stream
meka --oneshot --no-stream -p "read README.md and summarize it"
# Run one turn against an existing session
meka --oneshot -r 550e8400 -p "summarize what we decided"
JSON output
--format json keeps stdout empty during the turn and prints one object when it ends, so a script
reads the whole turn at once rather than parsing a stream. Errors still go to stderr and the exit
code; no object is printed for a turn that failed. A turn interrupted with Ctrl+C is reported, not
failed: its object is printed with stop_reason set to interrupted, and the run exits 130 as it
does without --format json.
meka --oneshot -p "how many files are here?" --format json
{
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"profile": "work",
"stop_reason": "end_turn",
"text": "There are 14 files in this directory.",
"tool_calls": [
{ "name": "file_find", "input": { "glob": "*" }, "is_error": false }
],
"usage": {
"input_tokens": 1180, "output_tokens": 42,
"cache_creation_input_tokens": 0, "cache_read_input_tokens": 1024
},
"notices": [
{ "level": "warn", "text": "Approvals are on but nobody can answer here, so 'shell_execute' was refused without asking." }
],
"compactions": []
}
stop_reason is end_turn, max_tokens, refusal (with refusal_text beside it when the model
gave one) or interrupted. session_id is omitted, not null, for a turn interrupted before the
session existed, which is the one way a run prints a report without one; every other field is always
present. text is the assistant’s text with the rounds joined by a blank line,
tool_calls lists every call in the order it was dispatched, and usage sums the rounds. notices
is what meka itself said during the turn (a refused approval, a lost write, a declined MCP
elicitation), each with
a level of info or warn and its text; it is empty when meka raised none. compactions lists
the compactions the turn ran, each in the shape of the HTTP API’s
context.compacted event. The flag applies to
--oneshot alone; a run without it is refused. meka account usage, whoami and stats take the
same flag and values, and meka session export --format takes markdown or json.
Session behavior
One-shot mode creates a new session for each invocation, unless you point it at an existing one with -c (most recent) or -r <SESSION> (specific). Those run a single turn against that conversation and exit, which is the usual shape for scripting against a session built up earlier.
No session id is printed. --format json carries it as session_id, -v logs it, and -c
resumes the most recent session without naming it:
meka -c
Piping
The answer goes to stdout and everything else to stderr, so meka -p … 2>/dev/null | next-tool
hands the next tool only what you asked for. That holds for every command, not just this one.
A reader that stops reading is its own decision, and meka exits 0 for it:
meka -p "summarize this log" | head -20 # exits 0; head got its lines
A stdout that cannot take the answer is a different thing, and fails the run:
meka -p "summarize this log" > /full/disk # exits non-zero, and says why on stderr
The distinction matters in a script: the first is how pipelines end, the second is data you asked for and did not get.
ACP (Agent Client Protocol)
meka acp speaks the Agent Client Protocol over stdio so editor / web / messenger clients can drive a meka turn end to end. Where Interactive mode and One-shot mode are for humans, ACP is for programs that want to host meka inside a richer UI: streamed diffs, native apply-buttons, hosted terminals, and slash-command palettes.
This page describes what meka’s ACP surface looks like to a client. Editor-specific setup belongs in each editor’s own documentation; the protocol contract is the same everywhere.
Starting an ACP server
meka acp
The process speaks JSON-RPC 2.0 with newline framing on stdio. There is no human-facing prompt; the binary is meant to be spawned by a client that owns the conversation. The client sends initialize, then session/new (or session/load / session/resume), then a series of session/prompt calls.
A few flags are worth knowing:
| Flag | Effect |
|---|---|
-v | Logs to stderr at info (incoming client identity, session lifecycle). |
-vv | debug (per-request JSON-RPC diagnostics). |
RUST_LOG=meka=trace | Trace level. |
Three flags are refused rather than ignored: -c, -r and --project. Each names one run’s session, and this host creates one per session/new, each naming its own profile and project. A new session starts on the host’s default profile and on no project; move either with session/set_config_option, and session/load restores whichever a session already recorded. --profile is accepted, since it selects which configured profile a session gets when it names none, which is a property of the connection rather than of one session.
On startup, after the client’s initialize arrives, meka logs ACP client connected: <name> <version> so you can confirm the client identity under -v.
What meka advertises (agentCapabilities)
These are returned in InitializeResponse.agentCapabilities:
loadSession: true: the client may callsession/loadwith any persisted session id.sessionCapabilities.list: the client may callsession/listto browse the persisted session catalog (cwd-filtered, cursor-paginated; sub-agent audit sessions are hidden).sessionCapabilities.resume: the client may adopt a persisted session id without replaying history.sessionCapabilities.fork: the client may branch a copy off a persisted session (see Forking). Unstable in the protocol.sessionCapabilities.close: the client may release the active session slot.sessionCapabilities.additionalDirectories: the client may send extra workspace roots onsession/new,session/load, andsession/resume(see Multi-root workspaces).promptCapabilities.embeddedContext: true: the client may inline @-mentioned file contents as embeddedresourceblocks (see Prompt turn).promptCapabilities.image: follows the process default profile’svisionflag (defaulttrue; setvision = falsein[profiles.<name>]for a text-only model). Per connection rather than per session, becauseinitializeis answered before any session exists. Whether a givensession/promptaccepts an image block is decided per session from the profile that session runs on, so a session moved onto a text-only profile refuses attachments even on a connection that advertisedimage.
mcpCapabilities is intentionally not advertised. meka is itself an MCP client, but the servers it consumes are configured via meka’s own config.toml rather than the mcpServers field on session/new. Advertising HTTP/SSE while silently ignoring the client’s array would have been misleading; the marker will return when client-supplied MCP server connections are actually implemented.
agentInfo carries meka’s name ("meka") and the running binary version.
What meka consumes (clientCapabilities)
The client advertises these in InitializeRequest.clientCapabilities; meka stashes them and lets the built-in tools route accordingly:
fs.readTextFile: true:file_readissuesfs/read_text_file { sessionId, path, line?, limit? }so the client serves the in-buffer view of the file, when the client also takes writes. A regex read fetches the file the same way and filters it; an image read has nofs/*analog and stays local.fs.writeTextFile: true:file_writeandfile_edit’s apply step issuefs/write_text_file { sessionId, path, content }. meka still attaches diff metadata to thetool_call_updateso clients with an apply-diff UI can render it, as it does forskill_write, whose file is in meka’s store; a memory write, which has no file, and a delete, which leaves no text after, carry their change as a unified diff in adiffcode block instead.terminal: not consumed. It means “I implementterminal/*”, i.e. the agent may run commands in the client, which meka never does. See Shell commands stay inside meka._meta.terminal_output: true: the client renders agent-owned terminals, soshell_executeoutput is streamed into a real terminal instead of a code block. A rendering choice only: meka still spawns and sandboxes the process either way. Advertised by Zed; independent of theterminalcapability above.elicitation.form/elicitation.url: when an MCP server asks the user for input mid-tool-call, meka issueselicitation/createso the prompt renders in the editor. The two are advertised independently and checked separately: a server asking for a form when onlyurlis advertised is declined rather than sent. Without the capability meka declines every elicitation, which is what it did unconditionally before. Elicitations raised inside a sub-agent forward to the parent session, like permission prompts.session.compaction: the client renders a compaction entry, so every compaction goes out as acompaction_update; without it, as a tool call card. See Prompt turn. Advertised by Zed.session.notices: the client presents notices outside the transcript, so an advisory meka raises goes out as anotice; without it, as a[meka]chunk. Advertised by Zed.
If the client omits a capability, the matching tool falls back to local syscalls; the user-visible behavior is the same as meka on the screen.
Shell commands stay inside meka
shell_execute never runs in the client’s terminal, whatever the client advertises and whatever the permission level. meka spawns the process itself so everything it wraps a command in keeps applying: the sandbox that read and workspace depend on (Landlock / bwrap / sandbox-exec / restricted token), the environment scrub that keeps API keys out of the child, the per-session cwd from /cd, the timeout, and the process-group kill that reaches backgrounded grandchildren. The client’s terminal/* offers none of that.
meka used to delegate in any level other than read, which made every sandboxed level a bypass: meka would refuse to run at all when no sandbox backend was available, then hand the same command to an unsandboxed editor terminal. Delegation is gone rather than narrowed, so workspace keeps its boundary here exactly as it does on the screen.
Live output
Because meka owns the process, it streams the output: while a command runs, what it has printed so far is pushed into the open tool call, so an editor shows a build or a test run progressing instead of a spinner. Updates are coalesced to at most one per 150 ms. stdout and stderr are interleaved in the live view, the way a terminal shows them, while the result the model sees keeps them separated.
How that output is drawn depends on the client:
Clients advertising _meta.terminal_output get an agent-owned terminal. meka announces one on the tool call, appends each chunk to it as it arrives, and closes it with the command’s real exit code and signal. The client renders a genuine terminal: ANSI color, selection, full scrollback, and an expandable view in the tool call. meka still spawns and sandboxes the process; the client only draws the bytes it is handed. Nothing is executed on the client side, and no terminal/* request is ever sent.
Everything else gets a console code block, replaced on each update with a trailing window of the output. The complete output arrives in the final update when the command exits.
The terminal path uses an extension rather than ACP proper: _meta.terminal_info to announce the terminal (on the opening tool_call, which is where clients read it), _meta.terminal_output to append, _meta.terminal_exit to close it with the command’s real exit code. The convention comes from codex-acp, claude-agent-acp emits the same shape, and Zed consumes it, advertising _meta.terminal_output: true to say so. Gating on that key rather than on terminal matters: a client that implements terminal/* but not these frames cannot resolve the terminal, and would render nothing at all for it.
This is deliberately temporary. ACP v2 standardizes agent-owned terminals as terminal_update / terminal_output_chunk, and meka should move to those once a client implements them; v2 is still a draft schema (v2.0.0-alpha.N, behind an off-by-default feature flag) that nothing speaks yet.
When the client won’t serve a path
Editors differ in which paths they will serve: Zed answers only for the project it has open, another client may serve any absolute path. meka models none of these rules. It asks per path and routes on the answer:
ResourceNotFound(-32002) means the client will not serve this path, so it holds no buffer for it. meka reads or writes the file locally, and a write says so in the tool result: the change still appears in that tool call’s diff, but not in the editor’s buffer or undo history. This is what keeps ACP as capable as the terminal: the agent can read and edit its own skills, prompts, and configuration even though they live outside the project.- Any other error means the client may own the file and hold unsaved changes for it, so the tool call fails instead of routing around the client. Reading on-disk bytes would hand the model a stale view of a file the user is editing, and writing them back would overwrite unsaved work.
The route is chosen once per tool call by the read, not per request: file_edit and file_write write back through whichever filesystem they read from, so a diff taken from the editor’s buffer isn’t applied to disk while the buffer keeps the old content. The read is also the more reliable signal: Zed reports an out-of-project path as ResourceNotFound on fs/read_text_file but as a generic error on fs/write_text_file, so a route chosen from the write’s own error would never recognize it.
A client advertising fs.readTextFile but not fs.writeTextFile leaves the write to meka, so file_read, file_edit and file_write read the file from disk as well: what the model reads is what its edit is computed from, and the edit lands where it was computed. The client still holds a buffer for the file, which the write diverges from; the tool result discloses that with its own note.
Session lifecycle
meka holds an in-memory map of sessionId → SessionEntry. Any number of sessions can coexist in one meka acp process, each with its own cwd, permission level, conversation, cancellation token, and per-session runtime mutex. Prompts on different sessions run in parallel; a second session/prompt for a session that already has one in flight is refused with InvalidParams. The session row is also locked on disk (the same lock the screen uses), so two meka processes can’t simultaneously write events for the same session id.
An unknown sessionId, a session another meka process holds, a sub-agent’s id, a turn over the profile’s max_request_bytes and a profile config.toml no longer has are all refused with InvalidParams; InternalError is reserved for faults in meka or below it.
What an InternalError’s data carries follows the same policy the HTTP API applies to a failed turn, so a deployment cannot have one surface withhold what the other publishes:
-
The provider’s own response text travels only when
[serve] relay_provider_errorsis on, which is the default. It can name the operator’s account with the provider and its rate-limit posture; turn the key off anddatacarries meka’s sentence alone. Relayed text is capped at 4 KiB with the cut marked, and the full text goes to the meka log either way. -
An MCP server’s connection reason never travels. The server names do, since that is the part to act on; the reason is meka’s own subprocess text and has carried a command line and its path.
-
A store or filesystem failure, and a
[web]/base_urlmisconfiguration, never travel: they name meka’s own directories or the operator’s config.datasays where the detail went. -
session/new { cwd, mcpServers }: mints a fresh persisted session, captures the cwd, takes the on-disk session lock, returns the session id and the currentSessionModestate.mcpServersis ignored, with a warning naming how many entries were dropped; meka’s servers come fromconfig.toml. On this and every other door,cwdmust be an existing directory and is recorded in its canonical spelling (symlinks resolved), the rule the screen’s/cdand the HTTP API apply too; anything else isInvalidParams. -
session/load { sessionId, cwd, mcpServers }: replays the session’s log, its newest 2,000 rows without what a rewind took back, as a stream ofsession/updatenotifications (user_message_chunk,agent_message_chunk,agent_thought_chunk,tool_call,tool_call_update) before the response. The turns before a compaction are replayed too, and the compaction is said where it happened, as the live run said it: a completedcompaction_updateto a client that advertisedsession.compaction, else a completed card, then the calls its checkpoint made; never the summary as a user turn. The recent turns a compaction kept are replayed once. A background report replays as the stage direction the live run said, a parenthesizedagent_message_chunk, never as the words the model read. A skill invoked by name replays as/<skill-name>and the words typed with it, never as its body; an MCP prompt invoked on the screen, which an editor has no command for, as the stage direction the screen says and then those words. A result is reported as the live run reported it, with the change it recorded and the result asrawOutput; a file edit’s change comes as a unified diff in adiffcode block, since the log keeps the change and not the file. Orphan tool calls (the persisted log stopped mid-tool) are closed out with afailedstatus so the client’s UI doesn’t render a stuck spinner. If the client’scwddiffers from the persisted one, meka updates the persisted row to match; the client wins, but only once the session has actually opened, so a load meka refuses (a sub-agent’s id, a profile that has leftconfig.toml, an account with no stored credential) leaves the session’s recorded directory and roots exactly as they were.mcpServersis ignored silently here and onsession/resume. A sub-agent’s id is refused withInvalidParamsbefore the session is locked; continue that conversation withagent_followupfrom the parent instead. -
session/list { cwd?, cursor? }: paginated index. Filtered to the requested cwd when present, compared in the canonical spelling every session records; sub-agent sessions are always hidden.nextCursoris opaque; round-trip it back to keep paging. -
session/resume { sessionId, cwd, mcpServers }: adopts the session id without replaying. Use this when the client already has the history rendered. Same cwd-update behavior assession/load, including that a refused resume writes nothing. A sub-agent’s id is refused on the same terms assession/load. -
session/fork { sessionId, cwd, additionalDirectories, mcpServers }: copies the session’s conversation into a new persisted session, adopts the copy as active, and returns its id. The source is left open and untouched. See Forking. -
session/close { sessionId }: cancels any in-flight prompt, waits for that turn to finish (the cancel does not cut short afile_reador anfs/*request already in progress), releases the on-disk session lock, and removes the entry from the map. -
session/cancel { sessionId }: interrupts the activesession/prompt. The response carriesstopReason: "cancelled". A cancel sent straight after a prompt still stops that prompt, even if it arrives before the turn has started: meka latches the signal and applies it as the turn begins. The latch is scoped to a prompt that is already on its way, so a cancel with nothing to stop is discarded rather than saved. Interrupting a turn, canceling twice, or canceling while idle all leave the next prompt you send to run normally. -
session/set_mode { sessionId, modeId }: flips the agent’sPermissioncell. A level outside[permissions].enabledis refused withInvalidParams. On success, meka emitssession/update: current_mode_update. The flip is atomic and applies to the next tool call within an in-flight turn; no need to wait for the turn to finish. -
session/set_config_option { sessionId, configId, value }: sets one of the entries inconfigOptions. Returns the full list with its new values. See Session config options.
session/new, session/load, session/resume and session/fork all answer with modes and configOptions, and each is followed by an available_commands_update (see Slash commands). --additional-workspace-directory on the meka acp command line adds to every session’s workspace, beside whatever the client sent as additionalDirectories; those are the session’s additional workspace directories at every level, writable at workspace and read-only at read as the working directory is, since session/set_mode moves the level while the session runs. --additional-readable-directory adds what every session reads beyond its workspace.
Idle sessions are released
A session untouched for 24 hours is dropped from the map by a sweep that runs every 5 minutes,
releasing its lock and detaching its MCP registry. Only the in-memory entry goes, and the next
session/prompt, session/set_mode or session/set_config_option naming it reopens it, in the
directory and with the roots its row records, so a client that keeps a thread open needs no special
handling; session/load reopens it with its history, as it does one from a previous run. Sticky
approval answers go with the entry, so they reset here as they do on session/close.
session/close is optional in the protocol and several editors never send one, which is what this
answers. Each open session holds an Agent, a tool registry the MCP manager keeps a clone of, and
an open file lock, none of them reachable from anywhere else meanwhile. Neither the window nor the
scan interval is configurable; both match [serve]’s
defaults for the same mechanism.
Prompt turn
A session/prompt carries a prompt array of ContentBlocks. meka accepts:
text: the baseline.resource_link: the model reads it as a<resource_link name="…" uri="…">description</resource_link>tag where the link sits among the words; meka does not fetch the resource server-side. A block that declares a MIME type addsmime="…"to the tag, here and onresourcebelow.resource(embedded @-mention contents): a text resource is read as a<resource uri="…">…contents…</resource>tag where it sits; a binary (blob) resource becomes a self-closing<resource uri="…" encoding="base64"/>marker (the payload is not inlined).
Each resource is stored in its place among the words, so a session’s title reads the words around it, and session/load replays it as a resource_link named as the editor named it (an embedded resource by the last segment of its URI) rather than as its contents. A prompt that opens with /<skill-name> is the skill’s invocation, whole: the model reads the words and the files sent with them, then the skill’s body, and session/load replays it as the command and those words, each file as its @name.
image: accepted only when the profile has vision on. The payload is normalized through meka’s image pipeline (size cap, format conversion) and forwarded to the model as native vision input (Claudeimage, OpenAI chatimage_url, Codexinput_image).
audio blocks (and image when vision = false) produce InvalidParams.
Images travel in the other direction too: when a tool looks at one (file_read on an image file,
image_render, web_fetch on an image URL), the picture is forwarded on that tool call as an
image content block rather than a placeholder, so the client renders what the model was shown.
While the turn runs, meka streams session/update notifications:
agent_message_chunkfor each piece of assistant text.agent_thought_chunkfor thinking blocks (Claude OAuth / extended-thinking models).tool_callwhen a tool starts, withkind,status: "in_progress", an absolutelocationsarray (relative paths resolved against the session cwd, with the start line forfile_read), the raw input, and a human-readabletitle. The title is the tool’s name followed by the argument that identifies the call, so editors show what’s running rather than the bare tool name:file_read <path>/file_edit <path>/file_write <path>,web_fetch <url>, and the same under an MCP tool’s own name.shell_executeis the exception: its title is the command itself, whole and with its line breaks, because a client renders anexecutecall’s title as the command (Zed shows it in the terminal card and copies it from there, and shows norawInputfor that kind). This is also what claude-agent-acp sends.tool_call_updatewhen a tool finishes, with the finalstatus(completed/failed), acontentarray, andraw_output(the structured tool result). A call that never ran, its stream cut after it arrived, is closedfailedbefore the turn’s next round, or when the turn ends there; one asession/cancelstopped is left for the client to mark, as the protocol has it mark every call unfinished at a cancel. A command left running in the background streams nothing more to its call.shell_executeoutput is wrapped in a fencedconsolecode block so editors render it monospaced;file_editandfile_writepopulate diff content blocks so clients can render the apply-diff UI. (Large outputs offloaded to the scratchpad show the scratchpad reference rather than the full payload.) A call goes under the id the provider gave it, unless the session has already sent a call under that id (some providers reuse theirs every round, or give none) or the editor’s thread came from before (session/resume,session/fork, a session reopened after the idle sweep), in which case under one meka makes unique; every report of the call goes under the same id.planwhenever achecklist_addorchecklist_editcall changes the checklist, so clients with a plan panel (e.g. Zed) render the open items. A deferred item ispendingthere, with what it waits on in its text; an emptied list is an empty plan.session_info_updateonce per session, carrying the title (the first user message’s words, cut to 200 characters) so a freshly created or loaded tab gets a label without asession/listcall.compaction_updatefor every compaction, automatic ones included, when the client advertisedsession.compactiononinitialize:in_progressunder acompactionIdwhen it begins, thencompletedwith the summary as the model wrote it,failedwith the error, orcancelledwhen you stopped it._meta.compactioncarries meka’s facts:triggerfrom the start, and once it lands the fields of the HTTP API’scontext.compactedevent. A client without the capability gets atool_callof kindthinkinstead, as claude-agent-acp sends one: titled “Compacting the conversation” while it runs, then completed under the compaction record’s own sentence with what set it apart and the summary as its content and those facts as itsrawOutput, or failed with the error (“Interrupted” when you stopped it). Each call its checkpoint makes (the memories it writes, the files it reads) is told as it finishes, a finishedtool_callcarrying its result under an id that begins with the compaction’s, however the compaction then ends; a load shows them after the compaction.noticefor an advisory meka itself raised during the turn (a lost write, an MCP elicitation it declined on your behalf), when the client advertisedsession.noticesoninitialize:severityisinfoorwarning, andtitlecarries the words the screen shows. A client without the capability gets the same words as a[meka]-prefixedagent_message_chunk,[meka warn]for a warning, so it can style the two apart. A scheduled job’s turn that failed or was interrupted is reported the same way, since it has nosession/promptresponse to carry its outcome.noticeagain for finished background work the model was shown, in the screen’s words:titleis the stage direction (Background task 7f3a1c22 finished after 12m 4s),descriptionthe task’s label (one line per task when several report at once), and_meta.background.tasksthe tasks asGET /v1/sessions/{id}/taskslists them, outcome included, so a client that wants the result has it without the transcript carrying it. A client withoutsession.noticesgets the direction as a parenthesizedagent_message_chunk, as a replay says it. The report the model read is shown to no client.- A
user_message_chunkcarrying a scheduled job’s prompt, pushed by meka itself before the turn it fires runs, so the transcript shows what triggered it. usage_updateafter every provider round, carryingused(tokens currently in context: all input tiers plus output) andsize(the model’s context window), so clients with a context gauge (e.g. Zed) show how full the window is while a long tool loop runs rather than once it ends; a turn that fails or is canceled reports its rounds too, and a compaction reports the estimate it leaves. One more onsession/new,session/load,session/resumeandsession/fork, with what the row recorded, so a reopened session’s gauge is not empty until its first reply. Emitted only once the window is known.- The
session/promptresponse additionally carriesusage: what that turn spent, as the protocol defines it, intotalTokens/inputTokens/outputTokens/cachedReadTokens/cachedWriteTokens, a sub-agent’s and a compaction’s spend inside the turn included. This is the turn’s cost, not the gauge:usage_updateanswers “how full is the window”,usageanswers “what did this turn cost”. A local command such as/statusspends nothing and says so.thoughtTokensis omitted because meka doesn’t meter reasoning separately from output.
The response carries a final stopReason:
stopReason | Meaning |
|---|---|
end_turn | The agent finished cleanly. |
max_tokens | The provider stopped because the model hit its maximum output tokens. The assistant message may be truncated. |
cancelled | session/cancel interrupted the turn, including the case where the cancel caused an error in an underlying operation. meka probes the per-session cancellation token after the turn; any error returned while the token has fired surfaces as cancelled rather than a generic JSON-RPC error. |
refusal | The model declined to comply (Claude stop_reason: "refusal" and the OpenAI equivalent). The assistant message contains the refusal text. |
Permission levels
meka’s Permission levels map 1:1 to ACP SessionMode ids:
| Permission | Mode id | Display name | Description |
|---|---|---|---|
None | none | None | No tools available. |
Read | read | Read | File reads and searches only. No writes, no shell. |
Workspace | workspace | Workspace | Writes confined to the workspace roots. |
Unrestricted | unrestricted | Unrestricted | Writes and shell commands reach anywhere on the machine. |
The full picker is advertised on every session-creation response (NewSessionResponse.modes, LoadSessionResponse.modes, ResumeSessionResponse.modes, ForkSessionResponse.modes) but only the levels in [permissions].enabled from your config.toml are listed; picking a disabled level would just error.
The same picker is also advertised as a configOptions entry, so a client that reads either field
gets it; see below.
With the approvals config option on, a tool call above the active level triggers a session/request_permission round-trip instead of a refusal. Clients render four options:
- Allow: run this call only.
- Always allow any
<tool_name>: run this call and skip the prompt for that tool for the rest of the session. - Deny: refuse this call only.
- Always deny any
<tool_name>: refuse this call and every subsequent call to that tool.
The sticky options name the tool because that is exactly their scope: the decision is keyed on the tool name and takes no account of arguments. The prompt’s title is <tool_name> <primary argument>, the tool’s name and the argument the indicator shows (for shell_execute, the command alone, as on the tool call), so for a shell command you are reading one specific command line while the sticky option covers every shell command the agent runs afterwards. If you want per-command control, use Allow and keep answering. The request’s rawInput, and a fenced json content block beside the title, carry every argument the call was made with, so a client that renders either shows what is being written and not only where. The request names the call’s card. A call the editor has not been shown opens one first: a compaction checkpoint’s call opens the card the call’s end completes with its result, and a sub-agent’s call, which the editor never sees, opens a card of the prompt’s own that closes with the answer as the screen says it (file_write allowed), leaving the agent_spawn call as it was.
Sticky decisions live in meka’s process memory with the session entry; they reset on session/close and when the idle sweep releases the session.
A prompt left unanswered for 30 minutes is denied, and the turn carries on; the HTTP API’s permission_required event has the same 30 minutes. This is a backstop against a client that is connected but will never reply (an editor whose UI thread has wedged, or a harness that speaks ACP without implementing prompts), not a deadline on you: session/cancel already resolves a prompt the moment you stop the turn, and without the backstop a client that does neither holds the session’s runtime mutex indefinitely, blocking session/close and session/set_mode behind it. Denying rather than allowing on expiry is deliberate: an unanswered prompt is not consent.
Session config options
Every session-creation response also carries configOptions, a list of options a client can
render and change with session/set_config_option. meka advertises three, four where a
project exists, in this order:
configId | Kind | Category | Values | Meaning |
|---|---|---|---|---|
permission | select | mode | The ids in [permissions].enabled | The same picker as modes, so it sits beside the one below |
profile | select | model | The profile names in your config.toml | The profile this session runs on |
project | select | none | "" (shown as “No project”), then every project’s name | The project this session is on, whose memories it sees beside the global ones; offered only when a project exists, and read from the store at every update, so one added while the editor runs is offered next |
approvals | boolean | mode | true / false | Whether a call above the level is put to you for approval rather than refused; see Permissions |
approvals takes the protocol’s boolean value, "type": "boolean", "value": true beside configId
in the session/set_config_option params, where the two pickers take a bare value id and no
type. A new session starts with
[permissions].approvals from the config file; session/load and session/resume restore what the
session recorded, since the switch is written to the session row like the level.
permission is deliberately advertised twice, once here and once in the legacy modes field. A
client that only understands modes keeps the picker it has; one that reads configOptions gets
permission and profile adjacent rather than in two unrelated menus. Setting it through either route
does the same thing, and neither picker is left stale: session/set_mode pushes a
current_mode_update and a config_option_update, while session/set_config_option pushes a
current_mode_update and returns the whole refreshed list in its response.
A session whose recorded profile has since left config.toml cannot be loaded at all:
session/load fails while building the runtime, so there is no entry for
session/set_config_option to change. Restore the profile in config.toml, or move the session
with meka -r <id> --profile <name> from a shell, and load it again.
Changing profile rewrites the session’s row, so it holds for every later turn and for a resume
from any surface, not just for this connection. This is the same change /profile makes in the
screen and PATCH /v1/sessions/{id} makes over HTTP. 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.
Changing project rewrites the row the same way, and the next turn’s context restates the
memories the session now sees; a new session is on no project, and a name no project has is
refused without moving the row.
While a turn is in flight either switch is refused with InvalidParams (cannot switch profile while a turn is in flight; cancel it first, or project), the answer a second session/prompt gets, and
nothing is written. Reasoning effort is deliberately not offered: which tiers a
model accepts is a fact about the provider’s system, and a fixed dropdown would be meka asserting
it. It stays on the profile.
Slash commands
Two kinds of slash command are advertised through session/update: available_commands_update (after session/new / session/load / session/resume / session/fork, and refreshed at the top of every session/prompt so a skill installed mid-session shows up without a reconnect):
- Built-in local commands:
/status(the permission level, then the screen’s block: profile, model, context usage, effort, thinking, compaction count, cumulative tokens),/mcp(configured MCP servers and their connection status) and/usage(the account’s rate-limit windows, subscription backends only). They render text back as anagent_message_chunkand end the turn immediately, with no model call. They take no argument, so a command followed by words,/usage looks wrong, why?, goes to the model as typed, and one sent with an image is refused withInvalidParams, since nothing would read the image. - Skills (see Skills): each installed skill is a top-level command carrying a free-form input hint (
"additional context (optional)").
When the user picks one from the palette, the client typically inserts /<name> and lets the user type extra context. meka parses the prompt as follows:
- A built-in local command (
/status,/mcp,/usage): handled agent-side, output streamed back, turn ends with no model call. Checked first, so a skill can’t shadow a built-in (a skill namedstatus,mcporusageis dropped from the palette). - Plain text (no leading slash): passes through to the model unchanged.
/<skill-name>matching an installed skill: loads the skill body via the same path as the screen’s/skillcommand and prepends any extra context the user typed.- Slash with a syntactically valid but unknown skill name (
/nonexistent): passes through to the model unchanged, with adebuglog. The filter false-positives on pasted text like/usr local lib, so a miss is read as “not a skill invocation after all” and the model can say it does not know the command. Only a skill that exists but cannot be read is an error (InternalError). - Slash with content that isn’t a valid skill identifier (
/etc/hosts,//comment): passes through to the model unchanged, so pasted paths and code comments don’t get intercepted.
Sub-agents
agent_spawn and skill-based delegation produce a sub-agent that runs through PermissionForwardingFrontend. The sub-agent’s own output isn’t streamed to the client (its final report flows back through the parent’s tool_call_update), but its permission prompts and fs/* requests forward through the parent’s connection, so the editor’s apply-diff UI sees a sub-agent’s writes the same as the root agent’s. A forwarded prompt opens a card of its own; see Permission levels.
ACP has no sub-agent primitive (no nested sessions, no nested tool calls), so a sub-agent is one tool call, and its progress is that call’s content. While it runs, each tool call it starts is appended to a rolling list (the last 20) and pushed as a tool_call_update on the parent’s agent_spawn call, so a long delegated task shows what it is currently doing instead of an opaque spinner. The whole list is resent on each update because clients replace a tool call’s content rather than appending to it. A nested sub-agent’s list is not forwarded further up: it already appears as an agent_spawn line in its parent’s list, and two writers on one tool call’s content would overwrite each other.
Multi-root workspaces
An editor whose workspace holds several folders (Zed’s Add Folder to Project) sends the first as cwd and the rest as additionalDirectories. Clients only send them when the agent advertises sessionCapabilities.additionalDirectories, so before meka advertised it every folder but the first was silently dropped and the agent would report files in them as missing.
What the extra roots do and don’t change:
- Search sweeps all of them.
file_findandfile_searchwalk every root when you don’t pass an explicitpath. The 60-second walk budget is shared across the whole call, not granted per root, so a four-folder workspace doesn’t get a four-minute ceiling. Passingpathsearches exactly that tree, as before. - A truncated
file_searchsays which roots it skipped. Roots are walked in order starting fromcwd, so a busycwdcan fill the 100-match cap before later roots are reached. When that happens the output names how many roots went unsearched, rather than leaving their absence to read as “nothing there”. Passpathto search one directly, orscratchpadto lift the cap.file_findis unaffected: its cap bounds only what it prints, so it still counts matches across every root. - Overlapping roots are collapsed. A root nested inside another (or a repeat of
cwd) is dropped, so its tree isn’t walked twice and its files aren’t reported twice. Symlinked duplicates aren’t detected. - The model is told they exist. Each root is named in the per-turn environment context, alongside the working directory.
- Relative paths still resolve against
cwdonly. This is what the spec requires:cwd“remains the base for relative paths”. Use an absolute path to reach a file in another root. - The shell still runs in
cwd.shell_executeis unaffected. - A stale root is skipped, not fatal. A root that no longer exists is passed over so the other roots can still answer;
file_searchreports “does not exist” only when no root existed. Root paths are escaped before they reach the glob engine, so a folder named2024*ornotes[1]matches literally instead of widening the search.
Every entry must be an absolute path; a relative one is refused with InvalidParams.
The list is persisted and reported back on session/list as SessionInfo.additionalDirectories, which is how a client rebuilds the workspace shape when you pick a session out of its history. session/load and session/resume carry the complete resulting list, so they replace what was stored rather than merging: reopening a session from a window that no longer has the second folder correctly narrows it, and an empty list clears the roots.
Forking
session/fork branches a copy off a persisted session: the new session starts with the source’s full conversation and continues from there, while the source stays open and unchanged. It’s the protocol’s way to explore a direction, or run something like a summary, without writing into the conversation the user is looking at.
The request is a session-creation request, not a row copy: it carries its own cwd and additionalDirectories, and meka applies those to the fork rather than inheriting the source’s. mcpServers is ignored, as on session/new. The response returns the new sessionId and the current SessionMode state, and the fork is registered as active immediately, so it can be prompted without a further session/load or session/resume.
There is no replay: unlike session/load, forking emits no session/update stream for the copied history, since a client that just forked already has the transcript rendered.
Sub-agent child transcripts are not copied, and a fork of an ordinary session records no link back
to its source. session/fork answers InvalidParams for a sub-agent’s own id: the copy would be a
sibling under the same parent and could not be driven, so the answer points at agent_followup
from the parent instead. It answers InvalidParams for
a source with a prompt in flight too, as a second session/prompt does, and for a source another
meka process has open: either copy would end on a prompt nothing answered. See
Forking a session for the full semantics.
This method is marked unstable in the protocol: it is not part of the spec yet and may change or be removed. Zed does not currently call it.
Known limitations
- A reopened session’s file diffs are code blocks. A session reopened with
session/loadshows a file edit’s change as a unified diff in adiffcode block rather than the protocol’sdiff, which the live run sends: a result records its change as numbered hunks, not as the two texts the protocol’sdiffcarries. The on-disk content is unaffected. - Calls of one round under one id. A provider that gives two calls of one round the same id, or none, has an approval prompt or a command’s live output for either shown on the first one’s card, and two such sub-agents’ activity possibly on each other’s; each result reaches its own.
terminal/*is never used: meka owns every process it spawns, so no command runs in the client’s terminal. Output streams into the tool call instead, as an agent-owned terminal where the client advertises_meta.terminal_outputand aconsoleblock otherwise. See Shell commands stay inside meka.- Image
file_read: stays local. Thefs/read_text_filerequest carries only text, so there’s no protocol surface to delegate it. A regexfile_readfetches the whole document through the delegate and filters it locally. audioprompts: not supported;audiocontent blocks produceInvalidParams.- No client-side model gate for images: when
visionis on, meka forwards images to whatever model the profile names; a non-vision model returns a provider error rather than meka refusing up front. Setvision = falsefor text-only endpoints.
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.
The server starts whether or not a profile is configured, unlike every other way of running meka, so an installation managed over the API can come up before its first account and profile exist and have them added through POST /v1/accounts and POST /v1/profiles. Until a session can get a profile, GET /v1/health/ready answers 503 with profile_configured: false, and a POST /v1/sessions that names no profile is a 422 carrying the configuration’s own sentence: no profile configured, or several and no default_profile. A body that names a configured profile is served either way.
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).
Under systemd, the unit lines in the shell tool’s Memory section keep a command that takes every byte from taking the server with it.
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 '{"working_directory": "/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 -r '[.messages[] | select(.role == "assistant") | .content[] | select(.type == "text") | .text] | join("")'
# → "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: 1793001600000000
data: {"turn_id":"...","session_id":"...","started_at":"2026-05-26T13:45:12Z","source":"client"}
event: assistant_text.delta
id: 1793001600000001
data: {"text":"This project is ","turn_id":"...","session_id":"..."}
event: assistant_text.delta
id: 1793001600000002
data: {"text":"a Rust workspace that..."}
event: tool_call.composing
id: 1793001600000003
data: {"id":"tu_1","name":"file_read"}
event: tool_call.executing
id: 1793001600000004
data: {"id":"tu_1","name":"file_read","input":{"path":"src/main.rs"},"display_summary":"src/main.rs"}
event: tool_call.completed
id: 1793001600000005
data: {"id":"tu_1","is_error":false,"content":[{"type":"text","text":"fn main() { ... }"}]}
event: turn.finished
id: 1793001600000012
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. An id is an integer that rises by one per event across the session’s turns. Each time a server loads the session, after an eviction or a restart, the sequence starts again above every id it issued before, at the microseconds since the epoch, so a Last-Event-ID from before always reads as behind it.
Detached turn
curl -s -X POST http://localhost:8080/v1/sessions/$SESSION_ID/turn \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"message": "refactor the parser", "detach": true}'
# → 202 Accepted, Location: /v1/sessions/.../turns/...
# → {"id":"...","source":"client","started_at":"2026-10-17T09:12:03Z","permission":"workspace","usage":{...}}
The turn runs whether or not this client stays. Its events are on the session feed, and its ending on the resource Location names; see Holding a turn.
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 screen and ACP sessions; they’re interchangeable.
POST /v1/sessions Create a session
GET /v1/sessions List sessions (paginated)
GET /v1/sessions/search Find sessions by the words of their conversations
GET /v1/sessions/{id} Get session details
PATCH /v1/sessions/{id} Update permission, approvals, working_directory, profile, title or pin
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).
Pinned sessions come first, newest pin on top, then the rest 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, working_directory=<path> keeps only the sessions in
that working directory, compared in the canonical spelling every session records, and
parent=<id> lists one session’s direct children: the sub-agents it spawned, which is what a
tree view asks for per expanded node, and what an audit asks to learn what a session dispatched.
parent names sub-agents by definition, so include_children is moot beside it; a session with
none, or an id no session has, is an empty page. profile=<name> keeps the sessions on one
profile, project=<name> those on one project (project= those on none),
pinned=true or pinned=false the pinned or the unpinned ones, and
updated_since=<RFC 3339> the ones whose updated_at is after that instant: a turn or a change
to what the session runs as, never a title or a pin. The filters compose.
When creating a session, specify the working directory and optionally a permission level, the approvals switch, a profile, a project, and capabilities:
{
"working_directory": "/home/user/project",
"permission": "workspace",
"approvals": false,
"profile": "work",
"project": "webdev",
"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",
"working_directory": "/home/user/project",
"permission": "workspace",
"approvals": false,
"profile": "work",
"project": "webdev",
"title": "list the files in src/",
"last_turn": {
"id": "7f3b...", "source": "client", "started_at": "2026-05-26T13:46:40Z",
"permission": "workspace", "ended_at": "2026-05-26T13:47:01Z", "status": "succeeded",
"stop_reason": "end_turn",
"usage": {"input_tokens": 12340, "output_tokens": 567, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 8000}
},
"capabilities": {"supports_reasoning_stream": false, "supports_permission_prompts": true},
"turn_in_flight": false,
"approvals_pending": 0,
"checklist": [
{"id": 3, "text": "write the tests", "status": "in_progress"},
{"id": 4, "text": "ask Sam about the venue", "status": "deferred", "reason": "waiting on Sam"}
]
}
checklist is the session’s open checklist: every item the agent has committed to
and not yet disposed of, with its id, text, status (pending, in_progress or deferred),
and on a deferred item its reason and the task it waits on when it waits on one. Present on a
session this server holds; the feed’s checklist.updated carries the same list on every change.
created_at is when the row was made; updated_at moves on a turn and on any change to what the
session runs as (permission, approvals, working_directory, profile, project), a PATCH included, but not on a
title or a pin; title is the title a client set, else the first user message’s words, whitespace
collapsed and cut to 200 characters. A pinned session also carries pinned_at.
last_turn is the session’s latest turn, whatever host ran it: the same record GET /v1/sessions/{id}/turns lists, carried here because a list of sessions is rendered from
it, one row per session. id is the turn’s id, the one turn.started announced it under and every
message it added names; source is who opened it (client, inbox, schedule, background, or
parent for a sub-agent’s turn); started_at is when; permission is the level it ran under,
the one its opening told the model, and for a turn still opening, such as the record a detached
request’s 202 carries, the session’s level, which it opens under unless the level moves first.
Once the turn has ended, ended_at says
when and status how: succeeded, with its stop_reason (end_turn, max_tokens or refusal),
failed, with an error carrying the same type and detail a Problem Detail would (never the
provider’s own text), or canceled; profile names the fallback that served the turn when the
session’s own profile could not (Fallback profiles), and is absent
otherwise; and usage is what every round of it spent. A turn that began
and shows no ended_at is either in flight, which turn_in_flight says, or one the process died
under. Whether a turn is running now is a live fact of the process that no row stores. Together they
answer the one question a session list asks: running, waiting on a person (approvals_pending above
zero), or stopped and how. A turn refused before it began, by a 4xx or the 503 for a required
MCP server, added nothing and leaves last_turn alone. The checkpoint turn POST /compact runs is
announced on the feed under a turn_id of its own, for the events it emits, but is not a turn of
the conversation: it has no row and no message names it.
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 until a turn has begun, approvals_pending on a
session this server does not hold, working_directory 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 capabilities, turn_in_flight, approvals_pending and checklist 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.
project names a project, which decides the memory tier the session’s writes land
in and the tiers it sees; GET /v1/projects lists them, POST /v1/projects adds one, and a name no
project has is a 422 whose detail reads no project named 'x' (configured: a, b).
Omitted or empty, the session is on no project, and every session response echoes project back,
"" for none. PATCH /v1/sessions/{id} with {"project": "movie"} moves a session onto a project
and {"project": ""} takes it off one; the row is rewritten, and the next turn’s context restates
the memories the session now sees. Like profile, it is a 409 when a turn is in flight.
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 working_directory, it is a 409 when a turn is
already in flight; cancel first. A PATCH naming any of working_directory, profile and
project holds the session while it writes, the way compact does, so a turn arriving meanwhile
answers 409 rather than starting under it, and so does a second such PATCH. permission and approvals are the two fields that apply during
a turn; see Permission levels over HTTP.
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 working_directory 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 working_directory 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 working_directory field is validated on create, fork and patch, by the same rule the screen’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 aworking_directoryfilter on the listing finds a session however its directory was spelled.
If working_directory 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 |
answers_prompts_on_return | false | A prompt in a turn no request holds waits for this session’s client to come back and answer it; an elicitation waits an approval’s thirty minutes there, or as long as the call that asked, if that is sooner |
supports_elicitation | false | An MCP server’s elicitation parks as an elicitation_required event for the client to answer; see Elicitations |
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.
Set answers_prompts_on_return: true on a session a person drives through a client that comes and
goes, a browser tab say. A gated call in a turn no request holds, a detached turn, an inbox turn, a
scheduled fire, then parks whether or not anyone is attending, survives the last attending reader
leaving, and closes only on an answer, the turn’s cancellation or its 30-minute expiry; the client
that comes back is handed it with the feed’s replay and answers it. The session is busy for as
long as the prompt waits. A held turn keeps the grace rule, since its request leaving stops the
turn anyway. Leave it false for a script or a service with no interface to show a prompt on,
where the default refusal is the right answer.
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 working_directory, matching ACP’s
session/fork, which likewise carries a workspace but no permission or capability fields. An
If-Match header holding the ETag of GET /v1/sessions/{id}/messages makes the copy
conditional on the source being as it was read; see Detecting a rewritten
history. Branching from a point is a fork under If-Match
followed by a rewind of the copy, which nothing else can run a turn on in between:
{ "working_directory": "/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.
A sub-agent is never a session this server holds: its parent runs it under the parent’s runtime.
Its record still says when it is in a turn, because the parent’s run registers it for exactly as
long as it runs, so turn_in_flight on a sub-agent’s record and in a listing is true while a
spawn or a follow-up is running it; last_turn on its row says how its last run ended. While it
runs, its id names its own event feed, which is read rather than attended; see
A sub-agent’s feed. A prompt a sub-agent’s call parks rides the parent’s
feed, with the sub-agent named, and the sub-agent’s feed mirrors it; see Approvals.
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.
Titles, pins and search
A session is labeled by its first words until someone titles it. PATCH /v1/sessions/{id} with
{"title": "Research notes"} sets the title every surface shows from then on, the CLI and editor
clients included, and answers with the updated record; an empty string clears it. A title is one
line of at most 200 characters, whitespace runs collapsed, and a longer one is a 422. {"pinned": true} pins the session, which lists it first on every surface and spares it from the retention
sweep; {"pinned": false} unpins it. Pinned sessions order among themselves by pinned_at,
newest first, and pinning an already pinned session keeps its time. Neither field moves
updated_at, so a rename does not reorder the list, and a body naming only these two, or only
profile, works on a session this server has not loaded and on one another process is running.
GET /v1/sessions/search?q=<words> answers {"sessions": [...]}: the sessions whose
conversations hold the words, best first, each the same record GET /v1/sessions lists plus an
excerpt, the line of the best-matching message the words were found on, whitespace collapsed
and cut short, marked (summary) when taken from a compaction summary. What is searched is what
was said, user messages, replies and compaction summaries; tool calls, tool results and thinking
are not. A session whose title holds every word comes first, with an excerpt only when its words
matched too; the rest are ranked by their best message. Every word must appear; when nothing holds
them all, the words are tried as prefixes, then sessions holding any of them, then any as a
prefix. Words match by their stem and with every diacritic folded; scripts written without spaces
match character by character. limit is how many at most (default 20, clamped to 1..100) and
include_children=true adds sub-agent sessions. A blank q finds nothing. Requires sessions:r.
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 connection dropped can tell “my turn is still running” from “my turn died”
without submitting a speculative turn and reading the 409. A dropped connection does not cancel
the turn at once: a held turn runs until its next round boundary after the grace, a detached one to
its end, 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 held JSON turn whose client gives up: a request timeout on your side does not
stop the turn until the grace is out, and a turn with a single round that is already under way ends
as it would have. Read what completed from GET /v1/sessions/{id}/messages, and the turn’s ending
from GET /v1/sessions/{id}/turns/{turn_id}. A client whose timeout is shorter than its longest
turn sends detach: true instead; see Holding a turn.
turn_in_flight answers for this process: a session it holds, and a sub-agent it is running under
a parent. A session another process is running, a screen open on it say, reads as idle, because only
that session’s file lock knows and a read never probes the lock. Once a turn has ended, however it
ended, last_turn on the record says how.
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. Every turn that began is a row: GET /v1/sessions/{id}/turns lists them, newest first, each with its id, source, when it began and ended, how it ended and what it spent, in the shape last_turn on the session record shows for the latest one. limit is how many at most (default 50, clamped to 1..200), and when more remain the response carries next_before, the id to pass back as before for the page of turns older than it. Every message a turn added names it in turn_id, which is also the id its turn.started announced.
POST /v1/sessions/{id}/turn Submit a turn
POST /v1/sessions/{id}/cancel Cancel an in-flight turn
GET /v1/sessions/{id}/turns The turns that began, newest first
GET /v1/sessions/{id}/turns/{turn_id} One turn, with its ending once it has one
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 six 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 | How the response is encoded: false → JSON; true → SSE stream |
detach | bool | false | Whether this request holds the turn; see Holding a turn |
options.skill | string | null | null | When set, activates the named skill for this turn (equivalent to /skill <name> on the screen). 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, as is a skill the session’s project hides |
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 screen does with a typed prompt. withdraw takes it back, for a client that resends a failed turn; see Resending a failed turn |
Holding a turn
stream and detach are independent. stream says how the response is encoded. detach says whether the request holds the turn: a held turn’s response ends with the turn, and the turn is stopped at its next round boundary once nobody has been listening for [serve] stream_reattach_grace, 30 seconds by default. A detached turn’s request is only the trigger: the turn runs for the session like an inbox turn, and only POST /cancel, a shutdown or its own failure ends it early.
detach: false (held) | detach: true | |
|---|---|---|
stream: false | 200 with the assembled turn once it ends. A dropped request stops the turn after the grace. | 202 Accepted at admission. The body is the turn record and Location names it under /turns/{turn_id}. |
stream: true | SSE through the terminal. A dropped stream stops the turn after the grace. | SSE from admission. Drop it any time; the turn runs on. |
A held request is for a client that is there for the turn: a killed curl should stop spending tokens whichever encoding it used. A detached request is for a client that cannot hold a connection open or does not want to, a browser tab that may close, a job runner behind a proxy with a short read timeout. The turn record a detached request is answered with is the row GET /v1/sessions/{id}/turns lists, with no ending yet; read GET /v1/sessions/{id}/turns/{turn_id} for the ending, or take it from the feed’s terminal event. The feed may announce turn.started before the 202 reaches you; correlate by turn_id.
A listener is any reader of the session feed, so a held turn with a feed subscriber attached is not stopped for its own request leaving. A detached stream is a feed reader narrowed to one turn: dropping it has no effect on the turn, and a reader that fell behind is caught up from the ring rather than ending the turn, as a feed reader is.
The inbox
POST /turn runs one turn for one request, the response scoped to it, and answers 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. The end of a reply is a boundary too: a steer that lands while the model writes the answer that would end the turn follows that answer, and the turn goes on to answer it. 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. Without a source, or with a blank one, the header is [Message arrived <time>] and names nobody: who sent a message is the client’s to say, and meka does not guess it from the token that carried it. GET /v1/sessions/{id}/inbox omits source for such an item. 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,
"retry_until": "2026-10-18T15:02:11Z"}
retry_until is the deadline for retries, the item’s enqueue plus [serve] inbox_retry_ceiling: an attempt that fails after it is not retried, and the item is given up on. An item not yet tried still runs when its turn comes, whatever the time, so under a short ceiling a fresh item can show a retry_until already past and be delivered all the same. A fresh item answers pending, unless a turn already running read it in the meantime. A replayed key answers with what the earlier item has become: beside its state, the not_before and last_error it shows while it waits, or the failure and last_error it was given up on with, so a producer that lost its place learns the item’s fate from the same call that enqueued it, without listing an inbox that no longer shows a withdrawn item.
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, when waiting can change the answer: the upstream could not serve the profile (an outage, a dropped stream, a quota or billing refusal), a required MCP server was not ready, or meka’s own store or disk refused (a lock another process holds, a full disk). Any other failure, a rejected credential, a request the upstream judged wrong, a profile that cannot read the session’s images, gives the item up at once, since every attempt would fail the same way. A retry waits 10 seconds, then twice that per attempt, up to five minutes between attempts, or less when the upstream said it would be back sooner (Retry-After, a quota reset); a stated time never makes it wait longer, since an account topped up early is noticed at the next attempt. Each deferral posts inbox.deferred on the feed and the webhook with the time of the next attempt and the error, and the item shows not_before and last_error, the same error as a Problem Detail (type, title, status, detail, never the upstream’s text), while it waits. Retrying ends at retry_until, the item’s enqueue plus [serve] inbox_retry_ceiling, an hour by default: the item is given up on, withdrawn, with inbox.failed on the feed and the webhook carrying the reason and the last error, so whoever is waiting is told rather than left with silence, and the item keeps that failure and last_error for a replayed key to answer; a canceled turn or a client’s DELETE withdraws an item with no error on it, since none decided it; "0s" gives an item up on its first failure. 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 screen 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 ceiling.
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 the bytes, {"media_type": "...", "data": "<base64>"}, or an image the
session’s history already holds, {"hash": "..."}, the hash its image block carries on
GET /messages. Bytes 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. A hash lets a client send a message again, after an edit or a rewind, without
fetching and uploading the bytes: the stored bytes and media type are used, and only an image the
session holds can be named, the scope GET /blobs/{hash} serves. A media_type beside a hash,
or a hash the session holds no image for, is a 422 naming the entry.
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.visionon each entry ofGET /v1/profilesis the flag to read, for whichever profile the session runs on. 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, and WebP pass through; BMP, 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 what set the compaction going, which strategy wrote it, the memories the checkpoint wrote, whether the recent turns were kept, 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.
Three more things make editing the history safe:
ETagandIf-Match.GET /messagesanswers with anETagbuilt fromrevisionandtotal, which together identify the view you read: a rewrite moves the first and an append moves the second, andrevisionalone would let a turn that landed between your read and your edit go unnoticed. Send it back asIf-MatchonPOST /rewindorPOST /fork, and an edit decided on a view that has since changed is refused with412/errors/precondition-failed, whoserevisionandtotalare the current state; read again and decide again. Without the header the call behaves as it always has. A rewind answers with the conversation’s newETag, so the next edit needs no re-read.conversation.rewoundon the session feed, with the newrevision, the newtotalandturns_removed, so every reader learns the view was rewritten, with the tag its next edit needs. It is emitted outside any turn and carries noturn_id.revisionon every terminal event. A repair, a redaction or a withdrawal rewrites the view inside a turn and has no event of its own; the terminal, where a client already decides what to re-fetch, carries the revision as it stands when the turn ends.
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 labels, beside total (the
length of the whole conversation, not the page) and revision, under an ETag header that
identifies the whole conversation’s current state. limit defaults to 200 and is capped at 1000;
offset defaults to 0. A message’s turn_id is the id of the turn that added it, as
turn.started announced it and GET /v1/sessions/{id}/turns lists it; it is omitted on a row no
turn added (a compaction summary, a repair’s replacement) and on one written before turns were
recorded. turn_label is a dense positional label (t_0001, t_0002, …): a message that opens a
turn, a user message carrying no tool result, starts a new label, and the assistant and
tool-result messages after it share it, which is also how POST /rewind counts turns. The number
of labels from a chosen one to the last is the turns a rewind to that point takes.
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, catalog changes and resume
notice meka injected for that turn, which the model saw as text ahead of the words. Everything
else meka writes into a user message keeps its own kind, so a client can show it for what it is
and never has to parse a header: a nudge (meka’s “keep going”, with its kind); an inbox
block for a message that reached the model through the inbox, with body, the person’s words,
and the item_id, class and source where they were recorded; a scheduled block for a
job’s fire, with job_id and prompt, the job’s words; and a background_report for finished
background work, with task_ids. What a person sent where meka expanded part of it keeps its
kind too: an invocation for a skill or an MCP prompt invoked by name, with its kind (skill
or mcp_prompt), the name invoked (an MCP prompt as server:prompt) and the directive
typed with it, blank when none was; and a resource for a file an editor attached, with its uri
and name, in the place among the text blocks where it was attached. A file attached to a
prompt that invokes a skill is part of the invocation instead: its directive names the file by
its mention (@name) where it was attached, its text holds the contents, and the message has no
resource block for it. On each of these text is what the model read, header, expanded body or
file contents included, as the nudge and turn_context blocks hold theirs.
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, and a later turn
names the image again by that hash; see Image attachments. 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. A tool result may also carry a tool_reference item, {"type": "tool_reference", "tool_name": "..."}: a deferred tool that tool_load or tool_search made callable on a profile
whose deferred_tools is native, which the
provider expanded into the tool’s schema where the item sits.
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> on the screen |
keep_recent | bool | (meka decides) | Whether to keep the most recent turns verbatim after the summary |
The response carries source (checkpoint 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 |
An If-Match header holding the ETag of GET /v1/sessions/{id}/messages makes the rewind
conditional on the conversation being as it was read, and a 412 says it is not; see Detecting
a rewritten history. The response carries turns_removed,
messages_before and messages_after, under the conversation’s new ETag, and the feed carries
conversation.rewound.
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",
"messages": [
{
"role": "user",
"content": [
{"type": "turn_context", "text": "[Context]\n..."},
{"type": "text", "text": "What is in src?"}
]
},
{
"role": "assistant",
"content": [
{"type": "text", "text": "Let me look."},
{"type": "tool_use", "id": "tu_1", "name": "file_read", "input": {"path": "src/main.rs"}}
]
},
{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "tu_1", "is_error": false,
"content": [{"type": "text", "text": "fn main() { ... }"}]}
]
},
{
"role": "assistant",
"content": [{"type": "text", "text": "Here are the files in src/: ..."}]
}
],
"usage": {
"input_tokens": 12340,
"output_tokens": 567,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 8000
},
"notices": []
}
Key fields:
messages: the messages this turn added to the conversation, in order and in the shapeGET /v1/sessions/{id}/messagesreads them back: first the message the turn opened with, your words behind theturn_contextblock meka wrote and any report that rode them, then the assistant’s messages with their text, their thinking when the session streams reasoning, and their tool calls, and the tool-result messages that answered those calls. The reply a bot shows is the text blocks of the assistant messages, joined.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, "background", "compaction" for the checkpoint turn POST /compact runs, or "parent" with parent_id and tool_call_id on a sub-agent’s feed) | Turn begins |
message.appended | role, content, turn_id, session_id | A user-role message carrying words entered the conversation: the message the turn opened with, whoever opened it, or the one an interrupt appends after a cut answer. content is the message’s blocks as GET /messages types them (turn_context, text, invocation, resource, scheduled, background_report, inbox, image by hash) |
turn.finished | turn_id, session_id, stop_reason, usage, revision, optional refusal_text, profile when a fallback profile served the turn | Turn completed successfully |
turn.failed | turn_id, session_id, error (Problem Detail shape), revision, message_withdrawn when the turn began, profile when a fallback profile served the turn | Turn failed mid-stream |
turn.canceled | turn_id, session_id, reason ("client" for POST /cancel, "server_shutdown", "abandoned" when a held turn’s request had been gone for the grace with nobody else listening, "sse_lag" when the only consumer fell behind and the turn was stopped for it, or "parent" on a sub-agent’s feed, whose turn stops with its parent’s), revision, message_withdrawn when the turn began, profile when a fallback profile served the turn | Turn was canceled |
turn.nudged | kind, text, turn_id, session_id | The turn went on past the model’s reply: meka wrote text into the conversation as a user-role message to send the model back to work, kind visible_reply after a reply with no visible text, checklist after one that left checklist items open |
task.reported | task_ids, text, turn_id, session_id | A turn in flight read finished background work at a tool-round boundary: meka wrote text, the report the model reads, into the round’s user-role message behind its results |
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. All three carry revision, the conversation’s revision as the turn ends, omitted only when the store could not say, and on a sub-agent’s feed, whose transcript is not the one the precondition guards; see Detecting a rewritten history.
message.appended is how a reader that did not open a turn learns what it answers: a scheduled job’s fire, a background report, another client’s words, an inbox item. It follows the turn’s turn.started and precedes every delta, tool event and inbox.delivered of that turn; the message an interrupt appends follows the notice announcing the cut. The feed mirrors the conversation at two grains: a message that enters whole is announced whole by this event, and a block added to a message a round is still building is announced by the event that names it. task.reported and turn.nudged carry the text meka wrote; inbox.delivered, which also covers an interrupt joined onto an opening nothing had answered, carries the items’ ids alone, since the words are the producer’s, and a reader that is not the producer takes them from GET /messages. An inbox block in message.appended says the item was appended, as its own appended state says; inbox.delivered is the moment the model read it. A client that mirrors the conversation drops a turn’s opening when the turn’s terminal carries message_withdrawn: true: a recurring job’s fire, or an inbox turn retried after a failure, appends its opening on every attempt and takes it back each time it ends with nothing from the model. The event carries the turn_context block as GET /messages does, so a reader’s replay ring grows with it.
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.deferred | item_ids, session_id, not_before, error (Problem Detail shape) | A turn on these items failed in a way waiting can change; the next attempt runs at not_before |
inbox.failed | item_id, session_id, reason, error (Problem Detail shape) when a failed turn decided it | meka gave up on the item and withdrew it: its retry ceiling passed, or its turn failed in a way no retry would change |
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, change | 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 shell_execute produced output; append chunk to what you show for the call |
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 shell_execute, whose output streams as tool_call.output_delta.
tool_call.output_delta is progress rather than history, and the feed treats it so: it carries no id, is never replayed after a reconnect, and never displaces 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. 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. What a sub-agent under an agent_spawn call is doing is not summarized on the parent’s feed: it is the sub-agent’s own feed, every call in full.
change is present on the completion of a call that changed a file, a memory or a skill (file_write, file_edit, memory_write, memory_edit, memory_delete, skill_write, skill_edit, skill_delete) and absent otherwise, a failed call included. It carries the whole text before and after as old and new, with path for a file or a skill’s SKILL.md; a memory’s text is what meka memory export writes. old is omitted when the call created the entry and new when it deleted it, so a client can draw the diff itself or show what the entry now says. It is live only: the log keeps no result metadata, so GET /messages never carries it, and a reconnect that has fallen past the replay ring cannot recover it.
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, and a round cut, or ended on an error the turn recovers from, leaves a tool_call.composing or a tool_call.executing with no completion while the turn goes on, sometimes followed by a later call under the same id; so close per-id state on the terminal event as well, and take a later tool_call.composing for an id as a new call. 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, expires_at, subagent_id when a sub-agent asked | Permission approval needed (approvals on, call above the level) |
permission_resolved | request_id, outcome (allow, deny, allow_always, deny_always, expired or canceled) | The parked prompt closed, however it closed |
elicitation_required | request_id, server, kind (form or url), message, schema on a form, url on a URL, expires_in_seconds, expires_at, subagent_id when a sub-agent’s server asked | An MCP server asked the person something, on a session with supports_elicitation |
elicitation_resolved | request_id, outcome (accept, decline, cancel, expired or canceled) | The parked elicitation closed, however it closed |
Context
| Event | Payload | When |
|---|---|---|
context.compacting | compaction_id, trigger | A compaction began; the checkpoint turn or the summarizer is about to run |
context.compacted | compaction_id, trigger, source, memories_written, kept_recent, replaced_count, generation | The conversation was summarized and the window replaced |
context.compaction_failed | compaction_id, trigger, error, interrupted | The compaction that began under compaction_id ended without replacing the window |
context.measured | used, window when known | What is in context now, the figure GET /v1/sessions/{id}/context answers with: after every provider round, and after a compaction or rewind with the estimate it leaves |
conversation.rewound | revision, total, turns_removed | POST /rewind removed turns; emitted outside any turn, so it carries no turn_id |
checklist.updated | items, the whole open list as the session record carries it | A checklist_* call changed the list; empty once everything is disposed of |
A turn that stops with a checklist item open does not end: meka sends the model back to the list
and the turn carries on, inside the same turn_id, with a turn.nudged carrying the message it
wrote, a notice for each nudge (Checklist: 2 open items, continuing, nudge 1 of 3) and a warn
notice when a cap ends the turn with items still open: three nudges in a row without a tool call,
or nine in the turn. The message is a user-role message of the conversation whose first block is a
nudge block, so GET /messages shows it between the reply it answered and the reply after it,
under the same turn_label, and a client can tell it from the person’s words; a blocking
POST /turn waits through those rounds and carries it among the turn’s messages. A steer that
arrives while the model writes the reply that gets nudged rides the nudge as a text block behind
it, the way one rides a tool round’s results, and inbox.delivered follows. See
Checklist.
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. trigger is what set it going: reactive (a measured occupancy crossed the ceiling), proactive (the turn’s projected request would have), manual (/compact or POST /compact), requested (the agent’s own context_compact), emergency (the provider refused the request as too large) or retention (a turn aged past turn_retention). source is checkpoint or summarizer (they differ in fidelity, not just mechanism), memories_written names what the checkpoint turn wrote, kept_recent says whether the recent turns were kept verbatim after the summary, 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. context.compacting opens the compaction under the same compaction_id before any request is made, since a checkpoint turn can take a while; context.compaction_failed closes one that ended without replacing the window, interrupted when the user stopped it.
The same object appears on GET /messages: the summary message carries it as compaction, without compaction_id, 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.
Falling behind
A stream’s events are broadcast to every reader of the session, and the server buffers up to 256 ahead of a slow one. A reader that falls behind is caught up from the replay ring, the same replay a reconnect with Last-Event-ID gets, done by the server on the reader’s behalf: it sees a pause and nothing else. When the ring no longer reaches back to what the reader last received, the hole is said as a feed.gap event, below, and the stream carries on.
One case ends the stream instead. A POST /turn with stream: true is run for its client, so when that client is the only reader and falls behind, the turn is canceled to stop burning provider tokens, and the stream ends with a terminal turn.failed carrying error type https://meka.run/errors/sse-lag. That event is the stream’s, sent before the turn has unwound, so it carries no message_withdrawn; the outcome recorded for a later re-attach is a turn.canceled with reason: "sse_lag" and does. Retry by submitting a new turn. A turn is never canceled for a reader of the session feed or the server feed, because the turn was not run for it.
The feed itself
| Event | Payload | When |
|---|---|---|
feed.gap | session_id on a session’s feed, dropped when the feed can count the hole | This reader’s copy of the feed has a hole: the replay ring no longer reaches the position it resumed from, or what it fell behind by. Read GET /messages (or GET /v1/sessions for the server feed) to fill it. Carries no id, since it is the reader’s and not the session’s |
dropped is how many numbered events the hole holds. It is absent when the reader resumed from an id this feed never issued, after a restart say, since nothing says how far back it stood.
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. That loading takes sessions:w, the scope that drives a session: a revived session is held in memory and under its cross-process lock for as long as the stream stays open, so a token with sessions:r alone attaches to a session this process has loaded and is answered 409 (/errors/session-not-loaded) otherwise. Such a reader keeps nothing loaded either: the idle timeout evicts the session past it, which closes its stream, and it reconnects to that same 409 until a writer loads the session again.
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: 1793001600000042" \
"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). The turn’s message.appended follows it the same way, marked "resumed": true and without an id:, for a reader that has not had it: one joining, or one resuming from behind it after the ring let it go. A reader the replay hands it to, or one resuming from past it, is not handed it again. So a client joining mid-turn reads the reply under the message it answers; it is the opening as first announced, and what the turn appended since is read from GET /messages. 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, the stream opens with afeed.gaprather than a transcript that silently skips; see Falling behind. 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 held turn is not canceled immediately when its client disconnects. It keeps running for
[serve] stream_reattach_grace(default 30s) waiting for a listener to come back; after that the agent loop stops at its next round boundary, since nobody is listening, and the turn ends withturn.canceledandreason: "abandoned". Set"0s"to stop it at the first boundary after the drop. The rule is only for turns a request holds,POST /turnwithoutdetachin either encoding: a detached turn, or one 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 feed subscriber is a listener, so a held turn with one attached runs on.
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 where POST /turn would. A sub-agent’s id is the one id the route never loads: it is the sub-agent’s own feed while its parent runs it, below.
A sub-agent’s feed
GET /v1/sessions/{id}/stream on the id of a sub-agent this process is running is that
sub-agent’s feed: the same events a session feed carries, every one of its tool calls in full,
with its own ids, ring and Last-Event-ID. The feed exists for exactly as long as the parent’s
spawn or follow-up runs the sub-agent, the span its record reports turn_in_flight; a client
that sees a sub-agent in flight, on a listing or from a session.updated on
the server feed, opens the feed with the same call it uses for any session.
Its openings are announced as any session’s: the message.appended after its first turn.started carries the words the parent’s spawn or follow-up gave it.
It opens with a turn.started whose source is "parent", with the parent’s parent_id and
the tool_call_id of the agent_spawn or agent_followup call running it, and closes with the
terminal, which carries no revision. A sub-agent’s turn ends when its parent’s does: a parent
canceled by a client, or stopped with the server, ends the sub-agent’s with turn.canceled
and reason: "parent".
The feed is read, never attended: a prompt a sub-agent’s call parks is on its parent’s feed,
where the one answerer is, and is answered on the parent’s responses route. The sub-agent’s
feed mirrors the permission_required and the permission_resolved, with subagent_id, so a
client watching the sub-agent sees it waiting and knows where to answer. ?attend=true on a
sub-agent’s feed is refused with 422 session-not-drivable.
Once the run ends the feed is gone, and the route answers 409 subagent-not-running, naming
the parent whose run it belongs to. What the sub-agent did is in its history, GET /messages and
GET /turns on its own id, and last_turn on its record says how the run ended. A sub-agent a
different process runs, a screen on the same store say, has no feed here: the same limit
turn_in_flight has.
The server feed
GET /v1/stream is the listing’s change feed: one connection, across every session this
process holds, carrying exactly the events that change a session record and nothing a session
feed is for. A client keeping a list of sessions keeps it current from this and opens a session’s
own feed only for the one it is looking at. It needs sessions:r, numbers its events on its own,
and replays from Last-Event-ID (or ?last_event_id=) out of a ring of its own, with the same
feed.gap when the ring no longer reaches back far enough and the same catch-up for a reader that
falls behind.
| Event | Payload | When |
|---|---|---|
session.created | the session record, as GET /v1/sessions/{id} answers | A session was created, forked, imported, or spawned as a sub-agent by this process |
session.updated | the session record | A PATCH changed it, or a sub-agent’s run on it began or ended |
session.deleted | id, session_id | A DELETE, or the idle sweep with delete_on_idle |
turn.started, turn.finished, turn.failed, turn.canceled | as on the session feed | Every turn on every session this process holds, deltas left out; a sub-agent’s run reaches this feed as the session.updated on its record |
permission_required, permission_resolved, elicitation_required, elicitation_resolved | as on the session feed | A prompt was parked or closed, which moves approvals_pending |
A session.updated carries the whole record so a client replaces its row rather than patching
it. The same session.updated goes to the session’s own feed, so a client on one session also
learns its title or profile moved under it. The feed sees this process’s changes only: a session
another process renamed or deleted, a screen open on the same store say, is not announced, the
same limit turn_in_flight has.
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.deferred", "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 the next round boundary of a running turn or 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.deferred, inbox.delivered and inbox.failed are the inbox’s three outcomes: an item waits for another attempt, the model read it, or meka gave up on it. The error on a webhook copy of inbox.deferred or inbox.failed is the Problem Detail’s type, title, status and detail; provider_response rides the feed’s copy alone. 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 seven.
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, canceled when the turn was stopped before it finished, as happens when the server drains during it, or not_run when the job’s session could not be opened for it (its profile left config.toml, or the session is gone); a not_run fire is tried again when its lease expires, so the status may repeat until the attempts run out.
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 screen’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. working_directory 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, held or detached, 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, unless the session declared answers_prompts_on_return, in which case a prompt in a turn no request holds parks and waits for the client to come back. 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, counted from when the prompt was parked, and expires_at carries the deadline itself, which is the one to show when the event reached you from the replay ring minutes after it was parked. It is the same backstop an ACP client’s prompt gets. When the last client that could answer disconnects, the prompt waits out [serve] stream_reattach_grace, the window a held turn already gives its reader, and is canceled only if nobody is attending by then: a browser tab reloading or waking from suspension finds its prompt still parked, and a closed one costs the call after the window rather than at once. On a session with answers_prompts_on_return, a prompt in a turn no request holds is not canceled for absence at all; it waits out its expiry. An approved call still runs at the session’s level: approval never widens reach, so an approved write at read lands only inside the session’s workspace.
However a prompt closes, answered by you or another client, expired, or canceled because the turn ended or nobody was left to answer, the feed says so with permission_resolved, carrying the request_id and the outcome, so a second client showing the same prompt can take it down rather than learn of it from a 404. While a prompt is parked, the session record counts it in approvals_pending, on the listing too, which is how a list of sessions says which ones are waiting on a person; the prompts themselves are on the feed, which hands every one still parked to a client that joins. A call a sub-agent makes is asked through its parent’s feed and answered on the parent’s responses route; the event names the sub-agent in subagent_id.
Approvals with JSON responses
A request with stream: false never sees a permission_required event, so it is not an answerer. With approvals on, no feed reader attending, and no answers_prompts_on_return on the session, every call that would need approval is refused without asking; each refused tool appends a notice saying so and pointing at attend=true and the capability.
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; declare answers_prompts_on_return on a session a person returns to.
Elicitations
An MCP server may ask the person something mid-call: a form, a JSON Schema of fields to fill, or a URL to open and come back from. On a session that declared supports_elicitation, the question parks as an elicitation_required event under the attendance rule approvals follow: someone attending, a streaming request or an attend=true reader, or answers_prompts_on_return. The event carries the server, the kind, the message, the schema or the url, and expires_in_seconds with expires_at: a minute, or thirty on a session that declared answers_prompts_on_return while no request holds the turn, the bound an approval gets there, after which the server is told the person declined; never past the tool call that asked, which MEKA_MCP_TOOL_TIMEOUT cuts at ten minutes by default, canceling the prompt with it. The server holds its own request open for as long as it chooses, so the longer wait holds only where the server waits too. The client answers at the same responses route a prompt takes:
POST /v1/sessions/{id}/responses/{request_id}
Content-Type: application/json
{"outcome": "accept", "content": {"name": "Ada"}}
accept carries content, an object of the fields the schema named (none for a URL the person opened), and an accept without it on a form, or with it on a URL, is a 422; decline says the person would not; cancel says they dismissed it. An approval’s answer to an elicitation, or an elicitation’s to an approval, is a 422 naming the kind the request is, and content on anything but accept is one too. The feed says how the prompt closed with elicitation_resolved, expired when its wait ran out and canceled when the turn ended, the server withdrew its request or nobody was left to answer, and the session record counts a parked elicitation in approvals_pending with the approvals. A session that did not declare the capability keeps the earlier behavior: the elicitation is declined without asking, and a notice says so.
Authentication
Every request requires Authorization: Bearer <token>, except the two health probes and, when [serve].docs is enabled, /v1/openapi.json. That one is off by default, so on a default deployment it answers 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 approval prompts; cancel background tasks. A session it creates may run at any level [permissions].enabled admits, in any directory, so at the default enabled set, which includes unrestricted, this too is shell on the host. The client chooses the directory too, so workspace reaches wherever it points, its home included: on a server for clients you do not trust, read is the only level that confines one |
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; add, rename and remove projects |
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 |
config:r | Read the installation: accounts (never a credential), each profile’s settings, each MCP server’s entry (keys only for env and headers) |
config:w | Write the installation: add, rename and remove accounts, store their keys, add, change and remove profiles, set the default, add, change, remove and log in MCP servers. This is the operator’s own grant, and it is shell on the host: an MCP server added with a command runs that command as the user meka serve runs as, so give it to the client that administers the installation and to nothing else |
Discovery endpoints (/v1/info, /v1/skills, /v1/mcp, /v1/profiles, /v1/projects) 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 reach them through the API; what its turns’ tools reach is bounded by the level and [permissions], not by the token’s scopes.
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.
A change to the tokens applies when the server serves the file: at a write of its own or at POST /v1/config/reload. From then on a removed token is refused, and every stream it holds ends, as does one whose token loses a scope the stream needs: sessions:w for a streamed turn, both sessions:r and sessions:w for an attending reader and a reader that keeps its session loaded, sessions:r for any other. A request without a stream that is already running, a stream: false turn say, finishes and answers.
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-Key,If-MatchandLast-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.
ETag,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
A held JSON turn (stream: false, detach: false) and a detached request in either encoding 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 held 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.
A detached request commits its key at admission, with the 202 it was answered. A replay answers that same record for the key’s lifetime whatever became of the turn, canceled included, and starts nothing: the client chose not to wait, so the outcome is the turn resource’s to report. A detached stream’s replay is answered the record too, not a stream.
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 a held stream; such a client reconnects through the session feed or submits a new turn.
Which endpoints are safe to retry
Idempotency-Key covers held JSON turns and detached requests. For everything else, know what a blind retry does before you configure one:
| Endpoint | Retry-safe | On a duplicate |
|---|---|---|
POST /turn (held JSON, with a key) | yes | cached response returned |
POST /turn (detached, with a key) | yes | the same 202 and turn record; the turn ran once |
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/projects/{name}, /v1/accounts/{name}, /v1/profiles/{name}, /v1/mcp/{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}, PUT /v1/instructions, PATCH /v1/profiles/{name} (settings), PATCH /v1/mcp/{name}, POST /v1/accounts/{name}/login (api_key), POST /v1/mcp/{name}/login (a secret), POST /v1/mcp/{name}/logout | yes | same body writes the same skill file, memory row, instructions, keys or credential; the same secret stored again changes nothing and restarts no server |
POST /v1/accounts/{name}/login (browser), POST /v1/mcp/{name}/login (browser), and their completions | yes, but | a second start replaces the first login, whose id then answers 404; a completion answers again once completed, and 422 once failed |
POST /v1/projects, POST /v1/accounts, POST /v1/profiles, POST /v1/mcp | yes, but | the resource exists, so the retry answers 422 naming it; nothing is added twice |
PATCH /v1/projects/{name}, PATCH /v1/accounts/{name}, PATCH /v1/profiles/{name} (a name) | yes, but | the old name is gone, so the retry answers 404; the resource is under the new name |
POST /compact | mostly | a second compaction summarizes the summary; fidelity drops, nothing is lost. It takes no If-Match, since it addresses no position in the history and a stale view costs a retry nothing it cannot undo |
POST /rewind | with If-Match | drops another turn without it. Under the ETag the first attempt was decided on, a retry after the first went through is refused with 412 |
POST /sessions/import | no | creates a second copy of the tree under new ids |
POST /sessions/{id}/schedule | no | creates a second job |
The two 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: GET /messages returns a revision that increments on every rewrite, and its ETag is what makes a rewind retry-safe.
Error handling
All HTTP error responses use RFC 9457 Problem Details with Content-Type: application/problem+json:
{
"type": "https://meka.run/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 aworking_directorythat 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 that ended before anything from the model reached the conversation and whose opening was to be withdrawn then: one sent withoptions.unanswered_messageset towithdraw, or on the feed a recurring job’s fire or an inbox turn, whose openings are withdrawn the same way. 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, project, account, profile, pending login, MCP server, background task, scheduled job, image blob, inbox item, or turn; 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] additional_directories directory; meka reads those but never writes to them, so writing here would shadow the file rather than change it |
/errors/instructions-overridden | 409 | The standing instructions come from --instructions, MEKA_INSTRUCTIONS, MEKA_INSTRUCTIONS_FILE or the instructions/ directory, which stand above instructions.md, so PUT /v1/instructions would change nothing a session sees; detail names the source |
/errors/account-in-use | 409 | A session or sub-agent running on this server may run on the account named for a rename, through its profile or one it falls back to, or another meka is refreshing the account’s credential, which refreshes under its name; delete the sessions or wait for them to be evicted, or retry shortly |
/errors/subagent-not-running | 409 | The id names a sub-agent this process is not running, so there is no feed to read; its feed exists while its parent runs it, and last_turn on its record says how its last run ended |
/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, the parent itself for a scheduled job, and the parent’s feed for a sub-agent’s attend. 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/precondition-failed | 412 | If-Match on a rewind or a fork names a conversation that has since changed; revision and total carry the current state. Read GET /messages again and decide again |
/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 Falling behind) |
/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/provider-exhausted | 502 | The upstream refused the request for quota or billing, by a signal it documents: a 402, a quota code on a 429, or the Claude subscription’s exhausted usage window. Nothing changes until the reset, which rides as Retry-After when the upstream stated one. A profile with fallbacks hands the turn to the next one instead of answering this |
/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/shutting-down | 503 | The server has begun shutting down and takes on nothing that would outlive it, such as a browser login begun or committing now |
/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 screen’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/projects | Any read scope | Projects, as {"projects": [...]}: every project, each with name, created_at (RFC 3339), memories (in its tier) and sessions (root sessions on it). POST /v1/projects adds one, PATCH /v1/projects/{name} renames one and DELETE /v1/projects/{name} removes one, under memory:w; see Projects |
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), fallbacks, the profiles a turn moves to when this one cannot serve it (omitted when it names none), vision, whether a session on it accepts image attachments, and active: true on the one a session gets when it names none. POST, PATCH and DELETE on /v1/profiles manage them under config:w; see Managing the installation |
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. Where the installation is, and whether it awaits a reload or a restart, is GET /v1/config’s, under config:r (see Managing the installation). Carries no profile, model or capability of one: GET /v1/profiles reports those 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, with the protocol version a connected one speaks, and orphaned_credentials |
GET /v1/openapi.json | None, and off unless [serve].docs is set | OpenAPI 3 spec |
Managing the installation
What meka account and meka profile do in a shell, /v1/accounts and /v1/profiles do over HTTP, under config:r and config:w, so a client that administers an installation (a desktop app wrapping meka serve, a web UI on a server nobody logs in to) can take it from an empty config.toml to a running profile without a shell. Every write goes through the same editor the CLI uses: config.toml keeps its comments and its order, the same refusals apply in the same words, and a credential goes to the database and never to the file.
An account is added with POST /v1/accounts, {"name", "backend"} and whatever of base_url, oauth_token_url, client_id, interleaved_thinking, prompt_cache_key, stream_usage and max_tool_name_bytes the backend reads; a setting the backend never reads is a 422 rather than written and ignored. An API-key backend takes its key as api_key in the same body, and the key is required there; a subscription backend takes none, is added unauthenticated, and is logged in afterwards. POST /v1/accounts/{name}/login with {"api_key"} rotates the key of an API-key account. On a subscription account it takes no body and begins the browser login meka account login runs at a terminal, answering 202 with authorization_url, the page the person opens, and a login_id; what the page gives is posted as code to POST /v1/accounts/{name}/login/{login_id}, which exchanges it, stores the credential and answers the account. completes_with says what that is: code on claude-subscription, where Anthropic’s page shows a code to copy, and callback_url on chatgpt-subscription, where the browser is sent to http://localhost:1455/auth/callback. For the latter the server holds that loopback listener itself while the login is pending, when the port is free (listening: true), so a browser on the server’s own machine completes the login with nothing posted; a client whose browser is elsewhere posts the URL the browser landed on. GET /v1/accounts/{name}/login/{login_id} says whether the login is pending, completed or failed and why. A login lapses after ten minutes, a wrong paste leaves it pending, an exchange the authorization server rejects ends it (the code is spent, so begin another), and beginning a login on an account that has one pending replaces it. A login stores its credential only while the account states what it began on: another write meanwhile leaves it pending, a rename or a removal of the account ends it, and an account moved by hand to another backend, client or token endpoint answers the completion with a 422 and stores nothing. A token endpoint that cannot be reached, or answers with something that is not a token, ends the login with a 502 and the reason in the meka log. Once the exchange has a credential, the login is the commit’s to finish, so its completion waits for the commit even past expires_at, and a login begun while the server shuts down is a 503 shutting-down. PATCH /v1/accounts/{name} with {"name"} renames the account, moving its credential and every profile on it, and is the only change an account takes: its other settings are what a login was made against, so another endpoint or backend is another account. A subscription account holding a credential is not renamed while a session open on the server, or a sub-agent running there, may run on it, through its profile or one that profile falls back to, or while another meka refreshes its credential (409 account-in-use): a running provider refreshes the credential under the old name. Rename it once those sessions have closed. An account is not added or renamed onto a name a removed account’s credential is still stored under, which orphaned_credentials lists: adopted, a credential minted for another backend or endpoint would be presented, so it is a 422 naming DELETE /v1/accounts/{name}, which clears it. A name with whitespace at either end is a 422 on every door that adds or renames an account or a profile. DELETE /v1/accounts/{name} removes the account and the credential stored under its name, or a credential alone when no account claims it, and is a 422 while a profile names the account. GET /v1/accounts lists them with authenticated (yes, no or unreadable) and, when there are any, orphaned_credentials, the names a credential is stored under that no account claims, the file’s included, so an account added in a shell and not yet served is not one. GET /v1/accounts/{name} answers one; each carries every setting the account states, base_url, oauth_token_url, client_id, interleaved_thinking, prompt_cache_key, stream_usage and max_tool_name_bytes, beside backend.
A profile is added with POST /v1/profiles, {"name", "account", "model"} and any of the profile fields; a field left out is left out of the profile, so its documented default applies, and "default": true makes it default_profile. The first profile needs no default: a sole profile is the default by the selection rule, and active says so. The second profile added names the first as default_profile unless it takes default itself, so the default stays where it was rather than lapsing with nothing in the answer to say so. GET /v1/profiles/{name} answers every setting the profile states. PATCH /v1/profiles/{name} changes settings in one write: a key left out keeps its value, null removes the key (an empty string does the same on a string key), a value sets it, and "default": true makes the profile the default in the same write; "default": false is refused, since the default is unset by removing the profile or choosing another. A {"name"} on the PATCH renames the profile, moving every session recorded on it and the name every resident session and running sub-agent holds, so a worker spawned mid-turn records the new one, and is sent on its own; a rename made from a shell reaches the rows alone, and a resident session takes it at its next turn. The profile meka serve --profile named is neither renamed nor removed while the server runs, and no other is made the default (422): it is the server’s default until it restarts. fallbacks are read the same on an add as on a change, each name trimmed and a blank one dropped. DELETE /v1/profiles/{name} removes it; sessions recorded on the name keep it and refuse to resume until it is configured again or they are moved, a profile another’s fallbacks names is a 422 until that list drops it, and the default is a 422 while two or more profiles would remain, naming PATCH /v1/profiles/{name} with "default": true as the way to move it first; with one profile left the key is dropped, and that profile is the default by the rule. A setting the profile’s backend never sends, a model that is blank, a fallback chain the profile could not run on, and an account that is not configured are each a 422 carrying the sentence meka profile would print. A config.toml that fails to parse, a key added by hand while the server runs say, or that holds anything else startup would refuse, an MCP server entry, a profile that could not run, a fallback chain naming no profile, a [serve] token whose variable is unset or [web] settings no client can be built from, refuses every write of the installation and every reload with a 422 naming the defect until the file is repaired in a shell, since the whole file is served after each write; a parse error is named by its line and the parser’s message, never by the line’s content, which may hold a value no listing shows. The file each write serves is held to all of it too, so a shell’s edit landing between a write and its serving is never served unchecked: one that leaves the file unservable answers the write with a 422 saying the change is written and not served. A write that fails partway undoes what it wrote: an add takes its entry back out and a change puts the entry back as it was. A removal that fails after clearing part of what it removes is finished by removing again, which clears what is left, an entry with nothing to present or a credential no entry claims.
An MCP server is added with POST /v1/mcp, whose body is the [[mcp.servers]] entry as the reference documents it, name and transport included, with auth_token or client_secret beside it for a secret the entry presents, which goes to the store; a key that does not fit the transport (headers_helper among the http ones), a name in use or reserved, a secret the entry cannot present, and a client_credentials block with no client_secret to present are each a 422. The server joins the running manager at once and connects in the background: the answer’s state is pending, GET /v1/mcp says when it is connected, and its tools reach every session as they are listed. GET /v1/mcp/{name} answers the entry as meka mcp get prints it: every key as written, the auth block included (keys only for env and headers, which may hold a secret), required and deferral as the server runs under them with inherited naming those the entry leaves to [mcp], the kinds of credential stored and presented to its url as credentials, each kind it holds for another endpoint under credentials_for_another_endpoint with the origin it was stored for, and the connection state. PATCH /v1/mcp/{name} changes keys in place, each left out to keep, null to remove, a value to set, auth_token and client_secret included; env, headers and tool_permissions merge key by key, a key set to null removing that one, since the detail shows the keys of the first two and never their values. It restarts the server when a key its connection is made from or a credential it stores changed; the same secret again is no change. A credential is bound to the endpoint it was stored for and presented there alone: an OAuth login’s tokens to that exact url, a bearer or a client secret anywhere on its scheme, host and port. A PATCH that moves url off the endpoint a login’s tokens were issued for, or changes auth, clears those tokens, revoking them at the provider once the change commits, and the server needs a login again; a bearer or a client secret on another origin stays stored and unpresented until a PATCH sends it again or a logout clears it. A change to its tool policy alone (permission, tool_permissions, trust_read_only_hint, allowed_tools, disabled_tools, eager_load_tools, deferral, required) applies on the connection it holds: the server lists its tools again under the new policy and every session takes the new set, so a stdio server keeps whatever state its process holds. A change to nothing leaves the server alone. {"disabled": true} stops it and {"disabled": false} starts it, and either way the file keeps its comments. name and transport are not changed: another transport is another server. DELETE /v1/mcp/{name} removes the entry, closes the connection or ends a connect still in flight, drops the server’s tools from every session and clears its credentials, revoking an OAuth grant at the provider once the removal commits; a name with credentials and no entry, which GET /v1/mcp lists under orphaned_credentials (a name neither the file nor the running manager holds), is cleared the same way, and POST /v1/mcp with that name is a 422 naming the route until it is. POST /v1/mcp/{name}/login with {"auth_token"} or {"client_secret"} stores the secret as meka mcp login --auth-token-stdin and --client-secret-stdin do, and with no body begins the OAuth login meka mcp login runs at a terminal, answered as an account’s browser login is: 202 with the URL and a login_id, the loopback listener held by the server, a browser elsewhere answered by posting its callback URL to POST /v1/mcp/{name}/login/{login_id}, and GET /v1/mcp/{name}/login/{login_id} for where it stands; a paste with no code in it, or carrying another login’s state, is a 422 that leaves the login pending, as an account’s is; the flow runs against a store of its own, so the bundle stored for the server, and the running server’s use of it, are untouched until the login commits. It commits only while the file states the connection it began on: another write meanwhile leaves it pending, removing the server ends it, and a change to the server’s endpoint or auth block answers the completion with a 422 that stores nothing. Once the credential is stored the server is restarted from its entry as the file then states it, so an auth block the login wrote is in force; the restart connects in the background, so the completion’s answer carries state: pending until it has. An MCP login lapses after two and a half minutes, the flow’s own bound rather than an account’s ten; expires_at says when. A login an endpoint cannot begin, one with nothing to discover, is a 502 with the reason in the meka log, and one on a disabled entry, or while a bearer is stored for the server, is a 422, the latter naming POST /v1/mcp/{name}/logout. POST /v1/mcp/{name}/logout clears every credential stored for the server and restarts it from its entry, so it runs without them, revoking the OAuth grant it held once the logout commits; a server that held none is left as it is. A client_credentials server needs no browser: POST /v1/mcp/{name}/login with no body connects it with its stored secret and answers 200 with the server, as storing a secret does. A server that holds no OAuth credentials for its url is failed with an error naming this route, and so is one with no auth block and no bearer that answers with a challenge, since the login, which assumes oauth, is what connects it.
The standing instructions are written with PUT /v1/instructions, {"content": "..."}, which lands in instructions.md in the config directory; a blank content removes the file. GET /v1/instructions says whether that file is what this server reads, writable, and, to a token that holds config:r or config:w, names it in path and where the instructions came from in source; when --instructions, MEKA_INSTRUCTIONS, MEKA_INSTRUCTIONS_FILE or an instructions/ directory with files in it stands above it, a write would change nothing a session sees and is refused with 409 naming the source. The sessions that open after the write run under it; one already open keeps what it opened under.
GET /v1/config, under config:r, names config_path and data_dir, for a client that has to find the installation, and carries config_loaded_at, when this process last read config.toml whole, beside config_modified_at, when the file was last written, config_changed, whether the file has changed since that read, and restart_required, the sections whose content differs from the file the server started on and that only a restart applies, empty while the file fails to parse: permissions, shell, [mcp]’s own keys as mcp, and the others When edits take effect lists. A write of the server’s own reads the file again, so only an edit made in a shell, which POST /v1/config/reload applies, sets config_changed; the device_id meka writes into a claude-subscription account that states none counts as read.
A write applies at once: the accounts and profiles every session on the server resolves against are replaced with what the file now holds, so the profile a client adds is the one its next POST /v1/sessions runs on, and the key it rotates is the one the next session presents. A session already resident keeps the provider it was built with until it is evicted or repinned, as it does across meka account login from a shell. An MCP server added, changed or removed is added to, restarted on, reconfigured on or taken out of the running manager, so its tools come and go from every session without a restart. An edit made by meka profile add, meka mcp add or by hand while the server runs is applied by POST /v1/config/reload, which reads the file again and serves it the way a write of the server’s own is served: the catalog of accounts and profiles is replaced, with the [thinking] budget and [session].context_window a profile stating none falls back to, an MCP server the file now names is started, one whose connection changed is restarted on it, one whose tool policy alone changed takes it in place, one the file no longer names is taken out, and the standing instructions are read again from wherever this server reads them, the last text staying in force when they cannot be. A credential stored from a shell, by meka mcp login say, restarts its server too, since the store counts every write of a server’s credentials. The answer names the servers it started, restarted, reconfigured and removed, and config_loaded_at moves to the read. Every other section of the file is read at startup alone, as When edits take effect says, and the answer’s restart_required names those the file has changed since the server started. Every write of the server’s own ends with the same read of the whole file, so an edit made in a shell before it, to any of the four, is served by that write; one made after it waits for the next write, a reload or a restart.
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, autonomous ones (inbox, scheduled, background outcomes) included. A client turn past the cap returns 429 with aRetry-Afterheader; an autonomous one waits for a free slot.
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. A UI that also keeps a list of sessions holds the server feed beside it and replaces a row whenever a session.* event names it. 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 "{\"working_directory\": \"$(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"}')
# The reply: the text blocks of the assistant's messages, joined.
echo "$RESULT" | jq -r '[.messages[] | select(.role == "assistant") | .content[] | select(.type == "text") | .text] | join("")'
# 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. GET /v1/schedule lists every
job, and GET /v1/schedule?session=<id> one session’s; DELETE /v1/schedule/{job_id} cancels by
the full id.
| 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, read in timezone | |
timezone | string | the host’s | IANA time zone a cron pattern is read in; refused beside at or every |
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 | servers, each with state and protocol_version while connected; orphaned_credentials when a credential is stored under a name no entry claims |
| POST | /v1/sessions | sessions:w | Create session |
| GET | /v1/sessions | sessions:r | List sessions |
| GET | /v1/sessions/search | sessions:r | Find sessions by the words of their conversations |
| 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; If-Match makes it conditional |
| GET | /v1/sessions/{id}/messages | sessions:r | List messages, under the conversation’s ETag |
| 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 | Answer a parked prompt: an approval (allow, deny, allow_always, deny_always) or an elicitation (accept with content, decline, cancel) |
| GET | /v1/sessions/{id}/stream | sessions:r | The session’s event feed, across turns; sessions:w to load a session this process has not, and ?attend=true (needs sessions:w) to be asked to approve gated calls. On a sub-agent’s id, its own feed while its parent runs it, read-only |
| GET | /v1/stream | sessions:r | The server feed: every change to a session record, across sessions |
| 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; If-Match makes it conditional |
| GET | /v1/sessions/{id}/export | sessions:r | Full transcript (?format=markdown|json) |
| GET | /v1/sessions/{id}/turns | sessions:r | The turns that began, newest first, paginated by before |
| GET | /v1/sessions/{id}/turns/{turn_id} | sessions:r | One turn, with its ending once it has one |
| 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 |
| GET | /v1/tasks | sessions:r | Every session’s background tasks, newest first; ?status= keeps one status |
| DELETE | /v1/sessions/{id}/tasks/{task_id} | sessions:w | Cancel a background task by its full id |
| GET | /v1/schedule | schedule:r | Scheduled jobs, every session’s or one session’s with ?session=<id> |
| 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 by its full id |
| 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, or with ?q=<words> the memories a search finds, best first; referencing, unverified, expired and project narrow either |
| GET | /v1/memory/{name} | memory:r | One memory, with its body; ?project=<name> when the name is in several projects |
| PUT | /v1/memory/{name} | memory:w | Create or update a memory, in the tier project names |
| DELETE | /v1/memory/{name} | memory:w | Delete a memory; ?project=<name> when the name is in several projects |
| 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, without the per-session files [instructions] names; writable says whether a PUT lands, and path and source are shown to config:r or config:w |
| GET | /v1/config | config:r | Where the installation is (config_path, data_dir), when config.toml was last read whole and written, config_changed, and restart_required |
| POST | /v1/config/reload | config:w | Read config.toml again and serve what a write over HTTP would have: accounts, profiles, MCP servers brought to the file’s entries, the standing instructions; name the sections only a restart applies; 422 when the file fails to parse or holds anything startup would refuse |
| PUT | /v1/instructions | config:w | Write the standing instructions to instructions.md, {"content": "..."}, blank to remove; 409 when another source is in charge |
| GET | /v1/accounts | config:r | Configured accounts, each with whether a credential is stored, and the credentials no account claims |
| GET | /v1/accounts/{name} | config:r | One account: every setting it states, and whether a credential is stored |
| POST | /v1/accounts | config:w | Add an account, with its api_key on an API-key backend; answers 201 with the account |
| PATCH | /v1/accounts/{name} | config:w | Rename an account, {"name": "<new>"}, moving its credential and every profile on it |
| DELETE | /v1/accounts/{name} | config:w | Remove an account and its credential; 422 while a profile names it |
| POST | /v1/accounts/{name}/login | config:w | Store a credential, {"api_key": "<key>"} on an API-key backend; with no body on a subscription backend, begin a browser login, 202 with the URL and a login_id |
| GET | /v1/accounts/{name}/login/{login_id} | config:r | Whether a browser login is pending, completed or failed |
| POST | /v1/accounts/{name}/login/{login_id} | config:w | Complete a browser login with what the browser gave, {"code": "<code or callback URL>"} |
| GET | /v1/profiles | read | Configured profiles |
| GET | /v1/profiles/{name} | config:r | One profile, with every setting it states |
| GET | /v1/mcp/{name} | config:r | One MCP server’s entry as written, required and deferral as it runs under them with inherited, the kinds of credential stored, its connection state, and error, why the last connect failed, on a failed one |
| POST | /v1/mcp | config:w | Add an MCP server, the [[mcp.servers]] entry with auth_token or client_secret beside it; connects in the background; answers 201 |
| PATCH | /v1/mcp/{name} | config:w | Change an MCP server’s keys in place (null removes one), restarting it, or applying a change to its tool policy alone on its connection; disabled stops or starts it |
| DELETE | /v1/mcp/{name} | config:w | Remove an MCP server, its connection, its tools from every session, and its credentials |
| POST | /v1/mcp/{name}/login | config:w | Store {"auth_token"} or {"client_secret"} for an MCP server; with no body, begin its OAuth login in the browser, 202 with the URL and a login_id |
| GET | /v1/mcp/{name}/login/{login_id} | config:r | Whether an MCP server’s browser login is pending, completed or failed |
| POST | /v1/mcp/{name}/login/{login_id} | config:w | Complete an MCP server’s browser login with the callback URL, {"code": "<URL>"} |
| POST | /v1/mcp/{name}/logout | config:w | Clear every credential stored for an MCP server |
| POST | /v1/profiles | config:w | Add a profile; "default": true makes it the default, and the second profile added names the one that was alone; answers 201 with the profile |
| PATCH | /v1/profiles/{name} | config:w | Change settings (null removes a key), make it the default, or rename it with {"name": "<new>"} sent alone |
| DELETE | /v1/profiles/{name} | config:w | Remove a profile; 422 while another’s fallbacks names it, or while it is the default and two or more would remain |
| GET | /v1/projects | read | Every project, with when it was added and its memory and session counts |
| POST | /v1/projects | memory:w | Add a project, {"name": "<name>"}; answers 201 with the project |
| PATCH | /v1/projects/{name} | memory:w | Rename a project, {"name": "<new>"}, moving every session and memory on it |
| DELETE | /v1/projects/{name} | memory:w | Remove a project; 422 with the counts while a memory or session is on it |
| GET | /v1/openapi.json | None, and off unless [serve].docs is set | OpenAPI spec |
GET /v1/sessions is paginated by limit and cursor (see Sessions), and takes include_children=true to list sub-agent sessions alongside root ones, working_directory=<path> to filter by working directory, and parent=<id> to list one session’s direct children. A sub-agent’s session record carries parent_id, which is what reconnects it to the session that dispatched it.
A memory record carries project, the tier it is in ("" for the global tier), updated_at (when the row last changed), created_at (when the memory was made, stamped once at creation) and verified_at (when the fact was last verified: the creation, until a write with verified: true moves it), plus its load (inline, index or search), its references, its source, its expires_at when an expiry was set, its tags and its read_count, how many times the agent has recalled it through memory_read. The timestamps are deliberately separate: a description edit moves updated_at without the note saying anything new, it is created_at that the model is shown as an age, and it is verified_at that search freshness decays from. GET /v1/memory/{name} adds referenced_by, the names of the memories whose references name this one.
PUT /v1/memory/{name} accepts tags, references, source, load and expires with the same omit-to-keep rule as body: omit to leave what the memory has alone, send [] or "" to clear a list or a string. project is the tier to file the memory in, a project the store has or "" for the global tier; omitted, an existing memory keeps its tier and a new one is global, and naming the other tier for an existing memory moves it. A name a global memory and a project memory would share, a project the store does not have, and a name that is in several projects when none is named are each a 422 carrying the store’s sentence. expires is RFC 3339 or YYYY-MM-DD for the whole of that day. verified: true records that the write follows a check that the fact is current, and is the only thing that moves verified_at on an existing memory. A write that would take the inline bodies past [memory] inline_max_bytes answers 422 with the store’s own sentence, naming the total, the largest inline memories and the remedy, and nothing of it lands; see The inline budget.
GET /v1/memory takes three filters beside q, the same three memory_search and meka memory list take: referencing=<target> for the memories whose references name a memory, path, URL or id (matched whole, without regard to case), unverified=<duration> for the memories not verified within that long (90d), and expired=true for those past their expiry. project=<name> keeps one tier (project= the global tier); without it every tier is read. Without q the filters list in index order; with it they narrow the search.
GET /v1/memory?q=<words> answers with the memories the agent’s own memory_search would find for those words, best first and at most limit of them (default 10, clamped to 1..25), each carrying the snippet it was found on; the same ladder of exact, prefix and substring matching, so an operator and the model find the same memories for the same words. Without q it is the whole index.
GET /v1/memory/{name} answers 404 for a name that is not stored, and 422 for a name that is in several projects when ?project= names none; DELETE answers the same and removes nothing on a 422. 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.
shell_execute 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} take the full id, which is what a client of this API holds; the 8-character short form meka schedule cancel and the schedule_cancel / task_cancel tools accept is a terminal’s ergonomic and matches nothing here. An id matching nothing is a 404, so a typo is never reported as a cancellation. A job that a scheduler sweep retired a moment before the delete is a 404 as well, for the same reason: 204 means this request canceled the job, not merely that it is gone.
GET /v1/tasks lists every session’s background tasks, newest first, and ?status=running (or completed, failed, canceled, interrupted) keeps one status; a status that is none of those is a 422. A task that is a backgrounded agent_spawn carries subagent_id, the session it runs, once the spawn has made one, so a dashboard connects the task to the sub-agent’s record without parsing the task’s label.
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 screen, 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.
A connected server in GET /v1/mcp’s servers carries protocol_version, the MCP revision its connection speaks: 2026-07-28 when the server answered meka’s probe, otherwise the version the initialize handshake settled on (2025-11-25 for a current server). The field is absent in every other state.
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 a connected server’s new connection could not be opened, whether its connect failed or ran past [mcp] connect_timeout. A connected server is given a new connection, which runs its headers_helper again, and keeps the one it has when the new one cannot be opened. Neither carries the reason, which is in the meka log: a connector’s reason has quoted a spawn command line and a URL with its key.
An MCP server’s OAuth login runs over HTTP as an account’s does, under config:w; see Managing the installation. A token holding mcp:w alone reconnects a server and nothing more.
For full request/response schemas, see /v1/openapi.json on a running server, or browse it in one of the tools below.
It is off unless you set [serve].docs, and unauthenticated when on, so CI pipelines and code generators can fetch the spec without a token. That combination is what makes it opt-in: it takes no token and it publishes 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
Permissions
meka uses a four-level permission system to control what tools the agent can use, and one switch, approvals, that decides what happens to a call the level does not cover. Together they give you control over the agent’s capabilities and prevent accidental modifications.
Permission levels
| Level | Color | What it allows |
|---|---|---|
| None | green | No tools without approval. |
| Read | yellow | Read-only tools: file_read, file_find, file_search, web_fetch, shell_execute (sandboxed read-only), checklist_*, agent_spawn, scratchpad tools |
| Workspace | orange | File and shell writes stay inside the workspace. Unconfined MCP calls may need approval or be refused; the shell needs an available sandbox |
| Unrestricted | red | Every tool, no boundary. shell_execute runs with no sandbox at all, and the conversation tools read every session in the store |
The ladder is ordered by reach: each level contains the ones below it, and a tool call that needs more than the session’s level is refused. With approvals on, it is put to you instead.
The workspace boundary
At workspace, a write may land under the workspace: the working directory and the additional
workspace directories. Those are:
- the working directory (which
/cdmoves), - any folder an ACP client supplied as an additional directory,
- any
--additional-workspace-directory <DIR>you passed, repeatable.
A sub-agent’s are what its spawn call stated, or its parent’s when it stated nothing; see
agent_spawn.
Directories are resolved to their canonical form, so a symlink inside the workspace that points out
of it resolves to where it actually lands and is refused. A directory that does not exist is
dropped rather than trusted; if none resolve, nothing is writable. An
--additional-workspace-directory that does not resolve at startup is reported as a warning, and
kept: a build directory that does not exist yet joins the workspace the moment it does.
The boundary follows the working directory. It is recomputed on every write rather than fixed
when the session starts, so /cd /etc at workspace makes /etc writable from that point on. This
is deliberate: the working directory is the workspace, and a boundary that stayed behind after you
moved would refuse writes to the place you are plainly now working in. The agent has no tool that moves the working
directory, so it cannot relocate its own boundary. You can, with /cd; and under meka serve a
client holding sessions:w can, with PATCH /v1/sessions/{id}.
Because the boundary follows the directory, the directory is recorded on the session row and a
resume reopens it rather than adopting your shell’s. Resuming a workspace session
from $HOME would otherwise make your whole home directory writable without you asking.
One consequence worth knowing: a relative --additional-workspace-directory resolves against
your shell, not against the session. meka -c --additional-workspace-directory build run from ~
grants ~/build, while the session itself may reopen in ~/project. That follows from the flag
belonging to the process rather than to the session; pass an absolute path when you mean a
directory inside the session’s.
--additional-workspace-directory and --additional-readable-directory belong to the process and
reach the screen, a one-shot run, and an ACP session. meka serve refuses both: the HTTP API is
single-directory, so a session there is confined to its own working_directory and nothing else. Extra directories supplied by an ACP client apply to that
client’s session only, and meka does not report your flags back to the client as though the client
had asked for them.
Because they belong to the process, they are not recorded on the session either, and resuming a
session does not bring them back: pass them again. This is the difference between them and the
profile and permission level, which are recorded and do come back. Writing them to the row
would mean a meka serve sharing the data directory could later grant those directories to a job
it fires, on the authority of a flag that process was never given.
The same set governs both halves, derived once so they cannot disagree: the file_* tools check it
before writing, and the shell sandbox is built from it. A refusal from file_write names the
workspace so the agent can retry somewhere valid.
A sub-agent can be handed a narrower workspace than its parent’s: agent_spawn’s
working_directory and additional_workspace_directories name
it, each inside the parent’s own.
The read boundary
Reads are not confined by default: the workspace boundary says “this cannot change things outside
the workspace”, not “this cannot see them”. [permissions].confine_reads = true adds the second
promise. Below unrestricted, a read, by a file tool or by a command, may then land only under:
- the workspace, the same set writes are confined to: the working directory, an ACP client’s
additional directories, each
--additional-workspace-directory; - the additional readable directories: each
--additional-readable-directoryon a run, and what a parent handed a sub-agent throughagent_spawn’sadditional_readable_directories; - the system directories, where the operating system keeps the programs, libraries and
configuration a command needs to run at all, and anything
[shell].additional_readable_directoriesadds. The home directory is not among them: that is the point; - the skill directories
[skills].additional_directoriesnames, so a skill’s bundled files stay readable, as meka’s own skills store does.
The switch is instance-wide and read at startup, so every root session the process runs is bound
by it; unrestricted remains the per-session way out, since it disclaims every boundary. The same
set governs both halves, derived once: file_read, file_find, file_search and
scratchpad_load_file refuse a path outside it and name the directories, file_find and
file_search step around what a symlink reaches outside it, and the shell sandbox is built from
it. The environment block the agent reads each turn names its additional workspace and readable
directories, or says anywhere when its reads are not confined.
What a command no longer sees, it mostly does not see at all: under Bubblewrap the root of the
sandbox is an empty filesystem with only the readable paths bound into it, so a path outside them
is absent rather than refused, and a symlink out of the roots points at nothing. Two consequences
worth knowing. Anything a tool reads from the home directory is gone, which covers ~/.cargo/bin,
~/.local/bin, version-manager shims, the global git configuration and every package cache; a
directory among them comes back by being named as a root, and nothing else does. And a path’s
existence, name and size may still be visible under Landlock alone and under macOS, where stat
is not mediated so that paths resolve through their ancestors; the bytes are what the boundary
protects.
The switch governs root sessions alone. A sub-agent’s reads are what its spawn call stated, or its
parent’s when it stated nothing: a parent whose reads are not confined still confines a
sub-agent’s by naming working_directory, and the sub-agent, and anything it spawns, then reads
its workspace, its additional readable directories and the system directories, the way a working
directory hands it a write boundary. A spawn turns confinement on, never off: a stated scope
confines reads, and a bare spawn follows the parent’s. A sub-agent’s reach never exceeds its
parent’s here either: every directory
it is handed is admitted against what the parent can read, the working directory and the
workspace list against what it can write when the sub-agent writes, and a follow-up re-admits the
whole scope against the parent’s reach at that moment, at the level the sub-agent runs at then.
The file tools refuse a path outside the boundary in the same words whether or not it exists, and
name it as it was given rather than where a symlink leads, so a confined session learns neither
which paths exist beyond its reach nor what its links point at; a link whose target is missing is
judged where it leads, as the open would be. agent_spawn judges every directory it is handed
the same way. A command under Landlock alone and under macOS can still tell a missing path from a
denied one, as above.
On Windows the AppContainer backend holds this boundary for the shell as the file tools hold it:
the readable paths become the environment’s read-only grants, and beyond them a command reads only
what Windows grants every AppContainer, the system directories. The restricted token cannot:
neither token mechanism confines reads, so there the file tools keep it, a command does not, the
agent is told so in its environment block, and startup warns once when confine_reads is on.
If [shell].sandbox = false, shell_execute is refused at workspace rather than run
unconfined. Nothing else would be holding the boundary, and half a boundary reported as a whole one
is worse than an error that says so. Use unrestricted for those turns.
What it does not cover
Four limits, stated plainly because none of them is visible from the inside:
- MCP servers are not sandboxed. They run in their own process, which meka does not confine, so
a tool from an MCP server can write anywhere the server can, and no boundary meka can express
reaches it. A tool with no permission annotation falls back to
unrestricted, and meka refuses it atworkspacerather than dispatching it, becausePermission::allowstreatsworkspaceandunrestrictedas equal and would otherwise let it straight through. To use one fromworkspace, name it in[mcp.servers.*].tool_permissionsat a level you are willing to grant, turn approvals on so the call is put to you, or switch tounrestricted. - meka’s own stores are outside the boundary and always writable: the store under
MEKA_DATA_DIR, memories included, and skills underMEKA_CONFIG_DIR. They are governed by their own config keys, not by this one. - Reads are not confined unless
[permissions].confine_readsis on. The boundary alone is “this cannot change things outside the workspace”, not “this cannot see them”; the switch adds the second half, as the read boundary describes. - The in-process fence resolves paths, it does not pin them.
file_writeandfile_editresolve every existing component of a target before judging it, so a symlink already planted on the path is caught. What is left open is the race: a directory checked and then swapped for a symlink before the write lands. Closing it means holding a directory descriptor through the write on every platform, which is a larger mechanism than this one. It needs a concurrent writer planting the link mid-call to matter, which is consistent with the sandbox being defense against an agent damaging your data by accident rather than an adversarial containment boundary.
Per-platform enforcement
| Platform | Backend | Confines the shell’s writes | Confines the shell’s reads, with confine_reads on |
|---|---|---|---|
| Linux | Bubblewrap (preferred) | Yes: read-only root bind, plus a writable bind per workspace directory, with the Landlock ruleset inside where the kernel has one | Yes: an empty root with only the readable paths bound into it, read-only, the roots read-write; what is not bound is absent |
| Linux | Landlock (fallback) | Yes: one path-beneath rule per workspace directory, or one per sibling where it contains meka’s directories | Yes: read and execute rules on the readable paths instead of on /; names and sizes outside stay visible to stat |
| macOS | sandbox-exec | Yes: writable subpath per workspace directory | Yes: data reads and executable mappings per readable path instead of everywhere; existence and metadata stay global so paths resolve |
| Windows | AppContainer (preferred) | Yes: a read-write grant per workspace directory in the environment, meka’s directories denied | Yes: read-only grants on the readable paths instead of on every drive; a path outside is neither readable nor listable |
| Windows | restricted token (fallback) | Yes: writes are permitted only where a workspace capability has an ACE | No: the file tools hold the boundary, a command reads anywhere, and the fallback warning says so |
Under Bubblewrap, /tmp, /run and /var/tmp are masked with a tmpfs, so paths there are not
merely unwritable but invisible. A workspace directory under /tmp is bound after the mask and stays
reachable.
meka’s own directories are hidden too: the config directory and the data directory holding
meka.db and every account credential. Bubblewrap masks them
after every workspace bind and sandbox-exec denies them last, so a confined command cannot read
the credential store even from a workspace directory at $HOME that contains it, and the in-process
file_write and file_edit refuse a target under them whatever directories the session holds. The
in-process read tools (file_read, file_search, file_find, scratchpad_load_file) refuse
them below unrestricted too, and a search from a directory above them steps around them. The one exception is meka’s own skills store, skills/ under the config directory, which those readers reach at every level: a skill’s bundled files are what its body tells the agent to read, and nothing secret lives there; a dot-entry at the store’s top level (.git and the like, which discovery skips too) stays hidden with the rest. The same
readers refuse a process’s own /proc directory (/proc/<pid>, /proc/self,
/proc/thread-self), whose environ, cmdline, fd and mem carry what the process was given,
meka’s own environment and an MCP server’s credential included; the system-wide files beside them
(/proc/meminfo, /proc/cpuinfo, /proc/mounts) stay readable. Landlock
rules only add access, so meka hides them there by never covering them: reading files is granted
per sibling along the path to each, which keeps their names and sizes visible and their bytes
not, and a workspace directory above them is split the same way, at the cost that a new file under
such a directory can land only in one of its subdirectories. The Windows AppContainer denies both
directories outright, listing included, under a read-only grant above them and inside a workspace
directory alike. The Windows restricted token cannot express the denial at all: under it a command
at read can still read the config directory and the store, account credentials included, and a
workspace directory containing them can write them; see
Shell. Only unrestricted writes there on the other
backends.
The host’s input is hidden from every confined command as well: opening another terminal’s pty, or
the device a keyboard is read from, would take what is typed there, a password at a prompt
included, from the program it was typed to. Bubblewrap gives each command a /dev of its own,
Landlock leaves /dev/pts, /dev/input, the raw keyboard devices (/dev/hidraw*) and the
virtual consoles, their terminals (/dev/tty0 and on) and screens (/dev/vcs*), out of what it
grants, and sandbox-exec refuses /dev/ttys* but for a pty the command opened itself.
A command meka runs has no terminal to need: it starts in a session of its own with stdin closed.
The Windows restricted token works differently enough to be worth stating; none of this applies
under the AppContainer backend, where the environment grants the workspace directories and nothing
is written to your ACLs. meka mints a deterministic capability SID per workspace directory, adds an
inheritable write ACE for it on that directory, and runs the shell under a WRITE_RESTRICTED token
carrying that capability. Three consequences:
-
It writes to your directory’s ACL. The grant is real, standing state, visible in
icaclsas anS-1-4-…entry. meka takes it back when the process exits, including on Ctrl+C, and logs how to remove it by hand if revocation fails. The next run re-adds it, which costs one pass over the tree.A crash or a kill still strands it. Nothing runs on those paths, so the ACE outlives them. It grants nothing to anyone but a meka run in that same directory, and is reused rather than duplicated next time, but if you want it gone:
icacls "<directory>" /remove:g *<the S-1-4-… from icacls>.The grant is tracked per process, not per session, so several sessions confining the same directory share one ACE, and it is released when the process exits rather than when any one of them ends. Under
meka servethat means the ACE stands for the lifetime of the server. -
It needs you to own the directory. Ownership supplies
WRITE_DACimplicitly, which is what lets meka grant without elevation. A network share or another user’s folder cannot be a workspace directory. -
Writes are restricted; nothing else is. A
WRITE_RESTRICTEDtoken intersects write accesses only. Anything carrying an explicitEveryone: WriteACE stays writable even outside the workspace, which has no Unix analog.This one is a deliberate trade, not an oversight. The restricting list has to include
Everyoneor PowerShell cannot start: the .NET runtime fails to initialize withE_ACCESSDENIEDbefore it evaluates anything, so every shell command dies. Measured both ways on Windows 11: droppingEveryonecloses the hole and takes the entire shell with it. Writes inside the workspace, to files new and pre-existing, and to meka’s own output pipe all behave the same either way, so a filesystem-only test makes the change look free. Files carrying an explicitEveryone: WriteACE are rare and usually a misconfiguration in their own right; aworkspacelevel that cannot run a command is not a usable level. -
The confined child shares meka’s console and integrity level.
readgets a private console and a Low-integrity token, so Windows’ UI privilege isolation stands between it and meka. Aworkspacechild gets neither: a restricted token cannot create a console, only inherit one, and the integrity label is deliberately left alone so ordinary tooling keeps working. The child can therefore write to the terminal outside meka’s own rendering, and window messages between the two are not blocked. It is confined on the filesystem, which is what the level promises, and it is not isolated from the meka process itself. -
A
workspacecommand can read meka’s process memory, and areadcommand cannot. This is the one axis on whichworkspaceis weaker than the level below it, so it is worth stating plainly.WRITE_RESTRICTEDintersects the restricting SIDs for write access only, and the integrity label is left alone, so nothing stops aworkspacechild callingOpenProcesswithPROCESS_VM_READagainst meka and reading whatever the process is holding, including your account credentials. Measured on real hardware: a native probe run atworkspaceread a canary string straight out of meka’s heap, while the identical probe atreadfailed atOpenProcesswithERROR_ACCESS_DENIED, because Low integrity refuses the handle. There is no clean fix inside the current design. Dropping theworkspacechild to Low integrity would confine it to the Low-integrity surface and take the workspace write grant with it, and a deny ACE on meka’s own process would have to name a SID the child carries but meka does not, which the restricted token does not provide. Treatworkspaceon Windows as protecting your files from the agent, not as protecting meka’s secrets from a command the agent runs. -
PowerShell runs in ConstrainedLanguage mode. The restricted token triggers it, and
readandunrestrictedare unaffected (both reportFullLanguage). Scripts that construct .NET types or set properties on them will fail atworkspacewhere they work atunrestricted. meka’s own UTF-8 output preamble is skipped rather than run there, so non-ASCII output atworkspaceis decoded with the host’s legacy code page and may be mangled.
The restricted token is a port of a community proof-of-concept rather than a vendor-supported sandboxing API, unlike Landlock, Bubblewrap and Seatbelt. It is the tightest boundary a Windows build without the process security environment API offers short of provisioning machine-level identities, which would need an Administrator setup step; a build with the API gets the AppContainer backend, which is the OS’s own.
Default permission
The default permission is read, with approvals off. The default enabled set is every
level, none / read / workspace / unrestricted.
Shift+Tab reaches workspace before unrestricted, so the confined level is the one you land on
first when you want the agent to change something.
You can change the start level with:
- CLI flag:
meka --permission workspace - Environment variable:
export MEKA_PERMISSION=workspace - Config file:
[permissions] default = "workspace"; see Config file
If --permission or MEKA_PERMISSION selects a level that isn’t in [permissions].enabled, meka
logs a warning and starts at the configured default instead of refusing to launch. A session whose
recorded level has since left [permissions].enabled is treated the same way wherever it is
reopened (a resume, meka serve re-attaching it, ACP session/load): it starts at the configured
default, with one warning naming the session, rather than at authority the configuration withdrew.
A level meka does not have in [permissions].enabled or default is refused at parse, with the
line. An enabled list that names nothing is different: meka warns and falls back to read
alone, not to the default set. An empty list asks for nothing, and answering it with four levels
including unrestricted would resolve to more authority than you wrote.
Upgrading from write
The write level was split in 0.42 and the name is retired. It resolves to nothing, and every
surface says which of the two replaced it:
workspacefor writes confined to the working directory. This is what mostwriteusers actually wanted.unrestrictedfor the old behavior exactly: no boundary, no sandbox on the shell.
write is refused rather than reassigned on purpose. The same words are also requirements in
[tools.tool_permissions], [mcp.servers.*].tool_permissions and [mcp].default_permission, where
silently re-pointing the name at the narrower level would have admitted tools a rung earlier than
their author intended. A hard failure at every door is the safe direction.
Anything meka persisted for itself (a sub-agent’s saved spec, a scheduled job’s gate) needs the one-shot migration script; those values were never typed by you and cannot be fixed by hand.
Changing permissions at runtime
Press Shift+Tab to cycle through permission levels:
none → read → workspace → unrestricted → none → ...
Disabled levels are skipped during cycling.
Or use the /permission slash command:
/permission workspace
/permission unrestricted
/permission <level> against a disabled level prints an error naming the currently enabled set.
The composer’s bar and the status row update immediately to reflect the new level. The agent learns the current level via a per-turn [Permission context] block prepended to your message (see How permissions work below).
Approvals
Approvals is one switch beside the level. Off, a call that needs more than the session’s level is refused, and the agent is told which level it would need. On, the call is paused for your approval instead:
• shell_execute
$ ls -la
Allow y
Allow for the session a
Deny n
Deny for the session
Enter to select · Esc to close
The call is drawn as the transcript draws it, its arguments under its row. The picked answer is highlighted; Up and Down move the pick.
It is off by default. Turn it on for a session with /approvals on on the screen, approvals on
POST /v1/sessions or PATCH /v1/sessions/{id} over HTTP, or the approvals config option in an
ACP client; set approvals = true under [permissions] to start every new session with it on. Like
the level, it is recorded on the session and comes back on a resume.
An approved call still runs at the session’s level. Approval turns a refusal into a question; it
does not widen reach. An approved file_write at read lands only inside the workspace, and
an approved shell_execute at read runs in the read-only sandbox. To let an approved call reach
further, raise the level. At unrestricted nothing sits above the level, so nothing is ever asked.
A call the level refuses however you answer is refused without a prompt: a write outside the
workspace, or shell_execute below unrestricted when nothing can sandbox it.
none with approvals on is the most cautious shape: every tool call is put to you, and nothing runs
unattended. Sub-agents share their parent’s switch, and their prompts are forwarded to the parent’s
frontend.
On the screen the prompt is a panel in place of the composer: the call as its title, every argument under it, and four answers. Move the cursor with Up and Down and press Enter, or press the answer’s key: y allows, n denies, a allows for the session, and 1 to 4 pick by position. Esc denies. If denied, the agent receives an error and may try an alternative approach.
Allow for the session approves this call and every later call to the same tool for the rest of
the session without asking; Deny for the session denies them the same way. Both are keyed on
the tool, not on the arguments: allowing shell_execute for the session approves every shell
command the agent runs afterwards, so use it for the tools you trust wholesale and keep answering
y for the rest. A new session starts with nothing remembered, and /fork moves you into a new
session, so the answers stay with the one you branched from. The same two answers are ACP’s
Always allow / Always deny options and the HTTP API’s allow_always / deny_always
outcomes. The decision is one row on the transcript, • shell_execute allowed for the session.
Where nobody can be asked at all (a --oneshot run, or meka serve answering a turn with no
stream to put the prompt on), a call that needs approval is denied and a warning names the tool
that was refused without asking: as a ! row on the screen, on stderr in a one-shot run, in notices on the JSON
surfaces. Without it a run whose every gated call was refused reads as a model that chose not to
use its tools.
Ctrl+C at the prompt cancels the turn and withdraws the question; the panel closes and the status row says so. Esc at the prompt denies the call and lets the turn go on.
This is useful when you want the agent to be able to try things but want to review each action that goes beyond the level before it executes.
What the prompt shows
Every argument the tool was called with, not just the one the call’s row picks out. That distinction matters: the row’s argument is the destination for every write-shaped tool, so a prompt built from it would ask you to authorize writing to a path without showing the content, or editing a file without showing the edit.
• file_write
│ path: src/auth.rs
│ content:
│ pub fn verify(token: &str) -> bool {
│ true
└ }
Allow y
A long value wraps rather than being cut, so the end of a shell pipeline cannot be hidden from the line you are approving.
Nothing is left out. A value too long for the panel wraps onto as many rows as it takes, and the panel scrolls: Up, Down, PgUp and PgDn move through it, and the footer says how many rows are below. A shell pipeline puts its consequence last, so a prompt that cut the value anywhere would hide the exact part you are being asked about; a call with two hundred arguments is two hundred arguments to scroll through, and that much to read is itself the signal to deny.
When a call is too much to read, denying costs nothing: say no, inspect the file or the
session with meka session export, and let the agent retry.
This is deliberately more than a call’s row shows: the row is a notification, the prompt is a decision, and a collapsed row does not make your approval prompts show less.
One consequence worth knowing: if the model passes a secret as a tool argument, an approval prompt puts it on screen. That is the correct trade at the moment you are authorizing the call, but it does mean such a value lands in your scrollback.
How permissions work
When the agent attempts to use a tool, meka checks whether the current permission level allows it:
- If allowed, the tool executes normally.
- If not, and approvals are on, you are prompted to approve or deny.
- If not, and approvals are off, meka returns an error message to the agent explaining which level is required and suggests asking you to raise it.
Telling the agent the current level
meka lists every registered tool in the per-turn <context> block with its required permission level inline (nothing is filtered out), and the same block carries a compact [Permission context] section:
<context>
[Permission context]
Level: read. Approvals: off.
[Execution context]
Image input: enabled.
[Environment context]
Working directory: /home/you/project
[Available tools]
- **file_read** (level `read`)
- **file_write** (level `workspace`)
...
</context>
The section carries only the two values that change; what each level allows and what the approvals switch does are stated once in the system prompt. That short section is almost the only permission-dependent content in the request; [Environment context] is the other, since it is empty at none and omits the readable line at unrestricted. The system prompt and the tools-array schemas stay byte-identical across /permission toggles, so mid-session level changes don’t invalidate the Claude prompt cache; the entire conversation stays warm.
The same reasoning is why the tool catalog itself lives here rather than in the system prompt. Prompt caching is prefix-based, and the system prompt heads that prefix, so anything cached there that later changes (an MCP server connecting late or hot-swapping its tools, a skill being installed) would re-cache the entire conversation behind it. The <context> block rides inside your own message instead, so changes are appended rather than rewritten.
Only what actually changed is re-sent. The first turn of a session carries the full catalog, skill list, and any MCP server instructions; a turn where nothing moved carries none of it, and a turn where something moved carries a short note naming just that change.
MCP tool permissions
MCP tools are classified through a 5-step resolution chain: per-tool override → server-level override → the server’s own readOnlyHint → [mcp].default_permission → a hardcoded unrestricted fallback. See the Permission resolution section of the Config file docs for the full rules and how to override a misclassified tool.
Built-in tool permissions
Any built-in tool’s required permission can be overridden from config.toml without editing code; see [tools]: built-in tool filters. The same section documents how to allow-list or block-list specific built-ins (e.g. disabling web_fetch in a locked-down environment).
Sub-agent permissions
Sub-agents spawned via agent_spawn inherit the parent’s permission level by default. At unrestricted the sub-agent can call file_write, file_edit, and unsandboxed shell_execute; at read it’s confined to read-only tools. To run one delegated task with reduced privileges, pass the permission parameter (e.g. agent_spawn({prompt: "...", permission: "read"})); a level above the parent’s is refused by name, so no sub-agent is ever escalated past the agent that spawned it. Where it works and what it reaches is the spawn call’s scope; see agent_spawn.
Examples
Read (the default)
> read the contents of main.rs
The agent uses file_read and shows the contents. Shell commands also work at read, but run in a read-only sandbox; the filesystem is write-protected for the child process:
> list the files in this directory
> show me the git log
Commands like ls, cat, git log, df, ps, and uname work normally. Commands that attempt to write to the filesystem (e.g. touch, rm, mkdir) fail with a permission error.
Two things the sandbox deliberately does not restrict, on every backend:
- Reads, on a root session unless
[permissions].confine_readsis on. A sandboxed command can otherwise read anything your user can, including~/.sshand~/.aws/credentials, and on Windows under the restricted token meka’s own store.readprotects the machine from being changed, not from being read. A sub-agent’s reads are confined to its scope whatever the switch says. - The network, unless
[permissions].networknarrows it:nonecuts a command off, which Bubblewrap, Landlock,sandbox-execand the Windows AppContainer hold and the Windows restricted token cannot;remotekeeps it off this machine alone, which the AppContainer holds and the others hold asnone, cutting the command off whole. Open, a command atreadcan still send what it read. Provider API keys are scrubbed from the child’s environment, but that is one vector, not a boundary.
On Windows under the restricted token, workspace extends that first point to meka’s own process. Its WRITE_RESTRICTED token restricts writes only, and unlike read it deliberately leaves the integrity label at the parent’s level, so a confined command can open meka with PROCESS_VM_READ and read its memory. Measured on Windows 11: OpenProcess succeeds and ReadProcessMemory returns data. This is the one respect in which workspace confines less than read, whose Low-integrity token Windows blocks from opening a medium-integrity process at all. It grants nothing that reading meka.db would not, which a command at either level can already do, but it is worth knowing if you were treating workspace as strictly wider than read in every direction. They are not ordered that way; see the ladder note above.
If no sandbox backend is usable, shell commands at read fail rather than running unconfined. On Linux that means Bubblewrap (preferred whenever bwrap is installed) or Landlock at ABI v9 or newer. Landlock below v9 cannot refuse a connect() to a Unix socket on disk, so a “read-only” command could still reach D-Bus and systemd-run --user and have them act on its behalf, and meka refuses it rather than promise a protection the kernel is not enforcing. Kernels below 7.1 therefore need bwrap installed for the shell at read; meka says so at startup.
If you ask the agent to modify a file:
> add a comment to the top of main.rs
The agent will explain that it cannot write files at read and suggest switching to workspace.
What read still writes
read means the agent cannot modify your tree. It can still write to stores meka owns, because otherwise an agent at read permission could never remember anything:
| Store | Location | Tools |
|---|---|---|
| Memory | the memories table in MEKA_DATA_DIR | memory_write, memory_edit, memory_delete |
| Skills | ~/.config/meka/skills/ | skill_write, skill_edit, skill_delete (only with [skills] agent_managed) |
| Scratchpad, scheduled jobs, background tasks | the store | various |
Of those, only the skill tools reach the filesystem at all; memory is a table in the store. Note what that makes skill_write at read: a persistence primitive. A skill it writes is read back into every later session’s prompt, so a prompt-injected instruction can outlive the turn that carried it. That is the reason [skills] agent_managed is off by default and the tool is never given to a sub-agent. That boundary is enforced in two places: a skill name must be one path component matching the Agent Skills spec’s own rule (lowercase letters, digits and hyphens), so it cannot contain .. or a path separator, and a symlink sitting at that name is refused rather than followed, so an existing link cannot redirect a write out of the skills directory. Memory names are governed by a different and wider rule ([A-Za-z0-9_-]), which is safe for a different reason: a memory name is a primary key in a table, never a path. file_write, file_edit and scratchpad_save_file are the only built-ins that touch your tree, and all three require workspace or above, and are fenced to the workspace roots at that level.
A directory you asked for is not always one you get. --additional-workspace-directory drops a path three ways, each with a warning: one that is not a directory, one naming a system directory the sandbox masks (/, /proc, /dev, /sys, /run, /tmp, /var/tmp, $XDG_RUNTIME_DIR), and one that does not resolve at startup; the last is kept rather than refused, so a build directory joins the workspace the moment it exists. See CLI options.
MCP tools are the exception
Tools from MCP servers are not built-ins and are not covered by that boundary. They execute inside the server’s own process, which meka does not sandbox, so what an MCP tool may do is bounded by the server, not by meka’s permission level.
What decides whether such a tool is reachable at read is the permission meka resolves for it, and by default a server’s own readOnlyHint: true annotation is enough to classify it as read. That hint is asserted by the server and not verified. A server that advertises it for a tool that in fact writes therefore gets to write your tree while meka sits at read.
For a server you have not audited, either pin its tools explicitly with tool_permissions or set trust_read_only_hint = false on it, which makes the hint advisory for display only and drops its tools to the strict unrestricted fallback, past [mcp].default_permission.
So the honest statement of read’s filesystem guarantee is: your tree is safe from meka’s built-in tools, plus whichever MCP servers you have chosen to trust.
Note: The read-only sandbox uses Bubblewrap or Landlock (ABI v9+, kernel 7.1+) on Linux,
sandbox-execon macOS, and an AppContainer or a restricted token on Windows. See Shell for what each backend covers. Where no backend is usable, shell commands are not available atreadorworkspace. You can disable sandboxed shell execution by settingsandbox = falseunder[shell]in the config file (see Config file), which makesshell_executerequireunrestrictedinstead.
Workspace
> run cargo test and show me the output
The agent uses shell_execute to run the tests and shows the results.
Sessions
Sessions persist your conversation so you can resume later. Each session has an id and lives in the store, a SQLite file.
How sessions work
- A session is not created when meka starts. It is created lazily when you send the first message.
- Its id is printed to stderr when it is created, resumed and left, so you can note it for later. Each banner has a switch under
[display]; the creation banner is off by default. - Sessions include the full conversation: your inputs, the agent’s responses, and tool call results.
Resuming a session
Continue the last session
meka -c
This resumes the most recently updated session. -c takes no value; give an opening prompt with -p: meka -c -p "and now add tests". With --project <name> it is the most recently updated session on that project, or a new one on it when there is none.
From a list
meka -r
With nothing after -r, the screen opens on a picker: the fifty newest root sessions in a table,
the title first, then the directory, the profile and when the session was last touched. A long
directory is cut from its start so its tail stays, and a narrow terminal drops the directory
column, then the profile. / then typing filters the
list, j and k move the pick, Enter resumes the pick, and Esc leaves you in a new session. The same picker opens from a running session with
/resume, and /resume <id> switches to a session by id or unique prefix; either way the lock
moves with you and the session you left stays as it was. meka -r --project <name> opens the
picker over that project’s sessions alone.
By id
meka -r 550e8400-e29b-41d4-a716-446655440000
The agent loads the previous conversation and continues from where you left off. --project <name> beside an id moves that session onto the project, rewriting its row, as --profile repins
its profile.
By id prefix
If the value passed to -r isn’t a whole id, meka treats it as a leading prefix and looks up sessions whose id starts with it. This avoids having to copy the entire id:
meka -r 550e # works if exactly one session starts with `550e`
meka -r 5 # likely ambiguous; meka lists matching ids and exits
When a prefix matches multiple sessions, meka prints the matching ids (most-recent first) so you can disambiguate. Type a few more characters until the prefix is unique.
What a resume restores
A session records what it runs on, and a resume brings all of it back:
- The profile. A session started with
--profile openairesumes onopenai, whateverdefault_profilesays. This matters beyond the surprise: a thinking block is tagged with the backend that produced it and is not replayed to a different one, so resuming on another profile could silently discard the reasoning the conversation recorded, and a different account would be billed. - The permission level, and the approvals switch. A session created at
unrestrictedresumes there without the flag, and one that had/approvals oncomes back asking.
Everything the profile and its account state comes with it: the model, the endpoint, the context
window the gauge and auto-compaction measure against, and whether images may be attached. A session
records the profile’s name, not a copy of its settings, so editing the profile with
meka profile set moves every session on it.
Two sessions on one meka serve can sit on profiles with different windows and each is measured
against its own.
Naming --profile on a resume repins the session: the row is rewritten, so it keeps that
profile from then on rather than for one run. --permission repins the same way.
You can also change the profile mid-session: /profile <name> on the screen,
PATCH /v1/sessions/{id} with {"profile": "..."} over HTTP, or the Profile picker in an ACP
client. Each rewrites the row.
That PATCH is also how you rescue a session over HTTP when its profile has left config.toml: a
body naming only a profile moves the row without building an agent for it, so it works on a session
that cannot currently run. From the CLI the equivalent is meka -r <id> --profile <name>.
Switching profile mid-conversation is allowed and is your call. From the next turn the model no longer sees the reasoning recorded under the old profile, for the reason above.
What a resume does not restore
--additional-workspace-directory and --additional-readable-directory are not restored, because
they belong to the process rather than the session; pass them again. See permissions for why recording it would be wrong.
A resumed session opens in the directory it recorded, not the one your shell is in. meka -c from anywhere reopens the session where it was working, and /cd is the only thing that moves it. This is deliberate: at workspace the working directory is the writable boundary, so adopting the shell’s would silently widen it: resume a project session from $HOME and the whole home directory becomes writable, with a scheduled job able to fire before you could react. If the recorded directory has since been removed, meka warns and opens where you are. To get back to your shell’s directory, run /cd with no argument.
A resume restores the conversation, not the world it ran in. The messages come back verbatim, which means the agent reads its own earlier tool calls and can reasonably assume their effects still hold. Two kinds of state do not survive the process that made them:
- Which files have been read. meka tracks reads in memory so
file_editcan refuse to write over a file the agent has not seen. A new process starts with that record empty, so the first edit to any file asks for afile_readfirst. - Anything an MCP server was holding. A loaded database, an authenticated session, a subscription: these belong to the server’s process, not to the conversation, and a reconnect drops them. meka has no way to model what a given server keeps open.
Everything else is restated in the per-turn context on every turn regardless (permission level, working directory, tool catalog), the checklist is read back from the conversation, and background tasks that were running deliver an interrupted outcome, so none of those can go stale unnoticed.
Because the second kind is unknowable from meka’s side, the first turn after a resume carries a [Session resumed] note telling the agent to re-establish rather than assume. It appears once and is not repeated. There is nothing to configure.
Session locking
Only one meka instance can be attached to a session at a time. This prevents race conditions from concurrent writes.
- The lock is taken the moment the session row exists, which for a brand-new session is at the start of its first turn rather than the end. A second invocation launched while that turn is still running is refused like any other.
- If you try to resume a session that is locked by a running meka process, you will get an error.
- If the locking process has exited (crashed or was killed), meka detects this and allows you to take over the lock.
- Under ACP (
meka acp), the lock is released as soon as the editor disconnects: closing the connection (stdin EOF) or sending SIGTERM/Ctrl-C makesmeka acpexit, so the session can be reopened immediately.
Fallback profiles
A session runs on the profile its row names, and that is where every turn starts. A profile can name
other profiles to serve a turn from when it cannot, in order, with
fallbacks:
[profiles.main]
account = "anthropic"
model = "claude-opus-5-5"
fallbacks = ["backup"]
A turn falls back when the profile’s upstream is still failing once the retry budget is spent (an outage, a rate limit, a dropped stream), or when it refuses for quota or billing by a signal it documents (a 402, a quota code on a 429, the Claude subscription’s exhausted usage window). It does not fall back on a context overflow, an invalid request, a tool refusal or an authentication failure, which another profile would not fix, and never once any of the reply has been shown, the same guard a retry has. The request is re-sent unchanged to the next profile, and the rest of the turn stays there, so a turn never alternates between backends.
The fallback serves one turn. The session keeps naming its own profile, meka session list and
GET /v1/sessions/{id} keep saying so, and the next turn starts on it again, unless its breaker is
open: a profile that failed over is skipped at the start of later turns for five minutes, or until
the upstream’s stated reset when that is sooner, never longer, and the turn starts on the first
fallback whose breaker is closed, or on the session’s own when every profile in
the chain is skipped. The breaker decides only where a turn starts. Once a turn is running and its
profile fails, it moves to the next profile in the chain it has not tried, breaker or not, wrapping
round to the ones its start skipped, so a turn fails only once every profile in the chain has
refused it, and an answer from any profile closes that profile’s breaker. The breaker lives in the
process, shared by every session and sub-agent it runs, and is gone with it. A sub-agent spawned
while its parent’s turn is on a fallback is bound to the parent’s own profile, as any spawn is, and
its turns start by the same breaker.
The list is a preference order. Every turn starts on the first profile in it, the session’s own included, whose breaker is closed, so a session comes back to the profile you would rather run on as soon as it answers again, rather than staying where the last turn happened to land. Each switch rebuilds the prompt cache at the new profile once, and since a turn never alternates, that cost falls at a turn boundary and never inside one.
Every fallback is visible: a warning in the log naming both profiles and the error, a notice on the
screen and the session feed (Profile 'main' is unavailable; this turn runs on 'backup'.), and the
turn’s record carrying the profile that served it, on
GET /v1/sessions/{id}/turns, on the turn’s terminal event, and in an export,
so a cost question later has an answer. A turn the session’s own profile served records
nothing. When the whole chain refuses, the turn fails on the last profile tried with that profile’s
error. A compaction’s checkpoint and summarizer fall back the same way, and a compaction run between
turns puts the named profile back when it ends.
A fallback crosses backends when the profiles do. A thinking block is tagged with the backend that
produced it and is not replayed to a different one, so from the switch the model no longer sees the
reasoning recorded under the earlier profile, which is the same loss a PATCH to another profile
takes.
A fallback must be able to carry what the session holds: the same vision, and a window and a
request cap no smaller (fallbacks). While a fallback
serves, the session keeps gauging against its own profile’s window. A profile with vision off,
reached by a switch rather than a fallback, refuses a turn while the history holds an image.
Storage location
Sessions live in the store, a SQLite file at a platform-specific location:
| Platform | Path |
|---|---|
| Linux | ~/.local/share/meka/meka.db ($XDG_DATA_HOME/meka/meka.db) |
| macOS | ~/Library/Application Support/meka/meka.db |
| Windows | %APPDATA%\meka\meka.db |
What else is in that directory
meka.db-wal and meka.db-shm are SQLite’s own companions to the open store, not backups. The
locks/ subdirectory holds the lock files meka uses to keep two processes off one session and off
one schema change; it is empty of anything worth reading.
You may also find one meka.db.v<version>.bak. meka copies the store aside before it changes the
schema, and keeps exactly one such copy: the next schema-changing upgrade takes a fresh one and
removes the one before it, so the copies do not accumulate. Expect the data directory to settle at
roughly twice the size of the store, and to peak higher than that during an upgrade, since the new
copy is written before the old one goes.
To restore one, stop every meka process and copy it over meka.db. It records the schema version it
was taken at, so the next start brings it forward again rather than mistaking it for a current store.
Because only the newest is kept, restoring undoes the most recent schema-changing upgrade and
nothing before it; move a copy of your own aside if you want to go back further.
An interrupted upgrade can leave a meka.db.v<version>.bak.partial behind. That is a copy that never
finished, so it is not restorable and nothing removes it; delete it whenever you like.
Anything else you put in this directory is yours and meka leaves it alone, including a file whose name merely resembles the above.
Store schema
The three tables below are the conversation itself, with messages_fts, the full-text index over
the words of messages that meka session search reads. The store holds
seven more, which the features that own them document: scheduled_jobs
(scheduling), background_tasks (background work),
memories and its memories_fts full-text index (memory), prompt_history (the
screen’s input history), and account_credentials and
mcp_credentials (secrets, never in config.toml).
sessions, one row per session:
| Column | Type | Description |
|---|---|---|
id | TEXT (UUID) | Primary key |
created_at | TEXT (RFC 3339) | When the session was created |
updated_at | TEXT (RFC 3339) | When the session was last updated |
parent_session_id | TEXT (UUID) | The session that spawned this sub-agent, or NULL |
working_directory | TEXT | Working directory the session is in; moved only by /cd, ACP, or PATCH |
permission | TEXT | Permission level a re-attached session resumes with, and the level a scheduled gate is re-checked against |
approvals | INTEGER | Whether calls above the level are put to the user for approval |
capabilities_json | TEXT | Per-session capability flags, for HTTP re-attach |
token_id | TEXT | Bearer token that created the session, for HTTP |
additional_workspace_directories_json | TEXT | The workspace beyond working_directory, as a JSON list |
subagent_spec_json | TEXT | The terms a sub-agent was spawned under |
turns, input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens, redactions, redacted_images, redacted_bytes | INTEGER | The cumulative counters behind /status |
prompt_index, turn_index | INTEGER | The position of the last turn opened, which the Claude subscription wire reports; both NULL on a session that numbers nothing, which is one that had turns before the columns existed |
profile | TEXT | Profile the session runs on. Never NULL, though a row carried forward from a store that predates the column can hold '' |
context_tokens | INTEGER | Context occupancy the provider last reported, which a resume checks its first turn against; NULL until a turn records one, and after a rewind |
title | TEXT | The title a user set, or NULL for a session labeled by its first words |
pinned_at | TEXT (RFC 3339) | When the session was pinned, or NULL |
blobs and message_blobs: image bytes by SHA-256 content hash, and which message rows
reference them. A message row holds a reference in place of the bytes, so a screenshot read twice is
stored once, and deleting a session removes the blobs nothing else references. An export carries the
bytes its sessions reference, and GET /v1/sessions/{id}/blobs/{hash} serves them over HTTP.
Locks are OS file locks under the data directory, not a column: a row cannot record a crashed process’s PID and lock a session forever.
messages, one row per message in a session:
| Column | Type | Description |
|---|---|---|
id | INTEGER | Auto-incrementing primary key |
session_id | TEXT (UUID) | Foreign key to sessions.id |
kind | TEXT | user_blocks (a turn: its turn_context and text blocks and any images), user (a plain text message meka wrote), assistant, tool_results, compact_boundary, repair, redact, or unsign |
content | TEXT | Message content (plain text or JSON) |
created_at | TEXT (RFC 3339) | When the message was saved |
scratchpad_entries, one row per entry:
| Column | Type | Description |
|---|---|---|
session_id | TEXT (UUID) | Part of composite primary key |
name | TEXT | Part of composite primary key |
content | TEXT | The stored content |
created_at | TEXT (RFC 3339) | When the entry was created |
Scratchpad entries are scoped to a session. Two sessions can have entries with the same name. Entries are preserved across compaction but deleted when a session is deleted.
History retention
meka never deletes sessions unless you ask it to. Conversation history isn’t reproducible, so there is no default cleanup by age and none at all by size.
If you do want a time window, set it explicitly:
[session]
retention = "30d" # delete sessions not updated in 30 days
With that set, meka deletes matching sessions when the agent starts, and then every hour for as long as meka serve, meka acp or the screen stays up, and says so at warn level, so a deletion you configured is still a deletion you see. A session another meka process has open is spared, and so is the one you are resuming with -c or -r, however old it is; so is a pinned session, and one with a scheduled job ahead of it. Unset (the default) keeps everything forever.
To prune on demand instead, remove on your own schedule:
meka session remove --older-than-days 90 # same window, run when you choose
meka session remove <id> [<id>…] # specific sessions
meka session remove --all # everything
Removing a session takes its messages, scratchpad entries, and any sub-agent children with it.
A session is locked from the moment it exists: the lock is taken before the row is written, so a sweep in another terminal cannot catch it in between. That holds for new sessions, for sub-agent sessions, for forks made from any surface, and for the root of an imported archive while it is written. Copying a conversation holds it still too: every fork door (meka session fork, /fork, POST /v1/sessions/{id}/fork, ACP session/fork) and meka session export refuse a session another process has open, because a copy taken mid-turn ends on a user message the model never answered and restores as an unusable session. meka session rewind has always done this.
No deletion touches a session another meka process has open. Naming one by id fails and says so; --all, --older-than-days and the startup sweep skip it and report how many they left behind. This matters most for the startup sweep, because only turns bump a session’s timestamp (resuming does not), so a screen left at its prompt past the window looks expired while somebody is sitting in front of it.
Turn retention
A session that is used for months, an assistant behind meka serve say, keeps every conversation it ever had unless told otherwise. turn_retention sets a window on the turns inside a session, where retention sets one on sessions:
[session]
turn_retention = "90d" # delete turns that ended over 90 days ago, once summarized
The sweep takes only what a compaction has summarized: the turns before the session’s latest compaction, since the summary is what stands in for them, and never the turns the model is still working from. When one of those is older than the window, the next turn compacts first, keeping no rounds verbatim and quoting none, and the sweep takes it afterward; turn_retention therefore needs auto_compact on. It runs at startup, at the start of each turn for that turn’s session, and hourly while a long-lived host stays up, and reports what it took at warn level.
A turn goes whole: its messages, its record, its words in the search index and the images only it carried. A turn the latest compaction kept verbatim stays in the model’s view as the copy the compaction wrote, and history shows that copy once the turn’s own rows are gone. Inbox items and background outcomes go once they have been delivered or withdrawn for longer than the window. Scratchpad entries and memories stay, since they are the agent’s own notes rather than the conversation. A fork copies what has survived, and an import brings in turns the next sweep will date like any other. What remains of a deleted turn is whatever the compaction summary said about it, which is the model’s memory and the only thing retention cannot take.
See Config file for details.
Context management
Every request carries the whole conversation as it stands, tool calls and their results included. Nothing is dropped by count: the context ceiling and compaction are the only bound. Once the occupancy the provider last reported passes context_ceiling_percent of the profile’s context_window (85% by default), auto-compaction summarizes the older turns and keeps the recent ones verbatim, between turns and between the tool rounds of one turn. See [session] for the switch and the percent.
The full history stays in the store either way, unless turn_retention is set. After a compaction a request carries the summary and the kept tail, and the tool catalog, skill list and MCP server instructions are restated in full on the next turn.
With auto_compact = false the conversation grows until the provider rejects a request for exceeding its window, which fails that turn; /compact summarizes on demand.
Compacting a session
When a session becomes too long, /compact replaces the older turns with a summary. A token-budgeted tail of recent messages may be kept verbatim, without splitting tool calls from their results. If too little older history remains to summarize, the whole conversation is summarized instead; an unanswered trailing prompt is still kept.
The kept tail is written after the summary as copies the model alone reads. Everything that shows the session’s history (the screen on a resume, an editor’s reload, conversation_search and conversation_read, the session search, a Markdown export and the turn count agent_list gives) shows each kept turn once, where it was first said, and shows the copy once turn retention has taken the turn’s own rows. A kept message the view had rewritten on its way to the model (thinking it stripped, an image it replaced) matches no row of its own and shows twice.
Whoever writes the summary, it has four sections: the rules and authorizations the user stated, in their words; each commitment and its state (done, pending, blocked, or superseded) and what must happen before the next; the facts established, each with its source; and the next action. A /compact <instructions> focus adds emphasis within those sections and never drops one.
The messages received during the summarized turns are not left to the summary. meka copies the most recent ones into the summary message as they were written, newest first within a third of the verbatim budget, oldest first when read, each quoted line by line; the kept tail takes the other two thirds, or the whole budget when there is nothing to copy. A message relayed from the inbox keeps the header naming who sent it. The one that overflows the budget keeps its beginning; conversation_read reaches all of it. Nothing already in the kept tail is copied twice, and a summary’s own quotes are never quoted again by the next compaction. The reason is the one loss an agent cannot notice: a restriction stated in a later turn and left out of a summary is gone from the window, and the agent cannot search for a rule it does not know it lost.
By default the summary is written by the agent itself, in a checkpoint turn that runs before anything is discarded. The agent gets its real system prompt, its memory index, the full conversation with its images, and the turn’s own tool list, and is told its context is about to be replaced. The request is the turn’s request with the checkpoint instruction appended, so the provider answers it from the turn’s prompt cache rather than reading the conversation again. It saves whatever must outlive the window (memory_write for facts and decisions that should still be true in a future session, the scratchpad for working material), then calls context_replace with the summary.
This matters because compaction is the one moment information is destroyed, and before this it was also the one moment the agent could not act. The alternative, a separate summarizer call, knows nothing about who the agent is or what it is for.
A checkpoint can save, but not act. It may call the memory, scratchpad, conversation-history and read-only search tools and checklist_read, and nothing else; the rest of the catalog rides along for the cache’s sake, and a call to any of it is refused: no shell, no file writes (scratchpad_save_file included), no sub-agents, no scheduling, no MCP. The delete tools are excluded too, since deleting is not saving and a mistaken delete in an unattended checkpoint is unrecoverable, and so are the checklist edits, since a checkpoint’s calls are not part of the conversation and the list is what the conversation’s calls add up to. A tool disabled in [tools] stays disabled here.
You can say what to keep:
/compact keep the auth refactor decisions, drop the debugging
The screen marks it with a rule, →← Context compacted, which opens to what set it going, the checkpoint’s calls (each memory it wrote among them) and the summary the agent now reads. An editor shows it as a compaction entry, or as a card where it has none, with the checkpoint’s calls after it. A one-shot run logs one line, shown with -v, which names what was written, because memories are durable and instance-scoped:
Compacted the conversation on user request. Wrote 2 memories: deploy-pipeline-quirks, api-rate-limits.
Note that an automatic compaction runs a checkpoint too, unattended, and can write memory without anyone watching.
Compaction preserves scratchpad entries, copies the open checklist into the summary verbatim, and re-injects environment context so the agent isn’t disoriented afterwards. The tool catalog, skill list, and MCP server instructions are restated in full on the next turn, since the messages that carried them may have been summarized away. Tools loaded via tool_load stay loaded; the deferred-tool active set is snapshotted into the compaction boundary. If a detail was dropped, the model can conversation_search / conversation_read the full pre-compaction history, which stays on disk.
Internally, compaction does not delete pre-compaction rows from the store. It appends a compact_boundary row to the messages table; the materialized view is reconstructed from the event log, so the persisted log itself stays append-only until turn retention, if set, takes the rows a boundary has superseded. A resume reads that log from its last compact_boundary row: nothing before it can reach the view, so opening a session costs the same however long its history is. conversation_search, conversation_read, GET /messages, a rewind and an export still read the whole log.
When the summarizer runs instead
A standalone summarizer, with no tools and none of the standing instructions, is the fallback. It runs when:
- The compaction is an emergency one, i.e. the provider has already rejected the request for exceeding the window. A checkpoint turn re-sends that same conversation, so it would be refused identically; the summarizer strips images and truncates long blocks, which is what lets it get through.
- The checkpoint turn fails or ends without calling
context_replace.tool_choiceisn’t available across meka’s backends, so the call can’t be forced, and a turn that ends on prose instead is not trusted to have written a summary: that prose is as often an answer to the conversation, or a sentence cut off at the checkpoint’s round limit, as a summary, and nothing can tell which. Memories the checkpoint wrote before it ended are kept either way. compact_checkpointis off.
[session]
compact_checkpoint = true # default
Turning it off leaves the standalone summarizer to write every summary, which saves one model call per compaction at the cost of the agent having no say in what survives.
Auto-compact
When auto_compact is enabled (default: true), meka automatically compacts the conversation once it is past context_ceiling_percent of the context window (default: 85%). The check runs at three points. At the start of a turn it reads the last reported usage, which a resumed session takes from its row, so the first turn after a resume is checked against the real number. Before the first request it projects the request from an estimate, so a turn whose own input jumps over the ceiling is compacted before it is sent. And after every round of tool results inside a turn it reads that round’s reported usage, so a long tool loop overshoots the line by one round rather than by the whole loop; the turn’s most recent rounds may be kept verbatim within the budget, its earlier ones are summarized with the history, the request the turn is answering is quoted after the summary as the user wrote it, and one crossing is answered once, until a later measurement reads under the line again. As a last resort, if the provider still rejects a request for exceeding the context window, meka compacts once and retries the turn instead of failing.
[session]
auto_compact = true
context_ceiling_percent = 85 # default
context_window = 200000 # optional override
Agent-initiated compaction
The agent doesn’t have to wait for the ceiling. context_compact asks for a compaction before the agent’s next step: it runs once the current batch of tool calls finishes, and the turn then carries on against the summary. With the default keep_recent, a retained tail may keep the most recent rounds within the verbatim budget, cut at a call so no call is parted from its result, and everything before that, the history and the current turn’s earlier rounds alike, goes into the summary; the request the turn is answering is quoted after the summary as the user wrote it, so the turn continues against the user’s words rather than a paraphrase. One compaction per turn: a further request once the first has run is ignored, and the agent can ask again on a later turn.
context_compact(instructions: "the day's work is in memory now", keep_recent: false)
keep_recent: false requests no verbatim tail. The checkpoint’s own context_replace has the last word, and without a checkpoint the tail is kept, since nothing was saved. When the tail goes, the summary and the restored live context carry the work forward; an unanswered trailing prompt is still preserved. That is the difference between compacting and turning the page, and it’s what makes a “start of a new day” routine work: a scheduled job at midnight can write the day’s diary to memory, then compact clean, instead of carrying yesterday’s context forward indefinitely.
The request is parked rather than applied where it is made: a tool cannot rewrite the conversation the agent loop is holding. It is drained at the next boundary between rounds, once the batch’s tool results are in, which is what lets the rest of the turn run against the summary.
What the agent sees
Once a turn has been measured, the per-turn context block carries a [Context budget] line reporting occupancy and the ceiling compaction fires past:
[Context budget]
Using ~84k of 200k tokens (42%). Auto-compaction at 85%.
What happens at the ceiling (a summary between turns or between two tool rounds, which loses detail while recent rounds may be kept verbatim, and what it omits stays reachable through conversation_search and conversation_read) is stated once in the system prompt rather than on every turn. With turn_retention set, that sentence bounds the reach by the window, and says that older turns are deleted and that what must outlive them belongs in memory. The context block then carries the date the record starts at, which moves every turn:
[Retention]
Turns older than 90 days are deleted; nothing before Thu, 16 Jul 2026 09:12:34 +0800 is on record.
The date is in the same zone and form as the block’s own Date: line, so the two read against each other.
The agent is expected to budget its own reading and to decide when a task will fit, so it needs the same number the harness uses. Without it, those are guesses. The line is suppressed when the window is unknown, and on the first turn of a session, when there is no measurement yet rather than a genuine zero.
It rides the per-turn context block rather than the system prompt because it changes every turn and the system prompt is the cached prefix.
From the second compaction onward the line also reports how many have happened, since a summary of a summary has lost considerably more than a first pass:
This conversation has been summarized 3 times, so early detail is now several
removes from what was said; write anything that must last to memory rather than
relying on it surviving another pass.
Because that block is rendered once per turn, it does not move while the agent works. During a long tool loop, which is exactly when context moves fastest, it is stale. context_check reports the live figures on demand: occupancy, headroom in tokens, the fixed overhead compaction cannot reclaim, how much of the recent conversation would survive verbatim, and the compaction count. Refreshing the pushed block instead would rewrite a message the provider’s prompt cache already covers, invalidating it on every iteration; a tool result appends at the tail and is cache-safe.
Listing sessions
To see past sessions:
meka session list
This shows a table with each session’s id, last update time (local, with its UTC offset), profile,
project (- for none, and the column only once a project exists), and its title:
the one you set, else the words of the first message. Pinned sessions come
first, newest pin on top, marked with *; the rest follow most recently updated first:
ID Updated Profile Project Title
7c1d9e02 2026-02-20 18:04 +00:00 work - * Research: LLM security blind spots
550e8400 2026-03-14 12:00 +00:00 work webdev How do I implement a binary search tree?
a1b2c3d4 2026-03-13 09:30 +00:00 personal - Fix the login page CSS
--project <name> lists the sessions on one project; --project "" those on none.
The ID column shows as much of each id as distinguishes it from the others on screen, widening only
if two would otherwise read the same. Every command that takes a session id (meka -r, export,
show, fork, rewind, remove) accepts any unique prefix, so what you see is normally what
you retype. An ambiguous prefix is refused and every match listed, rather than acted on.
Uniqueness is computed over the rows on screen, while the commands resolve against every session
in the store. A listing narrowed by -n, or one hiding sub-agent sessions (they are hidden unless
--include-children is given), can therefore print a prefix that a wider set makes ambiguous. That
fails closed: the command refuses and names both ids, so nothing is acted on and the full id is one
copy away.
For the whole id, and the working directory and permission the table has no room for:
meka session show 550e8400
By default the 20 most recent sessions are shown. Use -n to change, and -n 0 to show every
session; meka session search takes the same flag:
meka session list -n 50
Sub-agent transcripts are hidden by default, so the listing stays the conversations you started. Add
--include-children to see them too:
meka session list --include-children
The Profile column names the profile, which is the whole story: a session records a profile name
and nothing else, so the model and endpoint it runs on are whatever that profile and its account
currently say. meka profile list shows them.
Both commands take --format json. The listing becomes {"sessions": [...]} and show one object,
each session carrying id, created_at, updated_at, profile, title, approvals, and, when
the row records them, working_directory, permission, capabilities, parent_id, pinned_at and
last_turn: the fields GET /v1/sessions returns under the same names, less the
three only a running host can answer (turn_in_flight, inbox_pending, approvals_pending). Ids
are printed in full, and an empty store is
{"sessions": []}.
meka session list --format json | jq -r '.sessions[] | "\(.id) \(.profile)"'
meka session show 550e8400 --format json | jq .working_directory
Titling a session
A session is labeled by the words of its first message, which is not always what you want to find it by later. A session opened on a skill or an MCP prompt is labeled by the words typed with it, or by its name when none were, never by the body it expanded to. Give it a title of your own:
meka session set 550e8400 title "Research: LLM security blind spots"
The title is what every listing shows from then on: meka session list, the web and editor
clients, and GET /v1/sessions, all under the same title field. It is one line, at most 200
characters, and whitespace runs are collapsed. Clear it with an empty string, and the session goes
back to being labeled by its first words:
meka session set 550e8400 title ""
From the screen, /title <text> titles the session you are in and bare /title shows the current
one. Over HTTP it is PATCH /v1/sessions/{id} with {"title": "..."}. Titling a session does not
move its updated_at, so it stays where it was in the listing and meka -c still continues the
session you last used, not the one you last renamed. A fork carries the title with it, since it
holds the same conversation, and an export does too.
Pinning a session
A session you keep coming back to can be pinned, which lists it first, ahead of every unpinned session, on every surface:
meka session set 550e8400 pinned true
meka session set 550e8400 pinned false
Pinned sessions are ordered among themselves by when they were pinned, newest on top, so the shelf
does not reshuffle as you use them; unpin and pin again to move one to the top. meka session show prints when a session was pinned, the JSON forms carry it as pinned_at, and the listing
marks the title with *.
A pin is also a keep: the [session].retention sweep and meka session remove --older-than-days
leave a pinned session alone whatever its age. A session expires as a tree, by the age of its
root: a pin or a scheduled job anywhere in the tree keeps the root and every sub-agent under it,
and a root that expires takes its sub-agents with it. meka session remove <id> and --all still
do what they say. Like a title, a pin does not move updated_at and does not change which session meka -c continues. A fork is a new session and starts unpinned; an export carries the pin.
Searching sessions
When you remember what a conversation was about but not which session it was, search the words:
meka session search "blind spot timestamp"
ID Updated Title Match
7c1d9e02 2026-02-20 18:04 +00:00 Research: LLM security blind... The paper's point is that a timestamp in the prompt lets the...
3e0a77b1 2026-01-08 09:12 +00:00 what does the blind spot pape... what does the blind spot paper say about timestamps
What is searched is what was said: your messages, the assistant’s replies and compaction summaries.
Tool calls, tool results and the model’s thinking are not, so a file the agent read does not make
its session a match for every word in it; conversation_search, which the agent runs over the
sessions it names, still covers those. The agent has this search too, as
session_search, over the sessions it may
read. A session whose title holds every word is listed first, newest first
among those; the rest are ranked by how well their best message matches. Every word must appear;
when nothing holds them all, the words are tried as prefixes, so zebra okap still finds the
session about zebras and okapis; then sessions holding any of the words are listed, then any as a
prefix. Words are matched by their stem, so deployed finds deploying, and every diacritic is
folded, so viet finds Việt. Chinese, Japanese, Korean and other scripts written without spaces
are matched character by character, so a word is found inside a sentence and a query of one
character works. What the matching does not do: an Arabic word is matched as written, article
prefix included; a German compound is not split into its parts; Thai is matched by its consonants
alone. A turn a rewind took out of the model’s view is still part of the session, and is still
found.
Each row shows the session’s title and the line of the best-matching message the words were found
on, cut to fit; a line taken from a compaction summary is marked (summary). -n sets how many
sessions to show (default 20), --include-children adds sub-agent sessions, and --format json
answers {"sessions": [...]} with each session’s record plus excerpt. Over HTTP it is
GET /v1/sessions/search?q=.
The search reads an index the store keeps beside the conversation, a few percent of its size, kept in step with every message written and checked on every open. The first launch after upgrading builds it over every existing session, which takes a moment on a large store and is logged.
Exporting a session
You can export any session as a Markdown file:
meka session export 550e8400-e29b-41d4-a716-446655440000
This writes session-550e8400-e29b-41d4-a716-446655440000.md in the current directory with the full conversation. User and assistant messages are rendered as Markdown sections, while tool calls and results are wrapped in collapsible <details> blocks. The export always covers the entire session, including turns that were later hidden from the model by compaction (each compaction point is marked with its summary).
To write to a specific file:
meka session export 550e8400-e29b-41d4-a716-446655440000 -o conversation.md
To print to stdout (for piping):
meka session export 550e8400-e29b-41d4-a716-446655440000 -o -
JSON (structured, round-trippable)
Pass --format json for a structured export instead of rendered Markdown:
meka session export 550e8400-e29b-41d4-a716-446655440000 --format json
This writes session-<id>.json, a lossless dump of the session’s event log (including input images and compaction boundaries), its cumulative stats, its profile and project, and scratchpad entries. The archive carries format_version: 21, and an import refuses any other version rather than guessing at its shape, except an archive written by 0.59 or later (format_version 3 to 20), which is brought forward as it is read. An event that is a copy a compaction kept names the position of the event it copies (copy_of). Unlike Markdown, a JSON export also includes any sub-agent child sessions spawned during the conversation, and it can be re-imported with meka session import. It deliberately contains no credentials: API keys and OAuth tokens live in separate tables and are never part of an export.
Importing a session
Recreate a session from a JSON export:
meka session import session-550e8400-e29b-41d4-a716-446655440000.json
meka assigns the imported session (and any sub-agent children) new ids so they can’t collide with existing sessions, then prints the new root session id. Resume it like any other session:
meka -r <new-id>
Read from stdin with -:
cat session.json | meka session import -
The import preserves the full conversation, per-message timestamps, cumulative stats, scratchpad entries, the title and the pin, and the name of the profile the session ran on. That name is all an archive carries about the profile: the settings themselves come from whatever [profiles.<name>] and its account say on the installation importing it. An archive naming a profile this installation does not configure, on its root or on a sub-agent spawned with one, is refused rather than restored as a session every resume would refuse; meka --profile <name> session import moves every session in the archive onto a configured profile instead, the same explicit act that moves a session on resume. An archive that names no profile adopts this installation’s default; if nothing can supply one, because no default_profile is set and several profiles are configured, the import is refused: name one with meka --profile <name> session import. An archive whose messages reference an image it does not carry and this store does not hold is refused as malformed before anything is written.
updated_at is stamped to the import time rather than restored from the export, so that restoring an archive older than a configured retention window isn’t undone by the retention sweep on the next launch. created_at still carries the original.
Forking a session
Branch off an existing conversation without disturbing it:
meka session fork 550e8400-e29b-41d4-a716-446655440000
The copy starts with the original’s full conversation and continues from there under a new id, which is printed on stdout so it can be captured:
meka -r "$(meka session fork 550e8400-e29b-41d4-a716-446655440000)"
Use it to try a different direction from a known-good point, to run a throwaway question against a large accumulated context, or to keep a conversation you’re about to compact.
What the copy carries: the full event log, scratchpad entries, working directory, permission level and approvals switch, additional workspace roots, cumulative stats, and the title, since it names the same conversation. What it does not: sub-agent sessions (the sub-agent’s result already sits in the parent conversation as a tool result, so the copy is complete without them), the pin, since the copy is a new session, and the timestamps, which are stamped fresh.
A fork of an ordinary session records no link back to the one it came from; it is a root session
like any other. A fork of a sub-agent is the exception: it keeps that sub-agent’s parent and
spawn terms, so the copy is a sibling under the same parent rather than a promotion to a session of
its own, and it is continued through agent_followup like any other sub-agent.
Forking copies what has been committed to the store, and only between turns. A session another meka process has open is refused rather than copied (see Session locking), and so is one with a turn in flight in the process that holds it: POST /v1/sessions/{id}/fork answers 409 turn-in-flight and ACP session/fork answers InvalidParams. The user message is persisted before the model is called, so a copy taken mid-turn would end on a prompt with no reply and restore as an unusable session. Cancel the turn or wait for it.
The same operation is available from the screen as /fork, which switches you into the copy and leaves the original where you branched (an always or never given at an approval prompt stays with the original; see Permissions); over HTTP as POST /v1/sessions/{id}/fork; and over ACP as session/fork.
Fork or export/import?
Both produce a runnable copy under a new id. Reach for fork to branch a conversation you’re working on, and for export + import to move a session between machines or keep an archive. Export/import also copies sub-agent transcripts and preserves created_at, because an archive should restore whole.
Both copy a long session in short transactions, so another meka process writing to the store meanwhile is not held up for the length of the copy. The copy appears in listings, searches and meka -c only once it is whole. One that a crash interrupted is deleted the next time a meka host starts, unless another process still holds it.
Rewinding a session
Drop the most recent turns from a session:
meka session rewind 550e8400-e29b-41d4-a716-446655440000
meka session rewind 550e8400-e29b-41d4-a716-446655440000 -n 3
The cut lands on a turn boundary, so a tool call is never separated from its result, and a compaction summary counts as a turn, so a rewind that reaches it removes the summary too. Nothing is deleted: the dropped turns stay in the event log and still appear in meka session export, marked at the point of the rewind. The model simply stops seeing them.
The command takes the session lock, so it refuses to run while a screen, meka serve, or meka acp holds the session; that process has its own copy of the conversation in memory and would write over the rewind on its next turn. On the screen use /rewind instead. Under ACP there is no in-session equivalent, so close the session in the editor and run this command; over HTTP, POST /v1/sessions/{id}/rewind does the same to a live session.
Its main use is recovering a session a provider has started rejecting. A provider validates the whole conversation on every request, so one piece of content it rejects fails every later turn too.
meka repairs a rejection caused by content added since the last request the provider accepted, and repairs a mislabeled image on resume, but anything older than that needs rewinding past. That window is usually the current turn, and it reaches back into the previous one when a turn failed mid-tool-loop and left it unaccepted. A compaction widens it to the whole conversation, because a compaction replaces that conversation wholesale and nothing in the result has been accepted yet. The repair escalates: first it removes the attachments the turn added and leaves everything else alone, and only if that is refused as well does it empty the turn’s tool calls, moving each call’s arguments into the result that reports it and replacing the result’s body with an explanation. The second step exists because a tool result is usually text, which the first step cannot touch, and because a call’s own arguments can be what the provider objected to. A step the provider then accepts is not counted as spent, so the cheap one stays available if the turn is refused again later.
Neither step changes the shape of the conversation: a tool call stays a tool call and its result stays its result, marked as an error. That is deliberate. Removing one half of a pair is the one thing every provider refuses outright, so a repair that could do it might turn a recoverable rejection into a permanent one. The model sees a failed tool call, which it already knows how to read, with the arguments it sent quoted in the failure so it can tell which call not to repeat.
Nothing a repair removes is deleted. The log is append-only (within turn retention, when set), so the superseded messages stay on disk and meka session export renders them above a marker saying what replaced them. Use --format json to get a removed attachment back: the markdown export writes each message as a person said it, an invoked skill or prompt as the invocation rather than the body it expanded to and an attached file by its mention, and leaves image blocks out. Only the conversation the model sees is changed.
Whatever is removed is restored untouched if the retry carrying it is refused too. That restore is what bounds the risk, and it bounds it only in that direction: each step spends a fresh retry sequence rather than a single request, and a step whose retry succeeds keeps the loss, so the trigger is deliberately narrow. The words you typed are never rewritten by either step, though an image you attached to that prompt is exactly what the first one removes, replacing it with a note. Notes meka inserts into a conversation are prefixed [meka harness].
Both steps run whether the provider answered 400 or spent every retry on a 5xx. A gateway in front of a model reports a payload its own decoder choked on as a server error, which is indistinguishable from being overloaded, so meka honors the retries in full and treats a refusal that outlives them as one the content may explain.
On the 5xx path it does one thing more before touching anything. The retry sequence is short by design (two attempts across three seconds of backoff), which an ordinary overload outlasts, so meka waits eight seconds and sends the same request one last time. If that succeeds the outage was the whole story and nothing is lost; if it fails too, the reading that the body is the problem has been earned rather than assumed. The wait is paid only by a turn that was otherwise about to start deleting things, and only once per run of consecutive failures: a request the provider accepts makes it available again, since a later refusal is about work the earlier wait never saw. A 400 skips it, because the provider has already read the body and said no.
Nothing outside those two shapes degrades at all, because a degraded retry that succeeds only because the network came back would keep the loss. Excluded, then: a dropped connection, which never delivered the request for anything to judge; a 429, which is a statement about rate rather than about what was sent; a failure that arrives partway through a stream, after some of the answer has reached you, both because re-sending would print it twice and because the stream cannot tell an overload from anything else; and anything at all once the retries have not been exhausted. If a step it did try does not help, it says so and points here before the turn fails.
One cause of that refusal has its own fix. A session recorded by 0.41 can hold a tool_result whose content is a bare JSON string, a shape meka does not read: the row is dropped as the session loads, which leaves the tool_use it answered unanswered, and the provider rejects the next turn over the mismatch. Run the one-shot upgrade script, which converts those rows in place, rather than rewinding past a turn you wanted to keep.
One rejection reaches past the window those steps work in, and meka answers it on its own. Claude ties each thinking block to the conversation ahead of it, the tool list included, and on the accounts where Anthropic enforces that check it rejects a request that sends a block back after that conversation changed: most changes to the tool list, a compaction that keeps recent turns verbatim behind its summary, or a repair above can each make every later request fail. When Claude rejects a thinking block that way, or one whose signature it cannot verify at all, meka sends the conversation again without its thinking, back to the last compaction. The model no longer gets that reasoning back, and the turn goes on. Nothing on disk is rewritten: an accepted resend is recorded in the log as the point the thinking stopped being sent, so a resume sends the same, and an export marks that point and still shows the reasoning’s readable text. A rejected one restores the thinking, and the steps above take their turn. A compaction does the same for its own requests, the checkpoint’s and the summarizer’s. On an account that does not enforce the check the rejection never comes, and nothing changes.
Removing sessions
Remove specific sessions by id:
meka session remove 550e8400-e29b-41d4-a716-446655440000
Remove multiple sessions at once:
meka session remove 550e8400-e29b-41d4-a716-446655440000 a1b2c3d4-e5f6-7890-abcd-ef1234567890
Remove every session not updated in the last N days:
meka session remove --older-than-days 90
This is the manual counterpart to retention, and spares what the sweep
spares: a pinned session, and one with a scheduled job ahead of it. It can’t be combined with ids
or --all, and 0 is refused: it would match everything.
Remove all sessions:
meka session remove --all
--all takes no ids of its own: naming some sessions and then asking for every session are two
different requests, and it refuses rather than quietly doing the wider one. It means every session,
pinned ones included.
Input history
Separate from your saved conversations, meka keeps a rolling history of the prompts you type on the screen, so Up-arrow recall and Ctrl+R reverse-search work across runs (shell-style). This is distinct from a session (a stored conversation), which the screen reads back as you scroll up.
List recent input-history entries (oldest first; -n 0 shows all), one per line, or as
{"history": [...]} with --format json:
meka history list
meka history list -n 100
meka history list --format json
Clear it entirely:
meka history clear
Managing sessions via SQLite
You can also manage sessions directly through the store’s SQLite file. For example, to list all sessions:
sqlite3 ~/.local/share/meka/meka.db \
"SELECT id, created_at, updated_at FROM sessions ORDER BY updated_at DESC;"
Checklist
The checklist is the list the agent keeps of what it has committed to do, and the rule that a turn cannot end while an item on it is open. The agent adds items as it commits to them, checks them off as it finishes them, and if it tries to stop with an item still open, meka sends it back to the list instead of ending the turn.
It exists for one failure: an agent that plans five steps, does three, and reports back as if it were done. The list is the agent’s own words, so meka never has to judge the work; it only has to hold the agent to what it said.
How it works
The agent has three tools:
checklist_addappends items. Each is a text, or an object{"text": ..., "status": ...}within_progressfor the one it is starting on. The call returns the ids assigned and the whole list.checklist_editchanges one item byid: itsstatus, itsreason, the backgroundtaska deferred item waits on, or itstext. A call with anything wrong in it is refused whole, a task that has already reported included.checklist_readreturns the list.
An item sits in one of three states: pending, in_progress, or deferred. Two more words take
it off the list: completed, and canceled, which needs a reason. Completed and canceled items
leave the list at once; the conversation keeps what was done and why.
A turn cannot end while an item is pending or in progress. When the agent replies without
calling a tool and such an item remains, meka appends a message listing the open items and the
ways out, and the agent answers again. It can keep working, complete the items, cancel them with
a reason, or defer them with a reason. The turn ends once every item is disposed of. Each nudge is
announced as a notice, so a turn that keeps working after its answer does not look like a hang:
on the screen as a stage direction between the reply and the next round, Checklist: 1 open item, continuing, nudge 1 of 3, and on the HTTP feed as a notice with the same text.
Deferral is the honest way to stop. A deferred item does not hold the turn. It is for an item
that cannot proceed now because it waits on a person, an event, or an explicit “later”, never
because it is hard or tedious; the tool descriptions and the nudge both say so, and the reason is
recorded. A deferred item stays deferred until the agent or you pick it up again: meka does not
guess when a person has answered. The one case it can see is a background task. An item deferred
with the task it waits on reopens once the task’s outcome has been reported, however the task
ended; the report arrives as a turn of its own, and that turn cannot end without the agent
finishing, canceling or re-deferring the item. A task that has exited but not yet reported holds
nothing, so the turn that deferred on it ends normally even when the task ended under it, and
the report’s turn is the one held.
A task that has already reported cannot be waited on: checklist_edit refuses a deferral that
names one, or that would keep one from an earlier deferral, because the item would be open the
moment it was deferred. A deferral states what the item waits on, so a re-deferral with a new
reason states it afresh, and a task not named again is dropped with the old reason; that is how
one call moves an item off a task that has reported. A re-deferral that gives neither keeps both.
A message you send mid-turn, a steer over the HTTP inbox or a parent’s agent_steer, rides the
nudge the way it rides a tool round’s results: the model reads it with the list rather than after
the next tool call.
The cap. An agent that answers the nudge three times in a row without calling a tool is not
going to on the fourth. After three such nudges the turn ends with the items where they are, and a
warning notice says how many were left open. A tool call between nudges starts the count over, but
not without limit: a turn gets nine nudges in all, so an agent that reads the list before every
reply and changes nothing is not sent back to it forever, and the notice then says after 9 nudges this turn. The next turn’s end asks again.
The nudge fires only on a normal end of the turn. A canceled turn, a failed one, a stop at the output token limit and a refusal end on their own terms, with the list untouched.
What you see
The screen draws the list whenever it changes, one row per item: the one in progress marked ›,
the rest □, a deferred one » with its reason, which is also in the record, the feed and
checklist_read for whoever asks. A session record
over the HTTP API carries checklist, the open items, on a session the server holds, and the
session feed announces every change as checklist.updated. An ACP client shows the list in its
plan panel; a deferred item is pending there, with what it waits on in its text.
Nothing is injected into the agent’s context between turns. It sees the list in every tool call’s
result, in the nudge when it tries to stop, and through checklist_read whenever it wants to.
Compaction is the one exception: the open list is copied verbatim into the summary, because the
calls that built it are what the summary replaces.
Where it lives
The list is recovered from the conversation itself. Every checklist_add and checklist_edit
call is a row of the session’s history, and the list is what those calls add up to, so a resumed
session still owes its items, a rewind that drops a turn drops the items that turn added, and a
fork or an archive carries the list with the conversation. A compaction records the list on its
boundary, so a session that resumes from one starts from the list as it stood. Background tasks
are the session’s own and do not travel: in a fork or an import, an item deferred on one stays
deferred until the agent or you pick it up, because no report of that task will ever reach it.
A sub-agent has a checklist of its own, held to the same rule on its own turns, and keeps it
across an agent_followup. Its list and its nudges are its own business: they reach its own feed
over the HTTP API and nothing of the parent’s, so the parent’s list on screen is always the
parent’s. A sub-agent has no background tasks, so its deferrals carry a reason alone.
Turning it off
Filtering the three tools out through the
[tools] filters disables the
feature: with no tools there is no list, and with no list there is no nudge. There is no other
switch.
MCP
The Model Context Protocol is how meka reaches tools, resources and prompts it does not implement itself. A server is a process meka spawns or an HTTP endpoint it connects to; what it advertises is registered alongside the built-in tools and called the same way.
This page covers running servers: the command suite, where their secrets live, what happens on the wire, and what the agent can reach. The keys themselves are in the config file reference, which is also where tool permissions are resolved.
meka mcp CLI
A running meka serve offers the same verbs over HTTP, on /v1/mcp under the config:w scope,
and a server added, changed or removed there joins, restarts on or leaves the running manager
without a restart; see
Managing the installation.
Manage configured servers without editing config.toml by hand:
| Command | Action |
|---|---|
meka mcp list [--format json] | Print all configured servers, plus any stored OAuth credential that no server claims (see Leftover credentials). Under json, {"servers": [...]} with each server’s name, transport, required, disabled, and its permission, command, args or url where set. |
meka mcp get <name> [--format json] | Print full details for one server: the listing’s fields, deferral, inherited (which of required and deferral the entry leaves to [mcp]), env_keys, header_keys, the kinds of credentials stored and presented to its url (never their values), credentials_for_another_endpoint (each kind stored for another endpoint, with the origin it was stored for), and every other key as written: headers_helper, the auth block, allowed_tools, disabled_tools, eager_load_tools, trust_read_only_hint and tool_permissions. |
meka mcp add <name> <url-or-command> [args...] [flags] | Persist a server. Transport is auto-detected: a URL starting with http[s]:// means HTTP, anything else means stdio. Preserves existing formatting/comments via toml_edit. |
meka mcp remove <name> | Clear stored credentials, delete the server entry and drop any resource-update ledger entries, then revoke the stored OAuth tokens (RFC 7009) at the provider, best-effort. A name with stored credentials but no config entry is cleaned rather than refused. |
meka mcp disable <name> | Set disabled = true on the server entry. The next meka start skips it entirely. |
meka mcp enable <name> | Clear the disabled flag, so the server connects on the next start. |
meka mcp reconnect <name> | Smoke-test a connect; exits non-zero with the error when it fails. |
meka mcp tools <name> [--format json] | Connect and list every advertised tool with its resolved permission, the chain step that decided it, and whether the current config allows it. Useful for populating --allow-tool, --disable-tool, or --tool-permission overrides without leaving the CLI. Under json, the object GET /v1/mcp/{name}/tools answers with (server, tools[] of raw_name, description, required_permission, permission_source, allowed, loading) plus read_only_hint_declined. |
meka mcp login <name> | Drive interactive OAuth. If the server has no [auth] block and uses HTTP, assumes type = "oauth" and persists the block on success. With --auth-token-stdin or --client-secret-stdin, stores that secret and exits instead, which is also how you rotate one. A pasted callback the flow cannot read, or one carrying another login’s state, is refused and asked for again. |
meka mcp logout <name> | Clear every stored credential for the server, then call the provider’s revocation_endpoint (RFC 7009) for the OAuth tokens it held, best-effort. |
Credentials
An MCP server’s bearer token, OAuth client secret and OAuth token bundle are kept in the store (mcp_credentials, keyed by server name and kind, each bound to the endpoint it was stored for), never in config.toml. This is the same rule accounts follow, and for the same reason: config.toml is a plaintext file people commit, sync and share.
Each is read from stdin so it never reaches ps output or your shell history. One command reads one secret, so --auth-token-stdin and --client-secret-stdin cannot be combined:
$ pass show notion-token | meka mcp add notion https://mcp.notion.com/mcp --auth-token-stdin
$ pass show acme-secret | meka mcp login acme --client-secret-stdin
A confidential OAuth client holds two at once: the long-lived client secret it authenticates with, and the refreshable bundle it obtained. Store the secret first, then run meka mcp login <name> to complete the flow. Refreshing the bundle leaves the client secret alone.
A credential is presented only where it was given: the OAuth tokens a login obtained to the exact url they were issued for, which is their audience, and a bearer or a client secret anywhere on the scheme, host and port of the url it was stored with. A url moved to another host takes nothing along, so a credential is never sent to a server it was not meant for. The server then fails to connect, saying what it holds and for where, until a login, or the secret stored again, gives it one for the new url; a bearer beside an [auth] block, or a client secret beside a client_credentials_jwt block, is never sent and never stops a connect. A credential is bound to the url as this process’s environment expands it, so a ${VAR} in the url that the environment leaves unset refuses storing a secret for it. meka mcp get <name> lists which kinds a server has, without printing any of them, and names under not presented each kind it holds for another endpoint with the origin it was stored for. meka mcp list names servers that have a stored credential but no [[mcp.servers]] entry, which is what a hand-edited config strands.
meka mcp add flags
| Flag | Purpose |
|---|---|
--transport <TRANSPORT> | Force the transport, stdio or http; auto-detected otherwise. |
--env <KEY=VALUE> | Environment variable for stdio (repeatable). |
--header <KEY=VALUE> | HTTP header (repeatable). |
--auth <AUTH> | Configure the [auth] block: oauth, client_credentials or client_credentials_jwt. |
--auth-token-stdin | Read a static bearer token from stdin and store it. Mutually exclusive with --auth. |
--client-secret-stdin | Read an OAuth client secret from stdin and store it. Required by --auth client_credentials. |
--client-id <CLIENT_ID> | OAuth or client_credentials client id. Not a secret, so it goes in config.toml. |
--signing-key <SIGNING_KEY>, --signing-algorithm <SIGNING_ALGORITHM> | JWT signing key path and algorithm (RS256, RS384, RS512, ES256, ES384), client_credentials_jwt only. |
--scope <SCOPE> | OAuth scope (repeatable). |
--redirect-port <REDIRECT_PORT> | Fixed OAuth redirect port (default: ephemeral). |
--permission <LEVEL> | Per-server permission cap, applied to every tool on the server: none, read, workspace or unrestricted. Omitted, the level resolves as for any server without the key. |
--allow-tool <TOOL> | Raw tool name to allow (repeatable). When set, only listed tools register. |
--disable-tool <TOOL> | Raw tool name to block (repeatable). Applied after --allow-tool. |
--eager-load-tool <TOOL> | Raw tool name to eager-load (repeatable). Listed tools skip the tool_load round-trip and ship in the cacheable tools-array prefix from turn 1. |
--deferral <MODE> | Persist deferral: deferred, eager or hidden. Omitted, the server inherits [mcp].deferral. |
--tool-permission <TOOL=LEVEL> | Per-tool permission override (repeatable). LEVEL is none, read, workspace or unrestricted. |
--no-login | Skip the auto-login an HTTP server’s probe would otherwise start; run meka mcp login <name> later. |
--required | Persist required = true, so a turn is refused while this server is not connected. Omitted, the server inherits [mcp].default_required and is optional by default. |
--disabled | Persist disabled = true, so the server is skipped entirely at startup. Re-enable with meka mcp enable <name>. |
Example: Notion
These signposts are info logs, so they need -v; at the default warn level the command
succeeds silently and the exit code carries the result. Timestamps and targets are elided here.
$ meka -v mcp add notion https://mcp.notion.com/mcp
added 'notion' to ~/.config/meka/config.toml
probe: 'notion' requires OAuth
running OAuth authorization for 'notion' (use --no-login to skip)
no [auth] block for 'notion'; assuming OAuth authorization_code
…
authorized 'notion'
meka mcp add on an HTTP endpoint:
-
Probe: issues an unauthenticated
GET(3 s timeout, redirects off) and classifies the response per the MCP authorization spec + RFC 6750 + RFC 9728:2xx→ server is open, no login needed.401/403withWWW-Authenticate: Bearer …→ OAuth required. Theresource_metadata="…"attribute (RFC 9728) is captured at DEBUG.- Any other status → couldn’t infer, prints the status code.
- Network failure → prints the error.
-
Auto-login: if the probe says OAuth is required (or
--auth oauthwas explicitly set), the OAuth authorization_code flow runs immediately as though the user had chainedmeka mcp login <name>themselves. The synthesized[auth] = oauthblock is written back toconfig.tomlon success. -
Rollback on failure: if the OAuth flow errors out, the entry we just wrote is purged from
config.toml(alongside any partial credentials), leaving the user’s config clean. The command exits non-zero. -
--no-login: skips step 2. The entry is still persisted and the probe’s hint is still printed; runmeka mcp login <name>when ready. Useful for scripted setup or when you expect to edit[auth]by hand.
The probe and the auto-login only run for HTTP servers, and only when the user didn’t provide --auth-token-stdin (static bearer) or --auth (other than oauth). Stdio servers skip both.
Only meka mcp login runs the browser flow. A host that connects a server and finds no stored credential (the screen at startup, meka serve, meka acp, /mcp reconnect) marks it failed for want of OAuth credentials for its URL, which each surface words with its own login: meka mcp login <name> in a shell, /mcp login <name> on the screen, POST /v1/mcp/{name}/login over HTTP, rather than printing a URL to a terminal nobody may be watching.
Remote hosts / SSH sessions
The OAuth flow redirects the browser to http://127.0.0.1:<port>/callback. When meka is running on a different host than the browser (SSH session, container, Codespace, WSL), the browser can’t reach back and shows a “connection refused” error page. meka handles this automatically:
- While
meka mcp login <name>waits for the callback it also watches stdin. - The browser’s address bar still contains the full callback URL (including
codeandstate) even when the connection fails. Copy it, paste it into the meka prompt, and press Enter. - Whichever completes first, the TCP callback or the pasted URL, wins.
meka prints the URL exactly once and leaves opening it to you, so the flow is the same on a desktop
and over SSH. The authorized line is an info log, shown here with -v.
$ meka -v mcp login notion
To authorize, open this URL in your browser:
https://mcp.notion.com/authorize?response_type=code&…
Waiting up to 120s for the callback, or paste the callback URL here and press Enter:
http://127.0.0.1:46437/callback?code=…&state=… ← paste here
authorized 'notion'
Slash command parity
On the screen:
/mcp list(or a bare/mcp): a picker of the configured servers with their state; Enter offers reconnect, login and logout for the picked one./mcp reconnect <server>: replace the session’s connection to the server with a new one, which runs itsheaders_helperagain; a new one that cannot be opened leaves the old one serving./mcp login <server>//mcp logout <server>: run the auth flow or revoke. Either ends by readingconfig.tomlwhole and serving it to the screen, as a write over HTTP does: the server restarts on the credential, or without it, and an account, a profile or a server edited in a shell since the screen opened is served with it. A file the screen could not start on refuses both until it is repaired. On the screen the URL and the paste field above are one question in the composer’s place; Esc on it ends the login, and a paste the flow cannot read, or one carrying another login’s state, is refused and asked again./mcp <server>:<prompt> [args...]: render a server-defined prompt as the next user turn.
Resources and prompts
In addition to tools, meka exposes MCP resources and prompts through several builtin tools (deferred: the agent finds them under [Tool discovery] or with tool_search, calls tool_load for the schema, then invokes them; absent on a claude-subscription profile, whose endpoint refuses tool names starting with mcp_; they follow [mcp].deferral, so eager declares them from the first request and hidden leaves them out):
| Builtin | Purpose |
|---|---|
mcp_resource_list | List resources from one or every configured server. |
mcp_resource_read | Read a resource by server + uri; text inline, binary base64-encoded. |
mcp_prompt_list | List prompts from one or every configured server, including their declared arguments. |
mcp_prompt_get | Render a prompt by server + name with optional arguments; returns <role>: <text> lines. |
mcp_resource_subscribe | Subscribe to resources/updated notifications for a specific URI. Refused when the server will not watch it; otherwise the subscription outlives a reconnect. |
mcp_resource_unsubscribe | Cancel a prior subscription. |
mcp_resource_updates_list | List the latest recorded update per resource in the shared, bounded MCP context log; it resets when the host restarts. |
Startup concurrency
MCP servers connect in parallel at startup, partitioned by transport so a fleet of stdio servers (process-spawn bound) doesn’t fight a fleet of HTTP servers (network bound):
- stdio:
[mcp].stdio_concurrency(default3) - http:
[mcp].http_concurrency(default20)
Both are tuning knobs: rarely needed, but useful if you’re running ~30 stdio servers on a constrained box (lower it) or ~50 HTTP servers (raise it). 0 is refused at startup.
Connection lifecycle
- Protocol era: every connect opens with a
server/discoverprobe asking for MCP 2026-07-28 and falls back toinitializewhen the server does not answer as a modern one, the dual-era client the specification describes. The server’s answer decides which era the connection speaks;GET /v1/mcpand the screen’s/mcppicker show it. An HTTP server gets the fallback on the same connection, after a probe wait of up to ten seconds that[mcp].connect_timeoutdoes not shorten. A stdio server gets the probe on a child process of its own and, unless that child answers as a modern server within ten seconds (or half ofconnect_timeout, when that is less), a fresh child for theinitializehandshake: a server from before the probe existed may swallow a method it does not know and answer nothing afterwards. Once that handshake has succeeded the verdict is kept for the life of the meka process, so a legacy stdio server pays the probe’s wait once. A modern stdio server slower than the window to answer is giveninitializeinstead: one that answers it is spoken to as legacy until meka restarts, one that does not fails its connect and is probed again on the retry. - Reconnection is automatic for all transports (stdio, plain HTTP, OAuth-authenticated HTTP) when the transport closes mid-session. HTTP transports make five attempts 1s, 2s, 4s and 8s apart; stdio gets one immediate retry. A server with a
headers_helperis also reconnected once when it rejects a request as unauthorized with an HTTP 401, since the helper runs again on every connect, and the request is sent again on the new connection; requests rejected together share that reconnect, and none is tried again for a minute after one, so a server that keeps rejecting the helper’s token, at the handshake or after it, answers each request with its own rejection in the meantime. A request still running on a connection a reconnect replaces finishes there. Every subscription the model made is carried over onto the new connection without asking for another reconnect if that fails. The reconnect runs on a blocking thread to work around an upstream rmcp bug where the auth future is!Send. - Exit: a connect still in flight when meka exits is ended with it, and the child process it was spawning is killed, so a stdio server that ignores a closed stdin does not outlive meka that way.
- Failed initial connect is retried in the background with its own backoff (5s doubling to a 5 minute ceiling) until the server comes up, and the server’s tools are registered into every live session when it does. A server that is slow to boot, or that starts after meka, therefore recovers on its own rather than staying
failedfor the life of the process. This matters most for arequiredserver, where every turn is refused until it connects. - Session-expired recovery: on a 2025-11-25 connection rmcp transparently re-initializes HTTP sessions on 404 / JSON-RPC
-32001. meka relies on this; no per-call handling is required. A 2026-07-28 connection has no session to expire. - Cancellation: when the agent cancels a tool call (e.g. Ctrl-C), meka sends
notifications/cancelledto the server with the in-flight request id so the server can stop work. - Timeouts: tool calls default to 10 minutes; override with
MEKA_MCP_TOOL_TIMEOUT, a duration such as20m. A question a server asks mid-call ends with the call, however long the host would otherwise wait for an answer. - Tool list refresh: on
tools/list_changed, meka re-discovers the server’s tools and hot-swaps them in the registry; no restart needed. A 2026-07-28 server sends nothing unsolicited, so meka opens onesubscriptions/listenstream per connection asking for every list change and for the resources the model subscribed to; the stream is reopened whenever that set changes, and again, after a short backoff, when the server ends it or a proxy cuts it while the connection stands. - Progress notifications: MCP tool calls attach a per-request
progressToken; incomingnotifications/progressrender as a live status line under the tool invocation. - Call identity:
tools/callcarries two extra keys in_metaalongside the progress token.meka/sessionIdis the id (a UUID) of the session the call came from, letting a server scope per-session state (a cache, a workspace, an audit trail) to one conversation; a sub-agent reports its own child session id.meka/toolUseIdis the provider’s tool-use id for the call. Both are absent for calls made outside a session, such as connection-time handshakes. - Server instructions:
InitializeResult.instructionsis captured once per connection and delivered in the per-turn context (sanitized and truncated to 2048 chars) under[MCP server instructions]. A server that connects late, or reconnects with different instructions, is announced as a change rather than rewriting anything already sent. - stdio server logs: a stdio server’s own stderr (many servers log there) is captured, not inherited, so it never corrupts the screen display. Each line is re-emitted on meka’s
tracingstream atdebuglevel tagged with the server name, so it stays silent at default verbosity and surfaces under-v/RUST_LOG. resources/list_changed,prompts/list_changed, andresources/updatednotifications are logged atinfo/debuglevel.
Server-to-client features
| Feature | meka behavior |
|---|---|
elicitation/create | Routed to the calling session’s frontend (screen / ACP form or URL prompt, or an HTTP session’s elicitation_required event when it declared supports_elicitation) with a 60s timeout, when the server’s in-flight tool calls all come from one session. Declined, with a warning, when no tool call is in flight, when the user does not answer in time, or when two sessions have calls in flight on the same server: the protocol ties an elicitation to nothing that names the call that provoked it, so routing it would be a guess. |
input_required | How a 2026-07-28 server asks: inside the answer to tools/call, resources/read or prompts/get, instead of with a request of its own. An elicitation in a tool call’s answer goes to the same prompt as above, and the call is sent again with the user’s answer and the server’s opaque state, up to ten rounds, all under the one tool-call timeout; an interrupt ends the wait for an answer as it ends the call. In a resource read or a prompt render there is no call to route it to, so it is declined. Sampling and roots are never asked for, because meka declares neither. |
Instructions
Standing instructions are your own guidance to the agent, applied to every session on this machine. They land in the system prompt under a ## Standing instructions heading, introduced as the installation operator’s.
Use them for things that are true of your setup rather than of any one task:
- System policies: “Never install Python packages globally with pip. Always use
uvor a venv.” - Installed tooling worth knowing about: “Poppler is available; use
pdftotextfor PDFs.” - Workflow preferences: “Prefer ripgrep over grep.”
- Compliance rules: “Git commits on this system must be gpg-signed.”
Where they live
Instructions are content, not configuration, so they live at a conventional path beside config.toml rather than behind a key inside it; the one key there, [instructions] files, names further files and never holds text. Write:
~/.config/meka/instructions.md
If the set grows, split it into a directory instead. Every *.md file is concatenated in lexical order, so a numeric prefix controls the sequence:
~/.config/meka/instructions/
├── 00-style.md
├── 10-security.md
└── 20-tooling.md
The directory wins when it has content, so splitting a grown instructions.md is a rename rather than a migration. An empty instructions/ falls back to the file rather than blanking your instructions. Under a custom MEKA_CONFIG_DIR, both paths follow it.
A running meka serve writes instructions.md on request too, with PUT /v1/instructions under the config:w scope, and the sessions that open afterwards run under it; see Managing the installation.
Check what is actually in effect at any time:
meka instructions show # the resolved text, plus where it came from
meka instructions path # the paths meka checks, and whether each exists
show prints the text on stdout and the source on stderr, so meka instructions show 2>/dev/null pipes cleanly. Under --format json, show prints one object, {"source", "files", "content"}, each field left out when there is nothing to say, and path prints {"paths": [{"path", "exists"}]}.
Passing them as a string
A file is the right shape on a workstation, but not everywhere. When the channel carrying the value is a string rather than a filesystem, use one of:
| Source | Form | |
|---|---|---|
--instructions | text | per-run, replaces the standing text |
MEKA_INSTRUCTIONS | text | |
MEKA_INSTRUCTIONS_FILE | path | |
instructions.md / instructions/ | file | the default |
Resolution stops at the first one set, in that order.
This matters most for containers. The mekabox wrapper mounts your config directory into the container read-only and then replaces the instructions with container-specific ones, which is a single -e MEKA_INSTRUCTIONS=…. Requiring a path would mean writing a temp file on the host and bind-mounting it, and the read-only mount means it could not simply write the file where meka looks.
MEKA_INSTRUCTIONS_FILE covers the case where a file exists but you do not control where it is mounted, such as a Kubernetes ConfigMap or a Docker secret. It accepts a directory too, since a ConfigMap mounts as a directory of keys, and in that case takes any regular file rather than only *.md: a ConfigMap key is often just instructions, and a naming choice made in someone else’s YAML should not become a startup failure inside a pod.
Setting MEKA_INSTRUCTIONS= to the empty string means “no instructions”, suppressing the file rather than falling through to it. That is the way to run a container with your host instructions mounted but not applied.
Setting both environment variables is refused at startup. There is no reading under which someone meant both, so resolving one silently would hide the mistake until the agent behaved unexpectedly.
Reading a project’s AGENTS.md
[instructions] files in config.toml names more places to read from. Each entry is a file, or a directory of files, and its text follows the standing instructions under the same heading:
[instructions]
files = ["AGENTS.md", "~/notes/meka-site.md"]
A relative entry is resolved against the session’s working directory: where the screen or a one-shot run was launched, the cwd an ACP client or a POST /v1/sessions body named, and on a resume the directory the session recorded. That is what makes AGENTS.md mean “this project’s file” wherever the session opens. An absolute entry is read as given, and a leading ~ is expanded.
The list is empty unless you write it, and deliberately so. A relative entry lets whatever sits in the working directory speak with the operator’s authority: a cloned repository’s AGENTS.md lands in the system prompt verbatim, and under meka serve the client chooses the directory. Name a relative entry only where every directory a session may open in is one you trust, and prefer an absolute path for a file that is yours.
What to expect:
- An entry that is not there is skipped silently, since most directories have no
AGENTS.md. The list has one rule, so this holds for an absolute entry too. A file that exists but cannot be read, or is not UTF-8, is skipped with a warning. Neither stops the session. - A directory entry takes any regular file in it, in lexical order, the way
MEKA_INSTRUCTIONS_FILEreads one. - The files are read when a session opens, not at startup. A
meka -c, a fork, a re-attach and a newservesession read them fresh, so an edit applies from the next open./cddoes not re-read them. --instructionsandMEKA_INSTRUCTIONSreplace the standing text only; the listed files still apply. SettingMEKA_INSTRUCTIONS=to the empty string blanks the standing text and nothing else. To stop reading a file, remove it from the list.- Sub-agents receive them together with the standing text under
instructions: "inherit", and nothing otherwise. GET /v1/instructionson the HTTP API returns the standing text alone, since what a session read depends on its directory.- The size warning below counts the listed files together with the standing text.
meka instructions show and meka instructions path read the list against the directory they run in, so cd into a project and run them to see what a session opened there is told.
When they are read
Once, at startup. Editing takes effect on the next launch, not mid-session. The files [instructions] files names are the exception, read when a session opens, as described above.
That is deliberate, and it follows from size. The system prompt heads the prompt-cache prefix, so a large instruction set is billed once and served from cache on every later turn. Re-reading it per turn would either invalidate that prefix whenever the file changed, or push the text down into the conversation where it would compete with actual context.
This is the opposite of skills and memory, which do refresh mid-session. They can afford to: both are indexed rather than included in full, and the index is small.
meka -c makes restarting cheap when you do edit them.
Notes
- Empty or whitespace-only instructions are treated as unset.
- Sub-agents do not receive them by default. Instructions describe the root agent, and a sub-agent handed one task by one of its turns is not that agent; inheriting the persona is how a sub-agent ends up addressing the user as though it were the one they are talking to. The agent can pass
instructions: "inherit"toagent_spawnwhen a task genuinely needs the project’s standing rules, or pass a skill when the direction is reusable. - They apply at every permission level, including
none, because you wrote them. - Instructions that take more than 5% of the context window of the profile a session runs on (roughly 50k tokens on a 1M window, 6k on 128k) log a warning when the session opens. They still work, and they are cached, but every request carries them, and a share that large is usually a surprise rather than a decision.
- An unreadable file in a directory is skipped with a warning rather than hiding the rest of it. A path you named explicitly via
MEKA_INSTRUCTIONS_FILEis an error instead, since running without guidance you believe you supplied is worse than not starting. - A directory contributes at most 100 files; past that it is far more likely pointed somewhere unintended than intentional, so the rest are skipped with a warning.
Skills
Skills are knowledge packages that give the agent non-standard knowledge: manuals, procedures, tool-specific instructions, and experience the LLM doesn’t have natively. Each skill is a directory containing a SKILL.md file with structured metadata.
meka implements the Agent Skills specification, so a skill written for meka works in other compliant clients and vice versa, and a skill meka writes passes the ecosystem’s own skills-ref validate.
Skills are normally authored by you. An agent can also be allowed to write its own; see Letting the agent manage skills, which is off by default.
How skills work
- Skills live in
~/.config/meka/skills/(platform-specific config dir). Additional read-only directories can be added withadditional_directories. - Each skill is a directory:
skills/<name>/SKILL.md. A lowercaseskill.mdis accepted too. - Any entry whose name begins with
.is skipped at discovery. This covers VCS metadata (.git), editor/IDE state (.vscode,.idea), filesystem artifacts (.DS_Store,.Trash), and any other dotfile or dotdir that may sit alongside your skills. SKILL.mdstarts with a YAML frontmatter block declaring the skill’s metadata, followed by Markdown body content.- On every prompt, meka discovers all valid skills and lists them in the per-turn context with their
description. - The agent invokes a skill by calling the
skill_readtool with the skill name. The tool returns the full body, which the agent follows. skill_searchgreps the full text of every installed skill, for when the one-line descriptions are not enough to tell which skill covers something.- Skills are available at read, workspace and unrestricted (not at none).
- The whole subsystem can be switched off with
[skills] enabled = false, which keeps the skill tools’ schemas out of every request and stops the skills section from rendering.
File format
A skill is a directory under ~/.config/meka/skills/ containing a SKILL.md file:
~/.config/meka/skills/
└── download-videos/
└── SKILL.md
SKILL.md must begin with a YAML frontmatter block, followed by the skill body:
---
name: download-videos
description: Download videos from various websites using yt-dlp. Use when the user wants a video off a URL.
metadata:
author: John Doe <john.doe@example.com>
version: "1.0"
---
# Download videos with yt-dlp
## Installation
Install yt-dlp:
\```bash
pip install yt-dlp
\```
## Basic usage
Download a video:
\```bash
yt-dlp "https://example.com/video"
\```
Required frontmatter fields
| Field | Constraints |
|---|---|
name | 1-64 characters, lowercase alphanumerics and hyphens; no leading, trailing or consecutive hyphens. Must match the directory name. “Alphanumeric” is Unicode-wide, as the spec and its reference validator define it, so a non-Latin name is valid. |
description | 1-1024 characters. What the skill does and when to invoke it. Shown to the model in the per-turn context, so fold the trigger condition into this one line. |
A skill is skipped, and the reason reported, when it breaks a rule the spec states about its identity: a directory name outside the rules above, a name that disagrees with its directory, or a missing description. meka implements the Agent Skills specification, so a directory it cannot read as a conforming skill is not a skill it has, and saying so beats listing something no other client would accept.
A skip is not silent. It appears in the [Skills] index the agent reads, in skill_read, in meka skill get, and in a warning on startup, each naming the directory and the reason. The directory stays where it is, so renaming it is all that is needed.
Everything else loads: an over-long description (warned, since refusing would take the procedure with it), and frontmatter keys the spec does not define.
A name containing characters meka cannot render (a newline, a zero-width space) is refused by the same rule, and additionally cannot be reached by meka skill remove: the name meka would echo back is a different directory, which may itself exist. Rename it in a shell.
Keys meka does not model are kept, not dropped. A skill carrying Claude Code’s when_to_use, or a source_url naming where it was fetched from, still has them after an agent edits its description.
Frontmatter that is not valid YAML is not repaired. The client guide suggests quoting unquoted prose colons (description: Extract text. Use when: the user mentions PDFs) as a fallback, and meka deliberately does not: the reference implementation does no repair either, one repair rule is an arbitrary pick out of the many ways YAML can be malformed, and a file meka silently fixed on the way in is one that keeps working here and nowhere else. The skill is skipped instead, and said so out loud with the parser’s own line and column. Fix the file once and every client can read it.
Optional frontmatter fields
| Field | Description |
|---|---|
license | The skill’s license, as a name or a reference to a bundled file. Informational. |
compatibility | Up to 500 characters naming what the skill needs from its environment (Requires Python 3.14+ and uv). Shown to the model when the skill is activated, since it changes how the instructions should be carried out. |
allowed-tools | Tools the skill would like pre-approved. meka reads and preserves this but never acts on it; see Why allowed-tools is ignored. Written as a space-separated string; a YAML list or a bare number is read too, rather than costing you the skill. |
metadata | A map of extra properties. Where anything the spec has no field for belongs. |
Metadata keys
The spec reserves metadata for properties it does not define, and meka carries the whole map through untouched, including keys it has no meaning for, so a skill written elsewhere survives being edited here.
That includes values that are not strings. The spec describes a map of string to string, but skills in the wild carry lists and nested maps under metadata, and an edit here keeps them as they were:
metadata:
tags: [pdf, forms] # still a list after an agent rewrites the description
origin:
repo: example/skills # still a map
meka renders such a value as text where it needs one (meka skill list, meka skill get), but the file keeps the original.
metadata itself must be a map, though. A skill whose metadata: is a string or a list still loads, lists and reads normally, but rewriting it is refused: meka would have nowhere spec-legal to record meka-priority or author, and doing something other than what the caller asked without saying so is worse than declining. Fix the file, or write to a different name.
| Key | Default | Description |
|---|---|---|
author | none | Attribution, conventionally Name <email>. The spec’s own example key. Informational only. |
version | none | Free-form version label (e.g. "1.0", "2024-03-14"). The spec’s own example key. |
meka-priority | 5 | Listing rank 0-9, lower first. Orders the [Skills] index and decides which skills its cap drops. Not shown to the model; see How the agent uses skills. |
meka-priority carries a prefix because it is meka’s own concept and the spec has nothing like it; another client could reasonably read a bare priority the opposite way round. author and version do not, because the spec demonstrates exactly those keys.
Frontmatter written before the spec
A SKILL.md written before meka followed the spec carries author, version and priority at the top level of the frontmatter, where the spec has no place for them. The one-shot upgrade script moves all three under metadata:, and the three are not equally optional:
authorandversionkeep their names, and meka reads either spelling permanently (see below), so moving them changes nothing but tidiness.priorityis renamed tomeka-priorityas it moves, because that is the key a rank is read from and there is no other. A top-levelpriority:left where it is, or hand-moved undermetadata:under its own name, is a key nothing reads: the skill takes the default rank of 5, the[Skills]index comes out in a different order, its cap drops different skills, and nothing says so. This is the part of the move that has to happen.
meka does not rewrite a file it is only reading, so a skill it never writes to keeps the frontmatter you gave it until the script runs.
Reading a top-level author or version is permanent, not a transition. Claude Code’s plugin skills declare version at the top level, and the skill that documents skill authoring tells authors to put it there, so a reader that looked only under metadata: would print a dash for a version the file plainly states. meka reads both spellings and writes only the spec’s, which is what lets the move be a script you run once rather than something the binary does to your files behind your back.
Why allowed-tools is ignored
allowed-tools is experimental in the spec, and the spec itself notes that support varies. meka parses it, preserves it across a rewrite, and shows it in meka skill get, but never grants anything from it. The spec defines the field as a space-separated string and that is what meka writes, so a skill that spelled it as a YAML list comes back joined: [Read, Bash] becomes Read Bash.
Two things meka does not preserve across a rewrite, both worth knowing before you hand-edit a SKILL.md that meka will later write to:
- A joined
allowed-toolsentry containing a space cannot be told apart from two entries.["Bash(git diff:*)", "Read"]comes back asBash(git diff:*) Read, which no longer says where one entry ends. Prefer the spec’s string form for these. - Comments in the frontmatter block are dropped. The header is rebuilt through a YAML serializer, which does not carry them. Keys, values and nested structure all survive;
# notesbeside them do not. Put anything you need to keep in the body, which is passed through untouched.
A skill file is content, and content does not get to widen what the agent may run. meka’s permission level is the authority for that, and a SKILL.md dropped into the skills directory (or synced from a repository, or written by an agent) must not be able to pre-approve Bash(rm:*) on its own say-so.
Referencing bundled files
Refer to files bundled alongside SKILL.md by relative path (e.g. scripts/helper.sh). Every skill body is prefixed with a header naming the skill’s directory (see How the agent uses skills), so relative paths resolve against the skill rather than against the session’s working directory. A skill in meka’s own store sits under the config directory, which a shell command cannot see below unrestricted; file_read and file_search reach it at every level, and the header says so, so the agent reads a bundled script rather than running cat on it.
The body is passed to the model verbatim; meka does not rewrite anything inside it. Keeping skills free of host-specific placeholders is what lets the same SKILL.md run under meka and other Agent Skills hosts unchanged.
Storage location
| Platform | Path |
|---|---|
| Linux | ~/.config/meka/skills/<name>/SKILL.md ($XDG_CONFIG_HOME/meka/skills/) |
| macOS | ~/Library/Application Support/meka/skills/<name>/SKILL.md |
| Windows | %APPDATA%\meka\skills\<name>\SKILL.md |
This is the only directory meka ever writes to. It is created the first time something is written there, not at startup.
Reading skills from other directories
[skills] additional_directories adds directories to scan. They are read-only: meka never creates them and never writes into them, so listing one costs nothing if it does not exist.
[skills]
additional_directories = ["~/.agents/skills", "~/src/team-skills/skills"]
~ is expanded. A relative path resolves against the process working directory.
The default is empty. ~/.agents/skills has emerged as a cross-client convention, so pointing at it makes skills installed by other Agent Skills clients visible to meka, but whether to read a directory outside meka’s own namespace is your decision rather than a default.
Precedence. meka’s own store is searched first, then each additional_directories entry in order. When two directories hold the same skill name, the first wins and the shadowed one is logged.
Writes never follow. skill_write, skill_edit, skill_delete, meka skill add, meka skill remove, PUT /v1/skills/{name} and DELETE /v1/skills/{name} all target meka’s own store. Asked to write a name that resolves to a skill in a read-only directory, they refuse, because writing would create a second copy that shadows the original instead of changing it; the refusal says to edit or remove it where it lives, and the CLI names the directory. This holds whether or not the file there is valid: a directory whose SKILL.md does not parse still claims that name, and shadowing a broken skill is the case worth refusing hardest, since nothing then reports the original at all.
There is deliberately no automatic project-level scan. meka reads nothing from the working directory that config does not name, instructions included, because a cloned repository that could silently add instructions to the agent’s context is exactly the trust it never extends. Name a project’s skills directory in additional_directories if you want it read.
Listing skills
meka skill list shows a fixed set of columns, so output stays parseable when piped:
$ meka skill list
Name Author Priority External Description
deploy-service Jane Doe 2 no How to deploy the service. Use when asked to ship.
borrowed - 5 yes A skill another client installed.
External is yes for a skill found under additional_directories rather than in meka’s own store. It is always present, even when nothing is external, so a script’s field offsets do not shift with the store’s contents.
--paths adds the on-disk Path, which is how you find out where an external skill lives. Nothing else goes in this table: license, compatibility, allowed-tools, version and arbitrary metadata keys are per-skill detail, and meka skill get <name> prints all of them.
--format json prints {"skills": [...]}, each with the fields GET /v1/skills uses (name, description, priority, version, author, compatibility) plus external and source_dir, whether or not --paths was given. meka skill get <name> --format json prints one object with every frontmatter field, source_dir, body_path, metadata and the unmodeled extra keys; show --format json adds body, the text the agent receives.
How the agent uses skills
When skills are available, the per-turn context includes a [Skills] section like:
[Skills]
- **download-videos**: Download videos from various websites using yt-dlp. Use when the user wants a video off a URL.
- **deploy-kubernetes**: Deploy services to a K8s cluster. Use when the user asks to deploy to Kubernetes.
The list is sent once, not on every turn. Adding, editing, or removing a skill mid-session is picked up on the next prompt and announced as a short note naming just what changed, so a long session doesn’t pay for the whole list repeatedly.
A skill directory that could not be loaded is named there too, with the reason:
1 directory in your skills path could not be loaded, so it is unavailable and cannot be invoked:
- **deploy-kubernetes**: invalid frontmatter: mapping values are not allowed here
This is the counterpart to the same paragraph in [Memory], and it exists because the log is not a channel the agent can read. From inside a session an unparseable SKILL.md is otherwise indistinguishable from a skill nobody wrote: the index omits it, so the agent has no reason to ask for it by name, and whoever dropped the file in goes on believing the procedure is in force. Naming it lets the agent tell you rather than improvise a replacement. It appears and disappears with the file, so repairing the frontmatter is announced too.
Skills are listed in meka-priority order, lowest first, with the name breaking ties. The index is capped at 200 entries and 8 KiB; anything past that is replaced by a count and a pointer to skill_search, so a large skill store degrades into “search me” rather than silently eating the context window. The rank itself is not rendered: a skill should be invoked because the request matches its stated purpose, not because it outranks another one.
The agent loads a skill by calling the skill_read tool:
skill_read(name: "download-videos")
The tool returns the full body of SKILL.md as its output. The agent then follows the instructions.
Whenever a skill body is loaded (by the skill_read tool, --skill, /skill, agent_spawn, or meka skill show), it is prefixed with a header naming the skill’s directory:
Base directory for this skill and its bundled files: /home/user/.config/meka/skills/download-videos
This is what lets the agent locate files bundled alongside SKILL.md when the body refers to them by relative path (e.g. scripts/helper.sh).
A skill that declares compatibility gets a second line, since what the skill needs from its environment changes how its instructions should be carried out:
Environment this skill expects: Requires Python 3.14+ and uv
Running a skill in a sub-agent
The agent can delegate a skill to a sub-agent by passing the skill parameter to the agent_spawn tool. The sub-agent runs the skill in its own fresh context and returns a report, keeping the skill’s instructions out of the parent’s conversation:
agent_spawn(skill: "summarize-financial-news")
agent_spawn(skill: "summarize-financial-news", prompt: "focus on UK markets")
prompt is optional when skill is given; if both are supplied, prompt is prepended to the skill body as extra direction (the same ordering as meka --skill <name> -p <prompt>).
A skill is the reusable unit of sub-agent instruction. Sub-agents do not receive the instructions file unless the spawn call asks for it, since it describes the root agent rather than a sub-agent, so a skill is usually the better way to give a sub-agent standing direction.
Invoking a skill from the CLI
Any skill can be triggered directly from the command line with --skill <name>. The rendered body becomes the first user turn, and meka drops into the screen after the turn finishes:
meka --skill download-videos -p "https://example.com/video"
-p, if given, is prepended to the skill body as extra context (equivalent to typing /skill download-videos https://example.com/video on the screen).
To run the skill and exit immediately (useful for scripts), pair with --oneshot:
meka --oneshot --skill download-videos -p "https://example.com/video"
To invoke a skill mid-session inside the screen, use the slash command instead:
/skill download-videos
/skill download-videos this URL specifically
meka skill CLI
meka skill list, get, show and remove are described above; meka skill add scaffolds a new
skill at ~/.config/meka/skills/<name>/SKILL.md from a template:
meka skill add deploy-service --description "How to deploy the service. Use when asked to deploy." --priority 2
meka skill add imported --from-file ./SKILL.md
| Flag | Purpose |
|---|---|
--description <DESCRIPTION> | One-line description for the system prompt. |
--priority <PRIORITY> | Listing rank 0-9, lower first. Defaults to 5. |
--metadata <KEY=VALUE> | Frontmatter metadata as key=value. Repeatable. |
--from-file <PATH> | Copy this file instead of the template. Its bytes are kept verbatim, so it must declare name itself. |
--force | Overwrite the skill directory if it exists. A replace, not an update: bundled files alongside the old SKILL.md are removed. |
--edit | Open the new SKILL.md in $VISUAL, then $EDITOR, afterwards. |
Letting the agent manage skills
By default the agent can only read skills. Setting [skills] agent_managed = true additionally
registers skill_write, skill_edit and skill_delete, letting it create, refine, and remove
skills itself:
[skills]
agent_managed = true
This is off by default because for an ordinary terminal session you author and curate skills, and an agent rewriting that store is not something you asked for. It earns its keep in the opposite deployment: a long-running agent acting as a dispatcher over a team of sub-agents. A skill is the only thing in meka that both outlives the session and can be handed to a sub-agent as its task, so writing one is how such an agent gets a refined brief to the next sub-agent without routing the whole text through its own context window.
skill_write(name: "triage-build-failure",
description: "How to triage a failing CI build",
priority: 2,
body: "1. Fetch the log...")
agent_spawn(skill: "triage-build-failure")
Notes on how it behaves:
- All three run at read permission, like
memory_write. They write to meka’s own config directory rather than to your working tree, and the deployment they exist for typically runs at read permission permanently. The config flag is the authorization, not the permission tier. - Sub-agents never get them, whatever this setting says. A sub-agent that inferred something from one narrow task should not rewrite the instructions its siblings run on.
- Writing to an existing name updates it. Omitting
bodykeeps whatever the skill already documented, so a call that only changes the description or priority does not erase the procedure. Note thatmeka skill add --forceis a replace, not an update: it rewritesSKILL.mdfrom the template and removes any bundled files alongside it. The newSKILL.mdis written first, so a failure to clear a bundled file leaves the skill intact and says which files it could not remove. skill_editreplaces one exact string in the body and nothing else: the frontmatter is kept as it is, anold_stringthat matches more than once is refused unlessreplace_allis set, and an edit computed against a body that has since changed is refused rather than written over it.- Skills the agent creates are stamped
metadata.author: meka (agent-authored), someka skill listshows where an entry came from. An existingauthoris kept, so an agent refining a skill you wrote does not reassign it to itself. Informational, not a guard. - Every other frontmatter key survives a rewrite, including
license,compatibilityand anymetadatakey meka has no meaning for, with its YAML type intact, so ametadatalist stays a list. An agent asked to sharpen an imported skill’s description changes the description and nothing else. - The confirmation reports the rank the file ended up with, not the one the call asked for. The
two differ only when the skill’s
metadata:is not a map, which meka will not overwrite; the tool says so rather than claiming a change that did not happen. - A file that exists at that name but is not a valid skill is refused, not overwritten. Such a file is invisible everywhere else in meka, so nothing could tell you what was about to be lost.
- A skill from a read-only
additional_directoriesdirectory is refused by all three, since writing would shadow it rather than change it. - A hand-written skill in meka’s own store is not protected from being rewritten. The flag being off by default, and your config directory being in version control, is the safety net for that case.
skill_deleteremoves the whole skill directory, including any bundled files, matchingmeka skill remove.- All three report the
SKILL.mdbefore and after: the screen and ACP editors draw it as a diff, and the HTTP feed carries the two texts ontool_call.completed.
Tips
- Use short, unambiguous skill names (e.g.
setup-postgres, notpg). The name is what the agent sees and calls, and the spec allows only lowercase alphanumerics and hyphens. Every door that takes a name also requires it to be one directory name as the platform reads it: no separator, no drive prefix, no parent. - Anything meka lists, meka can remove, and so is almost anything it refuses to load. A name the spec forbids (
My_Skill,two words,not.a.skill) is skipped with the reason named, andmeka skill removestill takes it so you can clean up. The one exception is a name meka cannot render, which no command can address; rename it in a shell. One Windows reserves, likecon, loads normally: that is meka’s own write-time rule, not the spec’s. - Every write door applies the same rules.
meka skill add,skill_writeandPUT /v1/skills/{name}all refuse a name or a description the spec rejects, and refuse a skill whosenameis missing or disagrees with its directory, so a skill meka authors passesskills-ref validate.--from-filecopies your bytes verbatim, so it can still carry a key the spec does not define (that is how an imported skill keeps itswhen_to_use), but it must still declare the requiredname. Runuvx skills-ref validate <dir>when you want the reference’s own verdict on a file. - Write
descriptionconcisely, and fold the “use when…” trigger into it. It is sent to the model and consumes tokens. - Keep each skill focused on a single topic or procedure. Spawn multiple skills rather than one giant one.
- Bundle supporting files in the skill directory and reference them by relative path (
scripts/file.ext). - Skills are re-discovered on every prompt, so you can add, edit, or remove skills mid-session without restarting meka.
Memory
Memory is the agent’s own set of durable notes. It writes them itself, they survive compaction, and they outlive any single session.
Without it, an agent’s only state is its context window. When a long session compacts, detail is summarized away; conversation_search can still search the message log, but only for something you remember to look for. Memory is the deliberate half: a fact the agent decided was worth keeping, in a store later sessions can search.
How memory works
- Memories are rows in the
memoriestable of the store (~/.local/share/meka/meka.db, or underMEKA_DATA_DIR), one row per memory. - The store is scoped to the meka instance, not to a session or a directory. Everything sharing a
MEKA_DATA_DIRshares one memory; pointing a deployment at its own data dir gives it its own. Within it, a memory is global or a project’s; see Tiers. - The per-turn context includes a bounded memory index, updated when it changes. A memory’s
loaddecides how it gets there: aninlinebody is rendered whole, within a budget every inline body shares, anindexmemory is listed by its description, and asearchmemory is reached bymemory_searchalone. Other bodies are read on demand withmemory_read. - The index is re-stated in full at the start of a session, after every compaction, and whenever it scrolls out of the context window. This is what makes memory survive compaction.
- The agent is told to save durable preferences, constraints, decisions, and facts, to keep temporary task state in the scratchpad, and that current instructions and corrections take precedence over stored preferences.
- Memories are available at every permission level except none; all five memory tools ask only for read. Writing a memory therefore needs no write authority over your files, and
workspace’s boundary does not apply to it: the store belongs to meka, not to your working tree.
Memories live in
MEKA_DATA_DIR, alongside sessions, rather than in the config directory. A backup of your config directory does not capture them;meka memory exportis what does.
Tiers
A memory is in one of two tiers: global, which every session sees, or a project’s, which the sessions on that project see beside the global ones. A session on no project sees the global tier alone. Which tier a session is on is its project, set with --project, /project, or the session’s project field over HTTP and ACP; a project is a row meka project add or POST /v1/projects creates, and a name no project has is refused at every one of those doors.
memory_writefiles a memory in the session’s project unlesstier: "global"says otherwise. In a session on no project every write is global, andtier: "project"is refused. Writing to a memory that exists keeps its tier unlesstiernames the other one, which moves it: that is how the agent re-files a note it saved in the wrong tier, and the result line saysglobalwhen a project session’s write lands in the global tier.- A name is unique within a tier, and a global name is never also a project’s, so a session never sees two memories of one name; two projects may hold the same name. A write that would make a global memory and a project memory share a name is refused with
choose another name, at every door. - The inline budget is measured as a session’s band sees it: a project write counts the global inline bodies and the project’s, and a global write counts the global bodies and the fullest project’s, since a global memory is in every project’s band.
- The
[Memory]section a session on a project reads is grouped, the global tier first and the project’s after it, under one index budget. A move between projects restates the whole section under a line naming the project, and in a project session a change to a global memory is marked(global)in the per-turn delta, as a global hit is inmemory_search’s results.memory_searchtakestierto search one tier. - The operator’s doors see every tier:
meka memory listshows aProjectcolumn,--project <name>and?project=<name>pick one tier (an empty value, the global tier), and a name that is in several projects is refused byget,show,edit,removeand the single-memory HTTP routes until one is named.meka project renamemoves a project’s memories with it;meka project removerefuses while any remain.
Fields
| Field | Meaning |
|---|---|
name | Unique identifier, [A-Za-z0-9_-]. Case-insensitive: NOTE and note are one memory. |
description | One line used in the bounded index. Make it stand on its own. |
priority | 0–9, default 5: how important the note is, not whether it loads. See Priority. |
load | How the memory reaches the context: inline, index (default) or search. See Loading. |
tags | Lowercase labels ([a-z0-9-], at most 10) for grouping and filtering. |
body | Detail, loaded on demand by memory_read, or in context every turn when load is inline. |
references | What the memory depends on: memory names, paths, URLs, external ids. See References and backlinks. |
source | One line saying where the fact came from. See Source and verification. |
created | When the memory was made. Stamped once, at creation. |
updated | When the row last changed. |
verified | When the fact was last verified. Stamped at creation and moved only by verified: true. |
expires | When the memory stops applying, if a date was set. See Expiry. |
read count | How many times memory_read has opened it. Feeds search ranking. Only memory_read increments it: a search hit is weaker evidence, and reading through the CLI or the HTTP API is the operator rather than the agent. |
created versus updated
These answer different questions, and conflating them was a bug. A memory_write that changes only a description or a priority moves updated, and reading that as the observation date made a years-old note render as “today”, sort to the top of its priority band, and arrive through memory_read captioned “Saved today. This is what you recorded then”.
created is stamped once, when the memory is created, and carried forward untouched by every later write. It is what the index renders as an age, what ties are broken by, and what freshness weighting reads. updated is reported by meka memory get and the HTTP API and takes no part in ordering or ranking.
The rule is enforced by the INSERT ... ON CONFLICT DO UPDATE statement itself, which never assigns created_at on the update path, rather than by each write door remembering to preserve it.
Omitting a field keeps what is there
memory_write’s body, tags, priority, load, references, source and expires are all optional, and omitting any of them keeps whatever the memory already had. That makes a metadata-only update (a reworded description, say) a single call that cannot cost the note its contents, its labels, its rank, its dependencies or its deadline. To clear the body or the expiry, pass ""; to clear a list, pass []. A blank in any other field is the field omitted.
PUT /v1/memory/{name} and meka memory add <name> --force follow the same rule.
Editing a memory
memory_edit changes part of a body the way file_edit changes part of a file: it finds old_string exactly once and replaces it with new_string, refuses a match that is ambiguous unless replace_all is true, and deletes the text when new_string is empty. It exists because memory_write replaces the whole body, and a long-lived note rewritten whole on every small change drifts: wording moves, lists reorder, and a line is lost with nothing said. An edit touches the text it was told to and nothing else, and the result shows the lines around the change. Pass verified: true on an edit that follows a check, and it verifies the memory as a write would.
A rewrite computed against a body that changed meanwhile is refused rather than landing over it, the same compare-and-swap meka memory edit uses when $EDITOR comes back.
Priority
Lower means more important (the same direction as nice, the opposite of CSS z-index). Priority decides three things: where a memory sits in the index, which memories survive when the index hits its size budget, and how heavily search weighs it. It does not decide whether a memory’s body is in context; that is load, and a rule can be in front of the agent every turn without being its most important note.
| Range | Use for |
|---|---|
| 0–1 | Standing directives that always apply |
| 2–4 | Durable facts |
| 5 | Default |
| 6–9 | Situational or short-lived notes |
Within one priority band, the most recently recorded memory sorts first, so a fresh note never displaces a standing rule just for being new.
Because the agent picks a priority at write time and everything feels important then, priorities tend to drift downward over a long-lived instance. The Priority column of meka memory list shows that happening so you can rebalance. Search ranking compensates for the same drift from the other side: see Search.
Loading
load says how a memory reaches the context, independently of how important it is:
| Value | What the per-turn context carries |
|---|---|
inline | The whole body, every turn. For a standing directive the body is the directive, and leaving it behind a tool call means the agent has to look the rule up before it can follow it. Inline bodies share one budget; see The inline budget. |
index | The description, as one line of the index. The default. |
search | Nothing. The memory is reached by memory_search alone: a runbook, a reference note, anything worth keeping that is not worth a line every turn. |
The index ends with a count of the memories it lists nowhere, the search-only ones and the expired ones, so the agent knows there is more than it can see. On upgrade from a release before load, every priority-0 memory becomes inline and everything else index, which is what the priority used to mean.
The inline budget
Inline bodies share one budget, [memory] inline_max_bytes, 32 KiB unless set. It is counted in bytes of body text over the inline memories that are in force: an expired inline memory, an index memory however long its body, and an empty body spend nothing. The band is rendered into the opening message once per session and again after each compaction, so what the budget admits costs prefix tokens the cache holds, not tokens on every turn.
A write that would take the tier past the budget is refused at every door alike: memory_write, memory_edit, meka memory add, meka memory edit and PUT /v1/memory/{name}. The refusal states the total the tier would have reached, names the largest inline memories as they would stand, and gives the remedy: shorten one, or set load: index on one that is not a standing rule. Nothing of a refused write lands. A write that shrinks the tier, or leaves its size alone, always goes through, so a store over the budget can be brought back within it one memory at a time.
inline memories would total 33.4 KiB against the 32.0 KiB inline budget (`[memory]
inline_max_bytes`); shorten 'handbook' (20.1 KiB), 'deploy' (6.2 KiB) or 'tone' (1.1 KiB), or
set `load: index` on one that is not a standing rule
The budget is what the agent reads whole, and the doors keep a store within it. A store can still be over it: the key lowered beneath what the store already holds, the upgrade that made every priority-0 memory inline, or a row written past meka’s doors. The band then shows the bodies that fit, in index order, and lists the rest by description under a sentence stating the total against the budget and asking the agent to read one before relying on it and to shorten them or change their load; that list is held to the index’s own ceilings and counts what it cannot list, with the same tag histogram and memory_search pointer the index footer carries. No body is ever cut partway: a rule is shown whole or named as not shown. Mid-session the context changes carry the same: a memory pushed out of the band is named, and one that fits again after a cleanup is restated whole, since the agent has only ever seen its description.
The index budget
The index is capped at 8 KiB and 200 entries. When more memories exist than fit, the section ends with a line stating how many were left out, and, when they carry tags, what they are about:
4910 more memories not shown here, most common tags infra (820), people (611),
decisions (405); use `memory_search` to find them.
A bare count is not a usable signal once it runs to thousands: it says something is missing without saying what. The tag distribution is something the agent can turn into a query, which is most of what tags are for.
Nothing is lost. memory_search covers the whole store, including the entries the index omitted.
Reading long notes
memory_read returns up to 16,000 body characters per call. When more remain, the result gives a
continuation offset. Pass it as offset with the same memory name to read the next part. Offsets
are zero-based character positions in the displayed body, not byte positions. The description and
age are repeated on each page.
Search
memory_search is the primary way to reach a store larger than the index can show. It is backed by a SQLite FTS5 index over the same table.
Ranking combines three things, so the result is what you probably meant rather than merely what matched:
- relevance: BM25, weighting a hit on the name above the description, and the description above the body.
- importance: the declared priority, blended with how often you have actually read the memory. A memory opened forty times is important whatever it was labeled two years ago, which is the counterweight to priority drift.
- freshness: a gentle decay on
verified, which iscreateduntil the agent verifies the fact, disabled entirely for priority 0–1. A two-year-old standing rule is exactly as binding as a new one; a two-year-old situational note probably is not, unless somebody checked it last week.
Fuzzy matching works in four senses, and the result says which one answered so a guess is not mistaken for a recalled fact:
| Kind | Example | How |
|---|---|---|
| Word endings | preference finds prefers | Porter stemmer, always on |
| Diacritics | viet finds Việt | Every diacritic is folded, always on |
| Typos and truncation | Tokoy, Tok | Retried as a prefix match, then by spelling distance |
| Word beginnings | deployment finds deploy | The prefix retry also works the other way |
| Unsegmented text | 深圳 inside 办公室在深圳南山区 | Matched as a literal substring, beside the word matches |
| Different wording | verbosity for terse | Pass several phrasings in queries |
The second and third rows are the two the stemmer alone does not cover. SQLite’s Porter strips inflections (deploys, shipping, running) but not every derivation: deployment does not stem to deploy, so a search for it used to miss a memory whose body says Deploys. The prefix retry therefore runs in both directions, shortening the query as well as matching the start of the stored word, and says it was a prefix match either way.
The fourth row is why word-splitting is not the whole story. The tokenizer divides on non-alphanumerics, so Chinese, Japanese and Thai prose, and a long identifier, path or URL, arrive as a single token that only matches in full. A query term in a script written without spaces is therefore matched as plain text every time, beside the word matches for the rest of the query, and a memory holding both ranks first; the result names the terms the scan answered. When nothing else answers at all, the whole query is scanned as plain text, and the result says that is what it did.
What the matching does not do: an Arabic word is matched as written, article prefix included; a German compound is not split into its parts; Thai is matched by its consonants alone.
The last row is the important one: queries is a list, and supplying synonyms costs nothing. ["terse", "brevity", "verbosity"] in one call finds a memory that used any of them, which is the answer to “the agent has to guess the words it used months ago”: it does not have to guess right, only to guess several times.
Results carry enough to act on without a follow-up read: name, priority, age, read count, description, and the body itself when it is short, with “verified” when the fact was verified after it was recorded and “expired” when it has expired.
Filters
memory_search takes three filters beside queries, and so do meka memory list and GET /v1/memory, so a hygiene pass reads the same rows whoever runs it:
| Filter | Selects |
|---|---|
referencing | Memories whose references name this memory, path, URL or id, matched whole and without regard to case. |
unverified | Memories not verified within this long: 90d, 6months. |
expired | Memories whose expiry has passed. |
With queries a filter narrows the search. Without them it is a listing, in index order, most important first, and the result says which: “3 memories referencing ‘deploy-host’, most important first”. A scheduled job can run one (memory_search with unverified: "90d", then memory_read and memory_write with verified: true on what still holds) and keep the store honest without anyone remembering to.
References and backlinks
A memory’s references say what it depends on: other memories by name, a path, a URL, an external id, one line each and at most twenty. They are structured so the question “what else cites this?” has an exact answer:
memory_readshows what a memory references, with the description of each reference that names a memory, and what references it (“Referenced by: …”), so a changed fact can be revisited everywhere it is cited.memory_searchwithreferencing,meka memory list --referencingandGET /v1/memory?referencing=list the memories pointing at a target.memory_deletenames the memories that still reference the deleted one and leaves those references in place, as markers of a fact to revisit.
A reference that names no memory is allowed: it is a pointer to something worth writing later, and the read shows it bare where a resolved one carries a description.
Source and verification
source is one line saying where a fact came from: who said it and when, the log it was read from, the page it was taken from. It is shown on memory_read and meka memory get and takes no part in ranking.
verified is when the fact was last verified to hold. It answers a question neither other timestamp can: created is when the note was first written, and updated moves on any change to the row, a retag included, so neither says whether anyone has checked the note since. The stamp is set at creation and moved only by an explicit verified: true on memory_write or memory_edit, never as a side effect of an edit, because a stamp that moved on every write would answer the same question updated does. A write that states newly learned information should pass verified: true with it.
What the stamp buys: memory_read captions a memory “Recorded 47 days ago, verified yesterday” or “Recorded 47 days ago, not verified since”; search freshness decays from it, so a re-verified old note ranks as fresh without a rewrite; and the unverified filter lists what has not been checked within a period.
Expiry
expires is when a memory stops applying: an RFC 3339 timestamp, or a YYYY-MM-DD date meaning the whole of that day in local time, so a note that holds “until the freeze ends on the 25th” expires when the 26th begins. Past it the memory leaves the index and the inline band, stays searchable, and is marked as expired wherever it is read or found; nothing deletes it. The index counts its expired memories, and the context changes announce one expiring between turns, since an inline rule that expires has stopped applying without its text changing. Omitting expires on a write keeps the expiry, a rewrite of an expired memory keeps it expired and says so, and "" clears it.
The search index
The FTS index is an external-content table over memories, kept in step by three triggers. It is derived and disposable even though the memories themselves are not:
meka memory verify # check the index
meka memory verify --rebuild # regenerate it from the table
verify checks two things: that the index is structurally sound, and that it holds exactly as many documents as the store does. It deliberately does not claim more. FTS5’s own integrity-check does not compare an external-content index against its content table, so a memory whose text changed while a trigger was not firing leaves both checks happy; only searching for the new wording reveals it. If search is missing something you know is there, rebuild; it is one pass over the table and cannot lose a memory, because the index is derived.
Agent tools
| Tool | Purpose |
|---|---|
memory_write | Save a memory, or update one by writing to the same name; tier picks the tier |
memory_edit | Replace an exact string in a memory’s body |
memory_read | Read a memory body, continuing with offset when needed |
memory_search | Ranked full-text search over the memories the session sees, or a filtered listing; tier narrows it to one tier |
memory_delete | Remove a memory permanently |
A write, an edit or a delete draws what changed as a diff on the screen and in an ACP editor, over the text meka memory export writes, so a reworded description or a moved expiry is one changed line of frontmatter and a rewritten body shows line by line; the HTTP feed carries the same two texts on tool_call.completed.
memory_read states when the memory was recorded and when it was last verified, and notes that it is a point-in-time observation. A memory recorded months ago is not live state, and an old note asserted as current fact is the failure this guards against. It is also the only thing that increments the read count: a search hit is weaker evidence, and an operator reading through the HTTP API is not the agent recalling anything.
A write whose description runs past 200 characters gets a note in the result and nothing else: the index lists the description every turn, so one that has grown into a paragraph is spending the budget of several entries, and the fact belongs there with the detail in the body.
memory_write also names an existing memory whose description says close to the same thing, when there is one:
Saved memory 'alice-tz' (priority 5). It is in your memory store from the next
turn on, and `memory_search` will find it whatever the index has room to list.
Note: 'alice-timezone' already says something very similar. If this is the same
fact, call memory_write on 'alice-timezone' instead and delete 'alice-tz'. Two
near-copies both stay in the index for ever and neither supersedes the other.
This never blocks the write. The failure worth preventing is the silent one, where a store grows a hundred near-copies because nothing ever mentioned the ninety-nine.
What not to save
Memory is for what is not derivable from the material at hand. Code structure, git history, and file contents are all reachable with file_search, file_read, and shell_execute, so recording them produces stale duplicates of things the agent could just look up.
What belongs in memory: who someone is and how they prefer to work, guidance you have given that should not need repeating, decisions and their reasons, and pointers to where information lives in external systems.
CLI
meka memory list # index order
meka memory list --referencing deploy-host # what cites a memory, path, URL or id
meka memory list --unverified 90d # not verified in 90 days
meka memory list --expired # past their expiry
meka memory list --project webdev # one project's tier; --project "" the global tier
meka memory get k4yt3x-prefers-terse-replies # every stored field, backlinks included
meka memory show k4yt3x-prefers-terse-replies # the body
meka memory list --format json # {"memories": [...]}; get and show print one object
meka memory add tz --description "K4YT3X is in UTC+8" --priority 2 --tag people
meka memory add tz --force --description "K4YT3X is in UTC+9" # keeps body, tags, priority
meka memory add tz --force --description "K4YT3X is in UTC+9" --verified # the fact was checked
meka memory add rule --description "Answer in kind" --load inline --body "Match the user's language."
meka memory add runbook --description "Where the deploy runbook lives" --body "wiki/ops/deploy" --load search
meka memory add freeze --description "No deploys this week" --expires 2026-10-25 --source "ops, in chat"
meka memory add notes --description "Meeting notes" --from-file notes.md # the body, from a file
meka memory add stack --description "Uses pnpm" --project webdev # filed in a project's tier
meka memory get notes --project movie # the one in movie, when the name is in several
meka memory edit stale-note # $VISUAL, then $EDITOR, on the body
meka memory remove stale-note
meka memory export --directory ~/backup/memory # one Markdown file per memory
meka memory import ~/backup/memory # the reverse, into this store
meka memory add takes:
| Flag | Purpose |
|---|---|
--description <DESCRIPTION> | Required. The one line shown in every session’s memory index. |
--priority <PRIORITY> | 0 is most important, 9 least; defaults to 5. |
--tag <TAG> | Label for grouping and filtering; repeatable. |
--body <BODY> | Detail read on demand, or in context every turn with --load inline. |
--from-file <PATH> | Read the body from this file instead of --body. |
--load <LOAD> | inline, index (the default) or search. |
--reference <REFERENCE> | A memory name, path, URL or id the memory depends on; repeatable. |
--source <SOURCE> | Where the fact came from. |
--expires <WHEN> | RFC 3339, or YYYY-MM-DD for the whole of that day; an empty value clears it. |
--verified | Record that the fact was just verified current. |
--force | Update an existing memory instead of refusing; whatever is not mentioned is kept. |
--project <NAME> | The tier to file it in, an existing project or "" for the global tier; omitted, an existing memory keeps its tier and a new one is global. Naming the other tier for an existing memory moves it. |
On the screen, /memory is a picker of what is saved, by name and description, and Enter (or
/memory <name>) shows one memory in full.
--format json prints each memory with the fields GET /v1/memory uses (name, project, description, priority, load, tags, references, source, created_at, updated_at, verified_at and expires_at as RFC 3339) plus read_count; get adds referenced_by, show adds body as stored, and list leaves both out.
meka memory edit opens the body only. Metadata goes through meka memory add <name> --force --description ..., which keeps whatever it does not mention.
Export and import
meka memory export writes one <name>.md per memory: YAML frontmatter carrying description, project for a project memory, priority and load when they are not the defaults, created, verified when it differs from created, expires, source, read_count, references and tags when they are set, followed by the body. A project’s memories go under a directory of the project’s name, so two projects holding one name write two files; the global tier is at the root. Beside the files sits export.toml, carrying the snapshot’s format_version. --project <name> exports one tier. That is the grep, git and backup answer now that memories live in the store rather than in files.
read_count is there because it is the one value a file cannot otherwise reconstruct. Descriptions, bodies and dates are all in the note; how often the agent has actually opened it is not, and a restored backup with every counter at zero would silently lose each memory’s accumulated ranking weight.
meka memory export --directory ~/notes/memory # must be new or empty
The directory must be new or empty. An export is a snapshot, and merging into an existing one would leave a stale file behind for every memory deleted since, so it would never quite match the store. An export that fails partway removes what it had written rather than leaving a truncated snapshot, which would otherwise restore as a plausible fraction of your store.
The export directory is created at mode 0700 and each file at 0600, and an existing empty directory is tightened to 0700. A memory body is a private note and the store it came from is 0600; publishing the same text world-readable because that is what the umask said would be a strange way to take a backup.
What lands on disk is byte-exact: bodies, tags, priorities and recorded dates are written exactly as stored, including zero-width joiners, CRLF line endings and leading or trailing blank lines. read_count rides along too, because it is the one value the rest of the file cannot reconstruct.
Descriptions are the one field normalized rather than preserved: every write door collapses a description to a single line before storing it, so what comes back is what was stored. A description made only of characters YAML cannot carry has no such form, and meka memory export refuses the whole run and names it rather than writing a file whose frontmatter would not parse.
An export reads back with any tool that understands YAML frontmatter, and meka memory import <dir> reads it back into a store: every file of the snapshot in one transaction, so nothing lands unless everything does. The project of each memory is read from its frontmatter and must exist, so meka project add <name> comes first on the other installation; created, verified and read_count are restored as written, which is what makes the import a restore rather than a copy. A memory the store already holds in the same tier is refused by name until --force, which overwrites it with the file; a name a global memory and a project memory would then share is refused outright, since no tier may hold the other’s name; and the inline budget holds over the whole import as it holds over a write. The manifest is read first: a snapshot an older meka wrote at another format_version is brought forward in one place, the migration module, or refused by version.
The store stays the one source of truth. An export is a snapshot nothing reads until you import it, and an import is a one-time act, the way a session archive’s is; meka never watches the directory.
Coming from a file-backed store
Memories used to be Markdown files in <config>/memory/. If you are upgrading from 0.41, the one-shot migration script attached to the 0.42 release imports them into the store; run it once, check meka memory list, then remove the directory yourself. meka never reads those files again. What it brings forward on its own is the store; a directory of files you still have is yours to import when you get to it, and importing it twice is not something a startup pass could ask you about.
Configuration
Memory is on by default. To turn it off:
[memory]
enabled = false
Disabling it keeps the five memory_* tool schemas out of every request and renders no memory section, which is worth doing if you run lean sessions that will never use it. Memories already stored are left alone, and both the meka memory subcommands and the /v1/memory endpoints still reach them: whether an agent keeps memories is a different question from whether you can inspect or back up what is already there.
There is deliberately no environment variable and no CLI flag: whether an agent keeps memories is a property of the installation, not something to vary per run.
Projects
A project is a name a session is on. It groups the session with its sibling sessions and gives them a memory tier of their own beside the global one: the notes of that project, seen by every session on it and by no session off it.
A project is an idea, not a directory. Nothing binds one to a path, nothing opens one because of where meka was started, and a session with nothing named is on no project. Everything the agent writes about a project while on it is filed under the project; everything it writes while on none is global.
Defining a project
A project is a row in the store, created by meka project add or POST /v1/projects. Names follow the rule memories and skills follow: letters, digits, - and _, starting with a letter or digit, and never a name Windows reserves. Names are compared exactly, and two that differ only by case are refused.
meka project add webdev
meka project list [--format <FORMAT>] # name, when it was added, memories, sessions
meka project rename webdev web # moves every session and memory with it
meka project remove movie # refused while any session or memory is on it
project list shows every project with when it was added and how many memories and root sessions it holds. rename moves the row and every session and memory on it in one transaction, and remove refuses with the counts until nothing is on the project: project 'web' has 3 memories and 2 sessions; move or remove them first. A project lives in the store, so a running meka serve offers one the moment it is added.
Opening a session on a project
Every surface selects a project by name, and a session created from another session copies its project: a sub-agent spawned by agent_spawn, a fork, an imported archive. A sub-agent keeps following its parent: each agent_followup runs it in the project the parent is on then and records that on its row, as it does the parent’s profile. A name no project has is refused at every one of these doors, with the names that exist, so a typo never opens an empty tier.
On the command line, --project <name> opens a new session on the project, and beside -c or -r it follows one rule: it filters a selection meka makes and moves a session you named.
meka --project webdev # a new session on webdev
meka -c --project webdev # the latest session on webdev, or a new one there
meka -r --project webdev # the picker over webdev's sessions
meka -r 550e --project webdev # moves session 550e onto webdev
On the screen, /project opens a picker over the projects, with a first row that leaves the project; /project open <name> moves the session directly and /project close takes it off its project. Either way the row is rewritten, so the choice holds for a resume from any surface, and the next turn’s context restates the memories the session now sees. /status names the project the session is on.
Over HTTP, POST /v1/sessions takes project, PATCH /v1/sessions/{id} moves a session with {"project": "movie"} and takes it off one with {"project": ""}, every session record carries project, and GET /v1/sessions?project=<name> lists the sessions on one (project= those on none). See HTTP API.
The projects themselves are managed over HTTP as they are on the command line, under the memory:w scope, since a project is a memory tier: GET /v1/projects lists them with their counts, POST /v1/projects with {"name": "webdev"} adds one and answers 201 with the project, PATCH /v1/projects/{name} with {"name": "web"} renames one and answers it under its new name, and DELETE /v1/projects/{name} removes one. A name a project already has, or one the rule refuses, is a 422; a name no project has is a 404 on the path; and the removal is a 422 whose detail carries the counts while anything is on the project.
Under ACP, a project entry in configOptions is offered whenever a project exists, with a “No project” value first; see ACP.
A session whose project is gone from the store, which only an edit outside meka can bring about since project remove refuses while a session is on it, is refused when it next runs, with the names that exist: add the project back, or move the session with meka -r <id> --project <name>.
What a project changes
The memory tier, and nothing else. A session on a project reads the global memories and the project’s, memory_write files into the project unless the model asks for tier: "global", and the [Memory] section the model reads is grouped by tier; the rules are on the Memory page.
The profile, the permission level, the working directory, the standing instructions, the MCP servers and the skills are the installation’s and the session’s own, as they are on any session.
Taking a project elsewhere
A project’s memories move with an export: meka memory export --project <name> writes them, and on the other installation meka project add <name> followed by meka memory import <dir> restores them, stamps and read counts included. See Export and import.
Scheduling
Scheduling lets the agent arrange its own future turns. Without it, meka only ever acts when something outside it asks: a human typing, an editor sending a prompt, a client calling the HTTP API. A scheduled job is the one trigger nothing else supplies, which is what makes meka usable as a daemon or a standing assistant rather than a tool you drive.
The agent creates jobs itself through the schedule_create tool, so scheduling is usually a
conversation:
You: remind me in 20 minutes to check the deploy
meka: Created job
7f3a1b2c(once at 2026-08-11 15:22 +02:00). I’ll remind you then.
What a job is
A job pairs a schedule with a prompt. When it fires, the prompt is delivered as a turn.
| Schedule | Meaning | Example |
|---|---|---|
at | Fire once, then delete itself | 20m, 2h, 2026-08-12T09:00:00Z |
every | Fire on a fixed interval | 30m, 1h, 1d |
cron | Fire on a 5-field cron expression, in the job’s timezone | 0 9 * * 1-5 |
Durations use the same syntax as config.toml, so every = "30m" means what
[serve] idle_timeout = "30m" means. Two things to know about it: m is minutes and M is
months, and decimals work (1.5h and 1h 30m are the same duration).
Cron expressions have no seconds field, and follow standard Vixie semantics: when both
day-of-month and day-of-week are set, the job fires when either matches, not both. A six-field
expression is refused rather than read as Quartz, where */10 * * * * * would mean every ten
seconds instead of the every-ten-minutes it looks like.
A cron pattern is read in the zone its job names in timezone, an IANA name such as
Asia/Tokyo, and in the machine’s own zone when it names none, looked up at each fire, so a machine
that changes zone takes those jobs with it. The zone’s own daylight-saving rules decide the instant:
0 9 * * * in America/New_York fires at 09:00 there on both sides of a change. timezone beside
at or every is refused, since neither reads one. Zone names come from the system’s time-zone
database, kept current by the operating system; Windows, which has none, uses a copy built into
meka.
A pattern that matches no calendar date (0 0 30 2 *) is refused when the job is created. One whose
next occurrence is far off is not: 0 0 29 2 * waits up to four years for the next February 29th and
stays on the books until then.
Gates: watching something without burning tokens
A plain recurring job spends a full model turn on every fire, whether or not anything happened. Checking something every 15 minutes is roughly a hundred turns a day to say “nothing new” ninety-odd times.
A gate is a cheap check run before the turn. Only if it says something happened does the turn occur. The interval then costs a tool call or a process spawn instead of a model call, which is what makes a short cadence reasonable.
A gate has two halves. check is what to run, and when is what counts as “something happened”:
schedule_create(
every: "30s",
gate: {
check: { command: "gh pr checks 123 --json state -q '.[].state' | sort -u" },
when: "changed"
},
prompt: "CI state for PR 123 changed. Investigate and report."
)
What a gate can check
check | Runs | Needs |
|---|---|---|
{ command: "..." } | a shell command, unsandboxed | unrestricted |
{ tool: "name", arguments: {...} } | a tool call, by the name the model uses | read, and the tool must resolve to read |
A tool gate is the one to reach for when a tool exists for the job. It is available at a far lower
permission, because a structured call to a server you configured is not a shell, and it returns
structured data that when.at can point into:
schedule_create(
every: "1m",
gate: {
check: { tool: "mcp__mekabridge__unseen", arguments: {} },
when: { at: "/chats", is: "not_empty" }
},
prompt: "There are unseen chats. Read them and reply if anything needs an answer."
)
A gate may only call a tool that resolves to read. A gate asks a question; a tool that can act
is not one. This is checked when the job is created and again every time it fires, so a tool that
resolves higher after an operator retunes it stops being a gate rather than
carrying on with authority nobody granted it.
When a gate fires
when | Fires when |
|---|---|
"changed" (default) | the whole result differs from the previous evaluation |
"succeeded" | the command exits 0, or the tool call did not return an error |
{ matches: "<regex>" } | the result matches the pattern |
{ at: "<json pointer>", is: "not_empty" | "empty" | "changed" } | the pointed-at value satisfies the test |
One trap in the "succeeded" row: most MCP tools never set an error, so it is true on every
evaluation and the job fires every interval. It earns its place on a shell gate, where the exit
code is a real signal. For a tool, reach for at instead.
The gate’s output is passed into the turn it triggers, so the model does not re-run the check the
gate just ran. A pointer narrows what is judged, not what the model is told: the turn still sees
the whole result, because the surrounding fields are usually what makes the fire worth reading. The
turn sees at most 8 KiB of it, and at will read a document up to a megabyte; past that there is
nothing for a pointer to point into and the gate reports that the probe did not return JSON. A gate
should be reading a status, not a payload.
The two shapes that compare against the previous evaluation ("changed", and at with
is: "changed") always fire the first time: with nothing to compare against, “changed” is the
honest answer, and it means a typo surfaces immediately instead of lying quiet. The others judge the
result on its own, so a first evaluation is no different from any other: "succeeded" on a command
that exits non-zero does not fire, and neither does a matches whose pattern is absent.
changed is only as good as the stability of the result. The check should produce something
that changes when, and only when, the watched thing does, which is a stronger requirement than
“read-only” and is where most gates go wrong. It fails in both directions. A result carrying
something that moves on its own (a timestamp, an elapsed time, a request id, an unsorted list whose
order varies) differs on every evaluation, so the gate fires every tick and costs more than the
ungated job it replaced. A result that can return to an earlier value between polls (a bare count,
where two events arrive and one is consumed) reads as unchanged, and the gate silently misses what
happened in between.
This is the reason at exists. Almost any JSON result carries a field that moves on its own, so
"changed" over the whole of it is usually wrong; { at: "/chats", is: "changed" } watches the one
field you mean and ignores the checked_at beside it. For a shell gate, pairing a count with a
monotonic marker (git rev-list --count HEAD alongside the commit sha) does the same job.
A shell gate needs
unrestrictedpermission. It runs unattended, on a timer, until someone cancels it: a longer-lived grant thanshell_execute, which at least ends with the turn that called it. It also runs with no sandbox, soworkspacecannot authorize one: a level whose whole meaning is a write boundary must not hand out a command that has none. A tool gate is not held to this:readcarries it, provided the tool resolves toreadas well. Ungated reminders work atread.
shell_executeis a tool, and that is a door. Where a sandbox backend is usable it resolves toread, socheck: { tool: "shell_execute", arguments: { command: "..." } }is a legitimate tool gate atread: an arbitrary command, on a timer, from a session that could not have authorized the shell form. What makes that acceptable is that the two are not the same thing: a gate dispatches atread, which is the levelConfinement::resolvesandboxes, so the command runs read-only-confined rather than as the baresh -cacommandgate would be. Where no sandbox is available the same tool resolves abovereadand the gate is refused instead, so “admitted” and “confined” cannot come apart. The confinement blocks writes, not the network: treat such a gate as something that can read this machine and talk to the internet, unattended, for as long as the job exists.
A gate that cannot run at all (it times out, the shell fails to start it, or its MCP server is not connected) is not treated as “nothing happened”. It is logged as a warning and the occurrence is declined, because a watcher whose check broke otherwise looks exactly like a healthy watcher with nothing to report. The marker that tells the agent about it needs two consecutive failures, and those are now an occurrence or a lease apart rather than a poll interval, so a standing breakage takes two periods to be reported rather than twenty seconds. That is the price of not re-running a broken check at tick cadence.
Declined means spent, exactly as it does for a gate that ran and said no. A recurring job moves to
its next occurrence, so a six-hour job whose server is down is probed once every six hours rather
than on every poll tick. A one-shot has no next occurrence to move to, so it keeps its claim instead
and the retry waits out [schedule] claim_lease (an hour by default), long enough that a server
restarting near the job’s due time does not cost the reminder, and bounded, because each of those
retries counts against the ceiling below. Either way the gate’s stored baseline is left alone, so
when the check starts working again it compares against the last value actually observed and reports
the change that happened while it was broken.
A non-zero exit code is different, and only succeeded reads it as failure: for a large class
of good gates it is the signal. diff -q a b and git diff --exit-code exit 1 exactly when there
is a difference; grep ERROR log exits 1 through the entire quiet period it is watching; curl -f
exits non-zero until the endpoint returns. Every other predicate judges the output and logs the exit
code at debug level, so -vv will show you a command that is failing when you suspect one, without
a warning on every tick of the many gates for which a non-zero exit is the normal state.
Where jobs run
Jobs belong to the session that created them, and only fire while that session is live in some meka process. That makes the two hosts behave differently, and the difference is worth knowing before you rely on one:
| Host | Fires | Notes |
|---|---|---|
meka serve | Every job, except on a session another process has locked | Revives evicted sessions on demand. The durable path. |
| Screen | Only jobs for the session it has open | Best-effort; a job goes dormant if you next start a different session |
| ACP | Only jobs for sessions the editor has open | The prompt appears in the transcript as the turn that triggered the reply |
--oneshot | Never | The process exits; jobs stay on disk for a later run |
If you want a job to fire reliably whether or not you are sitting at a terminal, run meka serve.
There a fire’s turn is on the session’s event feed like any other,
opening with a turn.started whose source is schedule and which names the job_id, so a client
watching the session sees the agent act on its own schedule as it happens. A message posted to the
session’s inbox while a fire runs is read at the fire’s next round
boundary, and a followup waits for it to end.
On the screen, a job created in one session resumes only when that session does; meka --continue
picks up where you left off.
Jobs all live in the same store, so a host that does not fire a job has not lost it. A job whose
session nobody has open simply waits, and fires as soon as something that can run it picks it up:
another host, or a meka serve daemon pointed at the same data directory.
A job’s turn always joins the session that owns it, on every host. If what you want instead is a
recurring turn that carries no conversation at all, that is an external timer’s job rather than a
scheduled one: systemd, cron or Task Scheduler invoking meka --oneshot, with the level and the
profile stated outright rather than inherited from a session.
meka --oneshot --permission read --profile work -p "summarize today's alerts"
Claiming an occurrence
A due job is leased before it runs: the host records itself and an expiry on the row, delivers the turn, and then advances the schedule (or retires a one-shot). Three consequences worth knowing:
- A crash costs a retry, not the job. The row is untouched until the turn is delivered, so a host that dies mid-delivery leaves a lease that expires and the next host takes the occurrence. Before this a claim consumed the row, and a crash lost the occurrence outright: for a one-shot, the whole reminder, with nothing anywhere to recover it from.
- A cancellation always wins. Canceling deletes the row unconditionally; a host handing an occurrence back only releases its own lease, so a cancel issued while a gate is running cannot be undone by the handback.
- A job that keeps failing to be delivered is parked. Claims that end in neither a delivery nor
a handback are counted, and after three the job stops being retried. Two things reach that count:
a host that dies or panics mid-delivery, and a one-shot whose gate probe cannot be evaluated.
Both leave their claim to expire rather than giving it back, so those three attempts are a lease
apart rather than a tick apart: three panics in half a minute would otherwise park a job whose
only problem was a blip, and nothing retires a parked recurring job afterwards. The
job is not deleted: it stays listed, cancelable, and marked as held with the reason, because a
prompt that crashes meka is something to look at rather than something to throw away. Recreate it,
or cancel it. A host that simply declines a job it cannot take (
meka servefinding the session locked by a screen) does not count, since that says nothing about the job.
[schedule] claim_lease (default "1h") is how long a lease is good for, and therefore how long a
crashed host’s occurrence waits before another host takes it. It should exceed a gate probe plus a
turn: a lease that expires under a host still working lets a second host take the same occurrence,
and although the session lock stops that becoming a second turn, the occurrence still makes a round
trip and the gate probe runs again. A host refuses to start on a claim_lease at or under
gate_timeout, since that half is checkable; the turn after the probe is unbounded, so leave real
headroom on top rather than treating that check as the whole answer.
Two hosts sharing a session do not fight over its jobs. A session is held by one process at a time,
and meka serve leaves that session’s jobs to whoever holds it rather than reaching for them and
handing the occurrence back afterwards, which matters most for a gated job, since deciding late
would mean running its probe on every tick.
More than one host on the same store
Several meka processes pointed at one data directory (two meka serve instances, or a daemon and
a terminal) all poll the same table, so the same occurrence appears in several due lists at once.
Each occurrence is nevertheless run once. A host takes it by leasing it in a single conditional
write, recording itself and an expiry on a row that no other host currently holds, and the hosts
that lose that write stop before evaluating the gate: no duplicate probe and no duplicate turn.
Which host wins is a race between their tickers and is not something you can pin down; that only
one wins is.
Under ACP the editor is a live client, which changes one thing: approvals genuinely round-trip, so a scheduled job can prompt you in the editor rather than being denied for want of anybody to ask. Stopping a scheduled turn works the same as stopping any other.
When a job fires at an idle screen, the turn runs exactly like one you typed: output streams, Esc
or Ctrl+C stops it, and what you had half-typed stays in the composer. A stage direction above the
reply names the job and the opening of its prompt, Scheduled job 1a2b3c4d fired: remind me to stretch, and says how late the fire is when it
makes up for missed occurrences. The prompt the model is given, with its header and any gate
output, is not echoed, the way no tool’s output is; the reply is what you read.
Restarts and missed jobs
Jobs live in the store, so restarting the process (or the meka serve systemd unit) does not
lose them. What happens to jobs whose time passed while meka was down depends on the kind:
- Recurring jobs fire once and resume. A 30-second job that was down for six hours has 720 missed occurrences; it produces exactly one turn, which is told how many it stands in for. It is then rescheduled from now, so an outage never turns into a burst.
- One-shot jobs fire if they are still relevant. Past
[schedule] missed_grace(24 hours by default) they are dropped instead. A reminder to join a standup, delivered five days late, is noise. One that does fire is told how late it is, so the agent can judge whether it still matters.
That collapsing is per job. A session with several jobs all due at once still wakes to a turn each,
and a sweep runs at most [schedule] max_consecutive_fires (5 by default) of any one session’s
jobs before moving on. The rest keep their occurrence and their gate baseline and are taken by the
next sweep, most-overdue first, so nothing is lost and nothing starves. A job held over runs no gate
and is not claimed, so holding one over is free.
What this does and does not do. It bounds a batch, not a total: forty due jobs still produce
forty turns, and they are not spaced out: a sweep that ran long leaves the next one already due.
What changes is that they arrive in groups of five, so under meka serve another session’s single
due job is reached after five of the first session’s rather than after all forty.
If you want a large backlog not to land at all, that is not what this setting is for. Cancel the
jobs (meka schedule list, then meka schedule cancel <id>) before starting a host that will fire
them, or leave [schedule] enabled = false while you clear it.
A recurring job that fires and then fails (most often because the provider is unreachable)
leaves nothing behind in the conversation: its prompt is withdrawn, because the job produces it again
on the next occurrence. Without that, an outage would deposit one unanswered message per fire for as
long as it lasted. A one-shot keeps its prompt, because nothing will produce it again: its row
is retired as soon as the turn is delivered, so that message is the last trace the reminder ever
fired. A turn that got as far as running a tool keeps everything either way, since there is real work behind
it. Failures are recorded regardless: meka serve logs them and sends a schedule.fired webhook
with status: "failed", or "not_run" when the job’s session could not be opened for it (its
profile left config.toml, or the session is gone), in which case the fire is tried again when its
lease expires.
Unattended turns and permissions
Under meka serve a scheduled turn has no human on the other end, so with approvals on every
approval resolves to deny and the job fails to do whatever needed approval. The denial appears in
the session transcript rather than anywhere louder, so a job on such a session that seems to do
nothing is worth checking there first.
The screen and ACP both have someone attached, so approvals reach them normally.
A job’s turn runs at whatever permission the session holds when it fires, not at the level the
job was created with: the level lives on the session and the session is mutable, through Shift+Tab in
the screen or PATCH /v1/sessions/{id} under serve.
With one floor: a session at none fires nothing, gated or not. Nothing is executable there, so the
turn would read nothing, change nothing, and could not even reach schedule_cancel to stop itself
being woken again. The agent can see the job (a tool’s registration does not depend on the
permission level, so [Scheduled] still lists it and schedule_cancel is still offered) but every
call is refused at dispatch, which leaves it able to describe the problem and unable to fix it. An
every = "5s" reminder on such a session was a turn’s worth of tokens every five seconds with no
in-session way to stop it. Raise the session to restore the job; it is declined, not canceled, and a
one-shot that came due while the session was down there is kept rather than spent.
A job’s gate is the exception, because it runs unattended. Its bar is re-checked from two places
every time the job comes due: the level recorded on the job when it was authored, and the level the
session holds now. What that bar is depends on what the gate runs (unrestricted for a shell
command, read for a tool call), and for a tool gate the tool’s own resolved level is looked up
again too, so retuning a tool’s level takes effect on the next fire rather than whenever the job
is next rewritten. That level comes from [tools.tool_permissions] for a built-in, and for an MCP tool from the
five-step chain in Permission resolution: the
server’s tool_permissions, its permission, the tool’s readOnlyHint, [mcp] default_permission,
then unrestricted. Step four is worth knowing about here: one global line turns every unannotated
tool on every server into a read probe a gate may call. And readOnlyHint is asserted by the
server and not verified by meka, which is a weaker footing under a gate than under a call in
conversation, because nobody reads the result of a gate.
That second level is the session’s own, recorded on its row and kept current by whichever surface
owns it: Shift+Tab and /permission on the screen, session/set_mode under ACP, PATCH /v1/sessions/{id} under serve. The process that has the session open reads the level the session
holds right now, so a change takes effect there even if writing it to the row failed. Every other
process that polls the schedule reads the row, so withdrawing the level works across processes: a
meka serve daemon sharing the data directory will refuse a gate you just dropped in a screen.
Nothing else is read: every surface records a level when it creates a session, and a row that
somehow carries none fires nothing at all, rather than falling back to whatever the polling process
was started with.
Drop the session below what the gate needs and the gate stops running, and with it the job, because a gate is the condition on the job and an unevaluated condition has not been met. The occurrence is declined, and a warning is logged naming the job. Raise the session back to restore it. Unlike a gate that ran and said “nothing happened”, a held gate was never evaluated at all, so a one-shot that came due while it was held is kept rather than spent.
The agent is told too, not just the log. A job that cannot currently fire is marked in the
[Scheduled] block it sees every turn and in schedule_list, as NOT FIRING: <reason>, with the
same sentence the warning carries; the moment a gate is withdrawn or restored is announced as a
world change. This matters because the two states are otherwise identical from the agent’s side: a
held job and a healthy watcher with nothing to report both simply never fire. It can act on the
difference, since schedule_cancel needs only read.
A gate whose probe keeps breaking is marked the same way, after two consecutive failures. A server
that changed its schema, a command that was uninstalled, a pointer into a result that stopped being
JSON: each errors on every evaluation, and each is a dead watcher that looks exactly like a quiet
one. The first failure is deliberately not reported, because one failure is as often a blip as a
break. This one is tracked in memory rather than on the row, so it is known to the process running
the job: a restart re-establishes it within two poll intervals, and meka schedule list, which is a
separate process, does not see it.
Four surfaces report it, and each says only what it can establish:
-
[Scheduled]andschedule_listcarry the full sentence, since the agent is the one that can recreate or cancel the job. -
/schedulemarks a jobheldwhen it cannot fire andheld?when this process cannot establish the answer; no mark means “it will fire”, not “I did not check”. It showsheld?for a job whose session level it could not read, since that is unestablished rather than fine. -
meka schedule showspells the same verdict out on awithheld:line. It is a separate process from any host and so cannot resolve a tool gate, reporting that as unknown rather than as fine.meka schedule listdoes not carry it at all: a column that is blank on almost every row is a poor use of a table this wide.Both apply
[permissions].enabledwhen reading a session’s recorded level, so neither can report a job as able to fire that the host refuses. -
GET /v1/schedule, with or without?session=, carries awithheldfield with the same sentence, absent when the job can fire.
Firing the reminder ungated instead would be the more forgiving-looking choice and the wrong one: it
turns a conditional job into an unconditional one, so an every = "1m" watcher that normally speaks
once a week would deliver a turn a minute for as long as the session stayed below that bar. An
ungated job is unaffected by a gate’s authority, and keeps firing at any level above none.
At none nothing fires at all, gated or not. Every tool is refused at dispatch there, so the turn
would read nothing, change nothing, and be unable to reach schedule_cancel to stop itself being
woken again: tokens spent to produce an agent that can describe its predicament and do nothing
about it. POST /v1/sessions/{id}/schedule refuses to create a job on such a session for the same
reason; schedule_create needs read to dispatch at all, so the agent cannot reach it.
Both halves are load-bearing. The recorded level rarely refuses on its own, since a creation door already demanded it; the live level is what makes a withdrawal real. The recorded level still matters for a job created before it was stored, which reads as “no authority” and stays refused.
Inspecting jobs
The agent sees a short index of the current session’s jobs in its per-turn context, so it can avoid
scheduling a duplicate. For details it calls schedule_list.
From your side:
meka schedule list # every session's jobs
meka schedule list --session 0b5c # one session, by id or unique prefix
meka schedule show 7f3a1b2c # one job in full, by id or unique prefix
meka schedule cancel 7f3a1b2c # by id, or any unique prefix
meka schedule list --format json # {"jobs": [...]}, the shape GET /v1/schedule answers with
list is a table to scan: job id, the session it wakes, its schedule, how long until it next fires,
whether it is gated (shell, tool, or -), and the beginning of its prompt. Every cell is bounded
so the table stays legible. Under --format json, list and show print each job with the fields
GET /v1/schedule uses (id, session_id, schedule, prompt, gate,
created_at, last_fired_at, next_fire_at, withheld), ids in full and nothing truncated; the
gate’s check is always given, since the operator at the terminal can read every job in the store.
Both ids print as a UUID’s first segment, and widen only if that would show two rows the same
string, so what you see is normally enough to retype into show, cancel or --session, which
take any unique prefix. show prints both in full.
Normally, because uniqueness is computed over the rows being printed while show and cancel scan
every job there is. list --session <id> narrows the table, so it can print a prefix that another
session’s job makes ambiguous. It fails closed (the command refuses and names the ids that
collided), and an unfiltered meka schedule list always prints a prefix that resolves.
show is the one that answers what a job actually does: the whole prompt, the whole command or tool
a gate runs, the session’s full id, when it last fired, and whether it is withheld. Nothing there is
truncated, which is why it is a separate command rather than a wider table.
On the screen, /schedule is a picker of the current session’s jobs, each with its schedule and
prompt, and Enter (or /schedule show <id>) shows one in full; /schedule cancel <id> cancels one.
All three answer inside the conversation you are in: a job belonging to another session is not
found here, which is what makes the ids the picker shows the ids the other two take.
Configuration
[schedule]
enabled = true # default true; false hides the tools and stops the scheduler
poll_interval = "10s" # how often due jobs are checked
missed_grace = "24h" # how late a one-shot may be and still fire
gate_timeout = "30s" # wall-clock budget for a gate probe
max_jobs = 50 # per-session ceiling, refused at schedule_create
max_consecutive_fires = 5 # per-session ceiling on turns spent in one sweep
claim_lease = "1h" # how long a host's claim on an occurrence is good for
max_consecutive_fires bounds a batch, not a total. A sweep contains its turns, so lowering it does
not stop a backlog landing, nor slow it down; it splits it into smaller groups with other sessions
interleaved between them. Raising it above the number of jobs one session can have due at once has
no effect at all.
With a long poll_interval and a small budget, a large backlog can take long enough to drain that a
one-shot job ages past missed_grace and is dropped (with a warning) before its turn comes.
poll_interval is the real resolution floor: a job with a shorter interval fires once per tick, not
once per interval.
Setting enabled = false keeps the three schedule_* tool schemas out of every request and leaves
existing jobs on disk without firing. POST /v1/sessions/{id}/schedule refuses with a 422 rather
than accepting a job that could never run; GET /v1/schedule and DELETE /v1/schedule/{job_id}
keep working, so jobs left over from before the flag was flipped can still be listed and cleared.
Tips
- Write the prompt for a reader who has no context. The conversation that created the job may be long over, and after a compaction the turn that created it may not have survived.
- Reach for a gate whenever the answer is usually “nothing happened”.
- Keep gate probes fast and read-only. They run on every tick, and a gate that changes something is a side effect on a timer.
- Check what a gate’s probe returns across two runs where nothing happened. Identical output is the whole mechanism, and anything varying inside it turns the gate into a timer.
- If a schedule matters, check what
schedule_createreports back: it states the resolved next fire in absolute local time, which is how you catch a cron expression that parsed fine and means something other than you intended.
Background tasks
An ordinary tool call holds the turn open until it returns. That is right for reading a file and wrong for a twenty-minute build: the agent cannot answer anything else while it waits, and the alternative it reaches for on its own, nohup … & plus polling, gets no notification when the work is done.
A background tool call returns immediately with a task id and delivers its result later while a resident host is running. A one-shot run waits at exit and prints the outcome without another model turn, so work needed for its answer should stay in the foreground.
Off by default. Turn it on with:
[background]
enabled = true
max_tasks = 10 # concurrent per session
When to enable it
This changes the contract of the primary interaction. Without it, you ask and the agent answers. With it, you ask, the agent answers, and something else may interrupt you several minutes later.
That is right for an assistant that runs unattended, keeps talking while work proceeds, and reports when it lands. It is usually wrong for the interactive case, someone at a terminal using the screen like a command line, where blocking is what you want and asynchrony is a surprise.
Every other capability block ([schedule], [skills], [memory]) defaults on. This one does not, because those add capability without changing when a turn ends.
How the agent uses it
Once enabled, every tool gains an optional background parameter, including tools from MCP servers, since a slow MCP call is exactly the kind worth detaching:
shell_execute({"command": "cargo test --all", "timeout_ms": 900000, "background": true})
Background execution does not extend a tool’s timeout. The example gives the command fifteen
minutes; without timeout_ms, the shell’s usual thirty-second timeout still applies.
That returns something like:
Started in the background as task 7f3a1c22 (cargo test --all). Use `task_list` for status. Use `task_cancel` to stop it.
The agent then carries on. When the task ends between turns, its outcome arrives as a new turn:
[Background task reporting at 2026-08-12 14:31 +02:00]
7f3a1c22 (cargo test --all) finished after 12m 4s.
test result: ok. 1674 passed; 0 failed
That is the model’s prompt. The screen does not echo it, any more than it prints a tool’s output:
a stage direction above the reply says what reported, Background task 7f3a1c22 finished after 12m 4s, and the reply carries the result. Under meka serve the message reaches the feed as
message.appended with a background_report block. An ACP editor is told the same direction as a
notice, with the tasks and their outcomes under _meta.background, or as a parenthesized chunk
when it renders no notices; the prompt itself is shown to no editor.
When the task ends while a turn is running, the outcome lands inside that turn instead, at its next
round boundary: behind the results of the round that was running, in the same message, or after the
reply that would have ended the turn, which then goes on, the way a message sent to a working agent
does. The header is the same, and the trailer tells the model to carry on rather than to answer.
Nothing cuts a reply for a report: the outcome waits for the reply to end. The screen says the same
stage direction, meka serve puts a task.reported event on the turn with the text the model read,
and an ACP editor gets the same notice. A resumed session replays the direction where the report
landed, since the log records a report as a block of its own rather than as words.
Running tasks also appear in the per-turn context under [Background], so the agent can see what it already started and does not launch a second copy:
[Background]
Tasks still running.
- **7f3a1c22**: cargo test --all
That section is rendered fresh every turn from live state, so it is always current rather than something the agent has to reconstruct. It carries no results: an outcome is permanent and belongs in the conversation, delivered as its own turn. The section disappears entirely when nothing is running.
Outcomes
Every task ends in one of four states, and every one of them is reported: inside a running turn, as a turn of its own otherwise, and printed by --oneshot when its one turn did not carry it:
| Status | Meaning |
|---|---|
completed | The tool returned successfully |
failed | The tool returned an error |
canceled | Stopped on request, via task_cancel, /task cancel, or a second Ctrl+C |
interrupted | The session holding it went away: meka exited or was signaled, or the client closed the session |
interrupted is the one that matters most. A task in flight when meka lets go of its session cannot be resumed, so it is stopped and recorded as interrupted on the way out, and reported the next time something takes ownership of that session: a screen resume, a meka serve reattach, or an ACP session/load. A process that dies writes nothing; the next owner does the retiring then, because holding the session lock is what proves the previous owner is gone. Without this the agent would wait forever on a result it had usually already promised someone.
Large output is written to a scratchpad entry and the delivered turn carries the beginning plus the entry name, so a long build log does not occupy the conversation permanently. A call that names a scratchpad entry has its whole output saved there instead, whatever its size, and the delivered turn says where it went rather than carrying it; a task stopped before its work came back writes nothing there.
Managing tasks
The agent has task_list and task_cancel. You have:
/task # list this session's tasks
/task show 7f3a1c22 # one task in full, including its whole id
/task cancel 7f3a1c22 # stop one
/task cancel --all # stop all of them
A canceled task still reports back, so the agent learns it stopped rather than waiting on it, but it does not interrupt to say so. Every other outcome wakes the agent when it lands, because nobody chose it: a build finished, a tool failed, or a host died holding the task. A cancellation is always somebody’s deliberate act, and that somebody already knows, so it waits and is read at the next round boundary of a running turn, or at the top of whichever turn the session takes next (yours, or a scheduled job’s), as part of that message rather than as one of its own. Canceling several tasks costs no turns at all.
Under meka serve the turn that delivers an outcome is on the session’s
event feed, opening with a turn.started whose source is
background, so a client watching the session sees the report arrive and the agent act on it. One
read inside a running turn is a task.reported event on that turn, carrying the text the model
read.
Webhooks do not wait on any of that. Under meka serve, task.finished fires as soon as a task
reaches a terminal state, rather than when a turn gets around to reporting it, so a canceled task
is announced immediately, and one left running by a host that died is announced when the session is
next opened, which it was not before.
An outcome that rides a turn, or a round of one, is part of that turn, so /rewind over that exchange takes
the report with it. The task row is already stamped as reported and is not handed out again, so the
outcome is gone rather than redelivered. meka session export still has it: the log is append-only
(within [session].turn_retention, when set), and a rewind changes what the model sees rather than
what was recorded.
Esc and Ctrl+C
The first Esc or Ctrl+C cancels the turn only. Background tasks keep running.
This is the shell’s contract, where Ctrl+C signals the foreground process group and &-ed jobs survive. Losing a twenty-minute build to a keystroke aimed at the answer on screen is unrecoverable, and it is not what the keystroke meant.
meka prints what survived so nothing is hidden:
! Interrupted
• 2 background task(s) still running; stop them with `/task cancel --all`
A second press during the same turn stops them. Between turns, /task cancel --all is the route.
Where it works
| Host | Behavior |
|---|---|
| Screen | Full. An outcome lands in a running turn at its next round boundary, else between turns |
meka serve | Full, for sessions currently resident |
| ACP | Full, for sessions the editor has open |
--oneshot | A round boundary of the one turn carries what has ended; the run waits for the rest before exiting |
A one-shot run exits with the turn, so there is no later turn to deliver into. A task that ends while the turn is still calling tools reaches the model at the next round boundary, as anywhere else. For the rest, rather than kill the work halfway through, the run waits for every outstanding task and then prints the outcomes on stderr; the agent does not see those, since its turn is already over. So a background call whose result the answer needs is worth keeping in the foreground under --oneshot.
Sub-agents (agent_spawn) deliberately cannot start background tasks. A sub-agent’s session ends with the single turn that spawned it, so it could neither outlive that turn nor be around to hear the result.
Concurrent edits
Background tasks make it ordinary for two agents to work in one directory at once. meka does not lock anything: coordination is the orchestrating agent’s job, exactly as it is between two people on one machine.
What it does do is make a lost race visible. file_edit records what a file looked like when it was read, and refuses an edit against a file that changed since:
Error: file 'src/main.rs' changed on disk after you read it. Something else
wrote to it (a shell command, another agent, or the user). Read it again
before editing so you are not overwriting that change, or set force=true to
edit anyway.
This applies whether or not background tasks are enabled: a shell sed -i, or your own editor, produces the same situation.
A file served by the editor under ACP is checked against the editor rather than the disk, since the bytes the agent saw were the editor’s. The check is the same; only the thing it compares against changes. See read-before-edit.
Accounts and profiles
An account is a backend, an endpoint and the credential a login produced; a profile is an account
plus a model and every model-tied setting. Both are written in config.toml, whose
reference lists every key; this page is
the commands that write them and an example per backend.
meka account CLI
Add, re-authenticate, list and remove accounts without editing config.toml by hand. The
credential prompt / OAuth login runs as part of add and login, and secrets are written to the
store, never the config file.
| Command | Action |
|---|---|
meka account add <name> [--backend B] [--base-url U] [--client-id ID] [--oauth-token-url U] [--interleaved-thinking BOOL] [--prompt-cache-key BOOL] [--stream-usage BOOL] [--max-tool-name-bytes BYTES] [--api-key-stdin] | Add an account. Prompts for the backend and base URL when not flagged, then acquires the secret (OAuth login for claude-subscription / chatgpt-subscription, API-key prompt for anthropic-messages / openai-chat-completions / openai-responses). --api-key-stdin reads the key from stdin instead, and then needs --backend as a flag too, since a prompt would consume the piped key; it is refused for the two subscription backends, which have no key to read. --client-id and --oauth-token-url are refused on an API-key backend, which never reads them, and each of --interleaved-thinking, --prompt-cache-key and --stream-usage on every backend but the one that reads it, as --max-tool-name-bytes is on the two subscription backends. device_id has no flag, because meka resolves and persists it itself. |
meka account list | List configured accounts with backend, base URL, and whether each has a stored credential; --format json prints the same as one document. Also names any stored credential that no account claims (see Leftover credentials). |
meka account login <name> [--api-key-stdin] | Re-acquire the secret for an existing account (re-authenticate, recover from a dead OAuth refresh token, or rotate an API key). --api-key-stdin reads the key from stdin for scripted rotation, and is refused on the subscription backends, which have no key to read. Every setting on the account is kept. |
meka account remove <name> | Remove the [accounts.<name>] entry from the config file, then delete the stored credential from the store. Refused while any profile names the account, naming the profiles: remove or repoint those first. Works on a name with only one of the two halves, so it can clean up after a hand-edit, or after a removal whose credential failed to delete. |
meka account rename <name> <new-name> | Rename the account in place. The [accounts.<name>] table keeps its position and comments, every profile naming it follows, and its stored credential moves with it, so no login is needed. Refused when the new name is taken, a leftover credential is stored under it, or another meka is refreshing the account’s token at that moment. Stop running hosts first: a session a host already runs on the account refreshes its token under the old name, and that token is dropped rather than saved. meka serve refuses the rename itself while one may. |
meka account usage / whoami / stats | The read-only account views; see Account info. |
--api-key-stdin reads the key from standard input instead of prompting, for scripted setup:
$ printf '%s' "$OPENAI_API_KEY" | meka account add openai --backend openai-chat-completions --api-key-stdin
There is no account set. An account has three settings a user writes, and each is the kind of
thing a login was made against, so a change is an edit to config.toml followed by
meka account login <name> when the endpoint moved.
A running meka serve offers the same verbs over HTTP, on /v1/accounts under the config:w
scope; see Managing the installation.
meka profile CLI
Add, switch, edit and remove profiles. A profile holds no secret, so none of these commands runs a login.
| Command | Action |
|---|---|
meka profile add <name> [--account A] [--model M] [...] | Add a profile. Prompts for the account and model when not flagged (a sole account is offered as the default; the model prompt offers claude-opus-5-5 on a Claude account and gpt-6-astra on an OpenAI one), then offers an optional advanced step covering thinking, context window and effort, plus the thinking budget if you answer budgeted. Every other profile field has a flag writing the key of the same name: --context-window, --max-output-tokens, --effort, --vision, --thinking, --thinking-budget, --max-request-bytes, --cache-control, --thinking-display <DISPLAY>, --encrypted-reasoning and --reasoning-summary, so one non-interactive command can create a profile of any shape. An unflagged setting is left out of the profile so its documented default applies. Writes default_profile only when a second profile joins a sole one, naming the one that was alone. |
meka profile list | List configured profiles with account, backend, model and the default marker; --format json prints the same as one document. Names any profile whose account is not configured. |
meka profile set <name> <key> <value> | Change one setting on an existing profile, in place. --unset in place of the value removes the key instead. See Changing one setting. |
meka profile use <name> | Set default_profile to this profile. |
meka profile remove <name> | Remove the [profiles.<name>] entry from the config file. Refuses the default while two or more profiles would remain, so point default_profile elsewhere first with meka profile use; with one profile left the key is dropped, since that profile is the default by the rule. Warns if any sessions are pinned to the profile it deleted (those refuse to resume until it is configured again, or moved with meka -r <id> --profile <name>). The account and its credential stay. |
meka profile rename <name> <new-name> | Rename the profile in place. The [profiles.<name>] table keeps its position and comments, default_profile follows when it named the profile, and every session recorded on it moves, sub-agent sessions and their pinned spawn terms included. Refused when the new name is taken, when a root session already records it, or when a sub-agent is pinned to it; a sub-agent that follows its parent runs on the parent’s profile whatever its row records, and is not counted. Stop running hosts first, or have meka serve read the file again with POST /v1/config/reload: a host keeps the names it last read, and its sessions on the renamed profile are refused their next turn until it reads the new one. |
A running meka serve offers the same verbs over HTTP, on /v1/profiles under the config:w
scope, and serves what it wrote without a restart; see
Managing the installation.
Changing one setting
meka profile set <name> <key> <value> writes one key into [profiles.<name>]: every other
setting keeps its value, and every comment you wrote above or beside a key stays attached to that
key. This is how a profile’s model changes, since there is no per-run flag for it.
Keys are left in the order Profile fields documents, so a profile meka has written to is in that order whatever order it was in before. That is deliberate rather than incidental: every writer normalizes, so the file does not depend on which command last touched it, and there is one shape to read rather than one per history. Comments move with their keys, so an annotated profile stays annotated.
$ meka profile set work model claude-opus-5-5
$ meka profile set work context_window 200000
$ meka profile set work effort --unset
--unset removes the key so the profile falls back to meka’s default for it. That is not the same
as writing an empty value: an absent key follows whatever the documented default later becomes,
which is what an unstated setting has always meant. model is the one key with no default to fall
back to, so --unset model and an empty model are both refused.
Twelve keys are settable, each named after the profile field it writes, in the order the fields are documented:
| Key | Value |
|---|---|
model | Any non-empty string, forwarded to the provider verbatim; the one key --unset refuses |
fallbacks | Profile names, comma-separated; --unset removes the list |
context_window | A whole number of tokens |
max_output_tokens | A whole number of tokens |
max_request_bytes | A whole number of bytes |
effort | Any string |
vision | true or false |
deferred_tools | native or append |
thinking | adaptive, budgeted, or off |
thinking_budget | A whole number of tokens |
cache_control | true or false |
thinking_display | updates, summarized or redacted |
A token count must be whole and at most 9223372036854775807, the largest integer TOML can represent;
anything else is refused before the file is opened. A boolean takes true or false and nothing
else, so yes and 1 are refused rather than read as true. A key that is not on the list, and a
profile name that is not configured, are both refused by name with the valid ones listed.
account is on the profile but deliberately not settable, and the refusal says why rather than
leaving it silently off the list: moving a profile to another account moves every session on it
onto another credential and possibly another backend. Add a profile on the other account instead.
An account key (base_url, client_id, …) is refused with a pointer to the account table.
Three more rules are enforced on meka profile add and meka profile set alike, so neither door
can leave behind a profile the other would have declined:
-
A profile without a model. A session on such a profile is refused by name at its first turn, so
--unset model,set <name> model ""andadd --model ""are refused before the file is written. -
A key on a backend that never sends it.
thinkingandthinking_budgetare Anthropic Messages request fields, so profiles onanthropic-messagesandclaude-subscriptionaccounts carry them and nothing else does.thinking_displayis narrower still: it shapes a request onlyclaude-subscriptionsends, so a profile on ananthropic-messagesaccount takes a thinking field and declines the display beside it.setrefuses the key andaddthe flag, before any prompt, and neither writes anything: the key never lands where it would read plausibly and do nothing.set --unsetis allowed on all of them, because removing an inert key is the remedy rather than the offense, and a hand-edited file is the one place one can already be sitting; such a file warns at startup. The account keysclient_idandoauth_token_urlfollow the same rule onmeka account add, which refuses them for an API-key backend. -
A
max_output_tokensthat does not exceed the thinking budget, underthinking = "budgeted"on one of those two backends. The budget is drawn from the output cap, so such a profile cannot produce a valid request; both commands check the file they are about to write and refuse before writing it.
Leftover credentials
Adding an account by hand works: write an [accounts.<name>] block, then run meka account login <name> to attach the credential. Deleting one by hand is only half the job. Credentials live in the
store keyed by account name, so removing the block takes the settings away and leaves the API
key or OAuth refresh token behind, still valid.
Nothing deletes it on your behalf. meka will not sweep the store against the config at startup:
MEKA_CONFIG_DIR and MEKA_DATA_DIR are independent, so a config read from the wrong place, or one
meka could not parse, would present as “no accounts configured” against a real store and take
every credential with it. Losing an OAuth refresh token that way means redoing the browser login for
each account.
Instead, meka account list reports what it finds:
$ meka account list
Name Backend Base URL Authenticated
work anthropic-messages - yes
Stored credentials with no account: archive
meka account remove archive then deletes it. The same applies to MCP servers, reported by meka mcp list and cleaned by meka mcp remove <name>.
Examples
Each backend needs an account and then a profile on it; the account holds the login, the profile names the model.
claude-subscription
$ meka account add anthropic --backend claude-subscription
# Prints the OAuth login URL for you to open, then stores the token in the store.
$ meka profile add work --account anthropic --model claude-opus-5-5
anthropic-messages
$ meka account add anthropic --backend anthropic-messages
# Prompts for your Anthropic API key (sk-ant-api03-...).
$ meka profile add work --account anthropic --model claude-opus-5-5
openai-chat-completions
$ meka account add openai --backend openai-chat-completions
# Prompts for your OpenAI API key (sk-...).
$ meka profile add work --account openai --model gpt-6-astra
openai-responses
$ meka account add openai --backend openai-responses
# Prompts for your OpenAI API key (sk-...). Same key as openai-chat-completions,
# newer protocol; also reaches Ollama, vLLM, LM Studio and OpenRouter.
$ meka profile add work --account openai --model gpt-6-astra
chatgpt-subscription
$ meka account add chatgpt --backend chatgpt-subscription
# Prints the ChatGPT OAuth login URL for you to open.
$ meka profile add work --account chatgpt --model gpt-6-astra
Ollama (local, no key)
$ printf 'unused' | meka account add ollama --backend openai-chat-completions \
--base-url http://localhost:11434/v1 --api-key-stdin
$ meka profile add local --account ollama --model llama3
OpenRouter
$ meka account add openrouter --backend openai-chat-completions \
--base-url https://openrouter.ai/api/v1
# Prompts for your OpenRouter key (sk-or-...).
$ meka profile add sonnet --account openrouter --model anthropic/claude-sonnet-5.5
$ meka profile add gpt --account openrouter --model openai/gpt-6-astra
Account info
meka account usage, whoami and stats expose read-only account information obtained through a
provider’s OAuth API, so you can script things that aren’t otherwise reachable (a status bar, a cron
alert). Each takes the account by name, the way login and remove do (meka account usage claude), and a --format plain|json. Named nothing, it asks the account the default profile
bills, or the one account when there is exactly one. No profile is needed: the views ask the
account, never a model. The requested data goes to stdout; notes and errors go to
stderr, so meka account … 2>/dev/null | jq stays clean. The rest of the meka account suite
(add, login, list, remove) is documented under Accounts and profiles.
Availability is per backend: claude-subscription and chatgpt-subscription (subscription OAuth) support these;
for usage and stats, API-key backends, OpenAI-compatible endpoints and Ollama print a short
“not available” note and exit non-zero. whoami works on any account: it fills the fields it can
and fails only when the credential itself is invalid.
meka account usage
Current rate-limit windows (percentage used + reset time):
$ meka account usage
Account usage
5-hour (session) [##--------] 23% used (resets in 1h 58m, 2026-07-02 02:10)
Weekly [----------] 4% used (resets in 12h 48m, 2026-07-02 13:00)
$ meka account usage claude-max --format json
{
"account": "claude-max",
"windows": [
{ "label": "5-hour (session)", "used_percent": 23.0, "resets_at": 1782958200 },
{ "label": "Weekly", "used_percent": 4.0, "resets_at": 1782997200 }
],
"extra_usage": { "enabled": false, "utilization": null, "used": 0.0,
"balance": null, "currency": "USD" },
"note": null
}
resets_at is a Unix timestamp in seconds (date -d @1782958200). The extra_usage block reports
pay-as-you-go / overage state (whether it’s enabled, percent of the extra-usage limit consumed,
amount spent, and remaining credit balance); the plain view shows a line when it is enabled, has a
balance, or has recorded any spend.
meka account whoami
Account identity, plan, and local auth status. The auth block is computed from the stored
credential (no network), so even when the identity call fails because the token needs a re-login,
whoami still reports it and exits non-zero:
$ meka account whoami
account: claude-max
backend: claude-subscription
auth: valid (5h 45m)
plan: claude_max
tier: default_claude_max_20x
subscription: active
role: admin
$ meka account whoami --format json
{
"account": "claude-max",
"backend": "claude-subscription",
"auth": { "valid": true, "expires_at": "2026-06-29T12:37:09+00:00", "expires_in_seconds": 20709 },
"identity": { "plan": "claude_max", "tier": "default_claude_max_20x",
"subscription_status": "active", "role": "admin", ... }
}
identity is null when the backend has no identity endpoint. expires_at is RFC 3339 and
expires_in_seconds is in seconds; a negative expires_in_seconds (or valid: false) means “run
meka account login”.
meka account stats
Historical usage. chatgpt-subscription is rich (lifetime tokens, peak day, streaks, and per-day token
counts); claude-subscription reports only a first-used date:
$ meka account stats
account: claude-max
first used: 2026-04-01
$ meka account stats --format json
{ "account": "claude-max", "lifetime_tokens": null, "peak_daily_tokens": null,
"current_streak_days": null, "longest_streak_days": null,
"first_used": "2026-04-01T17:36:16.996974Z", "daily": [] }
For Codex, daily is a list of { "date": "YYYY-MM-DD", "tokens": N } you can feed into a graph.
Example: i3blocks
A block that shows the Claude 5-hour and weekly usage, refreshed every 5 minutes:
#!/bin/sh
# ~/.config/i3blocks/meka-usage (set interval=300)
u=$(meka account usage claude-max --format json 2>/dev/null) || { echo "claude ?"; exit 0; }
echo "$u" | jq -r '
(.windows[] | select(.label|startswith("5-hour")).used_percent) as $s |
(.windows[] | select(.label=="Weekly").used_percent) as $w |
"claude 5h:\($s|floor)% wk:\($w|floor)%"'
Each invocation makes one API call, so keep the poll interval sane (minutes, not seconds). The token is refreshed automatically when near expiry and written back to the store, exactly as during a normal session.
Providers overview
A backend is how meka reaches an LLM inference service. meka ships with five, each selectable as an account’s backend:
| Backend | Protocol | Endpoint | Auth |
|---|---|---|---|
anthropic-messages | Anthropic Messages | {base}/v1/messages | API key |
claude-subscription | Anthropic Messages | api.anthropic.com/v1/messages | Claude subscription |
openai-chat-completions | OpenAI Chat Completions | {base}/chat/completions | API key |
openai-responses | OpenAI Responses | {base}/responses | API key |
chatgpt-subscription | OpenAI Responses | chatgpt.com/backend-api/codex/responses | ChatGPT subscription |
A backend names the wire protocol, not a vendor. That is deliberate, and it cuts both ways. One vendor can serve several protocols: OpenAI publishes Chat Completions and Responses, and they are different request shapes, not options on one. One protocol is served by many vendors: /v1/messages is implemented by Anthropic, Amazon Bedrock, Databricks, LiteLLM and Ollama, so calling it “the Claude API” would misname it the moment you point it elsewhere.
The two subscription backends are the exception, and carry a vendor name instead. What you pick there is a billing relationship; the endpoint and the client shape come with it and are not yours to choose.
Synthetic is the clearest case for why this matters. One vendor, two protocols, two base URLs:
[accounts.synthetic-claude]
backend = "anthropic-messages"
base_url = "https://api.synthetic.new/anthropic/v1"
[accounts.synthetic-gpt]
backend = "openai-chat-completions"
base_url = "https://api.synthetic.new/openai/v1"
Configuring an account and a profile
A backend is reached through an account, which holds the endpoint and the credential, and asked
for a model through a profile on that account. The easiest way is the two command suites:
meka account add writes the account to the config file and stores the secret (API key or OAuth
token) in the store, and meka profile add names the model:
$ meka account add anthropic --backend claude-subscription
$ meka profile add work --account anthropic --model claude-opus-5-5
This produces an [accounts.anthropic] and a [profiles.work] entry in
~/.config/meka/config.toml:
[accounts.anthropic]
backend = "claude-subscription"
[profiles.work]
account = "anthropic"
model = "claude-opus-5-5"
Two profiles on one account share one login, which is how one subscription runs two models. With
one profile configured, it is the default. Once there are several, meka profile use <name> writes
default_profile; add never does.
Selecting a profile
A new session runs on the profile named by --profile <name>, else default_profile, else the
sole profile. Switch the default with meka profile use <name>:
meka --profile work # pick the profile this session starts on
meka profile use work # persist as default_profile
There is no environment-variable override for profile selection.
A resumed session ignores all three and runs on the profile it recorded, so meka -c stays
where the conversation was had whatever default_profile currently says. --profile on a resume is
not a per-run override either: it repins the session, rewriting the row so every later resume
keeps it. meka session list shows which profile each session runs on, which is the whole story: a
session records a profile name and nothing else. You can move a live session with /profile <name>
on the screen, PATCH /v1/sessions/{id} over HTTP, or the Profile picker in an ACP client. See
Sessions.
Pointing a backend somewhere else
Every API-key account takes a base_url, so the protocol you pick is independent of who serves it:
| Server | Chat Completions | Responses | Anthropic Messages |
|---|---|---|---|
| OpenAI | yes | yes | no |
| Anthropic | no | no | yes |
| Ollama | yes | yes (v0.13.3+) | yes |
| OpenRouter | yes | yes (beta) | yes |
| vLLM / LM Studio | yes | yes | no |
| Synthetic | yes | no | yes |
Where a server offers both OpenAI protocols, prefer openai-responses: it is what OpenAI recommends for new work and what the agent tooling ecosystem has moved to. Use openai-chat-completions for a server that does not serve Responses.
Note that several of these also expose a legacy /v1/completions endpoint. That is a third, different protocol: a bare prompt string in, choices[].text out, no tool calling. meka does not speak it. It cannot: the agent loop needs tool calls, which that protocol has no representation for.
anthropic-messages vs claude-subscription
Both talk to Claude’s /v1/messages endpoint, but the auth and request shape differ:
anthropic-messagesis the straightforward path: anx-api-keyheader and a plain system prompt, plusanthropic-beta: interleaved-thinking-2025-05-14whenever thinking is on (the default). Choose this when you have a Claude API key.claude-subscriptionreplicates the Claude Code CLI exactly: OAuth tokens, fingerprint-encoded version header, xxHash64 attestation over the request body, injected billing system block. Choose this when you want to use a Claude Code subscription. Any deviation from the expected shape causes requests to be rejected, so avoid proxies that rewrite headers or reformat the body.
Choosing between the OpenAI backends
Three backends, two protocols:
openai-chat-completionsposts to/chat/completionswith an API key. Choose it for a server that serves only this protocol.openai-responsesposts to/responseswith an API key, the same protocolchatgpt-subscriptionuses. Choose it for OpenAI, or for any server that serves Responses.chatgpt-subscriptionposts tochatgpt.com/backend-api/codex/responses, authenticating by OAuth againstauth.openai.comand mirroring the first-party Codex CLI. Choose it to bill a ChatGPT Plus / Pro / Team / Business subscription instead of a per-token API key.
The first two differ by protocol; the last two differ only by auth and endpoint.
Streaming vs non-streaming
By default, meka uses streaming mode: tokens appear in the terminal as they are generated. Use --no-stream to wait for the complete response before displaying it.
Streaming is recommended for interactive use. Non-streaming may be useful for scripting or when the provider does not support SSE. On the Claude and Responses wires it changes what the terminal shows, not the request: the reply is streamed and folded before it is shown.
--no-stream applies to every agent in the run, sub-agents included, whichever profile a sub-agent is pinned to. Neither mode puts a clock on a reply: a stream that stays silent for five minutes is treated as dead, and a whole reply may take as long as it takes, with a connection whose peer has gone caught by TCP and HTTP/2 keepalives instead.
Anthropic Messages
The Anthropic Messages API (POST {base_url}/v1/messages) with an API key. Use this when you have an Anthropic API key, billed per token; to bill a Claude subscription instead, see claude-subscription, which speaks the same protocol.
The protocol is not Anthropic’s alone. Databricks, OpenRouter, Vercel AI Gateway, LiteLLM, Synthetic and Ollama all serve /v1/messages, as does Amazon Bedrock on its Anthropic-compatible host (https://bedrock-mantle.{region}.api.aws/anthropic, with an API key, not bedrock-runtime, which is SigV4 and /model/{id}/invoke). This backend reaches any of them via base_url, which is why it is named for the protocol rather than for Claude.
Configuration
| Setting | Value |
|---|---|
Account backend | anthropic-messages |
| Default base URL | https://api.anthropic.com |
| Credential | API key (sk-ant-api03-...) kept in the store |
| Auth method | x-api-key header |
| API version | 2023-06-01 |
Quickest start
meka account add anthropic --backend anthropic-messages
meka profile add work --account anthropic --model claude-opus-5-5
meka account add prompts for your Claude API key, saves it to the store, and writes the
[accounts.anthropic] table. To read the key from a pipe instead of prompting, pass
--api-key-stdin. meka profile add then writes the profile that names the model.
Config file
The two commands write this for you (the key stays in the store, not here):
default_profile = "work"
[accounts.anthropic]
backend = "anthropic-messages"
[profiles.work]
account = "anthropic"
model = "claude-opus-5-5"
effort
meka sends the reasoning-effort control as output_config.effort in the request body. Unlike claude-subscription, no beta header is needed: the parameter is generally available on the direct Messages API. When effort is unset the field is omitted entirely, which is how you ask for Anthropic’s own default. See the effort config reference for the levels.
thinking
adaptive (the default) sends thinking: {"type": "adaptive"} and lets the model set its own budget; budgeted sends the older {"type": "enabled", "budget_tokens": N} form, taking N from the profile’s thinking_budget and falling back to [thinking].budget; off sends no thinking field. Pre-4.6 Claude models require budgeted.
Supported models
Any model available through the Claude Messages API; meka forwards the model string verbatim and doesn’t gate which strings are valid. For the current line-up and their retirement dates, see Anthropic’s models overview; meka profile add suggests claude-opus-5-5 for a profile on a Claude account.
Custom base URL
To use a Claude-API-compatible proxy or gateway, set the account’s base_url when creating it:
meka account add gateway --backend anthropic-messages \
--base-url https://gateway.example.com/anthropic
meka profile add gateway --account gateway --model claude-opus-5-5
A trailing /v1 is dropped, since meka appends it per request: publish https://gateway.example.com/anthropic or https://gateway.example.com/anthropic/v1, either works.
Anthropic-compatible endpoints
The model behind the endpoint doesn’t have to be Claude. Ollama, LM Studio and similar runtimes serve local weights over POST /v1/messages, and anthropic-messages reaches them with a placeholder key:
meka account add local --backend anthropic-messages --base-url http://127.0.0.1:11434
meka profile add local --account local --model 'hf.co/bartowski/Qwen3.8-27B-GGUF:Q8_0'
Nothing in the request is tuned to Claude unless you ask for it, with one exception. effort is omitted when unset, so a backend with no reasoning tiers is never handed one, and thinking is whatever the profile says rather than something inferred from the model name: set budgeted if your endpoint only implements the older encoding, or off if it implements neither.
The exception is the prompt-cache breakpoint, which goes out by default because Anthropic’s own API caches nothing without it. An endpoint that ignores it loses nothing, but a gateway may turn it into a paid cache of the whole prompt on every turn (OpenRouter does for Gemini), and a profile on such a model sets cache_control = false to send none and leave the endpoint’s own implicit caching to work.
The one setting worth stating is the context window. meka never probes for it, so an unset profile budgets against the 1M default; on a smaller model that means compaction only fires once the backend itself rejects the request:
[profiles.local]
context_window = 262144
thinking = "budgeted" # only if the endpoint rejects the adaptive form
API details
Endpoint: POST {base_url}/v1/messages
Headers:
x-api-key: <api_key>anthropic-version: 2023-06-01content-type: application/jsonaccept: application/jsonanthropic-beta: interleaved-thinking-2025-05-14, whenever thinking is on (the default) and the account does not setinterleaved_thinking = false
System prompt: Sent as a top-level system string.
Prompt caching: one cache_control: {"type": "ephemeral"} breakpoint on the last block of the last message, moved forward each request, unless the profile sets cache_control = false.
Tool format: Tools are defined with input_schema:
{
"name": "file_read",
"description": "Read the contents of a file at the given path.",
"input_schema": { "type": "object", "properties": { ... } }
}
Streaming: Server-Sent Events with named event types (message_start, content_block_start, content_block_delta, content_block_stop, message_delta, message_stop, ping). A non-streaming call (--no-stream, a compaction’s checkpoint or summary) is the same stream folded in place, so every request has one shape and reads one cache; a gateway can keep a separate cache per shape.
Claude subscription
The Anthropic Messages API billed to a Claude subscription. Authenticates by OAuth and mimics the Claude Code CLI’s exact request shape, headers and request signing. Use this instead of a per-token Anthropic API key; for that, see anthropic-messages, which speaks the same protocol.
Named for the subscription rather than the protocol because that is what you are choosing: the endpoint is always api.anthropic.com and the client shape comes with the billing relationship.
Note: This backend replicates Claude Code’s fingerprinting and attestation machinery exactly. Modifying the request body, headers, or OAuth flow will cause requests to be rejected by Anthropic. If you hit 401/403 errors, verify that no middleware is rewriting the request.
Configuration
| Setting | Value |
|---|---|
Account backend | claude-subscription |
| Default base URL | https://api.anthropic.com |
| Credential | OAuth bundle kept in the store (acquired via meka account add / login) |
| Auth method | Authorization: Bearer <oauth_token> |
| API version | 2023-06-01 |
Quickest start
meka account add anthropic --backend claude-subscription
meka profile add work --account anthropic --model claude-opus-5-5
meka account add prints an authorization URL for you to open, walks you through authorization,
and saves the tokens to the store under the [accounts.anthropic] table it writes.
meka profile add then names the model; a sole profile is the default.
Config file
The two commands write this for you; you can also edit it by hand (secrets stay in the store):
default_profile = "work"
[accounts.anthropic]
backend = "claude-subscription"
# device_id, oauth_token_url, client_id are all optional overrides
[profiles.work]
account = "anthropic"
model = "claude-opus-5-5"
effort = "xhigh" # optional; unset sends "medium", Claude Code's default for Opus 5.5
thinking = "adaptive" # optional; "adaptive"|"budgeted"|"off", default "adaptive"
thinking_display = "summarized" # optional; summarized|updates|redacted, default summarized
See Configuration → Config file for the full list of fields.
Backend-specific keys
effort
Sent as output_config.effort under the effort-2025-11-24 beta. When unset, meka sends medium, Claude Code’s default for Opus 5.5; only a model that takes no effort at all gets neither the field nor the beta. An explicit value is absolute: sent verbatim, with no validation or clamping, whatever model it is aimed at. Typical values: "low", "medium", "high", "xhigh", "max". See Reasoning effort.
thinking
adaptive (the default) sends thinking: {"type": "adaptive"}; budgeted sends {"type": "enabled", "budget_tokens": N} from the profile’s thinking_budget (falling back to [thinking].budget), which pre-4.6 models require; off sends no thinking field. temperature follows whether thinking is on at all, not which encoding it uses. The betas do not: they are gated on the model alone.
thinking_display
How the model’s thinking is presented, one of Claude Code’s three display modes. summarized,
the default, sends thinking.display = "summarized" and streams a short readable summary, which
is what Claude Code sends with its showThinkingSummaries setting on. updates, Claude Code’s
own default since 2.1.263, sends thinking.display = "updates" under the
thinking-display-updates-2026-08-18 beta: the server streams a running token count instead of
the text, which the screen draws as Thinking · 150 tokens. redacted sends the
redact-thinking-2026-02-12 beta and no display field, and the server may answer with opaque
redacted_thinking blocks. In every mode the thinking blocks come back signed, and meka stores
and replays them verbatim, so multi-turn reasoning continuity is maintained. With thinking off
nothing is displayed and the redaction beta is sent, as Claude Code does.
A stored block records that its signature is Claude’s, so resuming the session under an OpenAI profile does not replay a Claude signature as encrypted reasoning. The rule runs the other way as well: a thinking block with no Claude signature, whether the Responses API sealed it or an Anthropic-compatible endpoint returned it unsigned or with an empty one, is left out of a Claude request, because the API rejects a thinking block without one. A session recorded by 0.41 holds its blocks under a shape that names no provider, and meka does not reshape them when it opens a session; the one-shot upgrade script does. Until it runs, such a block keeps its readable text and loses its signature, so those turns are not replayed as verified reasoning.
device_id
Stable per-machine identifier embedded in metadata.user_id to mirror Claude Code’s ~/.claude.json device id (getOrCreateUserID in utils/config.ts).
If unset, meka first tries to adopt userID from ~/.claude.json (so meka and Claude Code on the same machine present as the same device). If that file is missing or has no userID, meka generates a 64-character hex string. Either way the resolved value is persisted back to [accounts.<name>].device_id in config.toml. Other backends ignore this field; no stub config file is written for them.
client_id
Optional override for the OAuth client id. Defaults to Claude Code’s client id; rarely needed.
Authentication
OAuth login
meka account add (and meka account login <name> to re-authenticate) performs an OAuth 2.0 Authorization Code flow with PKCE:
- meka generates a PKCE challenge and prints the URL of Claude’s authorization page for you to open.
- You authorize the application in your browser.
- You paste the authorization code back into meka (the redirect URI is the platform.claude.com hosted callback page, not a local listener).
- meka exchanges the code for access + refresh tokens.
- Tokens are kept in the store and refreshed automatically.
The OAuth client id defaults to Claude Code’s client id but can be overridden per account via client_id.
Token lifecycle
- Acquire the initial token with
meka account add/login. - The token bundle is kept in the store, keyed by the account name.
- On subsequent launches the token is loaded from the store.
- meka refreshes the access token automatically when it’s within 5 minutes of expiry; the new token is written back to the store under the same account.
- If the refresh token dies, run
meka account login <name>to re-authenticate. meka says so itself: a refresh the authorization server rejects ends the turn with that command in the error, naming the account. A refresh that fails because the token endpoint is rate-limited or down is retried with backoff instead, since neither answer means the grant is bad.
Token refresh URL: defaults to https://platform.claude.com/v1/oauth/token. Configurable via oauth_token_url on the account.
Supported models
Any model your Claude Code subscription exposes. For the current line-up and their retirement dates, see Anthropic’s models overview; meka profile add suggests claude-opus-5-5 for a profile on a Claude account.
meka forwards the model string verbatim and doesn’t gate which strings are valid. What is model-derived is a small set of gates, each pointed the way Claude Code points it. temperature is an allowlist, so an unrecognized model omits the field rather than earning a 400: it goes only to the models that still accept sampling params (Opus 4.6, Sonnet 4.6, Haiku 4.5, and older). mid-conversation-system-2026-04-07 and output_config.effort are denylists, so an unrecognized model gets both: withholding the first would silently drop mid-conversation system messages, and effort is what a newer model is for. The claude-code-20250219 beta is skipped for the Haiku tier. See Beta header and Reasoning effort.
API details
Endpoint: POST {base_url}/v1/messages?beta=true
Authentication & identity headers:
Authorization: Bearer <oauth_token>anthropic-version: 2023-06-01anthropic-beta: <comma-separated beta list>(computed per request, see below)x-app: cliUser-Agent: claude-cli/<version> (external, cli)X-Claude-Code-Session-Id: <uuid>(per-process)- Stainless SDK identification headers (
x-stainless-*) x-claude-code-request-class:mainfor a turn of the conversation,subagentfor a sub-agent’s turn (withx-claude-code-agent-type: general-purpose, the built-in agent anAgentcall without a custom definition runs),compactionfor a compaction’s requestsx-claude-code-agent-id: on every request a sub-agent makes, its compaction’s included, an id in Claude Code’s sub-agent shape (aand sixteen hex digits) derived from the sub-agent’s session;x-claude-code-parent-agent-idon a nested sub-agent’s, naming the sub-agent that spawned itx-claude-code-prompt-id: the same id the billing header’scc_prompt_idcarries, on every request that has one, which a compaction’s do notx-claude-code-prev-tool-durations: on the request after a tool round,name=millisecondsper call the round ran,;-separated, in dispatch orderx-cc-compaction-requestandx-claude-code-compaction: on a compaction’s requests, what set it going:manualfor/compact,POST /compactandcontext_compact,autofor a ceiling crossed,reactivefor a request the provider refused as too largex-cc-context-compactedandx-claude-code-context-compacted: the same word on the conversation’s first request after a compaction
These are what Claude Code 2.1.310 says about each request; for the two compaction facts it keeps a first-party name and a gateway-hint name and sends both.
Beta header
Composed dynamically from the model, window and thinking settings, mirroring Claude Code’s own assembly. Order is significant; the list below matches the Claude Code 2.1.310 interactive-CLI wire capture on Opus 5.5 (tools present, thinking on, display summarized) exactly; the updates display adds its beta, captured on 2.1.301:
| Beta | When |
|---|---|
claude-code-20250219 | All models except Haiku family |
oauth-2025-04-20 | Always (subscription auth) |
context-1m-2025-08-07 | The profile’s context_window is a million tokens or more; Claude Code sends it for the [1m] model variant its user selected |
interleaved-thinking-2025-05-14 | Any modern Claude (4.x+) |
redact-thinking-2026-02-12 | Any modern Claude under thinking_display = "redacted", or with thinking off unless the display is summarized |
thinking-token-count-2026-05-13 | Any modern Claude (4.x+) |
context-management-2025-06-27 | Any modern Claude (4.x+) |
prompt-caching-scope-2026-01-05 | Always |
per-turn-control-2026-07-01 | Opus 5.5, Sonnet 5.5, Haiku 5.5 and Fable 5.1, the four models Claude Code’s bundled catalog grants per-turn effort; nothing newer, since Claude Code sends nothing for a model its catalog does not list |
mid-conversation-tool-changes-2026-07-01 | Everything except Claude 3.x, Opus 4.7 and older, Sonnet 5 and older, and Haiku 4.5, which is where Claude Code’s catalog withholds mid_conv_tool_change |
advanced-tool-use-2025-11-20 | When the request carries tools (meka always does) |
effort-2025-11-24 | Every model that takes an effort at all, whether or not the profile set one |
fallback-credit-2026-06-01 | A compaction’s requests only. Claude Code 2.1.310 arms it on a turn only while its UI shows a fallback model, which the capture never did, and on a compaction whenever the model has a refusal fallback in its catalog; it only advertises that the server may answer with a fallback credit, and meka sends no fallbacks of its own |
thinking-binding-controls-2026-08-01 | Any modern Claude with thinking on. Nothing in the body goes with it: Claude Code leaves the binding behavior to the server |
thinking-display-updates-2026-08-18 | Any modern Claude with thinking on under thinking_display = "updates", paired with thinking.display = "updates" |
extended-cache-ttl-2025-04-11 | Every request but a compaction’s and a sub-agent’s, which take the API’s own TTL; see Cache control |
cache-diagnosis-2026-04-07 | Always, paired with the body’s diagnostics.previous_message_id: the id of the previous response’s message, on a compaction’s requests too, or null on a conversation’s first request and after a resume |
System prompt
Sent as an array of three text blocks:
-
x-anthropic-billing-header: cc_version=<version>.<fingerprint>; cc_entrypoint=cli; cch=<xxHash64-attestation>;plus, when they apply,cc_is_subagent=true;,cc_prev_req=<request id>;,cc_prompt_id=<uuid>;,cc_turn_origin=<origin>;andcc_prompt_index=<n>; cc_turn_index=<n>;, in that order. The fingerprint suffix is a 3-character hex hash derived from the first user message (SHA256(salt + msg[4] + msg[7] + msg[20] + version)[:3]); thecchtoken is xxHash64 of a filtered copy of the serialized request body, computed and patched in just before send.cc_prompt_ididentifies one prompt and stays the same across every request that prompt produces, including the whole tool loop; a sub-agent inherits its spawner’s.cc_turn_originsays where that prompt came from:humanfor words a person typed through any host,scheduledfor a fired job,peerfor inbox items,task_notificationfor finished background work delivering itself.cc_turn_indexis the turn’s 1-based ordinal in its session andcc_prompt_indexcounts the turns a person opened up to and including it; the pair is stamped when a turn opens and survives a resume, a fork and a compaction. A session that already had turns before meka numbered them sends no pair at all, as Claude Code does for a transcript it did not number.cc_prev_reqnames therequest-idof the previous response in the same conversation, so it is absent on a conversation’s first request and after a compaction that summarized that response away; a compaction’s own requests name it too and are never named by the turn after them. A sub-agent’s requests carrycc_is_subagent,cc_prev_reqand the prompt id, and neither the origin nor the position, which is what Claude Code’s own sub-agent sends; a compaction’s carrycc_prev_reqand none of the others, which is what its compaction sends. -
You are Claude Code, Anthropic's official CLI for Claude.(fixed identity prefix). A sub-agent’s request opens withYou are a Claude agent, built on Anthropic's Claude Agent SDK.instead, the line Claude Code’s own sub-agents carry, and that block is a breakpoint of its own:cache_control: {type: "ephemeral"}. -
One sentence naming block 2 for what it is (a requirement of this API that does not describe the session), then meka’s system prompt. The block carries
cache_control: {type: "ephemeral", ttl: "1h", scope: "global"}; on a sub-agent’s request{type: "ephemeral"}alone.
Only block 3 is marked for caching on a turn of the conversation, matching the captured Claude Code CLI wire; scope: "global" shares the cached prefix across sessions. Tools carry no cache_control (the rolling last-message breakpoint caches the tools+system prefix).
Body key order
Keys are serialized in Claude Code’s own order, which HTTP preserves:
model, messages, system, tools, metadata, max_tokens, thinking,
[temperature], [context_management], [output_config], [diagnostics], stream
Nothing in meka depends on that order. patch_request_body finds the cch=00000 placeholder by walking the JSON structurally to the top-level system key rather than by searching for the billing header, so a conversation that quotes one (which any session about this code does) cannot capture the attestation.
Other body fields
metadata.user_id: JSON-encoded{"device_id": "...", "account_uuid": "...", "session_id": "..."}(device_idfrom the account’sdevice_id;account_uuidfrom the OAuth token, empty until one is known;session_idis per-process).context_management.edits = [{type: "clear_thinking_20251015", keep: "all"}]: present when thinking is enabled on a context-management-capable model. Mirrors Claude Code’sapiMicrocompact.output_config.effort: see Reasoning effort.thinking.display: seethinking_display; absent with thinking off and underredacted.diagnostics.previous_message_id: the id of the previous response’s message in this conversation,nullon the first request, after a resume, and after a compaction that summarized that response away. A compaction’s own requests name it too, and what answers them is not the conversation’s previous response. Pairs with thecache-diagnosis-2026-04-07beta.temperature: 1(only whenthinking = "off", and only for models that still accept sampling params).tools: never meka’s MCP meta-tools (mcp_resource_*,mcp_prompt_*). The endpoint answers a request declaring a tool namedmcp_<x>with “You’re out of extra usage” whatever the tool is, while a server’smcp__<server>__<tool>names pass, so a session on this backend neither carries nor lists them.max_tokens:128_000underthinking = "adaptive",max(thinking_budget * 2, 32_000)underbudgeted,32_000underoff. Claude Code reads the figure off a per-model catalog, which says128_000for the 5.5 line and64_000for the rest of the current line-up;max_output_tokenson the profile states another.
Reasoning effort
Claude Code never leaves output_config.effort to the server on a model that takes one: it looks the model up in a table bundled in its binary, reads that model’s default_effort, clamps it to what the model supports, and sends the result. meka also always sends a value, but one value rather than a per-model one, and sends the effort-2025-11-24 beta alongside it.
| sent | |
|---|---|
profile sets effort | that value, verbatim |
| profile sets nothing | medium |
| model takes no effort | nothing, and no beta; a configured value is dropped with a warning |
One value for every model, not a copy of that table. medium is what the 2.1.310 table says for Opus 5.5, the model meka profile add suggests, and for Sonnet 5.5 and Haiku 5.5; most other entries say high, Opus 4.5 through 4.7 say xhigh, and high is what Claude Code falls back to for any model the table does not list. Carrying the per-model figures instead would add facts about Anthropic’s data that go stale on their release schedule and buy nothing, because the server cannot tell a default meka chose from a value you configured. Models that take no effort at all are the Claude 3.x line, Opus 4.0/4.1, Sonnet 4.0/4.5 and Haiku 4.5.
A value you configure is absolute. Claude Code silently lowers xhigh or max to high on a model whose bundled entry lacks the capability; meka does not, because that table is a snapshot of someone else’s system and quietly overriding what you asked for on the strength of it is worse than letting the API answer.
Only claude-subscription does this. anthropic-messages still omits effort when the profile sets none, because it can point at any Anthropic-compatible endpoint and has no standing to assert a default there.
Cache control
The most recent message’s last content block and the user system prompt carry cache_control: {type: "ephemeral", ttl: "1h"}. The 1h TTL is what an OAuth subscriber’s Claude Code turn carries on the wire. A compaction’s requests carry the breakpoints without the TTL and without the extended-cache-ttl-2025-04-11 beta, as Claude Code’s do: the summary is read once, and an hour of cache would be paid for and never read back. A sub-agent’s requests carry them the same way, and without scope: "global", which is the sub-agent wire Claude Code 2.1.284 sends.
Caching is prefix-based: the tools array precedes the system prompt, which precedes the messages, so a byte changing early invalidates everything after it. meka is built so that nothing which changes mid-session sits in that prefix.
- The system prompt is fixed for a session. It carries only the role description, permission model, standing instructions, guidelines, and OS/shell info, all resolved once at startup. The tool catalog, skill list, and MCP server instructions live in the per-turn
<context>block instead, because all three can change while a session runs. - The tools array does not change. Under the backend’s default
deferred_tools = "native"every deferred tool is declared from the first request withdefer_loading, and atool_loadanswers with a reference the API expands in place rather than adding to the array. Underappendthe array grows at the tail instead, and the request after a load rewrites the whole cache once. - Permission toggles cost nothing. See Permissions.
Three things do legitimately invalidate it, all by necessity rather than oversight: compaction, which rewrites the head of the conversation; a tool_load call on an append profile, which grows the tools array; and an MCP server withdrawing a tool via tools/list_changed, which removes it from the array. A change to the array re-caches everything behind it once, the system prompt included, and the following request reads it all back.
You can see the effect directly: /status reports the cache hit ratio, and reads should dominate from the second turn onward.
Streaming
Server-Sent Events with the same event taxonomy as anthropic-messages: content_block_start, content_block_delta, content_block_stop, message_delta, message_stop. Every request streams, as the captured CLI wire does; --no-stream and the compaction’s own calls fold the stream in place. Reasoning streams as thinking_delta events; redacted thinking arrives as a redacted_thinking block carrying an opaque data payload and no signature, rendered as [redacted thinking].
OpenAI Chat Completions
The Chat Completions API (POST {base_url}/chat/completions) with an API key. Works against OpenAI and any endpoint implementing that format: Ollama, vLLM, LM Studio, OpenRouter, Synthetic, LiteLLM.
This is not the legacy /v1/completions endpoint, which is a different protocol: a bare prompt string in, choices[].text out, no tool calling. Several of those same servers also expose it; meka does not implement it.
For the same key against OpenAI’s newer protocol, see openai-responses.
Configuration
| Setting | Value |
|---|---|
Account backend | openai-chat-completions |
| Default base URL | https://api.openai.com/v1 |
| Credential | API key (sk-...) kept in the store |
| Auth method | Bearer token (Authorization: Bearer <key>) |
Quickest start
meka account add openai --backend openai-chat-completions
meka profile add work --account openai --model gpt-6-astra
meka account add prompts for your OpenAI API key, saves it to the store, and writes the
[accounts.openai] table. To read the key from a pipe instead of prompting, pass
--api-key-stdin. meka profile add then writes the profile that names the model.
Config file
The two commands write this for you (the key stays in the store, not here):
default_profile = "work"
[accounts.openai]
backend = "openai-chat-completions"
[profiles.work]
account = "openai"
model = "gpt-6-astra"
Supported models
Any model reachable over the Chat Completions API that supports tool calling. For OpenAI’s current line-up, see OpenAI’s models overview; meka profile add suggests gpt-6-astra for a profile on an OpenAI account. Against a compatible endpoint the valid names are that server’s: whatever Ollama, vLLM, LM Studio or OpenRouter serves. meka forwards the model string verbatim and doesn’t gate which strings are valid.
Custom base URL
To use an OpenAI-compatible endpoint, set the account’s base_url when creating it:
# Ollama (no real key; pipe a placeholder)
printf 'unused' | meka account add ollama --backend openai-chat-completions \
--base-url http://localhost:11434/v1 --api-key-stdin
meka profile add llama --account ollama --model llama3
# OpenRouter
meka account add openrouter --backend openai-chat-completions \
--base-url https://openrouter.ai/api/v1
meka profile add sonnet --account openrouter --model anthropic/claude-sonnet-5.5
The resulting tables (the key, if any, lives in the store):
[accounts.ollama]
backend = "openai-chat-completions"
base_url = "http://localhost:11434/v1"
[profiles.llama]
account = "ollama"
model = "llama3"
API details
Endpoint: POST {base_url}/chat/completions
Tool format: Tools are sent as function definitions:
{
"type": "function",
"function": {
"name": "file_read",
"description": "Read the contents of a file at the given path.",
"parameters": { "type": "object", "properties": { ... } }
}
}
Tool results: Sent back as messages with role: "tool" and the corresponding tool_call_id.
Streaming: Uses Server-Sent Events (SSE) with data: {...} lines. The stream ends with data: [DONE].
Usage: a streamed request asks for its usage with stream_options.include_usage, which OpenAI’s own API needs before it reports any; an endpoint that refuses the field takes the account’s stream_usage set to false. The cached_tokens an endpoint reports under prompt_tokens_details count as cache reads in /status and the per-turn usage line.
Reasoning: the reasoning_content an endpoint streams (or reasoning, as vLLM and OpenRouter spell it) is shown as thinking, kept in the session, and sent back on every later request as the assistant message’s reasoning_content. DeepSeek’s thinking mode rejects a request with tools that omits an earlier turn’s reasoning; the other endpoints accept or ignore it. Reasoning another backend recorded is never sent here.
OpenAI Responses
The Responses API (POST {base_url}/responses) with an API key. This is OpenAI’s newer protocol
and the one it recommends for new work; it is also what chatgpt-subscription
speaks, so the two differ only in how they authenticate and where they post.
Setup
$ meka account add openai --backend openai-responses
$ meka profile add work --account openai --model gpt-6-astra
default_profile = "work"
[accounts.openai]
backend = "openai-responses"
[profiles.work]
account = "openai"
model = "gpt-6-astra"
Configuration
base_url
Defaults to https://api.openai.com/v1. meka appends /responses, so pass the base URL the server
publishes and nothing more:
base_url = "http://127.0.0.1:11434/v1" # Ollama
base_url = "https://openrouter.ai/api/v1" # OpenRouter
effort
Maps to reasoning.effort. When unset the whole reasoning block is omitted and the endpoint
applies its own default. See the effort reference.
Which servers serve this
| Server | Base URL | Notes |
|---|---|---|
| OpenAI | https://api.openai.com/v1 | The reference implementation |
| Ollama | http://127.0.0.1:11434/v1 | v0.13.3+ only; earlier versions 404 |
| vLLM | your deployment | |
| LM Studio | your deployment | |
| OpenRouter | https://openrouter.ai/api/v1 | Beta |
| Synthetic | not served | Not supported; use openai-chat-completions or anthropic-messages |
Only the non-stateful flavor is needed. meka replays the whole conversation every turn and sends
store: false, so it never uses previous_response_id or server-side conversation state, which is
also all the local runtimes implement.
Choosing between this and Chat Completions
Both take an API key and both reach most of the same servers, so the question is only which protocol the server implements and which one you want:
- Prefer
openai-responseswhere it is available. It is what OpenAI recommends for new work, and the agent tooling ecosystem has moved to it: OpenAI’s own Codex CLI dropped Chat Completions support entirely. - Use
openai-chat-completionsfor a server that does not serve/v1/responses, which is still a great many of them.
Neither is the legacy /v1/completions endpoint, which is a third protocol with no tool calling that
meka does not implement.
API details
Endpoint: POST {base_url}/responses
Auth: Authorization: Bearer <api key>
Streaming: SSE, always. complete folds the stream internally rather than issuing a separate
non-streaming request.
Request body fields meka sets: model, input, instructions (the system prompt, when non-empty),
tools, tool_choice: auto, store: false, stream: true, reasoning.effort (only when effort
is set), and max_output_tokens (only when max_output_tokens is set).
OpenAI’s own fields go out too, as chatgpt-subscription sends them, each with a switch for an
endpoint or a model that refuses it:
| Field | What it does | Switch |
|---|---|---|
include: ["reasoning.encrypted_content"] | Returns the model’s reasoning sealed, which meka sends back on the turn’s later requests so the model reasons on between its own tool calls | the profile’s encrypted_reasoning |
reasoning.summary: "auto" | Asks for the digest meka shows as the model’s thinking | the profile’s reasoning_summary |
prompt_cache_key | Routes one conversation’s requests to the cache holding its prefix; meka sends the session’s id | the account’s prompt_cache_key |
Most servers ignore a field they do not know, and one that refuses names the field in its error,
which is the switch to turn off. Sealed reasoning goes back only to the endpoint that sealed it, so
a session that ran on ChatGPT or another endpoint does not send that endpoint’s reasoning here.
Endpoints that stream reasoning unprompted (vLLM and Ollama emit response.reasoning_text.delta
without being asked) render their thinking either way. The cached_tokens an endpoint reports
inside input_tokens are counted as cache reads, so /status shows a hit rate on this backend
too.
ChatGPT subscription
The Responses API billed to a ChatGPT subscription, at chatgpt.com/backend-api/codex/responses, using the OAuth tokens issued by ChatGPT login and mirroring the first-party Codex CLI’s request shape. The OpenAI counterpart to claude-subscription: instead of paying per token via an API key, you authenticate with your ChatGPT Plus / Pro / Team / Business / Enterprise account and usage counts against your subscription.
For the same protocol with an API key, against OpenAI or any server that serves it, see openai-responses.
Note: This backend replicates the wire shape that OpenAI’s first-party Codex CLI sends. It targets
chatgpt.com/backend-api/codex/responsesusing the OpenAI Responses API, a different protocol thanopenai-chat-completions, which uses Chat Completions againstapi.openai.com. The two backends are not interchangeable.
Configuration
| Setting | Value |
|---|---|
Account backend | chatgpt-subscription |
| Default base URL | https://chatgpt.com (request path /backend-api/codex/responses); see base_url |
| Credential | OAuth bundle kept in the store (acquired via meka account add / login) |
| Auth method | OAuth 2.0 Authorization Code with PKCE |
| OAuth issuer | https://auth.openai.com |
| Required tier | ChatGPT Plus, Pro, Team, Business, Enterprise, or Edu |
Initial setup
meka account add chatgpt --backend chatgpt-subscription
# Open the printed URL; sign in to ChatGPT and approve.
# Tokens are saved to ~/.local/share/meka/meka.db (chmod 0600).
meka profile add work --account chatgpt --model gpt-6-astra
meka account add binds a local listener on 127.0.0.1:1455 to receive the OAuth callback, matching the redirect URI registered with OpenAI’s auth server. If port 1455 is already in use (e.g. you’re already running the Codex CLI), free it first.
On a remote or headless machine (SSH, container) the browser runs elsewhere, so the redirect to http://localhost:1455/... can’t reach meka. In that case, after approving in your browser, copy the full callback URL from the address bar (visible even though the page failed to load) and paste it at the prompt; meka picks the code and state out of it. The paste prompt runs alongside the local listener, so on a local machine the callback still completes automatically with nothing to paste.
Config file
The two commands write this for you (the token bundle stays in the store):
default_profile = "work"
[accounts.chatgpt]
backend = "chatgpt-subscription"
[profiles.work]
account = "chatgpt"
model = "gpt-6-astra"
effort = "xhigh" # optional; unset sends none, so OpenAI's default applies
The effort field maps to the Responses API reasoning.effort knob. When unset the reasoning block is omitted and OpenAI applies its own default; meka picks no tier and consults no catalog. An explicit value is absolute: sent verbatim, never clamped. The endpoint accepts none, minimal, low, medium, high, xhigh and max; Codex’s ultra is a mode of that client, not a value of the API, and is refused.
base_url
The account’s base_url defaults to https://chatgpt.com, and two shapes are accepted, told apart
by the path:
base_url | Turns are posted to |
|---|---|
An origin, or a proxy prefix, whose path has no backend-api or codex segment (https://chatgpt.com, https://proxy.example.com/openai) | <base_url>/backend-api/codex/responses |
A base whose path already contains a backend-api or codex segment, such as the https://chatgpt.com/backend-api/codex the Codex CLI’s own configuration uses | <base_url>/responses |
The segment test is on the path alone, so a host named codex.example.com is the first shape. The
account endpoints behind meka account usage, whoami and stats hang off /backend-api beside
codex rather than under it: a trailing /codex is stripped and /backend-api appended when the
path has none, so both shapes reach /backend-api/wham/usage.
Supported models
Whatever your ChatGPT subscription tier exposes. For the current line-up, see OpenAI’s models overview; meka profile add suggests gpt-6-astra for a profile on an OpenAI account. The model field on the request body is forwarded verbatim; meka doesn’t gate which model strings are valid.
How it works
Each request:
- Auth header set:
Authorization: Bearer <access_token>,ChatGPT-Account-ID: <workspace_id>(extracted from the JWT id_token at login),originator: meka_cli, plus aUser-Agentidentifying meka. A turn also carriessession-idandthread-id, both the session’s id; see Prompt caching. - Cookie jar enabled:
chatgpt.comis fronted by Cloudflare; bot-clearance cookies (__cf_bmetc.) persist across requests automatically. - Body: standard Responses API JSON:
instructions,input(an array ofmessage/reasoning/function_call/function_call_outputitems),tools, optionalreasoning.effort, plus the two reasoning parameters Codex also sends:reasoning.summary: "auto"andinclude: ["reasoning.encrypted_content"]. Both are sent on every request, whether or noteffortis configured. A turn addsprompt_cache_key, the session’s id. - Stream: SSE events:
response.output_text.deltafor text,response.output_item.added/…donefor tool calls,response.reasoning_summary_text.delta(andresponse.reasoning_text.delta) for thinking,response.reasoning_summary_part.addedfor the break between summary sections,response.completedfor end-of-turn with token usage.
Reasoning across turns
Requests are stateless (store: false), so the reasoning a model produced is only available to the next request if meka sends it back. It does: each reasoning item is recorded with its rs_… id, its encrypted_content and the origin of the endpoint that sealed it, and replayed verbatim, to that endpoint alone, as a reasoning input item immediately before the output it produced. This is what lets a multi-step tool-calling turn keep one chain of thought instead of restarting it at every call, and it mirrors what the first-party Codex client does.
The encrypted content is opaque: meka cannot read it, only replay it. It is stored under a shape that records which provider it came from, so a session recorded here and resumed against Claude does not hand Claude an OpenAI blob (nor the reverse); a block from the wrong provider is simply not replayed. The summary is the readable part, and what the screen shows as a thinking block.
A session recorded by 0.41 holds its thinking blocks under a shape that names no provider, and meka does not reshape them when it opens a session. The one-shot upgrade script does it, in a pass over the store you can watch finish, because it has to guess which provider each block came from and reports what it read before it writes. Until it runs, such a block keeps its readable summary and loses its encrypted half, so that reasoning is not replayed.
Prompt caching
OpenAI’s prompt cache is automatic and unbilled, but a hit needs the request to land on the machine that holds the prefix, and the endpoint routes on the session named in prompt_cache_key and the session-id header. meka names the session in both, as Codex does. Measured without them, a conversation whose prefix never changed was served from the cache on one request in four; with them, on nearly every request after the first. The endpoint fills its cache asynchronously, so a tool round that returns within a few seconds of the previous response can still miss. The cached share is the cache hit figure in /status and the per-turn usage line, read from the cached_tokens the endpoint reports inside its input count.
5. Token refresh: when the access token is within 5 minutes of expiry, meka transparently refreshes it against auth.openai.com/oauth/token before the next request.
Limitations
- Streaming-only: the Codex endpoint has no non-streaming shape, so meka always streams here and folds the stream internally to satisfy a non-streaming completion.
--no-streamis accepted and behaves normally; it changes what the terminal renders, not what goes on the wire. - Subscription required: you need a paid ChatGPT plan with Codex enabled. Free-tier accounts can complete the OAuth flow but most models will reject requests at the API layer.
- Bot detection: chatgpt.com may serve a Cloudflare challenge if request patterns look automated. meka’s reqwest client handles cookie-clearance automatically; if you hit a hard challenge, complete it once in a regular browser to refresh the cookies.
- Endpoint stability: this is OpenAI’s subscription-internal API; OpenAI doesn’t guarantee compatibility for third-party clients. Future Codex versions could add request signing or rotate scopes; meka will need updates if that happens.
Subscription vs API key
If you have both a ChatGPT subscription and an OpenAI API key:
- Use
chatgpt-subscriptionfor interactive work: it’s billed against your subscription’s usage cap rather than per-token, so heavy use is cheaper for most personal patterns. - Use
openai-responsesfor scripted / unattended work: it is the same protocol as this backend with a plain API key, so keys are stable, nothing depends on the Cloudflare cookie jar, and it also reaches Ollama, vLLM, LM Studio and OpenRouter. Fall back toopenai-chat-completionsfor a server that does not serve/v1/responses.
Logging out
meka account remove <name> deletes the stored credential from the store and removes the
account from the config file, once no profile names it:
meka profile remove work
meka account remove chatgpt
To re-authenticate the same account without removing it (e.g. after a dead refresh token), run
meka account login <name> for a fresh PKCE pair.
Tools overview
Tools are the actions that the agent can perform on your behalf. The LLM decides which tools to call based on your instructions.
Available tools
| Tool | Permission | Description |
|---|---|---|
file_read | Read | Read file contents |
file_edit | Workspace | Make string replacements in a file |
file_write | Workspace | Create or overwrite a file |
file_find | Read | Find files by glob pattern |
file_search | Read | Search file contents with regex |
web_fetch | Read | Fetch a web page as markdown |
shell_execute | Read | Run a shell command (see the note below) |
checklist_add | Read | Add items the turn has to finish |
checklist_edit | Read | Change one item by id |
checklist_read | Read | Read the checklist |
agent_spawn | Read | Delegate tasks to a sub-agent |
agent_list | Read | List the sub-agents this session spawned |
agent_followup | Read | Ask a sub-agent another question |
agent_steer | Read | Send a sub-agent a message without waiting for it |
agent_delete | Read | Discard a sub-agent and its records |
scratchpad_write | Read | Store content in the scratchpad |
scratchpad_read | Read | Read a scratchpad entry |
scratchpad_edit | Read | Edit a scratchpad entry |
scratchpad_list | Read | List scratchpad entries |
scratchpad_delete | Read | Delete a scratchpad entry |
scratchpad_merge | Read | Combine several scratchpad entries into one |
scratchpad_rename | Read | Rename a scratchpad entry |
scratchpad_load_file | Read | Load a file into the scratchpad |
scratchpad_save_file | Workspace | Write a scratchpad entry out to a path |
skill_read | Read | Load a named skill’s instructions |
skill_search | Read | Regex over the full text of every skill |
skill_write | Read | Create or update a skill |
skill_edit | Read | Replace an exact string in a skill’s body |
skill_delete | Read | Delete a skill and its directory |
memory_write | Read | Save a durable note that outlives the session |
memory_edit | Read | Replace an exact string in a memory’s body |
memory_read | Read | Read a saved memory, paging long bodies |
memory_search | Read | Ranked full-text search over every memory |
memory_delete | Read | Delete a saved memory |
image_render | Read | View an image from in-memory base64 or scratchpad |
context_check | Read | Measure the context window live: occupancy, headroom, compaction count |
context_compact | Read | Ask for a compaction before the next step of this turn |
context_replace | Read | Submit a checkpoint’s summary; refused outside a checkpoint |
conversation_search | Read | Search the full conversation history, including compacted turns |
conversation_read | Read | Read conversation turns by index |
session_list | Read | List the sessions the agent may read |
session_search | Read | Find a readable session by what was said in it |
schedule_create | Read | Schedule a future turn for this session |
schedule_list | Read | List this session’s scheduled jobs |
schedule_cancel | Read | Cancel a scheduled job |
task_list | Read | List this session’s background tasks |
task_cancel | Read | Stop a running background task |
tool_load | Read | Fetch the full schema of a deferred tool, one name or up to ten |
tool_search | Read | Find a tool by keyword across names and descriptions, deferred ones included |
The schedule_* tools require [schedule] enabled (on by default), the memory_* tools require [memory] enabled (on by default), and the task_* tools require [background] enabled (off by default). skill_write and skill_delete require [skills] agent_managed (off by default) and are never given to a sub-agent. A disabled subsystem registers no tools at all, rather than shipping schemas that could only fail.
Permission requirements
Tools are grouped by the minimum permission level required:
Read permission (available at read and above):
file_read,file_find,file_search,web_fetchshell_execute(sandboxed, filesystem write-protected)checklist_add,checklist_edit,checklist_read,agent_spawn,agent_list,agent_followup,agent_steer,agent_delete,image_render- All skill tools, including
skill_write,skill_editandskill_deletewhen they are enabled: like memory, skills live in meka’s own config directory, not your working tree conversation_search,conversation_read,session_list,session_search,context_check,context_compact,context_replace- Every scratchpad tool except
scratchpad_save_file, which writes to a path you name and so sits atworkspacewithfile_write - All memory tools. Writing a memory needs only read permission: memories live in the store, which is meka’s own, not your working tree.
Workspace permission (available at workspace and above; writes are confined to the workspace at workspace):
file_edit,file_write,scratchpad_save_file
shell_execute is not in that list: it asks for read when a sandbox backend is available and unrestricted when none is, so it is reachable at read and confined by the level, not by its own requirement.
With approvals on, a call above the level is put to you instead of refused. An approved call still runs at the session’s level: an approved shell_execute at read runs in the read-only sandbox, and an approved file_write lands only under the workspace roots. Raise the level when an approved call needs more reach.
At none, no tools are available. The agent can only respond with text.
Filtering built-in tools
Any built-in can be allow-listed, blocked, or have its required permission overridden via the [tools] table in config.toml. See [tools]: built-in tool filters. Run meka tool list to see every built-in with its effective permission and current status, and meka tool show <name> for one tool’s whole description and parameter schema as the model receives them.
How a built-in reads its arguments
Every string argument is read by one rule. A blank string, empty or whitespace alone, is the argument left out: omitted for an optional one, missing for a required one, which the tool refuses by name. A value of another type where a string is expected is refused the same way. The only fields that keep a blank are the ones where an empty string is itself the value: the replacement an edit makes in file_edit, memory_edit and scratchpad_edit, the content of file_write, scratchpad_write and scratchpad_edit, the body of memory_write and skill_write, and expires on memory_write, where "" lifts the expiry. A list argument takes a list, or one bare string as a one-entry list; a blank entry is dropped, an entry of another type is refused, and an empty list is an empty list. A true-or-false argument takes true or false; anything else, the string "true" included, is refused by name rather than read as false. A number argument takes an integer, 2.0 included; a quoted number, a fraction, or a negative where a count is asked for is refused by name rather than replaced by the default. An object argument, web_fetch’s headers or mcp_prompt_get’s arguments, takes an object, and headers an object of strings; anything else, the object spelled as a JSON string included, is refused by name rather than left off the call.
MCP tools
When MCP servers are configured, their tools are registered under a namespaced name of the form mcp__<server>__<tool> (e.g. mcp__notion__notion-search). The mcp__ prefix matches Claude Code’s convention and keeps MCP tools from colliding with built-in names. They appear in the per-turn context catalog alongside the built-ins, with their resolved permission level annotated inline, and are called the same way. A name longer than the serving wire accepts (128 bytes on the Claude and Responses wires, 64 on Chat Completions, or the account’s max_tool_name_bytes on an endpoint that holds names to less) reaches the model cut to fit, as its head, _ and twelve hex digits of the whole name’s SHA-256, and a call under it runs the tool by its whole name; everywhere meka records or shows a tool, the name is whole.
meka also exposes seven built-in MCP meta-tools for browsing server-side resources and prompts. All are deferred by default; tool_search finds them, and tool_load with the exact name makes the schema available on the next model request within the same turn. A session on a claude-subscription profile has none of them: that endpoint refuses any tool named mcp_<x>, so they leave the request and the catalog there, and a load or a call of one says so. They return the moment the session moves to another profile:
| Tool | Permission | Description |
|---|---|---|
mcp_resource_list | Read | List resources a server exposes |
mcp_resource_read | Read | Read a server resource by URI |
mcp_prompt_list | Read | List server-defined prompts |
mcp_prompt_get | Read | Render a server prompt with arguments |
mcp_resource_subscribe | Read | Receive change notifications for a resource |
mcp_resource_unsubscribe | Read | Stop receiving change notifications |
mcp_resource_updates_list | Read | Inspect the latest recorded resource updates |
Deferred tools
Most MCP tools are deferred: they are registered and listed under [Tool discovery] in the per-turn context, but their JSON schemas are withheld from the request until the agent calls tool_load. A large server can advertise fifty tools with multi-kilobyte schemas, and shipping all of them on every turn costs more than it returns.
Which tools are deferred is the deferral setting, for the install ([mcp].deferral) or per server: deferred (the default), eager (every tool of the server declared from the first request) or hidden (a tool that would be deferred is not registered at all: not in the catalog, the request or the context gauge). A name in a server’s eager_load_tools ships eager under every value, and the MCP meta-tools above follow the install’s setting. meka mcp tools <name> shows how each tool ships. When nothing is left deferred, tool_load and tool_search leave the request, and [Tool discovery] with them.
The listing is bounded, like the skills and memory indexes. Every deferred tool appears under its server with the permission level it requires. A one-line summary rides along while the whole section fits 8 KB; past that every entry keeps its name and loses its summary, and past 200 names the section stops and counts what it left out. Each server heading carries its tool count in every tier and headings are kept while they fit, so a server whose entries were cut is still named with its size; past that, the count line says how many groups are not listed at all.
The trade-off is that until a tool is loaded, the agent sees at most its name and a summary clipped to 250 characters. Anything past that clip is invisible, including optional parameters, and a summary that was clipped ends in ….
Two behaviors exist so this never turns into a silent wrong answer:
- Calling a deferred tool without loading it works. The agent may be confident about the required arguments, and forcing a round trip it doesn’t need is worse than allowing it.
- But when it does that and the tool has documented parameters it didn’t pass, meka appends a note to the tool result naming them, with their types, defaults, and descriptions. A wrong default stops being invisible. The note is emitted once per tool per run.
tool_search finds a tool by keyword across every registered tool’s name and full description, deferred ones included. Matching is forgiving: a word in the name outranks one in the description, a whole word outranks a substring, and a near miss (xerfs for xrefs) still counts. Each result carries the tool’s summary, whether it is deferred, and whether the session’s current permission level and approvals switch allow a call, by the same rule dispatch applies, so the agent never loads a tool only to find the call refused at that door. A tool’s own refusals once called (a shell with no sandbox at workspace, a path outside the roots) depend on the arguments and are not predicted. Up to ten results are shown, which is one tool_load batch.
tool_load takes one name or an array of up to ten, so a task needing several tools off one server costs one round trip. Each loaded schema is headed by the same line on whether the level allows a call, and a name that matches nothing is answered with the nearest registered names or, failing that, the keyword matches for the word:
tool_load({"name": ["mcp__notion__search", "mcp__notion__fetch"]})
Sub-agents see the same picture: a worker’s system prompt carries the identical [Tool discovery] section over its own registry (its parent’s servers minus whatever [subagents] and agent_spawn deny), and it has tool_search and tool_load like the root.
Tools listed in a server’s eager_load_tools skip all of this: their schemas ship from turn 1. Use it for tools whose optional parameters matter and that the agent reaches for constantly.
What a load costs depends on the profile’s deferred_tools strategy. Under append, a loaded schema joins the tools array, which heads the cached prefix on every wire, so the request after a load rewrites the cache once, at the size of the context at that moment; eager trades that for the schemas in the prefix from the first request, the better deal for a server whose tools are called on most turns. Under native, the default on the official Anthropic and OpenAI endpoints and on both subscription backends, every deferred tool is declared from the first request with defer_loading, the array never changes, and tool_load and tool_search answer with references the endpoint expands in place, so a load rewrites nothing. Such an answer is the definitions and nothing else, because the endpoint refuses text beside a reference: the permission line comes with the call instead, and a batch with a name that resolves to nothing loads nothing and says which. On openai-responses and chatgpt-subscription the endpoint’s own tool_search item stands in for the two discovery tools: a query finds, and names loads by exact name; what the search says in words follows its result as text, since the item itself carries only definitions. After a compaction the references are gone with the rest of the window: the catalog still lists the tools as deferred, and the agent loads them again when it needs them. When a server renames or removes a tool the agent loaded, the tool’s reference leaves the result it sat in while the others stay, and a result left with none names its tools in text. A registry whose active set is empty, such as a sub-agent allow-listed to one deferred MCP tool, has its deferred tools declared outright on every wire, with their schemas and with no load to make: a declaring endpoint refuses an array with nothing active in it, and an appending one would leave such a scope nothing it could call.
A server that changes its tool list mid-session (tools/list_changed) rewrites the prefix once under eager, since every tool of it sits in the array; under hidden a new tool does not appear until the config names it; under deferred it joins the catalog in the context block, and the array too on a native profile.
When writing a tool description for a server meka will consume, put whatever a caller must know to use the tool correctly in the first two sentences. That may be all anyone ever sees.
Background calls
With [background] enabled, every tool except context_compact and context_replace gains an optional background parameter, MCP tools included. A call that sets it returns a task id immediately and delivers its result later as its own turn, which is what makes a twenty-minute build affordable. context_compact does no work of its own: it parks a request the loop drains once the batch’s results are in, and detaching it would race that drain. context_replace hands a checkpoint its summary, and nothing waits on a task for that. See Background tasks.
shell_execute({"command": "cargo test --all", "background": true})
Like scratchpad, background is meka’s own: it is consumed by the agent loop and removed from the arguments before the tool, or a remote MCP server, ever sees it.
A tool that advertises background itself keeps it. meka does not splice its own parameter over a name a tool already uses, and does not strip or interpret one either, so a server with a background color or a detach flag of its own receives the argument untouched and the call does not detach.
These two are also the only parameters meka type-checks. A background that is not a boolean, or a scratchpad that is not a string, refuses the call and says what was expected, rather than being read as absent. Both decide what a call does rather than what it is called with, so ignoring a wrong type would silently turn a detached call into a blocking one, or drop output the agent asked to keep. A tool’s own arguments are the tool’s to validate: meka reports a mismatch as an advisory on the result and lets the call through, since a remote server is the authority on what it accepts. null counts as absent for both, which is what models emit for an optional argument they are not using.
Scratchpad parameter
A scratchpad string parameter saves a tool’s output to the scratchpad under that name instead of returning it inline, so a large result stays out of the conversation. It redirects the tool’s output alone: a refusal or an error stays inline, since the caller asked for the output and a sentence about the call is neither large nor sensitive; a command that exits non-zero is output, exit code included, and is saved.
shell_execute({"command": "pdftotext doc.pdf -", "scratchpad": "pdf_text"})
It is honored on every tool, MCP servers included: the redirect happens where the result is
recorded, not inside the tool. Eleven built-ins also advertise it in their schema, which is how the
model discovers it: file_read, file_edit, file_write, file_find, file_search,
web_fetch, shell_execute, conversation_read, agent_spawn, agent_followup
and the checklist_* tools, the last for uniformity alone, since the list is kept as state and nothing is redirected.
Three of those lift a cap when it is set, producing their full untruncated output: file_find (500
results), file_search (100 matches) and web_fetch (limit). An explicit limit on
file_find or file_search still applies.
How tool calls work
- The agent receives your instruction and decides which tools to call
- For each tool call, meka checks the current permission level
- A call above the level is refused, or put to you for approval when approvals are on
- If permitted, the tool executes and its output is fed back to the agent
- The agent may make additional tool calls or respond with text
- This loop continues until the agent has no more tool calls to make
Tool calls and their results are displayed in the terminal so you can see what the agent is doing.
checklist_add, checklist_edit, checklist_read
The list of what the agent has committed to do, and the rule that a turn cannot end while an item on it is open. Checklist describes the rule, the nudge and the cap; the tools are:
checklist_addappendsitems, each a text (addedpending) or an object{text, status}withstatuspendingorin_progress. Returns the ids assigned and the whole list. A text and a reason are kept as one line each.checklist_editchanges one item byid:status(pending,in_progress,deferred,completedorcanceled),reason(required withdeferredandcanceled, refused otherwise),task(withdeferredonly: the background task the item waits on, by the idtask_listshows; one that has already reported is refused, and a newreasondrops a task not named again), ortext. One wrong part refuses the whole call.checklist_readreturns the list and takes no arguments.
Completed and canceled items leave the list; every call returns what remains, with the ids the next edit needs.
agent_spawn
Spawns a sub-agent to perform research, analysis, or any other delegated task. The sub-agent gets its own private checklist (the checklist_* tools operate on the sub-agent’s own state), runs silently (its tool calls are not surfaced to the terminal), and returns a single text report. Use this to keep exploratory or speculative work out of the main conversation context.
Multiple agent_spawn calls in one assistant turn run in parallel; useful when independent investigations can proceed concurrently.
Recursion. Sub-agents may themselves spawn further sub-agents, so an agent can orchestrate a team. Nesting is bounded by [subagents].max_depth (default 3; 1 reproduces the old “sub-agents can’t spawn” behavior, 0 disables agent_spawn entirely). Pass the optional max_depth parameter to tune how deep a given subtree may recurse; a built-in absolute cap always bounds real nesting so recursion can’t run away.
Permission. By default a sub-agent inherits the parent’s permission level. Pass the optional permission parameter (none / read / workspace / unrestricted) to run it at a more restricted level; a level above your own is refused by name, so a sub-agent can never be escalated above its parent. This lets an orchestrator hand untrusted or risky work to a read-only sub-agent while keeping its own level for the rest of the turn.
Scope. Three fields say where a sub-agent works and what it reaches. Leave all three out and it has your whole scope: your working directory, your additional workspace and readable directories, and your level.
working_directory: where it works. Its shell starts there and its relative paths resolve there; writable atworkspace, read-only atread. Relative to your working directory, and"."is your working directory. Naming it states the sub-agent’s whole scope: this directory, the two lists below, and nothing else of yours. It is required beside either list, so a list never stands beside an unnamed directory.additional_workspace_directories: more of its workspace beyond the working directory, with the working directory’s rights: writable atworkspace, read-only atread, searched by default. Relative paths never resolve against them.additional_readable_directories: directories it may read beyond its workspace and never write, which a search covers only when named.
Every entry must be an existing directory within your reach: one you could write, for the working directory and the workspace list when the sub-agent writes, and one you can read otherwise. An entry outside your reach is refused in the same words whether or not it exists, so a sub-agent learns nothing about what lies beyond you; a missing entry inside it is told so. Every entry is judged, and one refusal names each that fails. A list beside unrestricted, asked for or inherited, is refused, since nothing is confined there. What adds nothing is merged rather than refused: a repeat, an empty list, a workspace entry inside the working directory, a readable entry inside the workspace, a directory named in both lists. Symlinks are resolved when the sub-agent is spawned, and the scope it holds is stated in canonical form.
A sub-agent with a stated scope reads its workspace, its additional readable directories and the system directories, and nothing else, whether or not your own reads are confined by [permissions].confine_reads; it writes its workspace at workspace and nothing at read. A spawn turns read confinement on, never off: a parent whose reads are confined cannot hand a sub-agent reads anywhere.
The shapes, one spelling each:
{"prompt": "..."} like you: your whole scope, your level
{"prompt": "...", "permission": "read"} like you, read-only
{"prompt": "...", "working_directory": "/a"} works in /a only
{"prompt": "...", "working_directory": "/a", "permission": "read"} reads /a only
{"prompt": "...", "working_directory": "/a", "additional_workspace_directories": ["/b"]} writes /a and /b
{"prompt": "...", "working_directory": "/a", "additional_readable_directories": ["/b"]} writes /a, reads /b
{"prompt": "...", "working_directory": ".", "additional_readable_directories": ["/b"]} your directory and /b, nothing else of yours
{"prompt": "...", "working_directory": "/a/out", "additional_readable_directories": ["/a"]} reads /a, writes out
What it reports. The result opens with the sub-agent’s id and the scope it holds, in the lines the environment block uses, and the report follows:
Agent: 3f2c…
Permission: workspace
Working directory: /home/u/repo-a
Additional workspace directories:
/srv/exports
Additional readable directories:
/home/u/repo-b
The workspace line is omitted when there is nothing beyond the working directory; none sits on the readable line when the sub-agent reads nothing beyond its workspace, and anywhere when its reads are not confined; a Network: line follows when the sub-agent’s network is narrowed, naming the setting and the tools it holds for: Network: remote (shell and web_fetch); Network: none (web_fetch) at unrestricted, where only the tool is withheld; or Network: remote (web_fetch), none (shell) where the shell’s backend holds the setting as a narrower one. A follow-up’s result opens the same way, with the scope as re-admitted against your reach at that moment.
Network. Pass network: "none" to cut the sub-agent’s shell off the network and withhold web_fetch from it, or network: "remote" to keep both off this machine while other machines stay reachable; the default is your own setting, [permissions].network. A setting above your own is refused, since a sub-agent never reaches wider than its parent, and asking for remote or none beside unrestricted is refused, since an unsandboxed shell cannot hold it; a sub-agent that inherits a narrowed setting at unrestricted holds what you hold, web_fetch under the setting and a shell nothing sandboxes. An MCP server runs outside the sandbox and keeps its own network: a sub-agent that must not reach the network is also given no server that can, through allow_servers or deny_servers. A follow-up narrows the recorded setting to yours at that moment, as it narrows the level. The scope lines of a sub-agent whose network is narrowed end with a Network: line naming it.
Tools. Four lists narrow what the sub-agent sees, and nothing widens it. allow_tools names exactly the tools it sees, by name as they appear in your own tool list, built-ins and mcp__<server>__<tool> names in one namespace; everything else is withheld, so a worker that needs two tools is spawned with two names rather than twenty denials. allow_servers names the MCP servers it may see at all; a server outside the list is withheld whole, its tools, its resources and its prompts, and built-ins are untouched. deny_servers and deny_tools withhold by name as before. The lists compose the way [tools] and each [[mcp.servers]] compose theirs: a tool must pass every list with an opinion about it, and a deny wins on overlap, so a name in both allow_tools and deny_tools is withheld. Every list narrows what [subagents] and your own ancestors already set: denials accumulate down the tree and allow-lists intersect, so a nested agent_spawn can only ever narrow further and cannot name back a tool its parent lost. An empty allow-list is refused; omit it to withhold nothing. A name that matches no built-in and no configured server restricts nothing and is warned about. Config is the place to put a restriction you always want, since the failure mode this guards against is an orchestrator forgetting to ask for it.
Profile. With [subagents].agent_chosen_profile on, pass profile to run the sub-agent on another configured profile; the parameter lists every configured name. A sub-agent given a profile keeps it on every agent_followup, whatever profile the parent has since switched to. Only an act on the sub-agent’s own session, such as an import onto another profile, moves it. Without the parameter the sub-agent runs on the parent’s profile and follows it across a switch. The parent’s own profile needs no naming.
Context is granted, not inherited. A sub-agent starts with a clean slate and receives only what you ask for:
memory: "read"grants read access to your memory store. Default"none", because memories from unrelated work are context the sub-agent pays for and reasons from. Sub-agents can never write to the store: record anything worth keeping yourself, from the sub-agent’s report.instructions: "inherit"hands over your instructions file verbatim. Default"none", because those instructions describe you: your persona, how to address the user, what to volunteer. A sub-agent handed one task by one of your turns is not you. Grant them when the task needs the project’s standing rules and quoting the relevant ones intopromptwould be lossy or expensive; pass askillwhen the direction is reusable.
Neither can be granted beyond what you hold yourself, so authority only narrows going down a chain of sub-agents. A sub-agent you gave no memory cannot give its own sub-agents any.
What it sees. A sub-agent gets the same per-turn context the root does, over its own registry: the tool discovery index, the skill index, the memories it was granted, and the instructions of the MCP servers it is allowed to use. Its conversation, checklist and scratchpad entries are private; filesystem changes are shared within the workspace it was given.
Follow-up. agent_spawn returns the sub-agent’s id on the first line of its result, above its scope and its report. Keep it if you might have a second question: with it you can call agent_followup instead of re-spawning one that would have to rediscover everything.
agent_list / agent_followup / agent_steer / agent_delete
A sub-agent is not a one-shot. Its conversation persists under its own session, so you can go back to it.
agent_list: the sub-agents this session spawned, one per line as<id>\t<cwd>\tturns=<n>\tlast_active=<timestamp>. Direct children only: a sub-agent’s own sub-agents belong to it and appear in its list.agent_followup({id, prompt, scratchpad?}): asks a sub-agent another question. It still has its own conversation, so it can build on what it already found rather than starting from your summary of it. Returns its new report. A sub-agent that is still running, because it was spawned or followed up withbackground: true, refuses a follow-up and says so: reach it withagent_steer, or follow up once it has finished.agent_steer({id, message, interrupt?}): puts a message in the sub-agent’s inbox and returns at once, with no answer. A sub-agent that is running reads it at its next round boundary, after that round’s tool results or after the reply it was writing, which it then answers; one that has finished reads it at the start of the nextagent_followup, after the follow-up’s own words. Either way it arrives under a header naming the parent as the sender and when it was sent. For a correction or a change of course while the work is under way; useagent_followupwhen you want a reply. Withinterrupt: truethe sub-agent does not finish its current step first: the answer it is writing is cut and kept as far as it got, and the message is the next thing it reads, inside the same turn; a tool it is running still finishes, and the message follows that tool’s result. The cost is the request sent again, so set it when the answer under way is being wasted, not merely when you have something to add. A profile that does not stream has no partial answer to keep: the reply being generated is dropped whole and the request goes again with the message.agent_delete({id}): discards a sub-agent: its conversation, its scratchpad entries, and any sub-agents it spawned in turn. Nothing it wrote to disk is touched. Worth doing once you have what you needed, so a long session isn’t carrying every sub-agent it ever ran.
agent_followup, agent_steer and agent_delete take the full id, or any prefix that is unique among this session’s sub-agents; a prefix two of them share is refused and both are named. All three refuse an id that isn’t a child of the current session, so one session can never drive, steer or delete another’s sub-agents.
A parent can also read a sub-agent’s conversation directly, without spending a turn on it: conversation_search and conversation_read take a sub-agent’s id, and session_list shows the ids.
All five go together. Denying agent_spawn in [tools].disabled_tools, or setting [subagents].max_depth = 0, removes the four lifecycle tools too: an agent that cannot delegate has no sub-agents for them to act on, and leaving them behind would let it drive the ones a previous run left in the store. meka tool list reports all five as disabled in either case. Denying only agent_list removes just that one.
A follow-up runs under the terms of the spawn, not your current ones. The permission level, the scope, the four tool lists, the memory level and the inherited scratchpad names are recorded when the sub-agent is created and replayed on every follow-up. If you spawned a sub-agent at read and have since switched to unrestricted, following up on it still runs it at read. That is deliberate: otherwise a second question would be a way to escalate a sub-agent you deliberately restricted. A sub-agent spawned with nothing named keeps the working directory it was spawned in and follows the rest of your scope; one with a scope of its own keeps that scope. Either way a follow-up re-admits what the sub-agent holds against your reach at that moment, at the level it runs at then: a directory you can no longer grant refuses the follow-up, and a level you have dropped to narrows it.
One thing does not survive a follow-up, because it only ever lived in memory: which files the sub-agent had read. It is told as much at the start of the turn. Its checklist does survive, read back from its own conversation, so a follow-up still owes what the sub-agent left open. Its context gauge does survive: the follow-up starts from the occupancy the sub-agent’s row last recorded, so its first turn back is checked against the ceiling like any other.
One follow-up at a time per sub-agent. A second concurrent call on the same sub-agent is refused rather than interleaved, since both would be appending to one conversation from a view of it that the other has already changed.
The skill_* tools
Skills are knowledge packages stored in ~/.config/meka/skills/<name>/SKILL.md. The per-turn context lists the installed ones with their descriptions; these tools open, search, and (when enabled) maintain them.
skill_read({"name": "<skill-name>"})returns the full body, prefixed with the skill’s base directory.skill_search({"pattern": "<regex>"})matches each line of every skill, bodies included. This is what reaches skills the capped index did not list, and what answers “which of my skills covers this” when the one-line descriptions do not.skill_write({"name": ..., "description": ..., "priority": ..., "body": ...})creates or updates a skill. Omitted fields keep their values; a givenbodyreplaces the existing one whole.skill_edit({"name": ..., "old_string": ..., "new_string": ..., "replace_all": ...})replaces one exact string in the body and leaves the frontmatter alone. Anold_stringthat matches more than once is refused unlessreplace_allis true, and an edit computed against a body that has since changed is refused rather than written over it.skill_delete({"name": ...})removes the skill’s whole directory, bundled files included.
The last three are registered only when [skills] agent_managed is on, and never for a sub-agent. See Skills for how to author skills and Letting the agent manage skills for when to hand authoring to the agent.
image_render
Displays an image the agent has in memory, as base64 bytes or in a scratchpad entry, as a multimodal content block. Complements web_fetch (network) and file_read (local file) by covering the third case: image data produced on the fly by a command pipeline.
Typical workflow:
shell_execute({"command": "ffmpeg -i input.mp4 -vframes 1 -f image2pipe pipe: | base64 -w0", "scratchpad": "frame"})
image_render({"from_scratchpad": "frame"})
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
from_scratchpad | string | one of two | Name of a scratchpad entry containing base64-encoded image bytes |
base64 | string | one of two | Base64-encoded image bytes, passed inline |
Exactly one of from_scratchpad or base64 must be provided. Prefer from_scratchpad for large images; inline base64 inflates tool-call JSON.
The bytes must decode to a supported raster image. PNG, JPEG, GIF, and WebP pass through unchanged; BMP, TIFF, ICO, HDR, EXR, TGA, PNM, QOI, DDS, and Farbfeld are auto-converted to PNG. Size cap is ~3.75 MB on the final payload.
Only call image_render when the current model supports vision input.
conversation_search / conversation_read
Search and read a session’s full conversation, including earlier turns that compaction summarized and removed from the model’s context. Compaction never deletes turns (it appends a boundary and hides the older ones); these tools read straight from the on-disk event log, so a detail the compaction summary dropped is still recoverable, within [session].turn_retention when that is set.
conversation_search searches and returns matching lines, each tagged with a message index (#N) and role:
conversation_search({"query": "auth token", "is_regex": false, "limit": 20})
query(required): text to search for; a literal substring (case-insensitive) unlessis_regexis set.sessions: session ids to search instead of this session, up to 20; a single id may be passed bare. Matches are grouped by session, and the read hint names the session beside the index.is_regex: treatqueryas a case-sensitive regular expression. Default:false.limit: maximum matches to return (capped at 100), shared across the sessions searched. Default: 20.
conversation_read reads turns by the #N index that conversation_search reports:
conversation_read({"start": 47, "limit": 3})
start(required): 1-based message index to read from.session: the session to read instead of this one.limit: number of consecutive messages to read (max 20). Default: 1.scratchpad: save the output to a scratchpad entry instead of returning it inline.
After a compaction, the summary message reminds the agent that these tools exist. Large tool outputs appear as <large-output> references in both conversation_search and conversation_read results (rather than inlining the full payload); read their full content with scratchpad_read.
Which sessions a call may read is one rule, shared with session_list and session_search below: the calling session and its direct sub-agents, by full id or by a prefix unique among them, or every session in the store when the session runs at unrestricted, the level at which the shell reaches the store anyway. The level is the live one, so a sub-agent at unrestricted reads its parent and its siblings; below it the read is one way, parent to sub-agent, because a parent’s log may hold reads from outside the sub-agent’s boundary. Below unrestricted, an id that is not readable gets the same refusal whether or not it exists, so the tool does not reveal which sessions are there. With approvals on this refusal is not put to you, as a path refusal is not.
session_list / session_search
The sessions the agent may read, so it can find one before reading it. Both follow the rule above and never refuse: they show what is in reach, which below unrestricted is the session itself and its sub-agents, and at unrestricted the store.
session_list takes no arguments. It lists the calling session first and then its sub-agents, or at unrestricted the store’s 50 most recent root sessions, one row each with the full id, the last update, a sub-agent’s parent, and the title. A listing cut at that count says so. It ends with This session: <id>, the one place the agent is told its own id.
session_search is meka session search for the agent: the same index, over the sessions in reach.
session_search({"query": "blind spot timestamp", "limit": 10, "include_children": false})
query(required): the words to find. What was said is matched, user and assistant text and compaction summaries, not tool calls, tool results or thinking;conversation_searchcovers those within a session.limit: maximum sessions to return (capped at 100). Default: 10.include_children: atunrestricted, sub-agent sessions too; within a tree they are always in. Default:false.
A session whose title holds every word is listed first, the rest by their best-matching message, each row with the id, the last update, the title and the line it was found by. Neither tool is available to a checkpoint turn, which may save but not read other sessions.
context_check / context_compact
Where conversation_* reads the archive (the full log on disk, including turns compaction removed from the window entirely), context_* manages the live window.
context_check takes no arguments and reports the current state:
Using 84000 of 200000 tokens (42%).
Headroom: 86000 tokens before the context ceiling at 85%. Auto-compaction fires there,
between turns or between two of your tool rounds.
Compaction may keep about 16000 tokens verbatim: recent rounds, with up to 5333
of that spent on the messages received as they were written; the rest
becomes a summary.
Fixed overhead: about 12000 tokens of system prompt and tool schemas (estimated).
Compaction does not reclaim this.
Conversation: about 72000 tokens, which is the part compaction acts on.
Compactions so far: none, so nothing has been summarized away yet.
This exists because the pushed [Context budget] block is rendered once, at the start of a turn, and so does not move while the agent works. During a long tool loop it is stale. See What the agent sees. The headroom is net of what this round’s whole scratchpad_read calls have already taken, so it is the room the next read gets.
context_compact requests a compaction before the agent’s next step. It runs once the current batch of tool calls finishes, and the turn then continues against the summary; one request is honored per turn.
instructions: what to preserve or drop. Active restrictions, authorizations, and commitments must still be preserved.keep_recent: allow recent rounds to be kept verbatim. Defaulttrue; a tail is kept only when enough older history can be summarized.falserequests no retained tail.
The third tool, context_replace, is how the agent submits its summary from a checkpoint turn. It is in every catalog, so a checkpoint’s request carries the same tool list the turn does and shares its prompt cache, and it is refused outside a checkpoint. It is deliberately outside [tools] configuration: no entry can disable it, since that would silently downgrade every compaction to the fallback summarizer. See Compacting a session.
File operations
file_read
Read the contents of a file at a given path. Supports text files and images.
Permission: Read
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
path | string | yes | The file path to read |
start | integer | no | First line to return, counted from 1 (default: 1) |
end | integer | no | Last line to return, inclusive (default: 2000 lines from start) |
regex | string | no | Return matching line:text rows (capped, exact value advertised in the tool’s parameter schema) instead of a line range; the line numbers are valid start values. Skipped for image files. |
scratchpad | string | no | Save output to the scratchpad under this name |
Behavior
- A read without
endreturns 2000 lines fromstart. Every windowed read ends with a notice naming the range shown and the file’s line count; when the read stopped short of the end of the file, whether because of that default or an explicitend, the notice also names the line to continue from. A definitive answer drawn from a silent truncation is worse than an error. startandendcount from 1 and include both ends, the wayregexmatches, editors and compilers name lines, so a line number from any of them is a validstart. Astartof 0 is refused rather than shifted, and so is anendbefore thestart.- A single read holds at most 16 MiB in memory. Asking for the whole of a file larger than that is refused, because there is no bounded way to return it; asking for a window of one is not, and streams past everything outside the window. So a command-output capture larger than the ceiling stays readable a page at a time, which is what
shell_executepromises when it spills one to a file. - A read that shows the whole file returns it byte for byte, so a CRLF file stays CRLF and an
old_stringcopied out of it applies as written. A windowed read normalizes line endings to\n; if a laterfile_editmisses for that reason it says so. - Under ACP the editor is asked for the whole document and the window is applied here, so both the truncation notice and the freshness fingerprint describe the document rather than the slice.
regexruns the pattern against each line and returnsline:contentrows (likegrep -n). It bypassesstart/endand is meaningless on image content. Under ACP it searches the editor’s copy of the file, like any other text read, so a search and the edit that follows it see the same document.
Image files
Recognized image extensions are returned as base64-encoded multimodal content:
- Provider-native (pass-through):
.png,.jpg/.jpeg,.gif,.webp - Convertible (decoded and re-encoded as PNG transparently):
.bmp,.tif/.tiff,.ico,.hdr,.exr,.tga,.pbm/.pgm/.ppm/.pnm,.qoi,.dds,.ff/.farbfeld - Unsupported (fall through to text read, which will fail on binary):
.svg,.jxl,.heic,.avif
Images are refused if the final payload exceeds 3.75 MB (~5 MB base64). Conversion can enlarge an image, so a small TIFF may produce a too-large PNG.
Every image file_read returns is decoded before it is sent, including the pass-through formats, and one that does not decode is a tool error naming the failure. The same door covers web_fetch, image_render, and an image a client attaches over ACP or the HTTP API. The decode is not about the extension: a truncated or corrupt PNG keeps a valid signature, so nothing short of decoding it tells the two apart. It matters because a broken image is not refused where it is read but inside the provider, by which time it sits in a tool result the session has already saved and every later turn re-sends.
The check is strict, including PNG chunk checksums, so a damaged file that some viewers still render is refused here. That is deliberate: meka cannot know which decoder is on the other end, and being wrong the other way puts an image the provider rejects into the session permanently. The error names what failed, so a file reported as corrupt is worth re-exporting.
JPEG is decoded through a separate strict path rather than the shared one. The library meka uses for every other format hardcodes its JPEG decoder into a permissive mode with no way to switch it off, and that mode returns a picture for a stream truncated to a tenth of its bytes; the file is the one most likely to arrive truncated, so it gets a decoder configured to say so. Truncation at any depth, and a scan corrupted in place, are both refused.
Three cases are not verified, and the last two are gaps rather than decisions:
- An image too big to decode: one whose pixel count would cost more than 128 MiB, roughly 33 megapixels. The ceiling exists to stop a crafted file exhausting meka’s own memory, and declining to decode achieves that; refusing as well would reject legitimate images, since a 6000x6000 screenshot compresses to a few hundred kilobytes and is inside Anthropic’s 8000 px single-image cap. Such a file is passed through and the provider decides. Note that meka cannot downscale one either, so it also bypasses the 2000 px multi-image cap the Claude provider applies.
- Frames after the first of an animated GIF or WebP: the decoder reads one frame, so damage confined to later frames is not seen.
- An image arriving from an MCP server, which sniffs magic bytes only rather than decoding a payload meka did not produce, and a conversation restored by
meka session import, whose message content is stored as supplied. A broken image through either door reaches the provider; the degrade-and-retry is what recovers the session when it does.
Only read image files when the current model supports vision input; text-only models will either error or silently drop the image block.
Examples
Read an entire file:
> show me the contents of src/main.rs
Read lines 10-20:
> show me lines 10 through 20 of src/main.rs
file_edit
Modify a file. Supports two modes: replace (swap old_string for new_string) and insert (place content before or after old_string while preserving the anchor). The file must have been read with file_read first (unless force is set).
Permission: Workspace
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
path | string | yes | The file path to edit |
old_string | string | yes | The exact string to find (acts as anchor in insert modes) |
new_string | string | one of three | Replace mode: replacement for old_string (an empty string deletes it) |
insert_before | string | one of three | Insert mode: text inserted immediately before old_string (anchor preserved) |
insert_after | string | one of three | Insert mode: text inserted immediately after old_string (anchor preserved) |
replace_all | boolean | no | Apply to every occurrence (default: false). If false and old_string matches more than once, the edit is refused as ambiguous |
force | boolean | no | Proceed despite the file not having been read first, or having changed since it was read (default: false) |
scratchpad | string | no | Save output to the scratchpad under this name |
Exactly one of new_string, insert_before, or insert_after must be provided. Mixing modes is refused.
Behavior
-
A path outside the workspace roots is refused unless the level is
unrestricted; the refusal names the roots a write may land under. -
If
old_stringmatches more than once andreplace_allis not set, the edit is refused. Add surrounding context to make the anchor unique, or setreplace_allto change every occurrence. -
To delete text, use replace mode with an empty
new_string. -
The file must have been previously read with
file_readon the same path. This prevents blind edits. Setforceto bypass this requirement. -
The read must still be valid. meka records the file’s modification time and size when it is read, and refuses an edit if either has changed since:
Error: file 'src/main.rs' changed on disk after you read it. Something else wrote to it (a shell command, another agent, or the user). Read it again before editing so you are not overwriting that change, or set force=true.This is a deliberately different message from the never-read case, because the next move differs: re-read to see what changed, then decide whether the edit still applies. Anything can be the other writer, a
shell_executerunningsed -i, a background task, or you in another window.file_writeand a successfulfile_editboth re-record the file, so consecutive edits never trip it.A read served by the editor under ACP is checked against the editor, not the disk. Those are two different documents that share a path: the editor serves its own copy of every file it owns, saved or not, so comparing one to the other would fire every time you save a file nobody edited and stay silent when you rewrite the buffer the agent is about to edit. meka fingerprints what the editor served and compares it against what the editor serves when the edit arrives, which it fetches anyway. Editing the buffer, or the editor reloading a file something else rewrote, is reported:
Error: file 'src/main.rs' changed in the editor after you read it. Someone edited the buffer, or the editor reloaded the file. Read it again before editing so you are not overwriting that change, or set force=true to edit anyway.Saving does not trip it: the document is unchanged, only the bytes on disk moved.
-
If
old_stringis not found, the tool returns an error (without modifying the file). -
On success, the response includes a small ±3-line snippet (with line numbers, lines truncated at 200 chars) around the first edited site so you can confirm the change landed without re-reading the file.
file_write
Create or overwrite a file with the given content.
Permission: Workspace
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
path | string | yes | The file path to write |
content | string | yes | The content to write to the file |
force | boolean | no | Proceed despite the file having changed since it was read, or existing but being unreadable (default: false) |
scratchpad | string | no | Save output to the scratchpad under this name |
Behavior
- Creates parent directories if they do not exist.
- A path outside the workspace roots is refused unless the level is
unrestricted; the refusal names the roots a write may land under. - Overwrites the file if it already exists.
- Overwriting an existing file is subject to the same staleness check as
file_edit: if the file was read and has changed since, the write is refused with the message shown above andforceis the way past it. A whole-file rewrite is the more destructive of the two, so it is not the more permissive one. Creating a new file needs no prior read.
Search tools
Both tools default to sweeping every workspace root: the
working directory, plus any extra folders an ACP client supplied. Passing path searches exactly
that tree instead.
file_find
Find files matching a glob pattern.
Permission: Read
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
glob | string | yes | Glob pattern to match files against |
path | string | no | Directory to search in. Omitted, every workspace root is walked |
limit | integer | no | Maximum results to return, at least 1 (defaults to 500 inline) |
scratchpad | string | no | Save output to the scratchpad under this name |
Behavior
- Results are limited to 500 matches inline;
limitraises the cap andscratchpadlifts it. - Returns one file path per line.
- The walk stops after 60 seconds. The result set is still returned, with a note saying it is incomplete, so a search rooted too high in the tree costs a minute rather than hanging the turn.
- Interrupting the turn (Ctrl+C, or
session/cancelfrom an editor) stops the walk. - Paths that cannot be read are skipped and counted; the total is reported once at the end rather than logged per path.
Glob patterns
| Pattern | Matches |
|---|---|
*.rs | All .rs files in the current directory |
**/*.rs | All .rs files recursively |
src/*.txt | All .txt files in src/ |
test_* | All files starting with test_ |
file_search
Search file contents using a regex pattern. Powered by the ripgrep library.
Permission: Read
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
pattern | string | yes | Regex pattern to search for |
path | string | no | File or directory to search in. Omitted, every workspace root is walked |
glob | string | no | Glob matched against the filename, not its relative path (e.g., *.rs) |
limit | integer | no | Maximum matches to return, 1 to 100 (default: 100; unbounded with scratchpad unless set) |
scratchpad | string | no | Save output to the scratchpad under this name |
Behavior
- Searches recursively through directories, without following symlinked directories.
- Skips hidden files (starting with
.), thetargetandnode_modulesdirectories, and, belowunrestricted, meka’s own directories: the config directory and the data directory holdingmeka.db.file_findsteps around the same two. .gitignoreis not honored. Only the matcher comes from ripgrep; the walk is meka’s own, and those exclusions govern the walk. A build directory that is ignored but not named above is searched, so passgloborpathto stay out of one.- Name a hidden file, skipped build directory, or symlinked directory explicitly with
pathto search it. Private meka directories still requireunrestricted. - Results are limited to 100 matches;
limitlowers the cap, andscratchpadlifts it unlesslimitis also set. The search stops once the cap is exceeded instead of reading the rest of the tree to fill a result set it will truncate anyway. - The search stops after 60 seconds, returning what it found with a note saying it is incomplete.
- Interrupting the turn (Ctrl+C, or
session/cancelfrom an editor) stops the search. - Each result includes the file path, line number, and matching line.
Web
web_fetch
Fetch a web page and return its content as markdown text.
Permission: Read
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
url | string | yes | The URL to fetch |
limit | integer | no | Maximum characters to return (default: 30000, 0 for no limit) |
headers | object | no | Custom HTTP headers (overrides defaults like User-Agent) |
regex | string | no | If provided, return only matching content (matches joined by newlines) |
raw | boolean | no | Return raw HTML instead of converting to markdown (default: false) |
scratchpad | string | no | Save output to the scratchpad under this name |
Behavior
- Fetches the page via HTTP GET.
- Converts HTML to Markdown using
fast_html2md(unlessrawis true).<nav>and<footer>containers are preserved (rewritten to<div>before conversion) so their links survive;fast_html2mdwould otherwise drop those subtrees as boilerplate.<script>/<style>/<head>are still stripped. - Resolves root-relative links against the page’s final (post-redirect) URL, so a
/docshref renders as the absolutehttps://host/docsthe model can follow directly. - Truncates the output to
limitcharacters (default: 30,000). Whenregexis given, the pattern runs against the whole document before this cap, solimitnever decides which matches exist; the cap then applies to the joined match list. - HTTP timeout: 30 seconds by default;
[web].request_timeoutchanges it, andconnect_timeout/read_timeoutadd tighter caps on the handshake and on a stalled body. - Reads at most 10 MiB of decompressed body, checked while streaming so a small compressed payload cannot expand past it.
- Returns the HTTP status code as an error if the request fails (e.g., 404, 500).
web_fetchis not a network boundary. It reaches whatever the process can reach, including private and loopback addresses, and so does a sandboxedshell_execute, whose network is open. Confine the network at the host, not per tool.
Image URLs
If the response Content-Type is a supported raster image format, web_fetch returns a multimodal Image content block instead of markdown. No disk is touched; bytes are base64-encoded in memory.
Provider-native formats (passed through unchanged):
image/png,image/jpeg(andimage/jpg),image/gif,image/webp
Convertible formats (decoded and re-encoded as PNG transparently):
image/bmp(andimage/x-ms-bmp),image/tiff,image/vnd.microsoft.icon/image/x-icon,image/vnd.radiance(HDR),image/x-exr,image/x-targa,image/x-portable-*(PNM),image/qoi,image/vnd.ms-dds,image/x-farbfeld
Unsupported formats (fall through to the text branch): image/svg+xml, image/jxl, image/heic, image/avif.
- The
limit,regex, andrawoptions do not apply to image responses. - Size cap of ~3.75 MB applies to the output bytes (after conversion). Conversion can enlarge an image, so a 1 MB TIFF may produce a larger PNG.
- Detection uses the response’s actual
Content-Typeheader, so redirect chains and extension-less URLs are handled correctly.
Only fetch image URLs when the current model supports vision input; text-only models will either error or silently drop the image block.
Shell tool
shell_execute
Execute a shell command and return its output.
Permission: read (sandboxed read-only) / workspace (sandboxed, writable inside the workspace) / unrestricted (unsandboxed)
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
command | string | yes | The shell command to execute |
timeout_ms | integer | no | Timeout in milliseconds (default: 30000) |
scratchpad | string | no | Save output to the scratchpad under this name |
Behavior
- Executes the command via
sh -c "<command>"on Unix, orpowershell.exe -NoProfile -NonInteractive -Command "<command>"on Windows, whether or not the command is sandboxed. - Captures both stdout and stderr.
- Returns the exit code along with the output if non-zero.
- Oversized output is losslessly persisted to the scratchpad by the agent layer; the tool never truncates what it returns to the agent.
- A command may print up to 64 MiB across stdout and stderr. Past that it is stopped, the way a timeout stops it, and the result states the reason and then carries everything it printed up to the stop. Nothing is elided and nothing is written to disk: the whole output is in the result, and in the scratchpad when it is too large for the conversation. The bound exists because a command that writes faster than the turn ends (
cat /dev/zero, a runaway build log) would otherwise grow one buffer until the process died; a command that hits it wants a narrower rerun (grep,head,tail), not a larger bound. - Default timeout is 30 seconds. If the command exceeds the timeout, it is killed with every process it started (on Unix through its process group, on Windows through its Job Object), and the result keeps what it printed before the kill.
- Supports cancellation: pressing Ctrl+C while a command is running kills the command and everything it started.
Shell-specific semantics
- Unix (
sh -c): POSIX$VARexpansion applies. Pass a literal$with single quotes ('$foo') or backslash escape (\$foo). - Windows (
powershell.exe -Command): The script body reaches PowerShell directly. Use PowerShell syntax ($var = ...,$env:PATH), and crucially, do not wrap your command in anotherpowershell -Command "...". The outer PowerShell will expand your inner$varreferences to empty strings before the inner shell runs, producing a parser error on mangled syntax. If you need to invoke a nested script, drop it into a.ps1file and run it by path, use-EncodedCommand <base64>, or escape each$as`$.
Network
The sandbox leaves the network open by default: the threat model is “no state mutation”, and curl http://x | pdftotext must keep working. [permissions].network narrows it for a confined command and for web_fetch alike. none cuts a command off: Bubblewrap unshares the network namespace, so not even loopback answers; Landlock handles every TCP bind and connect from ABI v4 and every UDP bind and send from ABI v10 (kernel 7.2), and grants no port, which leaves UDP open on a kernel between the two, and no other protocol, so an ICMP socket (ping) still reaches the network where Landlock confines alone; sandbox-exec withholds its outbound rule under (deny default), which closes every socket while the system resolver’s Mach service stays reachable, so a command can still look names up; the Windows AppContainer grants no network capability. remote keeps a command off this machine while other machines stay reachable, which the AppContainer holds; Bubblewrap, Landlock and sandbox-exec hold it as none, cutting the command off whole, and startup says so once with the value to set that silences the warning. The Windows restricted token cannot cut a command off at all and warns once at startup. The setting reaches web_fetch too, which runs in the meka process where no sandbox reaches: withheld whole under none, and under remote refusing this machine’s own addresses at the URL, at every redirect and at the resolver; with a proxy configured the proxy resolves the target and the resolver sees the proxy’s name alone, as under [web].refuse_private_destinations. It never reaches an MCP server, which is a process outside the sandbox: withhold the server instead. A sub-agent holds its parent’s setting unless its spawn narrows it with network, and the scope lines of a session whose network is narrowed name what the shell and web_fetch each reach, Network: none (shell and web_fetch) or Network: remote (web_fetch), none (shell).
Memory
A command that takes every byte the machine has is the process that loses it, not meka, wherever the platform has a lever, so the session survives and the result says the command died:
- Linux: every process meka starts runs at the highest
oom_score_adj, inherited by the shell, the Bubblewrap or Landlock layer around it and everything they start. Whether the service’s cgroup or the whole host runs out of memory, the kernel’s OOM killer takes that process, a stdio MCP server included, ahead of meka and of everything else on the host, whatever meka’s own score is. - Windows: every process meka starts runs in a Job Object whose committed memory stops at the machine’s physical memory. Past it the command’s own allocations fail and it exits; meka’s never do. The cap is per command, so two commands can still exhaust the machine together. The job ends with the command: when it returns, times out or is stopped, every process it started is ended, so a server a command left running in the background does not outlive the call. A program started through an app execution alias (the
pythonorwingetshims underWindowsApps) runs outside the command’s job, so neither the cap nor the kill reaches it; name such an executable by its real path. - macOS: no lever exists for one process over another. The system takes the largest footprint first, which is the runaway command.
This covers every process meka starts: shell commands, the screen’s ! commands, schedule gates, stdio MCP servers and an HTTP MCP server’s headers_helper. Two exceptions are the user’s own processes: the editor meka skill edit and meka memory edit open, and the browser the screen opens for a URL a server asks for.
Running meka serve under systemd, give the unit a ceiling and tell systemd that a command’s death is not meka’s:
[Service]
MemoryMax=75%
MemorySwapMax=0
OOMPolicy=continue
OOMScoreAdjust=-900
MemoryMax=75% is relative to the machine’s physical memory, so the unit moves with its host. OOMPolicy=continue keeps the unit running when the kernel kills a command inside it; the default, stop, ends meka too. OOMScoreAdjust=-900 ranks meka below everything else the host runs.
Read-only sandbox
At read, commands run inside a sandbox that blocks writes to the user’s real data. Reads, program execution, and network access still work normally: the threat model is “no state mutation, but curl http://x | pdftotext must keep working.”
What’s blocked vs allowed (across all backends)
| Surface | Blocked | Allowed |
|---|---|---|
| Filesystem writes outside tmp / Low-integrity paths | ✓ | |
| File metadata changes (mode, owner, timestamps, extended attributes) | Bubblewrap / macOS / Windows restricted token / Windows AppContainer, which alone still lets a command rewrite the ACL of a file the user owns, an owner right no grant withdraws | Landlock alone, on every kernel |
| Filesystem reads of meka’s config directory and store | Bubblewrap / Landlock / macOS / Windows AppContainer | Windows restricted token |
Filesystem reads outside the session’s roots and the system directories, with [permissions].confine_reads on | Bubblewrap / Landlock / macOS / Windows AppContainer | Windows restricted token |
Filesystem reads elsewhere, with confine_reads off | ✓ | |
Unix sockets the Bubblewrap masks do not cover ($HOME, /var/lib, the abstract namespace) | Landlock, and Bubblewrap on kernel 7.1+ (6.12+ for the abstract namespace) | Bubblewrap on older kernels / Windows |
| Program execution | ✓ | |
Outbound network, with [permissions].network at its default host | ✓ | |
Outbound network, with [permissions].network = "none" | Bubblewrap (the namespace is unshared) / Landlock (every TCP port from ABI v4 and every UDP port from ABI v10; UDP stays open below v10) / macOS / Windows AppContainer | Windows restricted token |
Outbound network to this machine, with [permissions].network = "remote" (other machines stay reachable) | Windows AppContainer; Bubblewrap / Landlock / macOS hold it as none and cut the command off whole | Windows restricted token |
| dbus / systemd-user state mutations | Bubblewrap / macOS / Landlock | Windows |
| Mach IPC state mutation (launchd, pasteboard, LaunchServices) | macOS | Linux / Windows |
| The user’s clipboard, other windows’ handles and messages, input injection, global atoms, system parameters, display settings and desktop switches (Windows) | Windows, on both backends: the job’s UI restrictions, with the IME and injection bits from the builds that have them | |
| COM / RPC to Low-integrity-accepting services (Windows) | ✓ | |
| Inheritance of sensitive parent env vars (API keys, OAuth tokens, …) | ✓ (all platforms) |
The sandbox is not an adversarial containment boundary; it’s defense-in-depth against an agent accidentally modifying user data. Set permission to none if you don’t trust a turn at all.
Scratch space
Whether a confined command has somewhere to write temporary files differs by backend:
| Backend | Scratch space | Effect |
|---|---|---|
Bubblewrap (read) | Private /tmp tmpfs, gone with the sandbox | mktemp, Python’s tempfile, gcc, patch and pip builds all work |
Landlock (read and workspace) | None outside the roots | Those tools fail with Permission denied |
Windows workspace | None outside the roots | New-TemporaryFile is denied (measured) |
macOS Seatbelt (read) | Per-backend; see below |
Under Bubblewrap the child writes into an in-memory /tmp that exists only inside the sandbox’s mount namespace, so nothing real is touched. Landlock can only decide which real paths a process may touch, and the only temporary directory meka could offer there would be a real one under the real /tmp; below unrestricted meka writes to nothing but its own store, so it offers none. A tool that needs temp files under Landlock is the reason to install Bubblewrap. git needs none of this: its lock file lives inside the repository, which is read-only under both backends alike.
The practical cost is diagnostic: the failure is a bare Permission denied naming a path in /tmp (or %TEMP% on Windows), with nothing in the message connecting it to the sandbox. If a command fails that way and you expected it to work, add the directory it wants as a writable root at workspace.
Environment variable scrubbing
The read sandbox still permits outbound network (the threat model intentionally keeps curl http://x | pdftotext-style pipelines working), so any secret in the parent process’s environment (ANTHROPIC_API_KEY, AWS_SECRET_ACCESS_KEY, GITHUB_TOKEN, OAuth tokens, etc.) would be a live exfiltration vector under prompt injection. meka scrubs the child environment at spawn time across every backend (Bubblewrap, Landlock, Seatbelt, and both Windows backends).
-
Unix (Linux + macOS): allow-list. Only a curated set of vars survives into the
readchild:PATH,HOME,USER,LOGNAME,SHELL,PWD,TERM,COLORTERM,LANG,TMPDIR,TMP,TEMP, plus everything matching theLC_*andXDG_*prefixes. Becausereadintentionally keeps outbound network working, the proxy and CA-bundle vars survive too:HTTP_PROXY,HTTPS_PROXY,NO_PROXY,ALL_PROXY(and their lowercase spellings),SSL_CERT_FILE,SSL_CERT_DIR,REQUESTS_CA_BUNDLE,CURL_CA_BUNDLEandNODE_EXTRA_CA_CERTS, several of which redirect TLS trust, so treat them as part of the boundary. Anything else is dropped, including credential-shaped vars (AWS_*,GITHUB_TOKEN,OPENAI_API_KEY, …) and credential-pointer vars (SSH_AUTH_SOCK,KUBECONFIG,GNUPGHOME,NETRC,GIT_ASKPASS,GIT_SSH_COMMAND, etc.) as well as benign-but-unlisted vars likeEDITOR,PAGER,DISPLAY, custom toolchain vars, and so on. Unknown vars are dropped by default. -
Windows: deny-list. PowerShell pulls in a long tail of system vars (
PSModulePath,APPDATA,ProgramFiles, etc.) that don’t fit a tidy allow-list, so the Windows path lets everything through except names that match a heuristic deny-list. Dropped names include:- Credential-shaped substrings:
*TOKEN*,*SECRET*,*PASSWORD*,*PASSPHRASE*,*API_KEY*,*_KEY*,*BEARER*,*CREDENTIAL*, etc. - Credential-pointer substrings:
SSH_AUTH_SOCK,KUBECONFIG,GNUPGHOME,NETRC,GIT_ASKPASS,SSH_ASKPASS,GIT_SSH_COMMAND. - Provider / service prefixes:
ANTHROPIC_*,OPENAI_*,AWS_*,GCP_*,GOOGLE_*,AZURE_*,GITHUB_*,OPENROUTER_*,GROQ_*,MISTRAL_*,COHERE_*,DATABASE_*,POSTGRES_*,MONGO_*,STRIPE_*,CLOUDFLARE_*,VAULT_*,OAUTH_*,JWT_*,SENTRY_*,SLACK_*,DISCORD_*, and others; seeis_sensitive_env_nameinsrc/sandbox.rsfor the full list.
The deny-list is intentionally aggressive on false positives (a legitimate
GITHUB_ACTORis dropped alongsideGITHUB_TOKEN) because the cost of a missing env var is a confusing tool error, while the cost of a leaked credential is a live exfiltration channel. - Credential-shaped substrings:
unrestricted keeps the full parent environment. This is the trusted-operation path where users legitimately need NPM_TOKEN for npm publish, AWS_* creds for aws s3 cp, GH_TOKEN for gh pr create, etc. If you need a specific var inside a sandboxed shell command, switch to it for that turn.
An approved command is not an exception: with approvals on, a command you approve at read or workspace still runs in that level’s sandbox with the scrubbed environment. Approval never widens reach, so the prompt is a question about whether to run the command, not a grant of the environment behind it.
Linux: pick a backend
Linux has two sandbox mechanisms and three values for [shell].sandbox_backend in config.toml:
- Bubblewrap (
sandbox_backend = "bubblewrap", recommended): wraps the command inbwrapwith--ro-bind /, tmpfs masks over/run,/tmp,/var/tmp, and$XDG_RUNTIME_DIR, plus--unshare-user --unshare-pid --unshare-uts --unshare-ipc. The tmpfs masks make the dbus session bus, systemd-user socket, and other socket-on-disk IPC paths unreachable, sosystemctl --user start <unit>,dbus-send, and similar state-changing calls fail. Network is not unshared. On a kernel with Landlock ABI v6 (6.12) or newer, the command also runs inside meka’s Landlock ruleset: bwrap execsmeka confine, which enacts the ruleset after the mounts and then runs the shell. That closes what the masks leave open: the abstract socket namespace and signals to outside processes, ioctls on real devices, and from kernel 7.1 a socket under$HOMEor/var/libtoo. Requires thebubblewrappackage and a kernel with user-namespace creation enabled; Ubuntu 24.04 also needs thebwrap-userns-restrictAppArmor profile fromapparmor-profiles. - Bubblewrap with Landlock (
sandbox_backend = "bubblewrap-landlock"): the same as Bubblewrap, but the Landlock layer inside is required rather than added where the kernel allows. On a kernel below 6.12 the backend is reported unusable and the shell atreadfails instead of running under bwrap alone. What the layer closes still follows the kernel, sockets on disk outside the masks only from 7.1. Auto-detection never picks it; pin it on a host where it must be certain the layer is there. - Landlock (
sandbox_backend = "landlock", fallback): uses the Landlock LSM. Blocks filesystem writes vialandlock_restrict_self. Requires ABI v9 (kernel 7.1+): that is the first ABI with a right overconnect()to Unix sockets on disk, and below it a sandboxed shell can invoke state-mutating dbus methods andsystemd-run --userescapes the filesystem restriction entirely. meka reports Landlock unusable on older kernels rather than sandboxing with a ruleset that leaves that door open, which means kernels below 7.1 (Debian 13, RHEL 10, Ubuntu 24.04 and 26.04 LTS) need Bubblewrap installed for the shell atread. Closing that route also breaks socket-based clients such asdockerandpsqlatread. meka’s own directories are hidden by never being covered by a rule: reading files is granted per sibling along the path to each of them, so their names and sizes stay visible but their bytes do not, and the one cost is that a workspace root above them (a root at$HOME) can create new files only in its subdirectories. What no Landlock ABI mediates is file metadata: a command atreadcan stillchmod,chown,touchand set extended attributes on any file you own. Prefer Bubblewrap, whose read-only bind refuses those and which removes the socket-on-disk paths on any kernel.
sandbox_backend is unset unless you pin it yourself; no command writes it. When unset, meka probes Bubblewrap once at startup and prefers it when available, falling back to Landlock with one warning at startup: that it did so, and that Landlock cannot stop a command changing a file’s mode, owner, timestamps or attributes. Pinning sandbox_backend = "landlock" accepts that and silences the warning.
[shell]
sandbox = true # default; set to false to disable
sandbox_backend = "bubblewrap" # or "landlock" or "bubblewrap-landlock"; unset = auto-detect
macOS
Uses sandbox-exec with a hardened SBPL profile (modeled after Codex’s vendored seatbelt policy, which is itself based on Chrome’s renderer sandbox). The profile is closed-by-default: filesystem writes are blocked, Mach-lookup is restricted to a curated allow-list of safe services, and mutation paths (launchd job control, pasteboard, LaunchServices, distributed notifications) are not in the allow-list. Network and DNS resolution remain available. macOS has one backend, so sandbox_backend takes no value there; a value from another platform is refused.
Windows: pick a backend
Windows has two backends and two values for [shell].sandbox_backend:
- AppContainer (
sandbox_backend = "appcontainer", preferred): runs the command inside a process security environment the OS enforces, on a Windows 11 build that has the API: 26H2, where meka measured it, and 24H2 and 25H2 from the August 2026 update, where Microsoft lists it. meka’s config directory and store are denied, the read boundary and a sub-agent’s scope hold for the shell as they do for the file tools,network = "none"cuts a command off, and no ACE is written to your directories. - Restricted token (
sandbox_backend = "restricted-token", fallback): spawns the child with a duplicated primary token dropped to Low integrity (SECURITY_MANDATORY_LOW_RID) viaSetTokenInformation(TokenIntegrityLevel, …)atread, and under aWRITE_RESTRICTEDtoken plus an ACE per workspace directory atworkspace. Writes to the home directory,%APPDATA%, Program Files, and system directories (any location with Medium-or-higher integrity ACLs) are blocked by the kernel. Low integrity also strips token privileges, and the same env scrubbing applied on Unix runs here (see Environment variable scrubbing above). It confines writes only: a command can read meka’s store,confine_readsholds for the file tools alone, andnetwork = "none"forweb_fetchalone.
sandbox_backend is unset unless you pin it yourself; no command writes it. When unset, meka probes AppContainer once at startup and prefers it when the build has the API at schema 1.1, falling back to the restricted token with one warning at startup naming what it gives up. A build whose API speaks schema 1.0 has no ingress table and so no loopback exemption: it is not auto-picked, the warning says so, and pinning sandbox_backend = "appcontainer" takes it with network = "host" held as remote. Pinning sandbox_backend = "restricted-token" accepts the fallback and silences the warning. A value from another platform (bubblewrap, landlock) is refused: the shell at read fails and startup names the values Windows takes.
Under AppContainer, read grants every drive root read-only and denies meka’s config and data directories, workspace adds a read-write grant per workspace directory, and confine_reads or a sub-agent’s scope replaces the drive roots with the readable paths; the environment is created per command and closed after it, which also removes the private %TEMP% the OS gives the command. Measured limits of the mechanism, not settings. A read-only grant refuses the directory open that cd makes, so cd from cmd, Python’s os.chdir and git’s own directory change (git -C, hooks) fail into a read-only directory, while the command’s initial directory, PowerShell’s Set-Location and cd into a workspace directory work; read-only git inspection of a checkout outside the workspace therefore fails at read and works at workspace. msys2 bash.exe cannot start at all, so a tool that runs git’s shell scripts fails. A volume without NTFS permissions (a FAT32 removable drive, measured) is unreadable, since it carries no ACE the environment could honor. An app execution alias, such as the python.exe the Python install manager places under WindowsApps, fails to start with “The file cannot be accessed by the system”, while the real executable runs by its own path. A mapped network drive is out of reach, and while one is mapped PowerShell prints Attempting to perform the InitializeDefaultDrives operation on the 'FileSystem' provider failed. on stderr once per command, which is noise and not a failure. Pin sandbox_backend = "restricted-token" where any of these matters more than the boundary. A meka started from an SSH session or a service grants its own window station to AppContainer processes at startup (never the interactive WinSta0, which has the grant already), since PowerShell cannot start inside the environment without it; a failed grant is warned about once.
Under the restricted token, Low integrity is not a total write-denial: the child can still write to the small residual Low-integrity-writable surface (%LOCALAPPDATA%\Low, %TEMP%\Low, any path with an explicit Low-integrity write ACE) and to files it creates itself.
Low integrity does not restrict reads at all, and neither does the workspace token below, so under the restricted token [permissions].confine_reads holds for the file tools alone, and startup warns once when it is on. Integrity levels stop a Low-integrity process writing up, not reading up. A command at read can therefore read meka’s config directory and store, account credentials included, which the Bubblewrap, Landlock, macOS and AppContainer backends hide; sandbox_backend = "appcontainer" is what changes it. Under the restricted token, treat every credential in the store as readable by any sandboxed command, and keep meka’s directories outside every workspace root, because a root’s write grant covers everything under it.
Windows at workspace under the restricted token
workspace under the restricted token uses a second mechanism, not the Low-integrity token above; under AppContainer it is a read-write grant in the environment and nothing below applies. meka derives a capability
SID from each workspace root, places an inheritable GENERIC_WRITE | DELETE ACE for it on that
root, and runs the shell under a WRITE_RESTRICTED token carrying that capability, so a write
succeeds exactly where one of those ACEs exists. Three consequences worth knowing before you use it:
- meka has to own the root, which is what lets it grant without elevation. A network share or another user’s folder cannot be a workspace root.
- PowerShell runs in ConstrainedLanguage mode under a restricted token, so scripts that
construct .NET types fail there while working at
unrestricted. meka’s UTF-8 output preamble is skipped for the same reason, so non-ASCII output may be mangled atworkspace. - The ACE is real, standing state on your directory, visible in
icacls. It is released when the process exits, Ctrl+C included, but not after a crash or a kill.
See Permissions for the full account.
When the configured backend is unavailable
If the configured backend can’t be used (bwrap isn’t on $PATH, user namespaces are denied, the Windows build lacks the AppContainer API, or the value belongs to another platform), shell_execute at read returns a hard error rather than silently falling back. The error names the configured backend, the specific failure reason and the key to fix. Either install what is missing, set sandbox_backend to a value this platform can use, or switch to unrestricted (Shift+Tab).
Disabling the sandbox entirely
To disable sandboxed shell execution altogether, set sandbox = false under [shell]. When disabled, shell commands require unrestricted: read loses the tool entirely, and workspace refuses it with an error naming the key, because there is no longer anything to hold the boundary that level promises. Reach for unrestricted on those turns rather than expecting workspace to quietly run unconfined.
[shell]
sandbox = false
Scratchpad
The scratchpad is a session-scoped working memory that the agent can use to store, retrieve, edit, and manage content without consuming conversation context. Entries are identified by string names and persist across turns within a session.
When the scratchpad is used
- Proactively: The agent stores intermediate results (extracted text, API responses, research notes) for later use.
- Via
scratchpadparameter: any tool call carrying one has its output saved there instead of returned inline. A failed call stays inline, since a refusal or an error is a sentence about the call and not the output the caller redirected; the exception is a command, whose exit code and output are its result however it ended and go to the entry as asked. See Scratchpad parameter for which tools advertise it. - Automatically: when a tool’s output exceeds 30,000 bytes, it is saved under a generated name (e.g.
shell_execute_a1b2c3_1) and replaced with a preview. Reading the entry back is never treated that way: ascratchpad_readreply stays inline however large, sized to what fits in the context window (seescratchpad_readbelow).
Tools
The whole family ships default-active; no tool_load round-trip is required to use any of them.
scratchpad_write
Store content in the scratchpad. If the name already exists, the content is overwritten.
Permission: Read
| Name | Type | Required | Description |
|---|---|---|---|
name | string | yes | Name for the entry |
content | string | yes | The content to store |
scratchpad_read
Read or search a scratchpad entry by name.
Permission: Read
| Name | Type | Required | Description |
|---|---|---|---|
name | string | yes | The entry name |
start | integer | no | First line to return, counted from 1 (default: 1) |
end | integer | no | Last line to return, inclusive (default: the last line of the entry). A read returns as much of the range as fits: one that would carry the context past the context_ceiling_percent line is cut there, whether or not auto_compact is on, on the last whole line that fits, and the reply names the line to continue from. The cut is sized by a token bound that errs toward cutting: letters count about five to a token, every digit, symbol and non-ASCII character counts as one, so dense text such as numbers, hashes or JSON is cut sooner than prose. A read never returns less than the 30,000 bytes any tool may return inline, and is bounded there when the context window is unknown. A single line larger than what fits is cut with a note saying so; save the entry to a file and cut it with the shell. |
regex | string | no | Search the entry and return matching line:text rows (capped, exact value advertised in the tool’s parameter schema); the line numbers are valid start values. |
scratchpad_edit
Edit a scratchpad entry in place. Provide content for a full overwrite, or old_string/new_string for targeted replacement.
Permission: Read
| Name | Type | Required | Description |
|---|---|---|---|
name | string | yes | The entry name |
content | string | no | Full replacement (mutually exclusive with old/new) |
old_string | string | no | String to find |
new_string | string | no | Replacement string |
replace_all | boolean | no | Replace all occurrences (default: false) |
scratchpad_list
List all scratchpad entries as a table with Name, Size, Created and Origin columns, the last own for an entry this session wrote and inherited for one a parent lent a sub-agent read-only. No parameters.
Permission: Read
scratchpad_delete
Delete a scratchpad entry by name.
Permission: Read
| Name | Type | Required | Description |
|---|---|---|---|
name | string | yes | The entry name to delete |
scratchpad_merge
Combine several entries into one without routing the bytes through the conversation. Useful for
collecting parallel sub-agent reports. The entries go in the order given: sources first, as
listed, then every own entry whose name starts with prefix, in name order. The sources are kept
as they are; nothing is deleted, and target is overwritten if it exists. A sub-agent cannot merge
into a name it inherited read-only from its parent, though it may name such an entry in sources;
prefix selects only its own entries.
Permission: Read
| Name | Type | Required | Description |
|---|---|---|---|
sources | array of string | no | Entry names to combine, in this order; optional when prefix is given |
prefix | string | no | Also combine every own entry whose name starts with this, in name order, after sources; target itself is never selected |
target | string | yes | Name to store the result under; overwrites if it exists |
format | string | no | concat_with_headers (default) puts a --- name --- line before each entry’s content, concat joins the contents with a newline, json_array parses each content as JSON (quoting one that is not) into one compact array |
scratchpad_rename
Rename an entry without round-tripping its content through the conversation. Errors if old does
not exist, if new already exists, or, for a sub-agent, if either name is inherited read-only.
Permission: Read
| Name | Type | Required | Description |
|---|---|---|---|
old | string | yes | Current entry name |
new | string | yes | Replacement entry name |
scratchpad_load_file
Read a file’s contents into a scratchpad entry without the bytes passing through the conversation.
The model never sees the payload, which is what makes this the way to stage a large log or document
for inherit_scratchpad. UTF-8 text only; a binary file is refused with its detected MIME type.
Overwrites an existing entry of the same name, and a sub-agent cannot load into a name it inherited
read-only from its parent.
Permission: Read
| Name | Type | Required | Description |
|---|---|---|---|
path | string | yes | The file path to read |
name | string | yes | Name to store the contents under |
scratchpad_save_file
Write a scratchpad entry out to a file, again without routing the bytes through the conversation. A sub-agent can save an entry it inherited, so a sub-agent’s report reaches disk without being copied through the model.
Permission: Workspace
This is the one scratchpad tool that leaves meka’s own storage, so it is the one that requires a
level that can write. It reads as the scratchpad’s file_write and is fenced identically: at
workspace the path must resolve inside a workspace root, and the refusal is the same one
file_write gives. Every other scratchpad tool stays at read because the scratchpad lives in
the store, not your tree.
| Name | Type | Required | Description |
|---|---|---|---|
name | string | yes | The scratchpad entry to read from |
path | string | yes | The file path to write to |
force | boolean | no | Proceed despite the file already existing, or existing but being unreadable (default: false) |
Handing entries to a sub-agent
agent_spawn’s inherit_scratchpad takes a list of the parent’s entry names and grants the
sub-agent read-only access to exactly those:
agent_spawn(prompt: "summarize the failures", inherit_scratchpad: ["build_log"])
The sub-agent’s scratchpad_read falls back to the parent for an inherited name, and its
scratchpad_list shows the entry with origin inherited. scratchpad_write, scratchpad_edit and
scratchpad_delete targeting one return an error, so a sub-agent cannot rewrite what it was lent.
This is how a large captured output reaches a sub-agent without being re-inlined into the prompt.
When you expect to delegate a result later, name it at the source with the scratchpad parameter
(shell_execute({command: "...", scratchpad: "build_log"})) so there is a semantic name to pass
through.
Lifecycle
- Entries are scoped to the session and persist across turns.
- Entries survive session compaction (
/compact). - Entries are deleted when the session is deleted.
- Two sessions can have entries with the same name without conflict.
- Writing to an existing name overwrites it silently.
Internals
A map of the source tree for contributors: how the modules depend on each other, how one turn
travels through them, where state lives, which rules are enforced by a single named predicate, and
what each host offers. The code is the reference; this page says where to look. Every name here
was checked against the tree when it was written, so if a name is missing, grep for it before
assuming the page is right.
Layering
The tree reads top-down. Each module calls the ones below it and never the ones above or beside it,
and tests/layering.rs scans every crate:: path in production code to keep it that way. Every
top-level module has a rank there; an edge may only point at a strictly greater rank. The tolerated
list is empty. One upward edge is by design, tools/subagent.rs building an Agent, because a
sub-agent is an agent.
rank module holds
0 main argument parsing; picks a host or a cli handler
1 host the layer between a host and the agent, and the four hosts under it
2 cli clap definitions and one handler file per subcommand group
2 relay tracing output routed to the screen, or to stderr
3 installation the doors that edit config.toml and the credential rows: accounts, profiles, MCP servers
4 render the screen's rows and markdown layout, the approval block, status lines, the one-shot's answer
5 agent the turn loop, tool dispatch, compaction, recovery
5 view the JSON record shapes --format json and the HTTP API share
6 tools the Tool trait, the registry, the built-ins, MCP tools as Tools
7 prompt the system prompt and the per-turn context block
8 session the materials a session is built from, and its live cells
9 scheduler the sweep that claims due jobs and hands a Wakeup to a host
9 background tool calls the agent starts and does not wait for
10 mcp the MCP client: connections, published tools, progress, auth
11 provider the Provider trait, wire types, the registry, one backend per API
12 frontend the Frontend trait, its events, and the two generic frontends
12 skills skill discovery and loading
12 instructions the standing instructions file
12 oauth expiry, refresh, the refresh lock, PKCE, the person's side of a browser login
12 sandbox read-only confinement for shell_execute
13 workspace cwd, roots, the write fence, private-directory refusals
13 tokens token estimates for the gauge between provider reports
14 store every SQL statement, behind one connection owner
15 schedule what a scheduled job is: parsing, gates, the scheduler's memory
16 conversation the event-log conversation and its title
16 stats per-session counters
16 config config.toml, ResolvedConfig; profile.rs holds accounts and profiles
16 memory the memory entry
17 entry what skills and memories share: an indexed entry
17 permission levels, the enabled set, the shared cell, the approvals switch
17 checklist the checklist: items, states, the open rule
17 image format detection and transcoding
18 fs private directories, atomic replace, file locks
18 process the one door for every process meka starts: behind meka for memory
19 error MekaError
19 sync locks that outlive a panic
19 streams raw stderr
19 paths the config and data directories
20 text pure text helpers: widths, columns, sizes, timestamps, unknown_name
relay is installed by main.rs as tracing’s writer; the screen drains it, and every other
run writes it to stderr. render is reached only from host and cli. text is the lowest leaf so that error
can render MekaError::ProfileNotConfigured through text::unknown_name.
Put new code where its callers already are: a type read by store and host belongs in store or
below, never in host. A new module fails the test until it is given a rank.
| Module | Holds |
|---|---|
src/main.rs | Dispatch only: parse arguments, pick a host or a CLI handler, map the exit code. |
build.rs | The Windows icon resource, and MEKA_GIT_REVISION: the commit the binary was built from, -dirty included, which the screen’s banner shows beside the version; absent outside a checkout unless the build’s environment sets it. |
src/cli.rs, src/cli/ | The clap definitions and one handler file per subcommand group: account, profile, project, session, history, mcp, tool, skills, memory, instructions, schedule, background. Everything that owns the stdout/stderr contract lives here; account, profile and mcp are the prompts, flags and listings over the doors in src/installation/. |
src/installation.rs, src/installation/ | The installation’s editor, shared by the CLI and the HTTP API: account.rs writes [accounts.<name>] under the config file lock, moves a credential on a rename, and holds the browser login that acquires one; profile.rs writes [profiles.<name>] and takes a ProfileChanges; mcp.rs writes a [[mcp.servers]] entry, takes a ServerChanges, stores a server’s secrets and runs its OAuth login behind a LoginPrompt. Patch<T> is the one tri-state every edit is spelled in: a key kept, removed or set. No prompt and no request body reaches it. |
src/host.rs, src/host/ | host/assembly.rs builds a session: SharedDeps, build_session_agent, hydrate_conversation, resolve_profile_switch, record_session_change. host/session.rs runs one: ResidentSession, TurnGuard, the CancelCell, the Sessions registry and its idle sweep, fork_and_lock, outcome claiming. host/scheduler.rs is HostHooks, run_wakeup, the out-of-band turn every host runs the same way, spawn_outcome_poller, the one background-outcome poller both servers spawn, and TurnSource, who started a turn. host.rs keeps wait_for_shutdown_signal, and host/assembly.rs apply_recorded_bindings, which both servers call ahead of every turn, POST /compact included, so a session runs on the profile and the project its row names. host/terminal.rs is what the screen and one-shot share: Ctrl+C, the interruptible turn. host.rs keeps COMMANDS, the slash-command table the screen offers and ACP advertises the rows with an editor_help of. host/tui, host/oneshot, host/acp and host/http are the hosts; host/tui/model.rs is the transcript the screen draws from (a bounded window of the session’s log plus this run’s tail), host/tui/view.rs how each item is drawn and the viewport, host/tui/panel.rs the panel a command’s answer, a picker or the approval prompt opens in, host/tui/screen.rs the task that owns the terminal; host/http/feed.rs is the session feed, the SSE channel and replay ring HttpFrontend produces into and the stream handlers read. |
src/agent.rs, src/agent/ | The Agent and its options; turn.rs runs a turn in phases (open_turn, run_rounds, settle_turn) and owns TurnInput, dispatch.rs executes tool calls and owns run_admitted_call, the one sequence a resolved call goes through, compaction.rs summarizes, recovery.rs decides what a failed request becomes. |
src/view.rs | The record shapes --format json prints and the HTTP API serves, each defined once with its From conversion from the store or config type it shows: SessionView, ProfileView, ProjectView, AccountView, McpServerView, McpToolView, ScheduledJobView, GateView, MemoryDetail, ToolView, SkillView, SkillDetail. A host adds what only it can answer around the shared core by #[serde(flatten)]: the HTTP SessionResponse flattens SessionView under capabilities, turn_in_flight, inbox_pending and approvals_pending; the CLI’s InstalledSkillView and ConfiguredToolView flatten a core under what only a terminal should see, such as a path on this machine. Every Option field is omitted when absent, never null. |
src/session.rs | What a session is made of: CoreMaterials and SessionMaterials (what every agent and registry of a session is built from), SessionCells (permission, cwd, roots, session id, the project, checklist, the published profile, the context gauge, background tasks, the frontend, the session lock slot, a pending compaction), ToolSite (the four cells a built-in reads), AgentOptions and CompactRequest. No host and no SQL. |
src/provider.rs, src/provider/ | The Provider trait, the wire types, MessageAccumulator, the registry of profiles, and one backend per API: anthropic/messages.rs and anthropic/subscription.rs over anthropic/shared.rs; openai/chat_completions.rs, openai/responses.rs and openai/subscription.rs, the last two over openai/responses_wire.rs. Every streaming backend reads SSE through provider/sse.rs; the refresh-once rule for a rejected subscription credential is oauth::send_with_one_refresh; provider/mock.rs is the scripted provider the test suites drive. |
src/tools.rs, src/tools/ | The Tool trait, ToolContext, admit_arguments, the registry and its builders, the gate toolset, the built-in tools, and mcp_adapter.rs: each remote MCP tool as a Tool, and the registry as a subscriber to the client’s tool lists. A built-in reads the session it serves through one ToolSite. |
src/mcp.rs, src/mcp/ | The MCP client: ServerEntry per server, the connector and its reconnects, the client handler, policy.rs (which of a server’s tools register, load eagerly, and at what level, as pure config policy), subscriptions.rs (what the model subscribed to, applied to every connection the entry adopts: per-URI requests on a 2025-11-25 connection, one subscriptions/listen stream on a 2026-07-28 one), each server’s tools published to whoever subscribes, progress and resource-update routing on McpClientContext, and auth. It never names a registry and never talks to a terminal: an interactive login goes through the oauth::LoginPrompt installed on the client context, which only a login run with a person at hand provides. |
src/store.rs, src/store/ | The store. migrations.rs is the append-only ledger; locks.rs the per-session file claim; backup.rs the copy taken before a migration; export.rs the archive format; projects.rs the projects table and the one transaction a rename or a removal is; every other file owns the statements for one table family. |
src/schedule.rs, src/schedule/ | Scheduled jobs as a domain: parsing, gates, and SchedulerMemory (schedule/memory.rs), the per-process record of which refusals have been reported. No SQL. src/scheduler.rs, above the store, is the sweep that fires them. |
src/config.rs, src/config/ | ConfigFile, loading, expand_env_vars (the one ${VAR} grammar), ResolvedConfig, and the vocabulary every layer reads; mcp.rs and serve.rs hold the [mcp] and [serve] schemas. profile.rs is Backend, an account and a profile as written, a profile as resolved through its account, select_profile and require_profile. Live services and probes are resolved by the host, not here. |
src/prompt.rs, src/prompt/world.rs | The system prompt and build_turn_context, the block the model sees ahead of every user turn; world.rs renders the world state, whole or as the diff against what the model was last shown. |
src/fs.rs, src/oauth.rs, src/sync.rs, src/text.rs | Private directories and the file locks; PKCE, refresh, the loopback callback and SubscriptionCredential, the one token-refresh-and-rejection machine both subscription backends run; the one place a poisoned lock is recovered; and every pure text rule the surfaces share. |
One turn
A host admits the turn, the agent runs it, and everything the user sees comes back through a
Frontend.
- Admission. A
ResidentSessioncounts its work in one cell,in_flight, and holding the conversation mutex is what “a turn is in flight” means. HTTP and ACP admit a typed prompt withadmit_turn, which hands back aTurnGuardand samples the cancel epoch; HTTP wraps it inhost::http::state::admit_turnto add the process-wide cap. A scheduled fire, an outcome delivery or an inbox turn goes throughHostHooks::admit, which underservecounts on the same cap and steps back for the next tick when it is full; HTTP compact and rewind takeclaim_idle, which refuses while anything is in flight. The screen and the one-shot drive one session from one thread, so they samplecancel.admit()themselves and take the conversation lock. - Input. The host builds a
TurnInput: the typed prompt or the outcomes it carries, images, and the retention, which the scheduler sets per job and the HTTP turn takes from the request.TurnInput::from_partsis the empty-prompt rule, raisingMekaError::EmptyPromptbefore admission. - The loop.
Agent::run_turnappends one user message of two blocks: aTurnContextblock holding everything meka injected (permission and environment context, world state, budget, background outcomes, the resume notice) and aTextblock holding the words as typed. Providers render the first as text ahead of the second; exports,GET /messages, replays and the title read the words. It then sends aCompletionRequestand dispatches every tool call the response carries.admit_argumentstype-checks the call’s arguments and takes outbackground, the flag that detaches it;admit_tool_calldecides run, ask or refuse. Each call gets aToolContext: the session id, the tool-use id, the prompt id, the frontend and the cancellation token. Every request carries the conversation whole; the context ceiling and compaction are its only bound. The world state’s[Tool discovery]index is bounded like the skills and memory indexes, inprompt::render_tool_discovery: summaries while the section fits 8 KB, names past it, and past 200 names a count that points attool_search. Root agents and sub-agents share this per-turn renderer over their own registries. Workers receive permitted server instructions and granted memories here; their system prompts carry the role and granted standing instructions, without repeating active tool descriptions. Skill updates use the same bounded renderer as the initial skill index. The execution context states the active profile’s image-input capability and whether the host can deliver later turns.admit_tool_calllives intools, wheretool_searchandtool_loadconsult it to say what a call would do before the model makes it. - Recovery. A failed request goes through
TurnRecovery, which decides between a retry, a degraded resend and a reported failure. A rejection thatMekaError::rejects_thinking_signaturenames, Claude rejecting a thinking block sent back, is answered first, by a resend of the conversation since the last compaction withMessage::without_claude_thinkingapplied, recorded as anEvent::Unsignthat names no message; a compaction’s checkpoint and summarizer do the same to their own copies. Once a profile’s retries are spent on an error that says the profile cannot serve (MekaError::is_profile_unavailable),agent::fallbackopens that profile’s breaker in theProviderRegistry, binds the next profile in its chain the turn has not tried, breaker or not, and re-sends under the retry’s own no-output guard; the turn’s end restores the named profile and its row records the one that served. The published profile cell holds both the named profile and the one serving, so a worker spawned mid-turn binds to the named one and a repin ends the substitution. Compaction runs when the context gauge says so, when the model asks throughcontext_compact, or when the user asks. A checkpoint’s request carries the turn’s own tool list, so it shares the turn’s cached prefix; its guidance names the subset it may call, and a call to the rest is refused at dispatch. Every summary, from the checkpoint or the summarizer, has the same four sections: rules and authorizations, commitments and their state, facts established, next action; a compaction-specific focus adds emphasis within them and drops none. The messages received during the summarized turns are copied into the summary message by code, newest first within a third of the verbatim budget when there are any, so what a person sent never depends on the summary. A checkpoint that ends without callingcontext_replacefalls back to the summarizer; its closing text is never taken as the summary. Every compaction restores full live context before the agent’s next request, including memory, discovery, permission, and execution guidance. The world snapshot advances only after the replacement is saved. Finished background outcomes are not delivered again during this rebuild. - Output. Text, thinking, tool calls, approval prompts and elicitations all reach the
user through the session’s
Frontend. The screen, the one-shot run, ACP and HTTP each implement it once; a sub-agent’sPermissionForwardingFrontendforwards its approval prompts and notices to its parent’s;SilentFrontendanswers a call with nobody behind it.
Where state lives
Non-secret settings live in config.toml. Secrets live in the store. Environment variables are
operational only. The store is one SQLite file, meka.db under MEKA_DATA_DIR, opened by Store,
and its shape is whatever the migration ledger says it is: a current store’s PRAGMA user_version
is the number of entries in the ledger, and HEAD_SCHEMA_FINGERPRINT in store/migrations.rs pins the
columns of the tables in HEAD_TABLES. The count is the ledger’s to state: the_ledger_is_append_only digests every entry, so read the ledger rather than this sentence for what the last ones do. The entries after messages_are_indexed_by_kind give sessions a title, a pin and a search index, fold every diacritic in the search indexes, and number turns. Before them, sessions_have_an_inbox adds the inbox table and messages_are_indexed_by_kind indexes a session’s rows by kind so a resume finds its last compact_boundary row and the context block counts them without reading the whole log; before those, names_follow_the_vocabulary gives every table, column and index the name the vocabulary uses and drops the provider_credentials view and a column nothing read. That view existed for one replay: the frozen sessions_name_their_provider reads the name when no default profile resolves, so classify_by_shape classifies a store that lost its user_version and has no such view at the version this entry leaves it, past that step, rather than at the baseline. Before it, sessions_record_their_context_tokens adds the occupancy a resume checks its first turn against, background_tasks_spell_canceled_with_one_l rewrites a task status an earlier meka stored as cancelled, and root_rows_take_the_default_level_once_the_config_reads stamps [permissions].default on a root row that still records no level and refuses to migrate while config.toml cannot be read.
| Table | Owner | Columns and indexes |
|---|---|---|
sessions | store/sessions.rs | id, created_at, updated_at, parent_session_id, working_directory, permission, approvals, profile, capabilities_json, token_id, additional_workspace_directories_json, subagent_spec_json, context_tokens, title, pinned_at, the eight cumulative counters turns, input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens, redactions, redacted_images, redacted_bytes, and the position of the last turn opened, prompt_index and turn_index, both NULL on a session that numbers nothing. idx_sessions_updated_at, idx_sessions_parent_session_id. |
messages | store/sessions.rs | id, session_id, kind, content, created_at, turn_id. idx_messages_session_id, idx_messages_session_id_kind. The writer names the turn; the store never infers one from the table. |
turns | store/turns.rs | id, session_id, source, started_at, ended_at, status, stop_reason, error_type (the kind’s name, never a URI), detail, the four usage counters. idx_turns_session_started_at, which the listing’s per-row last_turn subquery leans on: a second per-row join would be the one to measure. |
account_credentials | store/credentials.rs | account, credentials_json, updated_at. |
mcp_credentials | store/credentials.rs | server, kind, secret, updated_at; keyed by (server, kind), so a client secret and its refreshable bundle coexist. |
blobs, message_blobs | store/blobs.rs | hash, media_type, bytes, size_bytes, created_at; and message_id, hash for which message rows reference a blob. idx_message_blobs_hash. |
scratchpad_entries | store/scratchpad.rs | session_id, name, content, created_at. |
scheduled_jobs | store/schedule.rs | id, session_id, kind, spec, prompt, gate_kind, gate_spec_json, gate_last_output, gate_permission, claimed_by, claim_expires_at, attempts, created_at, last_fired_at, next_fire_at. idx_scheduled_jobs_next_fire_at, idx_scheduled_jobs_session_id. |
background_tasks | store/background.rs | id, session_id, tool, label, status, outcome, scratchpad_entry, started_at, finished_at, announced_at, delivered_at. idx_background_tasks_session_id_status. |
memories, memories_fts | store/memory.rs | id, name, description, tags, body, priority, created_at, updated_at, read_count. idx_memories_priority_created_at. The FTS index and its triggers are rebuilt to this build’s definition by reconcile_index on every open, so they are outside the ledger’s fingerprint. |
prompt_history | store/history.rs | The screen’s input history: id, command_line, created_at. |
Per-process state that is not a table has an owner too: the scheduler’s memory of which refusals it
has already reported is the SchedulerMemory on the Store; MCP progress routing and resource
updates live on the McpClientContext; the per-path write locks are the WriteLocks on the host’s
SharedDeps, cloned into every session’s CoreMaterials; a frontend’s sticky approval answers are
its StickyApprovals, in memory and never persisted.
The doors
Most defects that survive review are one rule enforced at one entry point and forgotten at a sibling. The rules below are each written once and called from every path that reaches the thing they guard. When adding a path, call the predicate rather than restating the rule.
| Rule | Predicate | Doors |
|---|---|---|
| Whether a turn may start | ResidentSession::admit_turn and claim_idle on the one in_flight cell | admit_turn: HTTP POST /turn (under the process cap, host::http::state::admit_turn) and ACP session/prompt, and through HostHooks::admit the scheduled fires, outcome deliveries and inbox turns in host/scheduler.rs (under the same cap in serve); claim_idle: HTTP compact and rewind. The screen and one-shot sample cancel.admit() directly. |
| Whether a second turn is refused | the conversation mutex, try_lock | HTTP POST /turn (try_lock_owned, 409), ACP session/prompt (InvalidParams) |
| Whether a session is idle enough to evict | host::session::idle_on, behind ResidentSession::is_idle_given and Sessions::sweep_idle | the HTTP GC and the ACP idle sweep |
| Whether a prompt is empty | TurnInput::from_parts for typed words, TurnInput::sent for blocks | every host, before admission |
| Whether a memory write may grow the inline tier | store::memory::inline_budget_refusal, judged after the statement inside the immediate transaction of MemoryStore::write and write_body, against [memory] inline_max_bytes; WorldSnapshot::with_inline_max_bytes lays the band out by the same measure | memory_write, memory_edit, meka memory add, meka memory edit, PUT /v1/memory/{name} |
| Whether a tool call runs, is put to the user, or is refused | tools::admit_tool_call, called through Agent::run_admitted_call, the one sequence from the level read at the enforcement site to the tool’s own refusal, the approval prompt and the schema advisory | inline dispatch, the checkpoint turn in agent/compaction.rs |
| Whether a session row is whole | store::whole_session_sql, false while a fork or an import copies its rows in short transactions behind the row; Store::delete_unfinished_copies, through the startup sweep, takes one a crash left | the listing, session search (through SessionReach::sql_term), last_session_id, find_sessions_by_prefix and all_session_ids, the session counts of a project and a profile, and the turn sweep; a reader that names an id needs none, since a copy’s id is its copier’s until whole; the profile rename guard counts an unfinished copy, which runs on its row once whole; the deleting doors take every row |
| Which rows history shows | messages.copy_of, the row a copy a compaction kept copies: written by Store::save_compaction (link_kept_copies, which matches each kept message to the row after the latest boundary that holds it), carried by a fork and an archive (as a position), and cleared by search::release_copies_of when the turn sweep takes that row, which also indexes the copy’s words; RowRange::History (Store::load_history), the log pages and the search index’s indexed_words leave a linked row out | the screen’s and ACP’s replays, conversation_search, conversation_read, agent_list, the Markdown export, session search; the view, a rewind, a fork and the archive read every row |
| What forgets the conversation’s old shape when the log is rewritten | Agent::reset_conversation_markers: the accepted-prefix marker, the world snapshot, the read tracker, the schema advisories, the gauge and its row | compaction, screen /rewind, HTTP POST /rewind |
| Whether a turn runs on the profile its row names | host::apply_recorded_profile, under the conversation lock | ACP session/prompt and its fires, HTTP POST /turn, POST /compact and its fires, through HostHooks::prepare |
| Whether a session is a sub-agent’s | store::spawned_session_sql in SQL; SpawnTerms::describe for the sentence every refusal starts with | every listing, meka -c, session search, the inbox driver; the refusals of the HTTP fork, the ACP fork, POST /responses, a job on it, and every builder |
| What the retention sweep may take | EXPIRABLE: a root by its own age, spared while a pin or a job sits anywhere in its tree | the retention sweep, meka session remove --older-than-days |
| How old a message row is, and which rows a turn sweep may take | ROW_AGE_SQL in store/sessions.rs (its turn’s end, else its turn’s start, else its own stamp), and only below the session’s latest compact_boundary row; a copy of a row it takes becomes the row history shows | Store::delete_expired_turns at startup, hourly and at each turn’s start; view_holds_a_turn_older_than, which decides whether a retention compaction has to run first |
| What a delete locks | Store::lock_tree: the session and every sub-agent under it, since the cascade takes them | the sweep and --all (over roots, through delete_the_unattached_among), session remove <id>, agent_delete, the servers’ deletes of a session they have not loaded |
What ${VAR} means in config.toml | config::expand_env_vars, one grammar (${VAR}, ${VAR:-default}; an unclosed ${, an empty name or a nested default is reported as malformed and left as written); what a miss or a malformed reference costs is the caller’s: [serve] refuses, an MCP server is refused when it sits in args, env, url or headers, and mcp add skips its probe | [serve] tokens and URLs, every MCP server field |
| What a tool call’s arguments are, and whether it detaches | tools::admit_arguments | dispatch, ahead of every tool |
| What a blank or wrong-typed argument means | tools/util.rs: require_str, optional_str, their _allowing_empty forms and string_or_list (blank is “not given”, a wrong type is refused by name, a blank list entry is dropped), with text::deserialize_blank_as_none for a serde-parsed tool; tests/layering.rs fails on a raw read outside its RAW_ARGUMENT_READERS ledger | every built-in’s string and list arguments |
| Where a process is started, and what it is handed for memory | process::spawn (and process::prepare with process::adopt where rmcp makes the spawn): the highest oom_score_adj on Linux, a job capped at the machine’s memory on Windows; tests/layering.rs lists every spawn site in its PROCESS_STARTERS ledger | the shell tool, the screen’s !command, schedule gates, stdio MCP servers and an HTTP server’s headers_helper, the Bubblewrap smoke test, the confined Windows command |
| How a host that serves the installation writes it, and what it serves | SharedDeps::write_installation, the one sequence: under installation_doors, installation_as_written parses the file once and refuses one that fails to parse, holds an MCP server entry the manager refuses, or, when it changed since the last whole read, anything ResolvedConfig::validate refuses; the edit decides on that parse and runs its door, which undoes its own file write when a later step fails, the file being served anyway when it moved; serve_installation then reads the file whole, holds it to everything startup holds a file to every time (SharedDeps::servable: ResolvedConfig::validate, the [web] client, and on meka serve the [serve] resolution, so an edit from outside landing between the door’s write and this read is never served unchecked), replaces the catalog with the [thinking] budget and [session].context_window its profiles complete with, under the provider cache’s lock so no build from the old catalog is cached after it (ProviderRegistry::resolve), resolves device ids, brings the manager to the file’s entries (restarted when the connection differs, installation::mcp::same_connection, or when the store’s count of the server’s credential writes moved, TokenStore::mcp_credential_generations; reconfigured in place when the tool policy alone differs, McpClientManager::reconfigure_server), records the read for config_changed, and reads the instructions again, keeping the last text when they cannot be. A credential write counts only when it changes a row. A browser login obtains its credential outside it, the MCP flow against an in-memory store, and commits through it, refused when the account or the connection it began on changed (account::commit_login, mcp::commit_login); once its commit has begun, nothing that ends logins settles it. Every MCP credential row records the endpoint it was stored for, and a connect presents it there alone (mcp::auth::presentable: OAuth tokens at their canonical resource URL, a bearer or a client secret on its origin); a credential is never bound to a url the environment leaves unresolved (installation::mcp::endpoint_of); a revocation is read before a clear and before any secret the same edit stores, carries the client secret stored for the grant’s own origin alone, and is sent after the write commits (mcp::auth::Revocation, #[must_use], so a door that drops one does not compile clean). A profile rename repoints the live cells of every resident session and running sub-agent (PublishedProfile::rename_profile, RunningSubagents::rename_profile), refuses only what would run on another profile (Store::sessions_run_on_profile_name: a root on the name, a sub-agent pinned to it), and undoes exactly what it moved (Store::undo_profile_move). The typed refusals’ HTTP wording lives in ProblemDetail::for_error alone. PUT /v1/instructions takes the same lock through write_instructions | every write of the installation over HTTP: accounts, profiles, MCP add, change, remove, secret and login commits, logout; POST /v1/config/reload, the write with no edit; the screen’s /mcp login and /mcp logout |
Where a MEKA_* variable is read | ResolvedConfig::resolve in config.rs, with paths.rs for the two that locate the config file; tests/layering.rs keeps every other file out through its ENVIRONMENT_READERS ledger | every operational override: the permission level, the standing instructions, the sandbox backend, the render mode, the MCP tool timeout, and the debug-only mock switches |
| The level a call must clear | tools::effective_permission: the [tools.tool_permissions] override, else the tool’s own | dispatch, the gate, tool_load’s listing, the catalog |
| What level a scheduled job runs at | scheduler::live_permission, reading the session row through admit_recorded | the fire door, the wake watcher, meka schedule show |
| Whether a recorded level still applies | EnabledPermissions::admit_recorded | a resume on every host (host/assembly.rs, host/http/reattach.rs, host/acp/session.rs), the scheduler |
| The refusal for a level the set does not admit | EnabledPermissions::disabled_level, raising MekaError::DisabledLevel | SharedPermission::try_set behind /permission, ACP session/set_mode and its permission option; HTTP create and PATCH |
| Whether a profile name is configured | config::require_profile, raising MekaError::ProfileNotConfigured | --profile and default_profile selection, resolve_profile_switch and the resume repin (host/assembly.rs), HTTP create and PATCH, the provider registry, meka profile use |
| What a session’s row may move to, and what a failed write costs | Store::update_session taking a SessionPatch; the policy is host::record_session_change | screen /permission, /approvals, /cd; ACP session/set_mode and session/set_config_option; HTTP PATCH; the profile switch and resume reconciliation. A patch that fails to write the level, switch, directory or roots is warned about and the command continues; one carrying a profile fails the request. The one writer below host is the sub-agent tool recording the profile a worker ran on, which warns. |
| Whether a working directory is accepted, and how it is spelled | workspace::accept_cwd; cwd_filter for a listing filter | ACP session/new, load, resume, fork; screen /cd; HTTP create, PATCH and ?cwd= |
| Whether a session accepts an image | ResidentSession::accepts_images, off the published profile’s vision | HTTP POST /turn, ACP session/prompt |
| A session’s title | Conversation::title (the first user Append in the log with a non-blank Text block that is not a redaction placeholder or harness note, whitespace collapsed, TITLE_CHARS = 200; TITLE_ROW_WHERE_SQL is the same rule in SQL) | SessionSummary, the HTTP turn response, ACP’s title update (from the resident log after a first prompt, from the row on a load, resume or fork, since a hydrated log begins at the last boundary) |
| Making a persisted session resident | Store::open_session_row: lock first, then read | screen and one-shot resume, serve re-attach (ensure_session_loaded), ACP session/load and session/resume |
| Whether a project exists, and what its name may be | store/projects.rs: Store::require_project refuses a name no row has with the names that do; add_project and rename_project hold a name to entry::validate_entry_name and refuse one taken under case folding; remove_project refuses while a memory or a root session names it | --project on a run, /project open, POST/PATCH /v1/sessions, the ACP option, meka memory add --project, session import, memory import, and the turn start of a session whose row names one |
| What a memory export is, and how it reads back | memory::render_memory writes a file and memory::memory_from_export reads one, with split_memory_file undoing the framing; MEMORY_EXPORT_FORMAT_VERSION in export.toml, brought forward by store::migrations::bring_memory_file_forward; MemoryStore::import writes the snapshot in one transaction under the write doors’ rules | meka memory export, meka memory import |
| Which tier a memory write lands in, and whether the name is free there | store::memory::resolve_write_tier, inside MemoryStore::write’s transaction over every tier: a named tier that holds the name is an update, an unnamed one keeps the one visible row’s tier or creates in the view’s own, naming the tier of the one visible holder moves it, a global and a project memory never share a name, and a name in several projects is refused where no one row is meant | memory_write, meka memory add, PUT /v1/memory/{name}; the view each door passes is MemoryView, the one predicate every read, update and delete by name carries |
| What a resident session’s log holds | Store::load_conversation: the rows from the last compact_boundary on (load_view_events), images inlined, orphaned tool_use dropped; the whole log is read only by load_events for the readers that want history | every host resume through host::hydrate_conversation, the sub-agent follow-up |
| Forking | Store::fork_session_locked with SourceLock::{Probe, HeldByCaller} | meka session fork (Probe); screen /fork through host::fork_and_lock (HeldByCaller); HTTP POST /fork and ACP session/fork, which hold a resident source still under HeldByCaller (refusing it mid-turn) and Probe a dormant one |
| What an HTTP caller may do | scope::Scoped<R> as an extractor | every handler, by its signature |
| Whether a path may be written | workspace::WriteScope | file_write, file_edit, scratchpad_save_file, shell_execute’s confinement |
Whether meka’s own directories, or a process’s own /proc entries, may be read, with the skills store as the readable part | workspace::private_read_refusal and workspace::ReadFence | tools/util.rs for the readers, file_find, file_search |
| Whether a backend reads a profile or account key | Backend::reads_profile_key and reads_account_key (in config.rs) | profile add, profile set, account add, the load-time warning |
| Which account a profile bills | config::account_for | config validation, the provider registry |
| Whether an MCP server’s config may be sent at all | ServerEntry::refused, a field set at construction | every connect door in mcp/connector.rs, ServerEntry::reconnect |
| How an MCP connection opens, and which protocol era it speaks | handler::http_lifecycle (probe, then the initialize fallback on the same connection) and handler::stdio_probe (the probe alone, on a child the connector discards when it finds a legacy server, the verdict kept in ServerEntry::legacy_era); ServerEntry::protocol_version afterwards | the stdio and HTTP arms of connector::connect_server, auth::connect_http_with_oauth; subscriptions.rs wherever the era changes the mechanism |
What becomes Connected, and what rides onto it | ServerEntry::adopt_connection, which refuses a connection once a shutdown has begun | the first connect, the cold-start retry, a reconnect |
Where a server’s tools/list_changed and resources/updated go | handler::refresh_tools_after_list_changed, handler::record_resource_update | the client handler’s notification arms (a 2025-11-25 server’s unsolicited notifications) and the listen stream’s pump in subscriptions.rs (a 2026-07-28 server’s) |
The rounds a request takes when a 2026-07-28 server answers input_required | handler::Rounds | McpTool::call_tool_once, mcp::read_resource, mcp::get_prompt |
| Whether a fire’s session is still resident | HostHooks::still_resident | scheduled fires and outcome deliveries, once the lock is won |
| What a frontend answered for a file operation | Delegation | file_read, file_edit, file_write |
| Whether thinking is on for a request | ThinkingOverride on CompletionRequest | the turn, the summarizer, the checkpoint turn |
| Where an image’s bytes rest | store/blobs.rs: externalize_images on write, inline_blobs on read | writes: PreparedEvent::from_event on the caller’s thread, then store::sessions::insert_event, the one door into messages, which every writer takes (save_event, save_events_atomic, a rewind, a withdrawal, import_sessions); the fork copies rows that came through it; reads: Store::load_conversation for every resume and the sub-agent follow-up; GET /messages serves the reference, GET /blobs/{hash} the bytes, and an export carries both |
| The sentence for a name that matches nothing | text::unknown_name | every refusal by name: profiles, accounts, MCP servers, configuration options, scopes, gates |
| How a terminal shows a time, a size, an id | text::format_timestamp with Precision, text::format_size with KIB and MIB, text::ID_PREFIX | every listing and status line; the wire keeps RFC 3339 and raw byte counts |
The Authorization value for a token | text::bearer | every backend that sends one, and store/credentials.rs |
| What a sticky approval answer covers | StickyApprovals in frontend.rs | the screen, ACP and HTTP frontends |
| How long a host waits for an approval answer | APPROVAL_TIMEOUT in frontend.rs (30 minutes) | the ACP and HTTP frontends; the screen has a human and no timeout |
| How a notice serializes | NoticeView | SSE notice, the blocking response, the one-shot JSON report |
| Whether an upstream’s own words may reach a caller | host::relay_provider_errors, bounded by error::bounded_upstream_body | ProblemDetail::for_error’s provider_response, acp_error_for’s data |
| How much of a reply is read | error::MAX_REPLY_BYTES, through error::read_whole_reply for a whole reply and provider::sse::drive for one streamed event; a redirect is refused ahead of either by provider::succeeded, since provider::build_http_client follows none | every provider reply, the token exchange included |
| Whether a skill’s name resolves outside meka’s own store | skills::refuse_foreign_write / refuse_foreign_delete, both on foreign_location | skill_write, skill_edit, skill_delete, meka skill add/remove, PUT/DELETE /v1/skills/{name}; the ForeignSkill it hands back renders with the path for a local reader and without it on the wire |
| Whether an older meka wrote the store | store/migrations.rs alone | nothing else may know |
One error, one mapping per host
MekaError carries the refusals the doors raise: SessionNotFound, TurnInFlight { doing },
EmptyPrompt, DisabledLevel { level, enabled }, ProfileNotConfigured { name, known },
RequestTooLarge, beside SessionLocked, SessionNotDrivable, Usage and Config. Each host
maps the enum once, and no handler rewrites the sentence:
- HTTP:
ProblemDetail::for_errorinhost/http/errors.rs.SessionNotFoundis 404 (session-not-found);TurnInFlightandSessionLockedare 409 (turn-in-flight,session-locked);EmptyPrompt,DisabledLevel,ProfileNotConfigured,UsageandConfigare all 422 underinvalid-body, so a client tells them apart bydetail;RequestTooLargeis 422 under its ownrequest-too-large, because meka refused it and there is no provider response behind it. ThetypeURIs live underhttps://meka.run/errors/. - ACP:
acp_error_forinhost/acp.rs. Everything the caller can act on isInvalidParams(-32602) with theDisplayasdata; everything else isInternalError. - Screen: an error item in the transcript, the
Displaytext. - One-shot and CLI: the
Displayon stderr and exit code 1; 130 when the turn was interrupted.
Two classes never travel verbatim, on either wire. Installation is meka’s own sentence about
the operator’s setup – a [web] client that will not build, a base_url shape a backend refuses
– so it names a path or an endpoint from config.toml that no caller can act on: HTTP answers a
sanitized 500 and ACP an InternalError with the text in the log, while the screen and the CLI print
it, since their reader is the operator. The web client is built once in build_shared_deps, so on
every host a bad [web] block fails the process at startup rather than surfacing per session.
And an upstream’s own response text travels only when [serve] relay_provider_errors says so, which
host::relay_provider_errors reads once for both hosts. for_error attaches it as
provider_response; acp_error_for appends it to data; both bound it with
error::bounded_upstream_body. An MCP connector’s reason, a Database and an Io never travel at
all, on either.
What each host offers
The hosts do not offer the same operations, and the gaps are recorded here rather than filled.
“Run” is meka with -p, -c, -r, --profile and --permission; the one-shot is that run
with --oneshot.
| Operation | Screen | One-shot | ACP | HTTP | meka session |
|---|---|---|---|---|---|
| New session | at launch | at launch | session/new | POST /v1/sessions | no |
| Resume | -c, -r at launch | -c, -r at launch | session/load, session/resume | implicit re-attach of a dormant id on any request | no |
| Fork | /fork | no | session/fork | POST /{id}/fork | fork |
| Delete | no | no | no (session/close releases only) | DELETE /{id} | remove |
| Rewind | /rewind | no | no | POST /{id}/rewind | rewind |
| Compact | /compact | automatic only | automatic only | POST /{id}/compact | no |
| Export | /export (Markdown) | no | no | GET /{id}/export | export |
| Import | no | no | no | POST /v1/sessions/import | import |
| Profile switch | /profile | --profile at launch | session/set_config_option | PATCH profile | no; meka -r --profile repins |
| Level switch | /permission, Shift+Tab | --permission at launch | session/set_mode, config option | PATCH permission, live | no |
| Approvals | /approvals | config only | session/set_config_option | PATCH approvals, live | no |
| List, show | /session, /status | no | session/list | GET, GET /{id} | list, show |
| Cancel a turn | Esc, Ctrl+C | Ctrl+C | session/cancel | POST /{id}/cancel | no |
The screen and the one-shot share COMMANDS in host.rs for what a slash command is; ACP advertises
the three rows with an editor_help (/mcp, /status, /usage) as available_commands.
Mid-turn
Only HTTP and ACP can receive a request while a turn holds the session, and the two answer differently by design.
- HTTP refuses what needs the turn to end. A
PATCHnamingworking_directoryorprofile,DELETE, fork, compact and rewind checkin_flight(or failclaim_idle) and return 409 withtypehttps://meka.run/errors/turn-in-flight, throughturn_in_flight_conflictandProblemDetail::for_error(MekaError::TurnInFlight). A secondPOST /turnon the session gets the same 409. Thedetailnames what was refused (doing), and asession_idmember carries the id. APATCHnaming onlypermissionorapprovalswrites the cells and the row without waiting, the same three steps ACP takes below.POST /v1/sessions/{id}/inboxnever refuses for a turn in flight: it writes aninbox_itemsrow (store/inbox.rs) and wakes the driver (host/http/inbox.rs). The row reaches the model at one of four places, all inAgent::run_attributed_turnso no host door can forget one: asteeror aninterruptat a round boundary, after a tool round’s results or after a reply that would have ended the turn, in the same user message or the one that follows the reply, stampedappended_atby the same transaction (save_events_atomic_marking_inbox); aninterruptthe moment it lands while a provider call is in flight; any class at the next turn’s opening, after the words, whoever started the turn; or as the opening message of a turn the driver starts (host::scheduler::run_inbox_turns) when the session is idle. The item isdeliveredwherelast_accepted_lenis stamped, which is the request the provider accepted, andFrontendEvent::InboxDeliveredis what the feed and the webhook relay. - An interrupt is decided by the loop, never by a host. Each provider call runs under a child
of the turn’s token, and
watch_for_interruptspolls the inbox once a second (INTERRUPT_POLL_INTERVAL) and cancels the child when aninterruptrow is pending: polled rather than signaled, so a parent’sagent_steeron a running worker needs no registry to reach it. The call returns as it does on a stop, andabsorb_interruptreads the tokens to tell the two apart. What streamed is kept throughwithout_tool_use, the items follow as the next user message, and the loopcontinues in the same turn. Nothing streamed means the items join the user message that was cut, as a withdrawal and a re-append in one write, so the log still ends on an appended turn opening andwithdraw_unanswered_prompttakes the items back with the prompt if the turn then fails before the provider accepts anything. A tool round is never cut. - ACP applies the level and the switch, and refuses the rest.
session/set_modeand the permission and approvals options ofsession/set_config_optionwrite the cells without taking the conversation mutex, so an editor toggle takes effect on the very next tool call, and then record the row. The profile optiontry_locks the conversation first and refuses withInvalidParams(TurnInFlight { doing: "switch profile" }) so that a switch refused for a turn in flight leaves the row where it was. A secondsession/promptis refusedInvalidParams(TurnInFlight { doing: "prompt" }), and so is asession/forkof the session (TurnInFlight { doing: "fork the session" }), whichtry_locks the conversation the same way and holds it across the copy.
Frontends
Seven things implement or stand in for Frontend: TuiFrontend (host/tui/frontend.rs, the
screen), PlainFrontend (host/oneshot/frontend.rs, the plain one-shot), AcpFrontend
(host/acp/frontend.rs), HttpFrontend
(host/http/http_frontend.rs, which records for the blocking response and broadcasts to SSE
through host/http/sse.rs), JsonFrontend (host/oneshot.rs, --format json), SilentFrontend
and PermissionForwardingFrontend (frontend.rs; the latter forwards notices and approval prompts
to the parent’s frontend and drops the rest). Where they differ:
| Event | Screen | ACP | HTTP stream | HTTP blocking | One-shot JSON | Silent |
|---|---|---|---|---|---|---|
Notice info / warn | a dim • row, or a yellow ! row | notice at severity info / warning when the client advertised session.notices, else an agent-message chunk prefixed [meka] / [meka warn] | notice event (NoticeView) | notices[] | notices[] | dropped |
McpProgress | the running call’s progress line under its row | tracing::info! | progress event | dropped | dropped | dropped |
CompactionStarted / CompactionFailed | the working row’s word while nothing more specific is said; a failure is the command’s or the turn’s to report | compaction_update in_progress, then failed or cancelled, for a client that advertised session.compaction; otherwise a think card opened, then closed as failed | context.compacting / context.compaction_failed events | dropped | dropped | dropped |
Compacted | the →← Context compacted rule, which opens to the record’s headline and departures, the checkpoint’s rounds and the summary as written, live and on a replay of the log alike | compaction_update completed with the summary and the record in _meta.compaction, or the card closed under the record’s headline for a client without the entry, its first report on a replay, which then sends the record’s checkpoint calls as CheckpointCallCompleted does | context.compacted event | dropped; GET /messages carries the record on the summary | compactions[] | dropped |
CheckpointCallCompleted (a compaction checkpoint’s call finished) | dropped: the compaction’s row shows the checkpoint from its record once it lands | a finished tool_call under <compaction id>/<n>, or the update that completes the card the call’s approval prompt opened | dropped: GET /messages carries the record | dropped | dropped | dropped |
ToolCallDropped (a call nothing will run: its round was cut or recovered from, or a retried stream did not bring it again) | the call’s row goes, found by its id and whether it had started | the card of one that had started closed failed, a terminal it opened ended | dropped: the turn’s terminal settles it | dropped | dropped | dropped |
| Approval with nobody to ask | warn approval_refused_without_asking, deny (the screen is gone) | asks the client; deny after APPROVAL_TIMEOUT, Canceled on cancel | permission_required event while a streaming client or a feed reader with attend=true is there; deny after APPROVAL_TIMEOUT, Canceled when the last of them leaves; refused with a warn notice otherwise, unless the session declared answers_prompts_on_return and no request holds the turn, in which case it parks and waits out APPROVAL_TIMEOUT | as HTTP stream: a feed attendee answers a JSON request’s prompt too; without one, warn notice, deny, the same capability aside | warn approval_refused_without_asking, deny | deny; the notice goes nowhere |
| Elicitation | asks the screen; warn elicitation_declined and decline when it is gone | elicitation/create; warn elicitation_declined and decline when the client lacks the mode or two sessions have calls in flight on the server | warn elicitation_declined, decline | same | trait default: warn elicitation_declined, decline | same, dropped |
TurnOpened (the level a turn opened under) | the turn’s prompts take the level’s color on their bar | dropped: the protocol has no place for it | dropped: the turn’s record carries it | the in-flight turn’s permission | dropped | dropped |
InboxAppended (inbox items added behind a round’s results, a nudge or a prompt an interrupt cut) | each item another client posted drawn as a prompt where it lands; one the screen enqueued itself is already drawn | UserMessageChunk per item | dropped: inbox.delivered says it, and the messages hold its words | dropped | dropped | dropped |
MessageAppended (the message a turn opens with, whoever opened it, or one an interrupt appends whole) | a dim • Scheduled job <id> fired: … or • Background task <id> … row per fire or report block; an inbox item another client posted is drawn as a prompt with its (steer) note, one the screen enqueued itself is already drawn; the person’s words are drawn as typed | UserMessageChunk per scheduled or inbox block; a background_report block is said as what reported, a notice with the tasks under _meta.background or a stage-direction chunk; the editor’s own words are not echoed | message.appended event (MessageView), re-issued with resumed: true to a client joining mid-turn | the opening, first in messages[] | a paragraph break | dropped |
| Scheduled fire failure | a red ■ row; ! Interrupted on a cancel | warn notice scheduled job '<id>' failed: ...; info on a cancel | schedule.fired webhook, status completed, canceled, failed or not_run; nothing on the frontend | same webhook | no scheduler | n/a |
The scheduled-fire failure row comes from each host’s HostHooks (finished) rather than its
Frontend, in host/tui.rs, host/acp/schedule.rs and host/http/schedule.rs. The fire’s
prompt takes no hook: the agent emits MessageAppended where it appends a turn’s opening, which is
the one place every door passes, so a report riding a client’s turn is announced as a fire’s is.
The ACP frontend sends each tool call under an id CallIds (host/acp/frontend.rs) gives it, since
a provider may give one id to every call or reuse its ids each round: the provider’s own id the
first time the session sends it, else one minted with a token unique to the frontend, and always a
minted one when the session was opened as EditorThread::Inherited (resume, fork, a reopen after
the idle sweep), whose thread the editor already holds. Every event about a call after its
ToolCallStarted, an approval prompt’s PromptedCall::Announced included, goes under that id, the
oldest still running under the provider’s id, and session/load’s replay goes through the same
CallIds, so a live call after it is told apart from the replayed ones. A sub-agent’s activity goes
under the id its agent_spawn card had when the sub-agent started (AcpFrontend::for_subagent),
since a background sub-agent outlasts its call. A call ToolCallDropped names, or one still running
at TurnEnded, is closed failed, unless a session/cancel stopped the turn, which the client
marks; a detached command streams no output (OutputRelay::for_call), its call having returned.
Building
Two Cargo features shape a build. serve, on by default, is meka serve: the HTTP API and the
dependencies only it needs, so --no-default-features builds a meka without one; src/host/http.rs
is behind cfg(feature = "serve"). mock-provider compiles in provider/mock.rs, the scripted
provider the test suites drive every host with; debug builds carry it regardless, and CI enables it
so a release-profile build is testable too. A shipped artifact is built without it. At run time,
MEKA_MOCK_PROVIDER=1 selects that provider on every host and MEKA_MOCK_PROVIDER_SCRIPT names
the JSON script it plays back; both are read by ResolvedConfig::resolve, like every other MEKA_*
variable, and acted on by host::build_shared_deps.
The integration crates are gated on the same fact: tests/acp.rs and tests/cli.rs carry
#![cfg(any(debug_assertions, feature = "mock-provider"))]; tests/serve.rs and
tests/multiprocess.rs add feature = "serve"; tests/tui_pty.rs adds unix. Only
tests/layering.rs runs unconditionally. Unit tests open the store with Store::for_test(), an
in-memory database at the current schema; Path::new(":memory:") is spelled out because None
means the default path. The integration crates share tests/harness/support.rs, whose Install is
a temporary root with a config directory, a data directory and a work directory; Install::env
points a meka command at it (MEKA_CONFIG_DIR, MEKA_DATA_DIR, HOME, XDG_*,
MEKA_MOCK_PROVIDER=1, the script when one was written) and Install::meka(args) builds one.
The exact gate CI runs is in AGENTS.md under “Build gate”; run it before declaring a change done,
since clippy and rustdoc deny warnings there and not locally. The one-shot conversion for a 0.45
config.toml, migrate-0.45-to-0.46.py, is not in the repository: like the 0.42 script before it,
it is attached to its release as an asset by hand and carries its own --self-test fixture.