ACP (Agent Client Protocol)
meka acp speaks the Agent Client Protocol over stdio so editor / web / messenger clients can drive a meka turn end to end. Where Interactive mode and One-shot mode are for humans, ACP is for programs that want to host meka inside a richer UI: streamed diffs, native apply-buttons, hosted terminals, and slash-command palettes.
This page describes what meka’s ACP surface looks like to a client. Editor-specific setup belongs in each editor’s own documentation; the protocol contract is the same everywhere.
Starting an ACP server
meka acp
The process speaks JSON-RPC 2.0 with newline framing on stdio. There is no human-facing prompt; the binary is meant to be spawned by a client that owns the conversation. The client sends initialize, then session/new (or session/load / session/resume), then a series of session/prompt calls.
A few flags are worth knowing:
| Flag | Effect |
|---|---|
-v | Logs to stderr at info (incoming client identity, session lifecycle). |
-vv | debug (per-request JSON-RPC diagnostics). |
RUST_LOG=meka=trace | Trace level. |
Three flags are refused rather than ignored: -c, -r and --project. Each names one run’s session, and this host creates one per session/new, each naming its own profile and project. A new session starts on the host’s default profile and on no project; move either with session/set_config_option, and session/load restores whichever a session already recorded. --profile is accepted, since it selects which configured profile a session gets when it names none, which is a property of the connection rather than of one session.
On startup, after the client’s initialize arrives, meka logs ACP client connected: <name> <version> so you can confirm the client identity under -v.
What meka advertises (agentCapabilities)
These are returned in InitializeResponse.agentCapabilities:
loadSession: true: the client may callsession/loadwith any persisted session id.sessionCapabilities.list: the client may callsession/listto browse the persisted session catalog (cwd-filtered, cursor-paginated; sub-agent audit sessions are hidden).sessionCapabilities.resume: the client may adopt a persisted session id without replaying history.sessionCapabilities.fork: the client may branch a copy off a persisted session (see Forking). Unstable in the protocol.sessionCapabilities.close: the client may release the active session slot.sessionCapabilities.additionalDirectories: the client may send extra workspace roots onsession/new,session/load, andsession/resume(see Multi-root workspaces).promptCapabilities.embeddedContext: true: the client may inline @-mentioned file contents as embeddedresourceblocks (see Prompt turn).promptCapabilities.image: follows the process default profile’svisionflag (defaulttrue; setvision = falsein[profiles.<name>]for a text-only model). Per connection rather than per session, becauseinitializeis answered before any session exists. Whether a givensession/promptaccepts an image block is decided per session from the profile that session runs on, so a session moved onto a text-only profile refuses attachments even on a connection that advertisedimage.
mcpCapabilities is intentionally not advertised. meka is itself an MCP client, but the servers it consumes are configured via meka’s own config.toml rather than the mcpServers field on session/new. Advertising HTTP/SSE while silently ignoring the client’s array would have been misleading; the marker will return when client-supplied MCP server connections are actually implemented.
agentInfo carries meka’s name ("meka") and the running binary version.
What meka consumes (clientCapabilities)
The client advertises these in InitializeRequest.clientCapabilities; meka stashes them and lets the built-in tools route accordingly:
fs.readTextFile: true:file_readissuesfs/read_text_file { sessionId, path, line?, limit? }so the client serves the in-buffer view of the file, when the client also takes writes. A regex read fetches the file the same way and filters it; an image read has nofs/*analog and stays local.fs.writeTextFile: true:file_writeandfile_edit’s apply step issuefs/write_text_file { sessionId, path, content }. meka still attaches diff metadata to thetool_call_updateso clients with an apply-diff UI can render it, as it does forskill_write, whose file is in meka’s store; a memory write, which has no file, and a delete, which leaves no text after, carry their change as a unified diff in adiffcode block instead.terminal: not consumed. It means “I implementterminal/*”, i.e. the agent may run commands in the client, which meka never does. See Shell commands stay inside meka._meta.terminal_output: true: the client renders agent-owned terminals, soshell_executeoutput is streamed into a real terminal instead of a code block. A rendering choice only: meka still spawns and sandboxes the process either way. Advertised by Zed; independent of theterminalcapability above.elicitation.form/elicitation.url: when an MCP server asks the user for input mid-tool-call, meka issueselicitation/createso the prompt renders in the editor. The two are advertised independently and checked separately: a server asking for a form when onlyurlis advertised is declined rather than sent. Without the capability meka declines every elicitation, which is what it did unconditionally before. Elicitations raised inside a sub-agent forward to the parent session, like permission prompts.session.compaction: the client renders a compaction entry, so every compaction goes out as acompaction_update; without it, as a tool call card. See Prompt turn. Advertised by Zed.session.notices: the client presents notices outside the transcript, so an advisory meka raises goes out as anotice; without it, as a[meka]chunk. Advertised by Zed.
If the client omits a capability, the matching tool falls back to local syscalls; the user-visible behavior is the same as meka on the screen.
Shell commands stay inside meka
shell_execute never runs in the client’s terminal, whatever the client advertises and whatever the permission level. meka spawns the process itself so everything it wraps a command in keeps applying: the sandbox that read and workspace depend on (Landlock / bwrap / sandbox-exec / restricted token), the environment scrub that keeps API keys out of the child, the per-session cwd from /cd, the timeout, and the process-group kill that reaches backgrounded grandchildren. The client’s terminal/* offers none of that.
meka used to delegate in any level other than read, which made every sandboxed level a bypass: meka would refuse to run at all when no sandbox backend was available, then hand the same command to an unsandboxed editor terminal. Delegation is gone rather than narrowed, so workspace keeps its boundary here exactly as it does on the screen.
Live output
Because meka owns the process, it streams the output: while a command runs, what it has printed so far is pushed into the open tool call, so an editor shows a build or a test run progressing instead of a spinner. Updates are coalesced to at most one per 150 ms. stdout and stderr are interleaved in the live view, the way a terminal shows them, while the result the model sees keeps them separated.
How that output is drawn depends on the client:
Clients advertising _meta.terminal_output get an agent-owned terminal. meka announces one on the tool call, appends each chunk to it as it arrives, and closes it with the command’s real exit code and signal. The client renders a genuine terminal: ANSI color, selection, full scrollback, and an expandable view in the tool call. meka still spawns and sandboxes the process; the client only draws the bytes it is handed. Nothing is executed on the client side, and no terminal/* request is ever sent.
Everything else gets a console code block, replaced on each update with a trailing window of the output. The complete output arrives in the final update when the command exits.
The terminal path uses an extension rather than ACP proper: _meta.terminal_info to announce the terminal (on the opening tool_call, which is where clients read it), _meta.terminal_output to append, _meta.terminal_exit to close it with the command’s real exit code. The convention comes from codex-acp, claude-agent-acp emits the same shape, and Zed consumes it, advertising _meta.terminal_output: true to say so. Gating on that key rather than on terminal matters: a client that implements terminal/* but not these frames cannot resolve the terminal, and would render nothing at all for it.
This is deliberately temporary. ACP v2 standardizes agent-owned terminals as terminal_update / terminal_output_chunk, and meka should move to those once a client implements them; v2 is still a draft schema (v2.0.0-alpha.N, behind an off-by-default feature flag) that nothing speaks yet.
When the client won’t serve a path
Editors differ in which paths they will serve: Zed answers only for the project it has open, another client may serve any absolute path. meka models none of these rules. It asks per path and routes on the answer:
ResourceNotFound(-32002) means the client will not serve this path, so it holds no buffer for it. meka reads or writes the file locally, and a write says so in the tool result: the change still appears in that tool call’s diff, but not in the editor’s buffer or undo history. This is what keeps ACP as capable as the terminal: the agent can read and edit its own skills, prompts, and configuration even though they live outside the project.- Any other error means the client may own the file and hold unsaved changes for it, so the tool call fails instead of routing around the client. Reading on-disk bytes would hand the model a stale view of a file the user is editing, and writing them back would overwrite unsaved work.
The route is chosen once per tool call by the read, not per request: file_edit and file_write write back through whichever filesystem they read from, so a diff taken from the editor’s buffer isn’t applied to disk while the buffer keeps the old content. The read is also the more reliable signal: Zed reports an out-of-project path as ResourceNotFound on fs/read_text_file but as a generic error on fs/write_text_file, so a route chosen from the write’s own error would never recognize it.
A client advertising fs.readTextFile but not fs.writeTextFile leaves the write to meka, so file_read, file_edit and file_write read the file from disk as well: what the model reads is what its edit is computed from, and the edit lands where it was computed. The client still holds a buffer for the file, which the write diverges from; the tool result discloses that with its own note.
Session lifecycle
meka holds an in-memory map of sessionId → SessionEntry. Any number of sessions can coexist in one meka acp process, each with its own cwd, permission level, conversation, cancellation token, and per-session runtime mutex. Prompts on different sessions run in parallel; a second session/prompt for a session that already has one in flight is refused with InvalidParams. The session row is also locked on disk (the same lock the screen uses), so two meka processes can’t simultaneously write events for the same session id.
An unknown sessionId, a session another meka process holds, a sub-agent’s id, a turn over the profile’s max_request_bytes and a profile config.toml no longer has are all refused with InvalidParams; InternalError is reserved for faults in meka or below it.
What an InternalError’s data carries follows the same policy the HTTP API applies to a failed turn, so a deployment cannot have one surface withhold what the other publishes:
-
The provider’s own response text travels only when
[serve] relay_provider_errorsis on, which is the default. It can name the operator’s account with the provider and its rate-limit posture; turn the key off anddatacarries meka’s sentence alone. Relayed text is capped at 4 KiB with the cut marked, and the full text goes to the meka log either way. -
An MCP server’s connection reason never travels. The server names do, since that is the part to act on; the reason is meka’s own subprocess text and has carried a command line and its path.
-
A store or filesystem failure, and a
[web]/base_urlmisconfiguration, never travel: they name meka’s own directories or the operator’s config.datasays where the detail went. -
session/new { cwd, mcpServers }: mints a fresh persisted session, captures the cwd, takes the on-disk session lock, returns the session id and the currentSessionModestate.mcpServersis ignored, with a warning naming how many entries were dropped; meka’s servers come fromconfig.toml. On this and every other door,cwdmust be an existing directory and is recorded in its canonical spelling (symlinks resolved), the rule the screen’s/cdand the HTTP API apply too; anything else isInvalidParams. -
session/load { sessionId, cwd, mcpServers }: replays the session’s history, its newest 2,000 rows, as a stream ofsession/updatenotifications (user_message_chunk,agent_message_chunk,agent_thought_chunk,tool_call,tool_call_update) before the response. The turns before a compaction are replayed too, and the compaction is said where it happened, as the live run said it: a completedcompaction_updateto a client that advertisedsession.compaction, else a completed card, then the calls its checkpoint made; never the summary as a user turn. The recent turns a compaction kept are replayed once. A background report replays as the stage direction the live run said, a parenthesizedagent_message_chunk, never as the words the model read. A skill invoked by name replays as/<skill-name>and the words typed with it, never as its body; an MCP prompt invoked on the screen, which an editor has no command for, as the stage direction the screen says and then those words. A result is reported as the live run reported it, with the change it recorded and the result asrawOutput; a file edit’s change comes as a unified diff in adiffcode block, since the log keeps the change and not the file. Orphan tool calls (the persisted log stopped mid-tool) are closed out with afailedstatus so the client’s UI doesn’t render a stuck spinner. If the client’scwddiffers from the persisted one, meka updates the persisted row to match; the client wins, but only once the session has actually opened, so a load meka refuses (a sub-agent’s id, a profile that has leftconfig.toml, an account with no stored credential) leaves the session’s recorded directory and roots exactly as they were.mcpServersis ignored silently here and onsession/resume. A sub-agent’s id is refused withInvalidParamsbefore the session is locked; continue that conversation withagent_followupfrom the parent instead. -
session/list { cwd?, cursor? }: paginated index. Filtered to the requested cwd when present, compared in the canonical spelling every session records; sub-agent sessions are always hidden.nextCursoris opaque; round-trip it back to keep paging. -
session/resume { sessionId, cwd, mcpServers }: adopts the session id without replaying. Use this when the client already has the history rendered. Same cwd-update behavior assession/load, including that a refused resume writes nothing. A sub-agent’s id is refused on the same terms assession/load. -
session/fork { sessionId, cwd, additionalDirectories, mcpServers }: copies the session’s conversation into a new persisted session, adopts the copy as active, and returns its id. The source is left open and untouched. See Forking. -
session/close { sessionId }: cancels any in-flight prompt, waits for that turn to finish (the cancel does not cut short afile_reador anfs/*request already in progress), releases the on-disk session lock, and removes the entry from the map. -
session/cancel { sessionId }: interrupts the activesession/prompt. The response carriesstopReason: "cancelled". A cancel sent straight after a prompt still stops that prompt, even if it arrives before the turn has started: meka latches the signal and applies it as the turn begins. The latch is scoped to a prompt that is already on its way, so a cancel with nothing to stop is discarded rather than saved. Interrupting a turn, canceling twice, or canceling while idle all leave the next prompt you send to run normally. -
session/set_mode { sessionId, modeId }: flips the agent’sPermissioncell. A level outside[permissions].enabledis refused withInvalidParams. On success, meka emitssession/update: current_mode_update. The flip is atomic and applies to the next tool call within an in-flight turn; no need to wait for the turn to finish. -
session/set_config_option { sessionId, configId, value }: sets one of the entries inconfigOptions. Returns the full list with its new values. See Session config options.
session/new, session/load, session/resume and session/fork all answer with modes and configOptions, and each is followed by an available_commands_update (see Slash commands). --additional-workspace-directory on the meka acp command line adds to every session’s workspace, beside whatever the client sent as additionalDirectories; those are the session’s additional workspace directories at every level, writable at workspace and read-only at read as the working directory is, since session/set_mode moves the level while the session runs. --additional-readable-directory adds what every session reads beyond its workspace.
Idle sessions are released
A session untouched for 24 hours is dropped from the map by a sweep that runs every 5 minutes,
releasing its lock and detaching its MCP registry. Only the in-memory entry goes, and the next
session/prompt, session/set_mode or session/set_config_option naming it reopens it, in the
directory and with the roots its row records, so a client that keeps a thread open needs no special
handling; session/load reopens it with its history, as it does one from a previous run. Sticky
approval answers go with the entry, so they reset here as they do on session/close.
session/close is optional in the protocol and several editors never send one, which is what this
answers. Each open session holds an Agent, a tool registry the MCP manager keeps a clone of, and
an open file lock, none of them reachable from anywhere else meanwhile. Neither the window nor the
scan interval is configurable; both match [serve]’s
defaults for the same mechanism.
Prompt turn
A session/prompt carries a prompt array of ContentBlocks. meka accepts:
text: the baseline.resource_link: the model reads it as a<resource_link name="…" uri="…">description</resource_link>tag where the link sits among the words; meka does not fetch the resource server-side. A block that declares a MIME type addsmime="…"to the tag, here and onresourcebelow.resource(embedded @-mention contents): a text resource is read as a<resource uri="…">…contents…</resource>tag where it sits; a binary (blob) resource becomes a self-closing<resource uri="…" encoding="base64"/>marker (the payload is not inlined).
Each resource is stored in its place among the words, so a session’s title reads the words around it, and session/load replays it as a resource_link named as the editor named it (an embedded resource by the last segment of its URI) rather than as its contents. A prompt that opens with /<skill-name> is the skill’s invocation, whole: the model reads the words and the files sent with them, then the skill’s body, and session/load replays it as the command and those words, each file as its @name.
image: accepted only when the profile has vision on. The payload is normalized through meka’s image pipeline (size cap, format conversion) and forwarded to the model as native vision input (Claudeimage, OpenAI chatimage_url, Codexinput_image).
audio blocks (and image when vision = false) produce InvalidParams.
Images travel in the other direction too: when a tool looks at one (file_read on an image file,
image_render, web_fetch on an image URL), the picture is forwarded on that tool call as an
image content block rather than a placeholder, so the client renders what the model was shown.
While the turn runs, meka streams session/update notifications:
agent_message_chunkfor each piece of assistant text.agent_thought_chunkfor thinking blocks (Claude OAuth / extended-thinking models).tool_callwhen a tool starts, withkind,status: "in_progress", an absolutelocationsarray (relative paths resolved against the session cwd, with the start line forfile_read), the raw input, and a human-readabletitle. The title is the tool’s name followed by the argument that identifies the call, so editors show what’s running rather than the bare tool name:file_read <path>/file_edit <path>/file_write <path>,web_fetch <url>, and the same under an MCP tool’s own name.shell_executeis the exception: its title is the command itself, whole and with its line breaks, because a client renders anexecutecall’s title as the command (Zed shows it in the terminal card and copies it from there, and shows norawInputfor that kind). This is also what claude-agent-acp sends.tool_call_updatewhen a tool finishes, with the finalstatus(completed/failed), acontentarray, andraw_output(the structured tool result). A call that never ran, its stream cut after it arrived, is closedfailedbefore the turn’s next round, or when the turn ends there; one asession/cancelstopped is left for the client to mark, as the protocol has it mark every call unfinished at a cancel. A command left running in the background streams nothing more to its call.shell_executeoutput is wrapped in a fencedconsolecode block so editors render it monospaced;file_editandfile_writepopulate diff content blocks so clients can render the apply-diff UI. (Large outputs offloaded to the scratchpad show the scratchpad reference rather than the full payload.) A call goes under the id the provider gave it, unless the session has already sent a call under that id (some providers reuse theirs every round, or give none) or the editor’s thread came from before (session/resume,session/fork, a session reopened after the idle sweep), in which case under one meka makes unique; every report of the call goes under the same id.planwhenever achecklist_addorchecklist_editcall changes the checklist, so clients with a plan panel (e.g. Zed) render the open items. A deferred item ispendingthere, with what it waits on in its text; an emptied list is an empty plan.session_info_updateonce per session, carrying the title (the first user message’s words, cut to 200 characters) so a freshly created or loaded tab gets a label without asession/listcall.compaction_updatefor every compaction, automatic ones included, when the client advertisedsession.compactiononinitialize:in_progressunder acompactionIdwhen it begins, thencompletedwith the summary as the model wrote it,failedwith the error, orcancelledwhen you stopped it._meta.compactioncarries meka’s facts:triggerfrom the start, and once it lands the fields of the HTTP API’scontext.compactedevent. A client without the capability gets atool_callof kindthinkinstead, as claude-agent-acp sends one: titled “Compacting the conversation” while it runs, then completed under the compaction record’s own sentence with what set it apart and the summary as its content and those facts as itsrawOutput, or failed with the error (“Interrupted” when you stopped it). Each call its checkpoint makes (the memories it writes, the files it reads) is told as it finishes, a finishedtool_callcarrying its result under an id that begins with the compaction’s, however the compaction then ends; a load shows them after the compaction.noticefor an advisory meka itself raised during the turn (a lost write, an MCP elicitation it declined on your behalf), when the client advertisedsession.noticesoninitialize:severityisinfoorwarning, andtitlecarries the words the screen shows. A client without the capability gets the same words as a[meka]-prefixedagent_message_chunk,[meka warn]for a warning, so it can style the two apart. A scheduled job’s turn that failed or was interrupted is reported the same way, since it has nosession/promptresponse to carry its outcome.noticeagain for finished background work the model was shown, in the screen’s words:titleis the stage direction (Background task 7f3a1c22 finished after 12m 4s),descriptionthe task’s label (one line per task when several report at once), and_meta.background.tasksthe tasks asGET /v1/taskslists them, outcome included, so a client that wants the result has it without the transcript carrying it. A client withoutsession.noticesgets the direction as a parenthesizedagent_message_chunk, as a replay says it. The report the model read is shown to no client.- A
user_message_chunkcarrying a scheduled job’s prompt, pushed by meka itself before the turn it fires runs, so the transcript shows what triggered it. usage_updateafter every provider round, carryingused(tokens currently in context: all input tiers plus output) andsize(the model’s context window), so clients with a context gauge (e.g. Zed) show how full the window is while a long tool loop runs rather than once it ends; a turn that fails or is canceled reports its rounds too, and a compaction reports the estimate it leaves. One more onsession/new,session/load,session/resumeandsession/fork, with what the row recorded, so a reopened session’s gauge is not empty until its first reply. Emitted only once the window is known.- The
session/promptresponse additionally carriesusage: what that turn spent, as the protocol defines it, intotalTokens/inputTokens/outputTokens/cachedReadTokens/cachedWriteTokens, a sub-agent’s and a compaction’s spend inside the turn included. This is the turn’s cost, not the gauge:usage_updateanswers “how full is the window”,usageanswers “what did this turn cost”. A local command such as/statusspends nothing and says so.thoughtTokensis omitted because meka doesn’t meter reasoning separately from output.
The response carries a final stopReason:
stopReason | Meaning |
|---|---|
end_turn | The agent finished cleanly. |
max_tokens | The provider stopped because the model hit its maximum output tokens. The assistant message may be truncated. |
cancelled | session/cancel interrupted the turn, including the case where the cancel caused an error in an underlying operation. meka probes the per-session cancellation token after the turn; any error returned while the token has fired surfaces as cancelled rather than a generic JSON-RPC error. |
refusal | The model declined to comply (Claude stop_reason: "refusal" and the OpenAI equivalent). The assistant message contains the refusal text. |
Permission levels
meka’s Permission levels map 1:1 to ACP SessionMode ids:
| Permission | Mode id | Display name | Description |
|---|---|---|---|
None | none | None | No tools available. |
Read | read | Read | File reads and searches only. No writes, no shell. |
Workspace | workspace | Workspace | Writes confined to the workspace roots. |
Unrestricted | unrestricted | Unrestricted | Writes and shell commands reach anywhere on the machine. |
The full picker is advertised on every session-creation response (NewSessionResponse.modes, LoadSessionResponse.modes, ResumeSessionResponse.modes, ForkSessionResponse.modes) but only the levels in [permissions].enabled from your config.toml are listed; picking a disabled level would just error.
The same picker is also advertised as a configOptions entry, so a client that reads either field
gets it; see below.
With the approvals config option on, a tool call above the active level triggers a session/request_permission round-trip instead of a refusal. Clients render four options:
- Allow: run this call only.
- Always allow any
<tool_name>: run this call and skip the prompt for that tool for the rest of the session. - Deny: refuse this call only.
- Always deny any
<tool_name>: refuse this call and every subsequent call to that tool.
The sticky options name the tool because that is exactly their scope: the decision is keyed on the tool name and takes no account of arguments. The prompt’s title is <tool_name> <primary argument>, the tool’s name and the argument the indicator shows (for shell_execute, the command alone, as on the tool call), so for a shell command you are reading one specific command line while the sticky option covers every shell command the agent runs afterwards. If you want per-command control, use Allow and keep answering. The request’s rawInput, and a fenced json content block beside the title, carry every argument the call was made with, so a client that renders either shows what is being written and not only where. The request names the call’s card. A call the editor has not been shown opens one first: a compaction checkpoint’s call opens the card the call’s end completes with its result, and a sub-agent’s call, which the editor never sees, opens a card of the prompt’s own that closes with the answer as the screen says it (file_write allowed), leaving the agent_spawn call as it was.
Sticky decisions live in meka’s process memory with the session entry; they reset on session/close and when the idle sweep releases the session.
A prompt left unanswered for 30 minutes is denied, and the turn carries on; the HTTP API’s approval.requested event has the same 30 minutes. This is a backstop against a client that is connected but will never reply (an editor whose UI thread has wedged, or a harness that speaks ACP without implementing prompts), not a deadline on you: session/cancel already resolves a prompt the moment you stop the turn, and without the backstop a client that does neither holds the session’s runtime mutex indefinitely, blocking session/close and session/set_mode behind it. Denying rather than allowing on expiry is deliberate: an unanswered prompt is not consent.
Session config options
Every session-creation response also carries configOptions, a list of options a client can
render and change with session/set_config_option. meka advertises three, four where a
project exists, in this order:
configId | Kind | Category | Values | Meaning |
|---|---|---|---|---|
permission | select | mode | The ids in [permissions].enabled | The same picker as modes, so it sits beside the one below |
profile | select | model | The profile names in your config.toml | The profile this session runs on |
project | select | none | "" (shown as “No project”), then every project’s name | The project this session is on, whose memories it sees beside the global ones; offered only when a project exists, and read from the store at every update, so one added while the editor runs is offered next |
approvals | boolean | mode | true / false | Whether a call above the level is put to you for approval rather than refused; see Permissions |
approvals takes the protocol’s boolean value, "type": "boolean", "value": true beside configId
in the session/set_config_option params, where the two pickers take a bare value id and no
type. A new session starts with
[permissions].approvals from the config file; session/load and session/resume restore what the
session recorded, since the switch is written to the session row like the level.
permission is deliberately advertised twice, once here and once in the legacy modes field. A
client that only understands modes keeps the picker it has; one that reads configOptions gets
permission and profile adjacent rather than in two unrelated menus. Setting it through either route
does the same thing, and neither picker is left stale: session/set_mode pushes a
current_mode_update and a config_option_update, while session/set_config_option pushes a
current_mode_update and returns the whole refreshed list in its response.
A session whose recorded profile has since left config.toml cannot be loaded at all:
session/load fails while building the runtime, so there is no entry for
session/set_config_option to change. Restore the profile in config.toml, or move the session
with meka -r <id> --profile <name> from a shell, and load it again.
Changing profile rewrites the session’s row, so it holds for every later turn and for a resume
from any surface, not just for this connection. This is the same change /profile makes in the
screen and PATCH /v1/sessions/{id} makes over HTTP. Switching mid-conversation is allowed and is
your call: a thinking block is tagged with the backend that produced it and is not replayed to a
different one, so from the next turn the model no longer sees the reasoning recorded under the old
profile.
Changing project rewrites the row the same way, and the next turn’s context restates the
memories the session now sees; a new session is on no project, and a name no project has is
refused without moving the row.
While a turn is in flight either switch is refused with InvalidParams (cannot switch profile while a turn is in flight; cancel it first, or project), the answer a second session/prompt gets, and
nothing is written. Reasoning effort is deliberately not offered: which tiers a
model accepts is a fact about the provider’s system, and a fixed dropdown would be meka asserting
it. It stays on the profile.
Slash commands
Two kinds of slash command are advertised through session/update: available_commands_update (after session/new / session/load / session/resume / session/fork, and refreshed at the top of every session/prompt so a skill installed mid-session shows up without a reconnect):
- Built-in local commands:
/status(the permission level, then the screen’s block: profile, model, context usage, effort, thinking, compaction count, cumulative tokens),/mcp(configured MCP servers and their connection status) and/usage(the account’s rate-limit windows, subscription backends only). They render text back as anagent_message_chunkand end the turn immediately, with no model call. They take no argument, so a command followed by words,/usage looks wrong, why?, goes to the model as typed, and one sent with an image is refused withInvalidParams, since nothing would read the image. - Skills (see Skills): each installed skill is a top-level command carrying a free-form input hint (
"additional context (optional)").
When the user picks one from the palette, the client typically inserts /<name> and lets the user type extra context. meka parses the prompt as follows:
- A built-in local command (
/status,/mcp,/usage): handled agent-side, output streamed back, turn ends with no model call. Checked first, so a skill can’t shadow a built-in (a skill namedstatus,mcporusageis dropped from the palette). - Plain text (no leading slash): passes through to the model unchanged.
/<skill-name>matching an installed skill: loads the skill body via the same path as the screen’s/skillcommand and prepends any extra context the user typed.- Slash with a syntactically valid but unknown skill name (
/nonexistent): passes through to the model unchanged, with adebuglog. The filter false-positives on pasted text like/usr local lib, so a miss is read as “not a skill invocation after all” and the model can say it does not know the command. Only a skill that exists but cannot be read is an error (InternalError). - Slash with content that isn’t a valid skill identifier (
/etc/hosts,//comment): passes through to the model unchanged, so pasted paths and code comments don’t get intercepted.
Sub-agents
agent_spawn and skill-based delegation produce a sub-agent that runs through PermissionForwardingFrontend. The sub-agent’s own output isn’t streamed to the client (its final report flows back through the parent’s tool_call_update), but its permission prompts and fs/* requests forward through the parent’s connection, so the editor’s apply-diff UI sees a sub-agent’s writes the same as the root agent’s. A forwarded prompt opens a card of its own; see Permission levels.
ACP has no sub-agent primitive (no nested sessions, no nested tool calls), so a sub-agent is one tool call, and its progress is that call’s content. While it runs, each tool call it starts is appended to a rolling list (the last 20) and pushed as a tool_call_update on the parent’s agent_spawn call, so a long delegated task shows what it is currently doing instead of an opaque spinner. The whole list is resent on each update because clients replace a tool call’s content rather than appending to it. A nested sub-agent’s list is not forwarded further up: it already appears as an agent_spawn line in its parent’s list, and two writers on one tool call’s content would overwrite each other.
Multi-root workspaces
An editor whose workspace holds several folders (Zed’s Add Folder to Project) sends the first as cwd and the rest as additionalDirectories. Clients only send them when the agent advertises sessionCapabilities.additionalDirectories, so before meka advertised it every folder but the first was silently dropped and the agent would report files in them as missing.
What the extra roots do and don’t change:
- Search sweeps all of them.
file_findandfile_searchwalk every root when you don’t pass an explicitpath. The 60-second walk budget is shared across the whole call, not granted per root, so a four-folder workspace doesn’t get a four-minute ceiling. Passingpathsearches exactly that tree, as before. - A truncated
file_searchsays which roots it skipped. Roots are walked in order starting fromcwd, so a busycwdcan fill the 100-match cap before later roots are reached. When that happens the output names how many roots went unsearched, rather than leaving their absence to read as “nothing there”. Passpathto search one directly, orscratchpadto lift the cap.file_findis unaffected: its cap bounds only what it prints, so it still counts matches across every root. - Overlapping roots are collapsed. A root nested inside another (or a repeat of
cwd) is dropped, so its tree isn’t walked twice and its files aren’t reported twice. Symlinked duplicates aren’t detected. - The model is told they exist. Each root is named in the per-turn environment context, alongside the working directory.
- Relative paths still resolve against
cwdonly. This is what the spec requires:cwd“remains the base for relative paths”. Use an absolute path to reach a file in another root. - The shell still runs in
cwd.shell_executeis unaffected. - A stale root is skipped, not fatal. A root that no longer exists is passed over so the other roots can still answer;
file_searchreports “does not exist” only when no root existed. Root paths are escaped before they reach the glob engine, so a folder named2024*ornotes[1]matches literally instead of widening the search.
Every entry must be an absolute path; a relative one is refused with InvalidParams.
The list is persisted and reported back on session/list as SessionInfo.additionalDirectories, which is how a client rebuilds the workspace shape when you pick a session out of its history. session/load and session/resume carry the complete resulting list, so they replace what was stored rather than merging: reopening a session from a window that no longer has the second folder correctly narrows it, and an empty list clears the roots.
Forking
session/fork branches a copy off a persisted session: the new session starts with the source’s full conversation and continues from there, while the source stays open and unchanged. It’s the protocol’s way to explore a direction, or run something like a summary, without writing into the conversation the user is looking at.
The request is a session-creation request, not a row copy: it carries its own cwd and additionalDirectories, and meka applies those to the fork rather than inheriting the source’s. mcpServers is ignored, as on session/new. The response returns the new sessionId and the current SessionMode state, and the fork is registered as active immediately, so it can be prompted without a further session/load or session/resume.
There is no replay: unlike session/load, forking emits no session/update stream for the copied history, since a client that just forked already has the transcript rendered.
Sub-agent child transcripts are not copied, and a fork of an ordinary session records no link back
to its source. session/fork answers InvalidParams for a sub-agent’s own id: the copy would be a
sibling under the same parent and could not be driven, so the answer points at agent_followup
from the parent instead. It answers InvalidParams for
a source with a prompt in flight too, as a second session/prompt does, and for a source another
meka process has open: either copy would end on a prompt nothing answered. See
Forking a session for the full semantics.
This method is marked unstable in the protocol: it is not part of the spec yet and may change or be removed. Zed does not currently call it.
Known limitations
- A reopened session’s file diffs are code blocks. A session reopened with
session/loadshows a file edit’s change as a unified diff in adiffcode block rather than the protocol’sdiff, which the live run sends: a result records its change as numbered hunks, not as the two texts the protocol’sdiffcarries. The on-disk content is unaffected. - Calls of one round under one id. A provider that gives two calls of one round the same id, or none, has an approval prompt or a command’s live output for either shown on the first one’s card, and two such sub-agents’ activity possibly on each other’s; each result reaches its own.
terminal/*is never used: meka owns every process it spawns, so no command runs in the client’s terminal. Output streams into the tool call instead, as an agent-owned terminal where the client advertises_meta.terminal_outputand aconsoleblock otherwise. See Shell commands stay inside meka.- Image
file_read: stays local. Thefs/read_text_filerequest carries only text, so there’s no protocol surface to delegate it. A regexfile_readfetches the whole document through the delegate and filters it locally. audioprompts: not supported;audiocontent blocks produceInvalidParams.- No client-side model gate for images: when
visionis on, meka forwards images to whatever model the profile names; a non-vision model returns a provider error rather than meka refusing up front. Setvision = falsefor text-only endpoints.