# Local-agent stdio protocol
## Invocation and implementation status
`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`, `history.page`, `turn.start`, `turn.cancel`, `approval.answer`. All nine capabilities are advertised. 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.
Initial transport supports Unix pipes (macOS/Linux); Windows `serve` fails explicitly, without changing normal CLI support. TTYs, regular files and sockets are not supported transport endpoints. Other launch flags cannot accompany `serve`; existing default/`--prompt` TTY requirements and legacy `--app`/`daemon` rejections remain unchanged.
Ma-gi supplies cwd `~/.ma-gi/agent` and owns one child for its lifetime. Backend resolves normal `MC_HOME` (default `~/.magi-code`), without copying credentials or adding frontend-specific defaults. Backend owns history; client owns drafts/UI and a selected **durable** session ID. Backend tools are not an OS sandbox. Never fall back to frontend provider/tool execution.
Build/setup instructions, revision-pinned synthetic traces and verification limits: [Backend handoff](local-agent-handoff.md). Configure login and models through Mission Control before launching the child; never add authentication UI or credential-copying to this protocol.
## 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`: params exactly `{ "required_capabilities": [string, ...] }`, at most 16 names. 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`; client saves relaunch ID only then. 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.
## Turns, approvals and events
One attached session and one active run; no queued turns, steering, frontend tools/skills or auth mutations. 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. Backend response (identifiers illustrative):
```json
{"protocol":"magi-code.local-agent","version":1,"type":"response","instance_id":"<child-uuid>","seq":1,"id":"r1","result":{"capabilities":["initialize","status","shutdown","session.create","session.open","history.page","turn.start","turn.cancel","approval.answer"],"limits":{"frame_bytes":65536,"json_depth":32,"id_bytes":64,"prompt_bytes":16384,"history_page_bytes":32768,"history_page_entries":100,"content_chunk_bytes":16384,"pending_responses":32,"requests_per_child":4096,"output_deadline_ms":2000},"status":{"state":"idle","readiness":"ready","provider":"fixture","model":"fixture-model","context_id":"<digest>","session_id":null,"run_id":null}}}
```
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.
See [Backend handoff](local-agent-handoff.md) for historical tested revision, captured traces and separate native-frontend/live/platform limitations. Current repository retains no actual-process or correctness-test suite; compilation and profiling do not establish protocol coverage.