magi-code 0.101.0

Repository-aware CLI coding agent for terminal work
Documentation
# Local-agent stdio protocol

## Start a local agent

`magi-code serve --stdio` serves **magi-code.local-agent**, version **1**, on inherited stdin/stdout pipes. No embedding API, daemon, sockets or second binary.

Implemented: `initialize`, `status`, `shutdown`, `session.create`, `session.open`, `session.list`, `session.discover`, `model.catalog`, `model.catalog.status`, `model.refresh`, `operation.cancel`, `selection.set`, `history.page`, `turn.start`, `turn.cancel`, `approval.answer`. All sixteen capabilities are advertised. Require `session.discover`, `model.catalog.status`, `model.refresh` and `operation.cancel` individually during initialization to enable those operations; calling one without negotiation returns `unsupported_capability`. Initialization loads the same configuration, instructions, skills, profiles and strict cancellable MCP policy as Mission Control, without terminal state. Turns use the existing full agent runner, not a separate provider path.

The transport requires Unix pipes (macOS/Linux). Windows serving, TTYs, regular files, and sockets are unsupported. Other launch flags cannot accompany `serve`.

The client chooses the working directory and owns the child process. The backend resolves normal `MC_HOME` storage independently, owns history, and executes tools. The client owns drafts, display state, and its selected durable session ID. Tool access is not an OS sandbox; do not fall back to client-side execution. The explicitly negotiated `client.tools` extension is the only exception: clients execute their own startup-registered tools, never built-ins or MCP tools.

See [Connect a local client](local-agent-handoff.md) for setup. Configure authentication and enabled models through Mission Control before launching the child. Do not copy credentials or add authentication UI to this protocol.

Explicit `tool.cards` negotiation adds live and saved display cards plus one capability and its schema descriptor. Base-only clients keep the original sixteen capabilities and event/history shapes. See [tool cards](local-agent-tool-cards.md).

Explicit `client.tools` negotiation registers immutable client-provided tools, adds one capability and its descriptor, and enables `client.tool.call`, `client.tool.cancel` and `client.tool.result`. It works independently of `tool.cards`. See [client-provided tools](local-agent-client-tools.md) for schemas, identity, permissions, bounds and lifecycle.

## Frames, correlation and compatibility

UTF-8 JSON objects, one per LF-terminated line. No banner, ANSI styling, raw JSONL, internal event serialization or raw chain-of-thought. CR before LF counts toward byte limits and is JSON whitespace. Empty lines are invalid. EOF without LF does not execute a partial request. Each request contains exactly these fields (unknown/duplicate top-level fields rejected):

```json
{"protocol":"magi-code.local-agent","version":1,"id":"r1","method":"initialize","params":{"required_capabilities":["status","shutdown"]}}
```

Examples here are **synthetic**, not provider/native frontend traces. Every response contains `protocol`, `version`, `type="response"`, `instance_id`, `seq`, `id`, and exactly one of `result` or `error`. `error` is `{ "code": "..." }`, never submitted text or underlying exceptions. Invalid/unrecoverable request identity uses `id=null`; otherwise `id` equals request ID. Initialization and session/history operations are asynchronous: later status/shutdown responses can precede their results. Clients must correlate by ID, not order. Sequence numbers start at 1, strictly increase across **all** output frames, and reset with a new child. Partial output lines are not usable messages.

Every child has a fresh UUID `instance_id`; scope all ephemeral identifiers to it. Context identity is an opaque SHA-256 digest of canonical cwd and canonical admitted storage-root paths, captured once after normal runtime directory creation. Equivalent storage-root aliases produce the same context identity for the same cwd; distinct real roots or cwd values produce different identities. It does not disclose those paths, assert permissions, hash settings, or provide a security boundary. Configuration and discovery are one admitted snapshot per child. External settings changes require child restart; normal runner credential refresh remains unchanged.

Request IDs and method/capability names: 1–64 ASCII bytes from `[A-Za-z0-9._-]`. Request IDs **must never be reused within a child**, even after response. Backend retains at most 4096 IDs (including rejected well-formed requests); the next new ID gets `request_limit` and closes. Duplicate IDs get `duplicate_id` and never reexecute work. This stronger lifetime rule also prevents duplicate live requests. Clients retain their own bounds and never replay uncertain accepted requests after disconnect.

Protocol/version mismatch emits `protocol_mismatch`, then closes. Clients must fail closed on unknown protocol/version, frame type, error code, event name or enum semantics; unknown additional result fields may be ignored. Required capability negotiation, not version alone, admits future functionality. Adding optional fields/capabilities can retain version 1; changing required fields, meanings or limits incompatibly requires a new version. Do not implement reserved operations by accepting private Rust DTOs.

## Implemented operations

- `initialize`: base params exactly `{ "required_capabilities": [string, ...] }`, at most 16 names. Negotiated `client.tools` additionally requires `client_tools` and permits at most 18 names; see its extension contract. Unsupported required capability → `unsupported_capability`; no settings/auth reads or filesystem/startup effects. Accepted initialization starts one worker and returns only after readiness or failure. Success contains `capabilities`, `limits`, `status`. Another initialization after admission, including failure, → `already_initialized`; restart to retry.
- `status`: params exactly `{}`; requires admitted initialization. While pending: `state="starting"`, `readiness="initializing"`, with null provider/model/context. Success: `{ "state":"idle"|"running", "readiness":"ready", "provider":"<effective-provider>", "model":"<effective-model>", "context_id":"<digest>", "session_id":string|null, "run_id":string|null }`. Running state retains the active run ID until worker completion and event draining. Selection and session identity reflect the last completed attachment, including during an operation. Failure: `state="failed"`, readiness equals safe startup error code; selection/context may be available. Never performs a provider request.
- `shutdown`: params exactly `{}`; allowed before initialization. Result `{ "state":"stopping" }`; no later input admitted. Cancels startup, session/history work or active turn and resolves a pending startup/session request with `startup_cancelled` or `operation_cancelled`, then drains output and cleans resources under separate bounds. Run terminal delivery remains subject to transport deadlines. Cancellation does not promise rollback of already admitted work.

Initialization result `limits` publishes the concrete bounds below. Provider/model names are bounded to 256 bytes and omitted (`null`) if secret-like, redacted or control-bearing. Never return tokens, headers, credential paths/files or raw provider responses. Startup errors do not open auth UI; configure login/settings through normal magi-code.

### Shared startup and effects

After capability preflight, initialization resolves normal storage paths, creates runtime directories, performs normal configuration loading/migration, and reads provider credentials. Global settings and exact-cwd project settings retain normal precedence. Instruction order, additional Markdown paths, skill collisions/disabled filtering, primary-agent selection and disabled subagent profiles use the shared Mission Control policy. No session handle/history or terminal renderer is constructed.

Approved enabled MCP servers initialize through the existing strict cancellable path and remain connected for child lifetime. This can spawn subprocesses or make MCP HTTP/OAuth refresh requests; startup does not prompt for MCP login. Global approval of the canonical definition source remains mandatory; project `enabled=true` cannot approve itself. One failed enabled server fails initialization and cleans connected resources. Direct and Code Mode tool permissions stay independent.

Provider readiness checks supported selection and available authentication locally, including Codex expiry/refreshability. No provider generation, catalog fetch or provider OAuth exchange occurs during initialization. `ready` means locally configured, not proven remote availability. Turns reuse the existing full runner, including credential refresh, hooks, tools, Code Mode, subagents, protections and primary auto-compaction. Invocation identity is `local_agent`, not Mission Control or subagent; no Herdr integration.

## Bounds and failure policy

| Bound | Version 1 value |
| --- | --- |
| Input frame | 65,536 bytes excluding LF |
| Output frame | 65,536 bytes including LF |
| JSON nesting | 32 containers; serde recursion guard also retained |
| ID/method/capability | 64 ASCII bytes |
| Prompt | 16,384 UTF-8 bytes, non-whitespace |
| Content chunk | History: 16,384 UTF-8 bytes, reduced for JSON escaping; live events: 4,000 UTF-8 bytes |
| History page | 32,768 serialized result bytes, at most 100 entries |
| History snapshot | 8 MiB serialized entry projection, at most 10,000 parts |
| Stored history read | 64 MiB original JSONL bytes, at most 100,000 lines |
| Pending output | 32 frames, each at most one output frame |
| Worker events | 32; best-effort deltas may drop, critical events wait at most 2000 ms |
| Assistant content per run | 8 MiB, at most 512 segments |
| Activity identities per run | 4096, internal IDs at most 1024 bytes |
| Activity/approval/diagnostic summary | 1024 UTF-8 bytes after redaction |
| Approval expiry | 60,000 ms, default deny |
| Request IDs per child | 4096 |
| Output deadline | 2000 ms from enqueue, **not** reset by partial progress |
| Cleanup wait | 2000 ms after output draining stops; slow teardown may detach for process exit |

One partial input frame, one 4096-byte read batch, one initialization worker, one session/history worker or turn worker, and at most 32 transport output frames are buffered. Turn events use a separate bounded 32-event channel; accepted assistant text remains bounded for reconciliation. Each parse is limited by frame size/depth; history work retains one bounded snapshot. Caller must bound in-flight requests independently. Reading remains enabled during provider, tool, approval and history work and when output stalls; one nonblocking ordered writer handles all protocol bytes. Queue overflow terminates with exit 1: no silent authoritative drop. The oldest output frame exceeding its absolute deadline terminates with exit 1. Partial writes cannot postpone this deadline. Broken output terminates with exit 1. EOF stops admission, cancels pending work and drains output under a 2000 ms closing deadline; expiration before draining/turn completion exits 1, successful drain exits 0. An unterminated frame gets `incomplete_frame` if deliverable. EOF does not guarantee a pending operation response or run terminal. Oversize detection happens on byte 65,537, emits `frame_too_large`, then closes without unbounded draining. Idle clients have no input deadline, but cannot exceed one partial-frame buffer.

EOF, output failure/overflow/deadline and explicit shutdown cancel through existing cancellation. Cleanup owns startup/session/turn workers, admitted runtime and retained MCP manager off the protocol loop; session writer/active leases remain held until runtime teardown finishes. All connected MCP servers receive shutdown before their joins or HTTP session deletion can wait. The child waits at most 2000 ms for cleanup, then detaches slow teardown for process exit. Thread-exhaustion fallback deliberately leaves resources to process exit rather than blocking on synchronous destruction. Output draining and cleanup waits are separate: a stalled drain can consume 2000 ms before the cleanup wait begins. Critical worker-event delivery failure cancels the run with `output_unavailable`; delivery after connection loss is never guaranteed. No uncertain work is auto-replayed. Message display completion, worker cleanup and durable history are distinct.

Protocol errors: `invalid_frame` (depth/framing structure), `invalid_request` (JSON/UTF-8/envelope/identity), `invalid_params`, `protocol_mismatch`, `unsupported_method`, `unsupported_capability`, `not_initialized`, `already_initialized`, `duplicate_id`, `request_limit`, `frame_too_large`, `incomplete_frame`. Startup errors: `configuration_invalid`, `provider_unavailable`, `profile_invalid`, `mcp_unavailable`, `startup_cancelled`, `startup_failed`. All nonfatal errors leave the connection usable; failed admitted startup requires restart, while capability/parameter preflight failures allow corrected initialization. Mismatch/request-limit/oversize close as specified. Errors do not echo untrusted input. Runtime transport failure exits 1 without stderr diagnostics: even a fixed diagnostic could block cleanup on a full stderr pipe. Stdout contains protocol bytes only. Invalid launch exits 2 before runtime side effects. An error response does not imply failed execution of a previously admitted request with the same ID.

## Owned sessions and chat history

These commands require successful initialization and advertised capability. During pending startup they return `not_initialized`; after startup failure they return that safe failure code. Params reject unknown/duplicate fields. One operation at a time; overlap returns `busy` without implicit cancellation. Create/open never executes tools or provider generation, fetches a model catalog, or appends placeholder history.

- `session.create`, params `{}`: result `{ "session_id":string, "durable":false, "revision":string, "notices":[] }`. Allocate one attached lazy handle; no placeholder history. Same-child `session.open` may reopen that handle before persistence. Turns make it durable after the first required input append and acknowledge `input.persisted`; explicit `selection.set` can also persist preferences and acknowledge `durable=true` without a prompt. Client saves relaunch ID only after either durable acknowledgement. If child dies earlier, explicitly create again.
- `session.open`, params `{ "session_id":string }`: exact ID only; result `{ "session_id":string, "durable":boolean, "revision":string, "notices":[string] }`. Restart requires an existing safe JSONL file; an empty existing file is durable, a deleted file is missing. Never most-recent/new-session fallback. Restore saved provider/model/thinking from admitted settings and cached model availability without writing history/settings. Unavailable selection retains startup selection and saved requested thinking, with `saved_selection_unavailable`; other restoration notice uses `saved_selection_notice`. Reject busy attachment while work/relevant cleanup owns resources. Writer/active leases survive worker/MCP cleanup, then release. Failed opens leave the previous attachment intact.
- `history.page`, params `{ "session_id":string, "revision":string, "cursor":string|null }` (cursor may be omitted): result `{ "session_id":string, "revision":string, "entries":[entry], "next_cursor":string|null, "complete":boolean }`. Start at null cursor. Revision denotes immutable bounded projection; stale snapshot/cursor → `stale_revision`. No attachment → `session_missing`; a different session ID → `stale_session`. Page order follows stored event order. `complete=true` only on last page of a complete projection. Interrupted turns without durable completion include a partial diagnostic and keep completeness false; recorded failed/cancelled turns retain partial replies but can have a complete projection. No silent truncation; oversized entries use content chunks or `content_limit`. Snapshot budget: 8 MiB serialized entry projection, at most 10,000 parts; exceeding it → `history_limit`, not a fake complete page. Cursor max 64 ASCII bytes.

Each entry: `{ "message_id":string, "kind":"user"|"assistant"|"reasoning_summary"|"activity"|"compaction"|"diagnostic", "text":string, "partial":boolean, "part":integer, "last_part":boolean }`. IDs scope to session + revision, not forever across compaction/history replacement or repeated opens. Part starts at 0; concatenate contiguous parts of the same message only. Each text part respects content-chunk bounds. Project one reconciled assistant copy, never both stored chunks and complete output. Reasoning summaries require explicit stored `provider_summary=true` provenance; missing/false provenance is omitted, including older unverified records. Permitted summaries keep stored item/turn identity within a turn; repeated identified summaries replace the earlier entry. Tool summaries show completion/failure or partial started state, never raw result bodies. Exclude internal automatic/provider-only prompts, structured private assessments and raw reasoning. Reconcile before redacting and chunking; sanitize all projections. Provider replay remains backend-owned and distinct from presentation; this filter does not alter Mission Control hydration.

All text chunks fit both raw UTF-8 and serialized page bounds; JSON escaping may require smaller parts. Revision, cursor and message IDs are opaque strings following the 64-byte ID grammar. Keep one history snapshot per attachment; content fingerprints and replay generations detect mutation, and invalidation stays sticky. Reopen to obtain a new revision. `session.create`/`session.open` replace the attachment only while idle, releasing previous ownership after cleanup; active work rejects `busy` without implicit cancellation.

Session IDs match existing backend grammar `[A-Za-z0-9_-]+`, at most 64 ASCII bytes. Reject missing (`session_missing`), owned (`session_busy`), unsafe/unreadable/malformed (`session_invalid`), over-budget (`history_limit`) or wrong-workspace (`cwd_mismatch`) sessions explicitly. Stored reads reject diagnostics and mismatched event session IDs. Required visible text must be valid; legacy assistant output without text uses its stored chunks. Fixed-workspace resume requires every nonempty recorded cwd to be absolute and canonicalize to canonical child cwd; missing recorded path fails `cwd_mismatch`; absent older metadata yields `cwd_unverified` notice, never fabricated verification. Cwd identifies context, not an OS sandbox. Attachment retains stored delegated task-scope ceilings. Existing CLI retention remains unchanged (30-day default; zero disables auto-prune); this endpoint adds no pruning sweep, and attached sessions retain active deletion protection.

Session/history errors: `busy`, `stale_session`, `session_missing`, `session_busy`, `session_invalid`, `cwd_mismatch`, `history_limit`, `stale_revision`, `content_limit`, `operation_cancelled`. Errors carry no raw paths/content and leave the connection usable.

## Read-only session discovery

Require capability `session.list`. Params exactly `{ "revision":string|null, "cursor":string|null, "limit":integer }`; all fields optional, defaults null/null/100. Limit 1–100. Revision and cursor must both be null/omitted for a fresh snapshot, or both nonnull for continuation. Both follow 64-byte ID grammar. Unknown/duplicate fields, invalid bounds or unmatched revision/cursor → `invalid_params`.

Result exactly `{ "revision":string, "sessions":[entry], "next_cursor":string|null, "complete":boolean, "notices":[string] }`. Entry exactly `{ "session_id":string, "title":string|null, "updated_at":string|null, "eligibility":"available"|"cwd_unverified"|"busy", "notices":[string] }`. IDs remain exact existing backend session IDs. Order: ascending ASCII session ID, independent of directory iteration and timestamps. `updated_at` is greatest durable event timestamp, UTC RFC 3339; empty history → null. Title comes only from durable title/compaction metadata, sanitized to existing 50-character title bound and at most 256 input bytes; missing/unsafe title → null, never derived from a prompt or generated. Use session ID as fallback display.

Every primary `.jsonl` candidate is checked through existing safe held-file reads and the same all-recorded-cwd rule as exact open. Wrong workspace, nonabsolute/missing recorded cwd targets are excluded. Legacy histories with no recorded cwd remain explicitly `cwd_unverified`, with notice `cwd_unverified`; no workspace verification is invented. Subagent/archive histories and lazy handles without JSONL are not listed. Malformed, unsafe or unreadable histories are omitted with one bounded top-level `sessions_omitted_invalid` notice (no names/content/paths). Invalid/overlong primary filenames or inaccessible/unsafe root → `discovery_unavailable`. A vanished entry or changing inventory during scan → `stale_revision`. Unknown eligibility from open-specific validation is not promised: `session.open` remains authoritative for history projection, scope, availability and writer ownership.

`busy` is conservative advisory presence of another writer/active/append lease file. Listing never acquires, renews, recovers or removes a lease; stale/unsafe lock files can conservatively report busy. This child's attached session is available to this child. Exact open rechecks real ownership, may return `session_busy` even after available listing, and never changes prior attachment on failure. Active/admitting turn or session/history worker → `busy`, without cancellation or deferred attachment; listing uses the existing single cancellable session worker.

Keep one discovery snapshot per child. Each continuation rescans bounded content/inventory and compares fingerprint, including file identity/permissions, event content, lease presence and attachment. Any detected mutation invalidates snapshot **stickily** with `stale_revision`; start again with null revision/cursor. Lease renewal timestamps alone do not invalidate. Fresh listing replaces old discovery snapshot, not attachment/history snapshot. No state across children; cursor/revision opaque. No promise of an OS-atomic store transaction: files may change after response; exact open always revalidates.

Bounds advertised in initialization: `discovery_page_bytes=32768`, `discovery_page_entries=100`, `discovery_directory_entries=4096`, `discovery_sessions=256`, `discovery_session_bytes=8388608`, `discovery_session_lines=10000`, `discovery_scan_bytes=67108864`, `discovery_scan_lines=100000`, `discovery_scan_ms=2000`, `discovery_title_bytes=256`. Directory bound includes sidecars/locks/non-session names; two inventory passes each enforce it. Read-work limits apply per request, not per page entry; failed reads without trustworthy consumed-byte/line stats conservatively charge their full admitted per-file budget. Directory work counts iterations, including repeated names under mutation; metadata derived from those bounded events without sidecar rebuild. Snapshot holds at most 256 bounded entries; serialized result ≤32768 bytes including metadata. Cooperative 2000 ms scan deadline and cancellation checks between files/entries; filesystem calls remain OS-owned. Work stays off protocol loop → status/shutdown remain responsive. Any limit exceeded → `discovery_limit`, **no partial page or fake complete result**. `complete=true` only when all safely eligible entries in that bounded inventory have been paged; invalid omissions are explicit notices, never silent scan truncation.

Listing never attaches/switches, appends/rewrites settings/history/sidecars, prunes, generates titles, executes/replays turns, accesses frontend drafts or fetches provider catalogs. Errors additionally `discovery_limit`, `discovery_unavailable`, `stale_revision`, `operation_cancelled`, plus existing startup/busy errors; bounded code-only responses leave connection usable. Shutdown cancels through existing session-worker cleanup policy.

Synthetic version-1 wire example (response envelope omitted for brevity):

```json
{"protocol":"magi-code.local-agent","version":1,"id":"discover1","method":"session.list","params":{"limit":1}}
{"revision":"<opaque-revision>","sessions":[{"session_id":"older-id","title":null,"updated_at":null,"eligibility":"cwd_unverified","notices":["cwd_unverified"]}],"next_cursor":"<opaque-cursor>","complete":false,"notices":[]}
{"protocol":"magi-code.local-agent","version":1,"id":"discover2","method":"session.list","params":{"revision":"<opaque-revision>","cursor":"<opaque-cursor>","limit":1}}
```

See [Browse local sessions](local-agent-discovery-handoff.md) for a client workflow.

For bounded incremental discovery without a global candidate ceiling, negotiate `session.discover`. Its provisional completeness semantics, separate extension limits and cancellation contract: [local-agent extensions](local-agent-extensions.md).

## Provider/model catalog and deliberate selection

Require capabilities `model.catalog` and `selection.set`. Existing required nullable `status.provider`/`status.model` remain unchanged; new optional result field `status.selection` is null before completed readiness and on failed startup, otherwise this object:

```json
{"revision":"<opaque-selection-revision>","provider":"<provider>","model":"<model>","requested_effort":"high","effective_effort":"high","effort_support":"supported","permitted_efforts":["default","low","medium","high"]}
```

Selection fields are exactly `revision:string`, `provider:string|null`, `model:string|null`, `requested_effort:effort`, `effective_effort:effort|null`, `effort_support:"supported"|"unsupported"|"unknown"`, `permitted_efforts:[effort]`. Effort values: `default`, `low`, `medium`, `high`, `xhigh`, `max`; no aliases/case folding. Requested effort is backend's retained request, including saved requests that no longer apply. Effective effort uses normal backend capability resolution, not frontend inference. `default` means **no explicit effort override**; it does not name or predict provider's remote default. Supported controls → effective backend-resolved effort; explicitly unsupported controls → null/not-applicable; unknown/missing/unreadable capability information → null/unavailable. Unknown controls still admit `default` as backend no-override policy, never invented explicit levels. Built-in backend profiles are authoritative permitted policy, not inferred frontend model-name defaults. Saved unsupported requests can remain requested while effective is default/null; deliberate unsupported mutation is rejected instead of silently normalized.

Status is memory-only, never a catalog/provider/auth request. It reflects last completed startup, attachment or selection, including while another worker runs. Successful catalog observations reconcile current effort metadata and rotate selection revision only if those semantics changed. Every successful attachment or deliberate mutation gets a fresh selection revision. External settings require restart; changed local catalog caches are observed by catalog/attachment/turn admission. Before provider work, a changed effort observation fails accepted turn with `stale_selection` (no input acknowledgement), exposes refreshed status after terminal cleanup, and requires deliberate client recovery; no automatic retry/replay. Backend retains normal runner credential refresh and current policy validation.

### `model.catalog`

Params exactly `{ "revision":string|null, "cursor":string|null, "limit":integer }`, all optional, default null/null/100; limit 1–100. Revision/cursor both null/omitted for fresh snapshot, or both nonnull for continuation; 64-byte ID grammar. Unknown/duplicate fields, unmatched cursor/revision or invalid bounds → `invalid_params`.

Result exactly `{ "revision":string, "choices":[choice], "next_cursor":string|null, "complete":boolean, "notices":[string], "source":"cache_only" }`. Choice exactly `{ "selection_id":string, "provider":string, "model":string, "provider_name":string|null, "model_name":string|null, "availability":"available"|"authentication_unavailable", "effort_support":"supported"|"unsupported"|"unknown", "permitted_efforts":[effort] }`. Selection ID is stable opaque 64-ASCII-byte digest for provider/model tuple, not credential identity; mutation submits exact provider/model fields. Names sanitized/bounded; missing/unsafe display names → null. No description, URL, headers, credentials or private DTOs exposed. Order ascending provider then model, UTF-8 byte order; duplicate choices → `catalog_unavailable`. Choices require explicit inclusion in admitted `providers.catalog.enabled` settings; the default empty list admits no choices. Retired selections remain excluded; unavailable authentication stays explicit for otherwise admitted cached choices. Available means locally admitted, not remotely proven.

Only validated normal backend cached catalogs are read, using admitted settings/fingerprint rules. Missing/invalid/empty relevant provider caches yield bounded `provider_catalog_unavailable` notice; unused providers do not create a global warning. Relevant means provider has an enabled model ID or is currently selected. Other cached choices remain usable. Cache age alone never means expiry. Secret-like/control-bearing selection identifiers are omitted with `catalog_entries_omitted_unsafe`; overlong identifiers fail the operation. `complete=true` means all safely admitted **cached** choices paged, not all choices offered remotely. No silent scan/page truncation: oversized work/projection → `catalog_limit`, no partial result. Unsafe/unreadable cache → `catalog_unavailable`; errors expose code only, never raw content/paths.

One catalog snapshot per child. Continuations rescan and compare cache-content and local-readiness fingerprint. Mutation or unreadable/over-limit continuation invalidates snapshot stickily → `stale_catalog`; restoring old files cannot revive it. Fresh catalog request replaces previous revision even if content unchanged; obtain fresh status before mutation if effort semantics changed. No OS-atomic catalog transaction is promised; selection revalidates twice before persistence. Revision/cursor scoped to child, opaque, at most 64 bytes.

Advertised bounds: `catalog_page_bytes=32768`, `catalog_page_entries=100`, `catalog_providers=32` (includes three built-ins), `catalog_models=2048` (scanned before exclusions), `catalog_file_bytes=1048576`, `catalog_scan_bytes=8388608` (cache bytes per scan), `catalog_snapshot_bytes=1048576` (serialized choices), `catalog_string_bytes=256`, `catalog_scan_ms=2000` (cooperative deadline per scan). Result includes metadata in page-byte limit. Page may have fewer than requested entries to fit bytes; use cursor, not length. Each catalog page performs one bounded scan plus ≤1 MiB current-selection metadata read; mutation performs two scans plus normal single-provider cached validation (existing 2 MiB read bound) and bounded owned-history validation/refresh. Shared authentication reads retain 1 MiB/file bounds and at most one provider config resolution per admitted provider per scan. Normal Claude availability invokes bounded `claude auth status --json` (existing 10-second subprocess bound); scan deadline checked afterward. Blocking filesystem/readiness calls cannot be interrupted by cooperative scan checks. All work stays on cancellable session worker, not protocol loop; status/shutdown remain responsive and cleanup wait remains 2000 ms.

`model.catalog` never fetches models, refreshes catalog caches, generates replies, executes agent tools, opens auth UI or rewrites settings. Existing local authentication-readiness policy may inspect saved credentials/environment or run Claude's auth-status subprocess; no provider OAuth exchange occurs in this read operation. Use separately negotiated `model.catalog.status` for provider-scoped reasons and `model.refresh` for deliberate remote refresh: [local-agent extensions](local-agent-extensions.md). External configuration changes require restart. Native CLI owns its authentication-status implementation; no credentials returned to frontend.

### `selection.set`

Params exactly `{ "session_id":string, "selection_revision":string, "catalog_revision":string, "provider":string, "model":string, "effort":effort_request }`. First five fields required; session ID uses existing grammar/64-byte limit; both revisions use opaque 64-byte ID grammar; provider/model 1–256 safe UTF-8 bytes. Effort optional, omission means `{ "mode":"keep" }`. Allowed effort request objects exactly:

- `{ "mode":"keep" }`: preserve previous **requested** effort, not a guessed default. If incompatible with new model → `effort_unsupported`.
- `{ "mode":"default" }`: explicit reset to backend no-override policy.
- `{ "mode":"set", "value":"low"|"medium"|"high"|"xhigh"|"max" }`: explicit permitted effort. `set/default` is invalid; use reset mode.

Unknown/duplicate fields anywhere, null effort/value, value on keep/default, missing set value, unknown modes/efforts or unsafe/oversized strings → `invalid_params`. No arbitrary config patch, auth data, global/project persistence option or provider fallback.

Exact attachment required: missing → `session_missing`; wrong ID → `stale_session`; wrong selection revision → `stale_selection`; missing/replaced/changed catalog → `stale_catalog`. Unknown/disabled/retired/noncatalog provider/model → `selection_invalid`; known choice without ready authentication → `provider_unavailable`; incompatible requested effort → `effort_unsupported`. Reuse normal cached model resolution, provider/config/auth admission, reasoning policy and exact owned-history/scope validation. Normal aliases that would change submitted tuple are rejected, never another-model fallback. Startup/admitting/executing turn, attachment/history/catalog/selection worker or relevant cleanup → `busy` without cancellation, deferred application or queued mutation. Before initialization → `not_initialized`; failed startup retains its safe failure error. Catalog and selection are serialized on same existing worker; concurrent status returns prior completed selection.

Validate entire candidate and revisions before persistence. Append one owned, bounded `session_preferences` record (advertised `selection_append_bytes=4096`, existing 64 MiB history bound), then replace in-memory config atomically. Changes apply only to subsequent explicitly submitted turns; no prompt appended/replayed, no provider generation/tools from mutation. Global/project settings remain unchanged. `scope="session"`; exact reopen restores saved selection through normal admission. Selecting on lazy handle makes preferences durable without input; this is explicit mutation, not placeholder history.

Successful result exactly `{ "session_id":string, "selection":selection_object, "catalog_revision":string, "scope":"session", "durable":true, "history_revision":string|null, "notices":[string] }`. Result selection agrees with subsequent status. Catalog revision unchanged unless explicitly refreshed/replaced later. History revision refreshed after preferences append; prior history/discovery revisions are stale. Notices bounded fixed values: `selection_normalized` (safe backend cached-resolution notice, never tuple fallback), `metadata_update_failed` (JSONL durable, derived sidecar failed), `history_unavailable` (selection durable, projection unavailable; history revision null, reopen explicitly). Only durable append success acknowledges durable selection; save failure → `persistence_failed`, prior runtime selection intact. Under uncertain storage failure, exact reopen is required to determine durable outcome; never auto-retry uncertain request. Invalid/unavailable/unsupported/stale requests write nothing and preserve all previous selection fields/revision.

Errors additionally `catalog_limit`, `catalog_unavailable`, `stale_catalog`, `stale_selection`, `selection_invalid`, `effort_unsupported`, `persistence_failed`, plus shared startup/session/history/cancellation errors. Nonfatal errors leave connection usable. Shutdown cancels through existing worker ownership and cleanup bounds; already admitted durable mutation may survive cancellation. No authentication/configuration UI or transport change.

Synthetic version-1 mutation example (IDs illustrative; full response envelope remains required):

```json
{"protocol":"magi-code.local-agent","version":1,"id":"catalog1","method":"model.catalog","params":{"limit":10}}
{"protocol":"magi-code.local-agent","version":1,"id":"select1","method":"selection.set","params":{"session_id":"saved-id","selection_revision":"<status-selection-revision>","catalog_revision":"<catalog-revision>","provider":"configured-provider","model":"cached-model","effort":{"mode":"set","value":"high"}}}
```

See [Select a local-agent model](local-agent-selection-handoff.md) for the client workflow and historical evidence.

## Turns, approvals and events

One attached session and one active run; no queued turns, steering, frontend skills or auth mutations. Frontend tools require startup-only `client.tools` negotiation. Session/history work also rejects overlap with an active run.

- `turn.start`, params `{ "session_id":string, "prompt":string }`: result `{ "run_id":string, "accepted":true }` means admission **only**, not persisted user input or successful execution. UUID run IDs scope to child + session. Missing/wrong session → `session_missing`/`stale_session`; another run/cleanup → `busy`; preflight rejection emits no persisted acknowledgement. Existing complete runner performs provider/tools/Code Mode/MCP/subagents/compaction.
- `turn.cancel`, params `{ "session_id":string, "run_id":string }`: result `{ "cancellation_requested":true }`; idempotent for the active run, no guarantee tools roll back. Wrong/finished run → `stale_run`. Terminal event follows cleanup, not this acknowledgement.
- `approval.answer`, params `{ "session_id":string, "run_id":string, "approval_id":string, "allow":boolean }`: result `{ "recorded":true }` for exactly one winning reply. Wrong/expired/answered identity → `stale_approval`; never authorize again. Approval expires after 60,000 ms and defaults deny on expiry/cancel/disconnect. Global MCP startup approvals remain separate and cannot be bypassed by this operation.

Every run event `data` includes `session_id`, `run_id`:

| Event | Additional data / meaning |
| --- | --- |
| `input.persisted` | `message_id`, `durable:true`, `revision`; emitted only after required input append succeeds, before provider execution |
| `message.delta` | `message_id`, `part`, `text`; best-effort redacted append to an assistant segment |
| `message.reconcile` | `message_id`, `part`, `text`, `last_part`, `partial`; authoritative replacement chunks, part starts at 0, final part completes reconciliation, not run execution |
| `activity` | `activity_id`, `kind` (`tool`, `subagent`, `compaction`), `state` (`started`, `completed`, `failed`), `name`, `summary`; summary at most 1024 UTF-8 bytes; no raw params/results |
| `approval.requested` | `approval_id`, `summary`, `expires_in_ms`; safe summary at most 1024 bytes, run-scoped UUID |
| `diagnostic` | `code`, `summary`; redacted summary at most 1024 bytes, including persistence warnings |
| `run.finished` | `outcome` (`completed`, `cancelled`, `failed`), `persistence` (`durable`, `degraded`, `not_started`), `revision` (string or null), `error_code` (string or null); once, after worker/cleanup accounting |

Message IDs scope to child/session/run; tool-separated assistant segments may have separate IDs. No deltas after terminal. Reconciliation **replaces** accumulated deltas, never appends a cumulative snapshot. Final content can span frames; reconcile only when all parts arrive. Bound authoritative content to 8 MiB/run and 512 segments; exceeding limits fails with `content_limit`, reports partial/degraded status truthfully, never silent truncation. A terminal event must follow required reconciliation, unless transport fails. Deltas may coalesce/drop under pressure, authoritative reconciliation/terminal never silently drop. Failure/cancellation keeps redacted partial replies; persistence degradation must not claim durable history. Displayed completion may precede tools/append; it is never proof of run success/durability. Omit late auxiliary/title UI updates; retain their backend ownership until cleanup completes.

Delta parts start at 0 and increase per message, independently of reconciliation parts. Missing delta parts mark display incomplete until authoritative reconciliation arrives. Concatenate reconciliation parts in order only after receiving `last_part=true`; never present a missing chunk as a complete message. `turn.start` admission response precedes its events; `input.persisted` precedes provider activity. Assistant message IDs are run-scoped opaque IDs. Input message ID identifies its persisted projection within the acknowledgement revision; history is busy during execution and receives a fresh revision after the turn. Use `run.finished.revision` for subsequent pages, not the input acknowledgement revision. Null terminal revision means reopen explicitly to obtain history.

Turn errors additionally: `stale_run`, `stale_approval`, `execution_failed`, `persistence_failed`, `output_unavailable`, plus applicable startup/session errors above. Required-input append failure yields failed/not_started; later append failure yields degraded, regardless of displayed answer. Transport success proves delivery only. Clients must not auto-resubmit after uncertain admission.

## Synthetic consumer trace

Client sends the initialization example above. A minimal base-only request can require just the operations it uses:

```json
{"protocol":"magi-code.local-agent","version":1,"id":"r1","method":"initialize","params":{"required_capabilities":["session.create","turn.start","history.page","shutdown"]}}
```

The response supplies an `instance_id`, sequence number, capabilities, limits, and current status. Read those returned limits rather than relying on a copied response.

Then send `session.create` with fresh ID and `{}` params. Use returned `session_id` in `turn.start` with a nonblank prompt. Admission returns a run ID; save the session ID only after `input.persisted`. Collect assistant segments and activities, answer approvals explicitly, and replace displayed deltas with complete reconciliation parts. Wait for `run.finished` before another prompt or history request; use its revision for `history.page`. For Stop, send `turn.cancel` with session/run IDs and still wait for terminal accounting. For relaunch, open only an acknowledged durable ID; never invent or select a replacement after failure. Finally send `shutdown` with another fresh ID and `{}` params.

The [archived backend handoff](../archive/local-agent-handoff.md) preserves full captured responses, revision information, and native/live/platform limits. Compilation and profiling alone do not establish protocol correctness.