magi-code 0.77.1

Repository-aware CLI coding agent for terminal work
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
# Application service protocol

[Feature docs index](README.md) · [Repository README](../../README.md)

## Purpose

`magi-code --app service` is the non-interactive conversation interface for frontend clients; terminal users launch Mission Control with `magi-code`. The service reads normal configuration and provider credentials, then accepts bounded JSONL requests over stdio. It is not a plain-text one-shot prompt command.

Version 1 supports service discovery, durable sessions, streamed agent turns and activity, authentication, and configuration:

- `initialize`, `status`, and `capabilities` describe the service;
- `session.list`, `session.create`, `session.open`, `session.replay`, and `session.close` manage sessions;
- `turn.start` and `turn.cancel` run and cancel agent turns;
- `auth.status`, `auth.login.start`, `auth.login.callback`, `auth.login.cancel`, and `auth.logout` manage shared authentication;
- `catalog.providers`, `catalog.models`, and `catalog.refresh` expose provider/model choices;
- `config.get` and `config.set` read and persist frontend-controlled settings.

The stdio service is not a persistent daemon or reconnect transport. Session, turn, login, and replay cursor IDs are opaque values returned by the service; durable replay cursors can be reused by a later service process.

Persistent wire version 2 is specified separately in [RFC-0002](../rfc/0002-persistent-service-protocol.md). The [persistent coordinator](persistent-service-core.md) also serves the [private Unix daemon](unix-daemon.md) on macOS and Linux. This stdio contract is unchanged.

## Launch and streams

Launch the service as a child process:

```sh
magi-code --app service
```

`--app service` cannot be combined with `--prompt`, provider, model, credential, theme, session-selection, or subcommand arguments. Startup may create the normal runtime directories and generated settings schema. Sessions are created or attached only through session operations or a valid `turn.start` request.

- **stdin:** newline-delimited JSON request records.
- **stdout:** protocol JSONL only. Responses and events are flushed as they are ready. Progress appears only in structured events, never as banners, prompts, tool logs, or diagnostic text.
- **stderr:** startup and I/O diagnostics only. Service activity exposes selected status and usage fields, not raw provider/tool details.
- **EOF:** cancels active turns and login, drains their workers and any configuration worker, emits pending responses and terminal events when output is still available, and then ends the service.

A record may use `LF` or `CRLF`. Blank and malformed records produce a structured error and do not stop the loop. An over-limit record is discarded through its newline, produces `record_too_large`, and the next record is still processed. The adapter writes each outbound batch before processing the next input record; streamed turn events can be written between later request responses.

Before an outbound batch is written, the adapter validates and encodes every message in it. If a response or event is oversized, or an internal serialization failure occurs, the adapter stops with an I/O diagnostic and emits no replacement message. It never turns a correlated event into an uncorrelated response.

## Envelope

The protocol version is `1`. Field names are fixed and are not JSON-RPC names.

A request has this shape:

```json
{
  "protocol_version": 1,
  "kind": "request",
  "request_id": "req-1",
  "session_id": "opaque-session-1",
  "method": "turn.start",
  "payload": {}
}
```

`session_id` is optional. `request_id` and `method` are required. IDs are opaque strings; clients must not encode or infer filesystem paths, credentials, account identifiers, or session contents in them.

`request_id` is the correlation key and must be unique among requests currently in flight in this service process. Synchronous request IDs are released after their response is written. A `turn.start` or `auth.login.start` ID remains reserved through its terminal event. Reuse while reserved returns `duplicate_request_id`.
For a parseable malformed record, duplicate checking takes precedence over decoder errors. An active safe `request_id` returns `duplicate_request_id`, preserves independently safe identity fields, and leaves the original reservation untouched. A fresh safe ID retains the decoder error and remains reserved until its response is written. Invalid JSON, record-too-large input, and records without a safe request ID are not correlated by `request_id`.

A response always has this shape. Unused optional fields are `null`.

```json
{
  "protocol_version": 1,
  "kind": "response",
  "request_id": "req-1",
  "session_id": "opaque-session-1",
  "method": "turn.start",
  "payload": {},
  "error": null
}
```

A successful response has `payload` and `error: null`. An error response has `payload: null` and an error object. A response uses the server's current protocol version, including when it reports an unsupported request version. Identity fields that cannot be safely extracted are `null`.

A service event has this shape:

```json
{
  "protocol_version": 1,
  "kind": "event",
  "event_id": "event-1",
  "request_id": "req-1",
  "session_id": "opaque-session-1",
  "event": "turn.assistant_delta",
  "payload": {}
}
```

Events are not responses and do not consume a request's response slot. `event_id`, `request_id`, and `session_id` are correlation values; clients must not interpret them as paths or durable filenames.

## Initialization and negotiation

The client must initialize before requesting status, capabilities, sessions, turns, authentication, catalogs, or configuration. Send:

```json
{
  "protocol_version": 1,
  "kind": "request",
  "request_id": "init-1",
  "method": "initialize",
  "payload": {
    "supported_protocol_versions": [1],
    "requested_capabilities": []
  }
}
```

`supported_protocol_versions` is a required array of unsigned 16-bit integers; it must be non-empty and contain `1`. `requested_capabilities` is an optional array of operation-name strings: omission means `[]`, but `null` is invalid. Both input lists contain at most 8 items. Supported names are the operations listed above, not event names or activity flags. An unknown requested name returns `unsupported_capability`. An empty list discovers all operations without requesting each individually. Requested operations are requirements checked at initialization, not an allowlist restricting later calls.

A successful response contains `protocol_version`, `server_name`, `server_version` (the installed package version), `limits`, and `capabilities`. Capabilities contain `protocol_versions`, `operations`, `events`, `transports`, `limits`, and the activity object below. The advertised sets are complete, not filtered by the request. Currently there are 20 operations and six events; the eight-entry input negotiation bound does **not** apply to advertised sets. Advertised arrays instead obey `max_array_items` (currently 64), name/string bounds, and overall payload/record bounds.

Initialization is repeatable, with a fresh in-flight request ID. Success sets the process's initialized state; it does not reset sessions, workers, reservations, or configuration. Failure leaves that state unchanged: an initially uninitialized process stays uninitialized, but failure after success does **not** revoke the earlier success. A client must therefore stop affected requests itself after any failed renegotiation, rather than assume the server has disabled them.

An unsupported envelope version or a version list without `1` returns `unsupported_version`; an empty or over-eight version list returns `limit_exceeded`. Malformed list types, `null`, and unknown initialization payload fields return `invalid_payload`. Unsupported requested operations return `unsupported_capability` (overlong names or lists return `limit_exceeded`). These failures do not start session/auth/config/turn work. Before the first successful initialization, those routes return `not_initialized` for otherwise valid requests. Request framing, validation, and duplicate-ID checks still apply first; initialization is not a bypass for them.

### Client preflight: complete event set, no subscriptions

Before issuing any session, auth, configuration, or turn mutation, the client must:

1. Send `initialize` using a supported envelope version. Wait for a correlated successful response; do not pipeline mutations behind it.
2. Validate the response, protocol selection, `stdio` transport, required operations, known capability fields, and limits against its own supported bounds. If more than eight operations are needed, discover with an empty request list and check the returned set locally; do not send an over-limit list.
3. Verify it can safely consume the **entire advertised event set**: currently `turn.started`, `turn.assistant_delta`, `turn.activity`, `turn.terminal`, `auth.login.progress`, and `auth.login.terminal`. This includes identity, ordering, terminal, cleanup, and replay semantics, even if the UI does not display every event.
4. On a missing required operation/flag, unknown event name, or unsafe bound/semantic mismatch, report a local incompatibility and send no mutations. This is **client preflight rejection**, not a server error response. Server initialization may already have succeeded; it cannot detect a client's decoder limitations.

There is no event subscription or optional-event acceptance exchange. Requesting only `turn.start` does not suppress activity or auth events. No unknown or unadvertised event is safely ignorable under this contract: stop consuming the stream as trusted state, report a protocol mismatch, and stop further mutations. Likewise reject unknown message kinds. Do not fabricate completion, release an active start reservation as successful, or automatically resend accepted work. A closed/broken output stream can prevent terminal delivery; do not interpret disconnect as a successful terminal.

Rendering recognized activity is optional, but consuming its correlation/sequence information is not. A client may omit its visual presentation only after validating the recognized payload and honoring compaction replay requests. Terminal text, status, drop counts, replay flags, auth cleanup, errors, and identities must never be discarded as optional metadata.

## Evolution and consumer decoding

These are consumer requirements and rules for future changes, not a new permissive request decoder. Requests remain strict: unknown envelope or operation-payload fields are rejected. Methods and their wire objects remain owned by their service domain; a generic extension parser must not replace method-specific validation.

### Required, optional, absent, and null

- **Required** means the field must be present with the documented JSON type and meaning. Do not default missing terminal status/text, IDs, sequences, booleans, counts, error semantics, or required capability/limit values. Counts are nonnegative integers within both the documented and locally representable range; strings are not numbers, and `0`/`1` are not booleans.
- **Optional** means only a field explicitly documented as optional, conditionally emitted, or unavailable in a supported feature profile. Omission has only that field's documented meaning. For example, omitted request `session_id` and explicit `null` both mean no session identity; omitted `requested_capabilities` means an empty list, while `null` does not. An omitted activity `parent_activity_id` means no supplied parent; `null` is not a parent ID.
- **Nullable** is separate from optional. Current output envelopes always include their shown fields, including nullable response identity, `payload`, and `error` slots. Activity `timed_out:null` means unknown, not `false`; nullable tool names and token counts must not become empty strings or zero. Missing a required nullable field is invalid. Do not treat absent and null as interchangeable globally.
- Successful responses require a method-valid payload and `error:null`; errors require `payload:null` and a valid error object. Null identities are allowed only where the documented error/correlation rules permit them, not as a way to accept an unbound success or event. Validate envelope identities against pending requests and payload identities against the attached session/turn/login.
- The initialization core fields and capability sets/limits above are required. The current server always emits `activity`. A client supporting a profile without advertised `turn.activity` may accept an absent activity object as unavailable, not as implicitly enabled; an activity-capable profile must have the object and validate its documented flags/bound. Advertising `turn.activity` without its capability object, or with `events:false`, is inconsistent. When activity is advertised, shared sequences and terminal activity/replay metadata are required, not optional additions to ignore. Supporting an earlier profile requires explicit rules and fixtures; this contract does not promise every historical v1 profile works.

### Unknown output fields and values

Consumers must allow unknown **members** of recognized response/event envelopes, payload objects, capability objects, and limit maps as optional extensions, while fully validating known members. Do not compare an object's key set or key count to a snapshot. Unknown fields cannot replace required fields, override identities, change the success/error distinction, or weaken a known value's validation.

Validate the entire encoded record and payload, nesting, arrays, object field counts, strings, and keys before ignoring any unknown value. Apply independent local decoding/allocation bounds as well as supported advertised bounds. Unknown limit-map entries must still be positive, locally representable integers; unknown capability members may be bounded JSON values, but never imply support for an unknown feature. Known flags remain booleans and known limits retain their units and scope. An unknown advertised operation may be left unused; the event set has the stricter preflight rule above.

Unknown enum values are **not** unknown optional fields. Reject unsupported values in status, kind, activity category/source/transport, readiness, scope, and other semantic enums unless that field explicitly defines a safe fallback. The defined tool transport value `unknown` is a literal accepted value, not permission to accept arbitrary transports. An unknown error code may be represented as a generic unsuccessful response after validating its error shape, bounds, and correlation; it must not become success or trigger an automatic retry. Never guess terminal status or auth cleanup from an unfamiliar value.

Examples (all acceptance cases remain subject to bounds and correlation):

| Received form | Consumer outcome |
| --- | --- |
| Valid response with extra envelope `"future_hint":true`, or valid payload with bounded optional metadata | Accept; ignore only the unknown member. The same extra request field is rejected by the server. |
| Limits contain a new `"max_future_page_items":16` | Accept the extension; still validate all required known limits, not an exact 10/15/16-key count. |
| New limit is `"16"`, zero, negative, or over the local integer range | Reject; an extension is not exempt from limit-value validation. |
| Optional metadata absent, or present as bounded JSON `null` | Accept if no known field's rules are changed. This does not make known nonnullable fields nullable. |
| `provider_auth_ready` absent, `null`, or `"false"` | Reject; a required boolean is missing or mistyped. |
| Activity-capable terminal lacks `assistant_text` or `sequence`, has unknown status, or names another turn | Reject; authoritative settlement cannot be inferred. |
| Valid shared sequence jumps from 2 to 4 | Accept the gap; reject duplicate/regressing sequences and events after terminal. |
| Advertisement adds an unknown event | Reject locally before mutation, even if every requested operation exists. |
| Unknown field contains an over-limit array/string or excessive nesting | Reject the whole record, not just the field. |

### Protocol version versus package version

`protocol_version` is the wire contract selection; `server_version` is a package release identifier, not a substitute for negotiation. A package change alone neither proves nor disproves client support. Fixture schema versions are separate again.

Within a supported protocol profile, bounded optional output members, additional unused operations, and additional limit keys may be added without a version bump when existing required meanings remain intact. Known fields cannot be removed, renamed, made newly required, retyped, given different units/meaning, or have null/default semantics changed as an optional extension. New semantic enum values require a defined fallback or a negotiated profile that understands them. New event names require complete-set preflight; a client unable to handle them must refuse mutations even when the numeric version is unchanged. This permits a coordinated v1 addition, not universal acceptance by old clients.

A change to required semantics needs a new protocol version unless an explicit, bounded capability negotiation isolates the changed behavior and defines absence, rejection, and mismatch handling **before** affected work. Merely advertising a flag or accepting an operation name is not such isolation: current initialization does not select event delivery or alternate wire formats. Any future opt-in design must be documented and tested before it is relied upon. Unsupported approvals, steering, MCP server lifecycle, raw tool output, and activity replay remain explicitly `false`; do not advertise them in anticipation of implementation.

## Operations

### `status`

Payload must be `{}` and initialization must have succeeded. The result is deliberately small:

```json
{
  "service": "ready",
  "provider_auth_ready": false
}
```

`provider_auth_ready` reports whether the configured shared provider can prepare authentication, including a refreshable local Codex credential where applicable. It does not identify the provider and never returns a token, API key, bearer header, account ID, authorization code, provider response, path, or session data. `service: "ready"` describes the running service; it does not mean provider authentication is ready.

The value is evaluated from reloaded settings, current shared auth, and the process environment on every initialized, valid `status` request. It follows the current effective provider selection rather than the startup selection. Missing or invalid credentials report `false`. If settings or protected shared auth cannot be read, the correlated status response uses `internal_error` with its fixed sanitized message. This check never refreshes credentials, mutates auth, or makes a network request.

### `capabilities`

Payload must be `{}` and initialization must have succeeded. The result lists the supported protocol version, operations, events (including `turn.activity`), stdio transport, fixed limits, and the `activity` capability object described below. Initialization returns the same activity capabilities.

### Authentication

All auth requests require initialization and must omit `session_id`. Unknown payload fields are rejected. Auth uses the resolved `MC_HOME` shared store; clients never read or write the store themselves.

#### `auth.status`

Payload: `{}`. The response contains a `providers` array, including `openai-codex`, `anthropic`, and custom providers configured at service startup. Each entry has:

```json
{"provider_id":"openai-codex","auth_method":"oauth","source":"shared_store","readiness":"missing","ready":false}
```

- `auth_method`: `oauth`, `api_key`, or `none`.
- `source`: `shared_store`, `environment`, or `none`. Environment values and variable names are not returned.
- `readiness`: `ready`, `missing`, or `needs_login`; `ready` is the matching boolean. Refreshable Codex credentials count as ready; this does not guarantee a future refresh succeeds.
- Codex uses the shared OAuth record. Anthropic prefers a nonempty `ANTHROPIC_API_KEY`, otherwise its shared API-key record. Custom providers use their configured environment variable or no-auth mode; no-auth providers are ready without credentials.

Every request reads current auth and environment readiness without refreshing or making a network request. Malformed or unreadable storage returns a fixed `internal_error`, not file contents or a path. Unlike `status` and configuration routes, auth operations retain startup provider definitions until restart.

#### Codex login

1. Send `auth.login.start` with `{"provider_id":"openai-codex"}`. Its response contains `login_id`, `provider_id`, and `status:"started"`. Only one login runs per service. Other providers return `provider_unavailable`; an active login returns `auth_busy`.
2. Receive `auth.login.progress` events correlated to the **start request**, with `session_id:null`. Payloads contain `login_id`, `provider_id`, and `state`. The first state, `authorization_url`, also includes the bounded transient `url` and `open_browser:true`. This is a request for the client to offer browser opening, display, or copying; the service does not launch a browser itself.
3. `waiting` follows. The existing loopback listener waits up to five minutes. If loopback is unavailable, manual submission remains available. Submit `auth.login.callback` with `{"login_id":"<id>","input":"<final redirect URL or code>"}`. Input is nonempty, at most 4096 bytes, accepted once, and never echoed. A successful response says `status:"submitted"`; it does not mean the callback was valid.
4. `callback_received` precedes callback validation; `callback_accepted` follows successful validation. Redirect URLs must match the attempt's OAuth state. Bare codes use the existing manual-code flow and remain tied to the selected login ID.
5. Exactly one `auth.login.terminal` event ends the flow and releases the start request ID. It contains `cleanup_complete:true` after worker cleanup. Successful exchange and storage commit produce `state:"exchange_succeeded"`.

Terminal failures are fixed labels: `callback_timeout`, `callback_failed`, `exchange_failed`, `auth_changed`, `storage_failed`, or `internal_error`. No underlying provider response or callback text is included. A rejected callback ends that flow; start another login to retry.

Send `auth.login.cancel` with `{"login_id":"<id>"}` to receive `status:"cancellation_requested"`. Wait for the terminal event, normally `state:"cancelled"`; cancellation is not itself a terminal outcome. A storage commit that already won the race may still finish successfully. An in-progress exchange finishes within its existing bounded network timeout before cleanup. Unknown or finished IDs return `unknown_login`; repeated callback submission returns `auth_busy` while the login remains active.

Auth events and callback requests are transient: never put them in client logs or durable session/settings data. The authorization URL is the sole URL-bearing auth event. Access tokens, refresh tokens, API keys, account IDs, authorization codes, raw callback URLs, bearer headers, raw provider responses, and auth-file paths/content are absent from all auth responses and events. Callback input is consumed only inside the login flow.

#### `auth.logout`

Payload: `{"provider_id":"anthropic","confirmed":true}`. Missing or false confirmation returns `confirmation_required`; an unknown provider returns `provider_unavailable`. Success contains `provider_id`, `status:"logged_out"`, `removed`, and `environment_unchanged:true`.

Logout removes only that provider's local record and preserves unrelated records. It does not unset environment variables, revoke remote credentials, or undo credentials already handed to an active turn. New Anthropic turns reread the store; an environment-backed provider can remain ready after local logout. Codex logout also requests cancellation of the service's active login.

Protected auth writes retain permissions, atomic replacement, and cross-process locking. Logout advances the provider generation even if its record is absent. Login commits only against the generation observed at start, so concurrent logout, login, or refresh can cause `auth_changed` instead of overwriting newer state. Fresh login never inherits another account's refresh token. Credential refresh remains internal with its existing generation checks; there is no explicit credential-refresh route. `catalog.refresh` refreshes model discovery, not a client-visible credential record.

### Catalogs and configuration (issue #467)

These five routes require initialization, omit outer `session_id`, and reject unknown payload fields. They return one ordinary correlated response, with no progress or change events. One configuration worker handles all five routes; another catalog/config request while it is occupied returns `configuration_busy`. Turn output and cancellation remain available during catalog network work. Wait for the response before sending the next configuration request or starting a turn that depends on a settings write. Request IDs remain reserved through response writing; shutdown drains the worker rather than cancelling its write or refresh.

#### Provider and model catalogs

| Method | Request payload | Success payload |
| --- | --- | --- |
| `catalog.providers` | `{"offset":0,"limit":32}` | `{"entries":[{"provider_id":"anthropic","label":"anthropic"},{"provider_id":"openai-codex","label":"openai-codex"}],"next_offset":null,"refresh":"cache_only"}` |
| `catalog.models` | `{"provider_id":"anthropic","offset":0,"limit":32}` | `{"entries":[<model metadata>],"next_offset":null,"state":"fresh_cache","refresh":"cache_only"}` |
| `catalog.refresh` | `{"provider_id":"anthropic"}` | `{"provider_id":"anthropic","state":"fresh_cache"}` |

Provider entries include the built-ins `anthropic` and `openai-codex`, plus currently configured custom providers, sorted by ID and deduplicated. Model entries preserve cached order and are deduplicated by model ID. `offset` defaults to `0` and must be at most `100000`; `limit` defaults to `32` and must be `1..32`. Follow non-null `next_offset` for another page; an offset beyond the end returns an empty page. Pages are not a retained snapshot: restart paging after refresh or external configuration changes.

Each model entry contains only:

```json
{
  "provider_id": "anthropic",
  "model_id": "claude-example",
  "label": "Example model",
  "available": true,
  "context_window": null,
  "max_output_tokens": null,
  "available_thinking_levels": ["default", "high"]
}
```

The example is illustrative, not a promised model. Token limits are nullable numbers; `null` means unknown. Reasoning choices are model/provider-specific, at most six wire values: `default`, `low`, `medium`, `high`, `x_high`, `max`. Use the returned list rather than inventing choices. Fresh metadata informs that list; stale or missing metadata uses the shared provider/model fallback rules.

Provider/model IDs are nonempty, at most 256 ASCII bytes, restricted to letters, digits, `-._/:`, and rejected or omitted if they look like secrets. These are selection IDs, not the 128-byte envelope correlation IDs. Labels are display-sanitized and limited to 128 bytes, falling back to a bounded ID. Unsafe model IDs are omitted; disabled models remain visible with `available:false`.

`catalog.providers` and `catalog.models` are cache-only: no discovery request or credential refresh. Model states describe the validated cache:

- `fresh_cache`: unexpired cache matching the provider configuration. Only non-disabled entries are `available:true`; this is selection eligibility, not authentication readiness or a guarantee of inference success.
- `stale`: expired matching cache; entries may still be displayed, but all are unavailable for selection.
- `unavailable`: no usable cache, including missing, unreadable, malformed, empty, or configuration-mismatched data. Entries are empty.

`catalog.refresh` explicitly invokes existing provider discovery, which may use network access, shared credentials, and catalog cache writes. It does not change the selected model or settings. Existing fallback behavior can retain stale data: a successful response is not a guarantee of `fresh_cache`. Inspect the returned state, then reread `catalog.models`; a surfaced refresh failure returns `catalog_refresh_failed` without provider details. There is no force flag, progress event, or refresh-cancel route.

#### Read effective settings

`config.get` takes `{}` and returns this bounded projection, not the settings document:

```json
{
  "selection": {"provider_id":"anthropic","model_id":"claude-example"},
  "thinking_level": "high",
  "configured_thinking_level": "high",
  "available_thinking_levels": ["default","high"],
  "fast": false,
  "precedence": ["environment","project","global"],
  "writable_scopes": ["global","project"],
  "global_only": ["fast"]
}
```

`selection` follows current settings and `MC_PROVIDER`/`MC_MODEL` overrides, with the normal provider/model defaults when unset. `configured_thinking_level` is the merged saved choice (or `default`); `thinking_level` is its resolved supported value. Reading configuration does not require a fresh catalog or confirm that the selected model can run. `precedence` describes the layers, not per-field provenance; fast mode is global-only.

#### Persist a scoped change

`config.set` requires `scope` and at least one of `selection`, `thinking_level`, or `fast`:

```json
{
  "scope": "project",
  "selection": {"provider_id":"anthropic","model_id":"claude-example"},
  "thinking_level": "high"
}
```

- `scope` is `global` or `project`. Selection and thinking level support both; `fast` is a boolean allowed only with `global`. A project request containing `fast`, even `false`, fails as a whole.
- `selection` requires both IDs. It must name an enabled model in a fresh matching catalog. Missing/stale catalogs require refresh before selection or reasoning changes. A fast-only write does not require catalog validation.
- A selection change without `thinking_level` resets the saved reasoning choice to `default`. A reasoning-only change validates the current effective selection, including environment overrides and normal defaults, using the same resolution as `config.get`.
- Omitted fields are unchanged, except that selection resets reasoning as above. Explicit `null`, empty mutations, unknown fields, malformed types, and unknown reasoning values return `invalid_payload`; a recognized but unsupported reasoning value returns `unsupported_reasoning`. There is no reset/delete or arbitrary-settings route.

Success is `{"persisted":true,"scope":"project","effective":<config.get projection>}`. The service validates the entire change and resulting projection before committing through the existing locked, atomic settings writer. It preserves unrelated and unknown settings fields. Explicit project selection/reasoning writes pin those fields even when they currently equal inherited values. Validation or persistence failure returns an error, never `persisted:true`.

Global writes use the resolved shared settings scope; project writes use the service's launch project scope, not a session-specific path. They survive service restart and use the same settings as CLI/TUI. Higher-precedence project/environment values can mask a successful global write; inspect `effective` instead of assuming the requested value won. Writes do not change environment variables, credentials, provider definitions, tools, MCP, or session history.

New turn workers reload selection, reasoning, and fast settings before provider execution. Already-running turns keep their captured settings; startup instructions, skills, and other runtime resources are not rebuilt. `status` also reloads effective selection, while auth operations retain startup provider definitions. There are no configuration push events: reread `config.get` after writes made elsewhere.

Catalog/config responses omit settings/cache paths, raw settings documents, provider URLs, headers, environment variable names/values, credentials, account identifiers, pricing, raw reasoning metadata, and underlying provider responses/errors. Labels and IDs are filtered display metadata, not access to private configuration. Clients must use these routes rather than inspect local settings or catalog files.

### Sessions

All session requests require initialization and reject unknown payload fields. IDs are opaque, at most 128 bytes, and validated by the server before path resolution. Do not construct IDs or inspect storage files. There are no session lifecycle push events: these operations return ordinary correlated responses.

| Method | Outer `session_id` | Request payload | Success payload |
| --- | --- | --- | --- |
| `session.list` | Omit | `{"after":null,"limit":32}` | `{"sessions":[{"session_id":"<id>","title":null,"updated_at":"<RFC 3339 timestamp>"}],"next_after":null,"truncated":false,"incomplete":false}` |
| `session.create` | Omit | `{}` | `{"session_id":"<id>","status":"open"}` |
| `session.open` | Required | `{}` | `{"session_id":"<id>","status":"open"}` |
| `session.replay` | Required | `{"after":null,"limit":32}` | Replay page below |
| `session.close` | Required | `{}` | `{"session_id":"<id>","status":"closed"}` or `status:"closing"` |

`after` and `limit` are optional. The default limit is 32; valid limits are 1–32. Continuation strings are at most 128 bytes. `session.open` attaches an existing session; resume by sending `turn.start` with that ID. Create records the new session durably, even before its first user turn.

#### Listing

Lists are ordered by session ID, not activity time. Pass `next_after` as the next list request's `after`; it identifies the last examined ID, which need not have a returned summary. `truncated:true` means more candidates remain. `incomplete:true` means some candidates could not be represented or read; it is not a continuation flag. Titles are optional, sanitized, and passed through the existing redaction policy.

Each request scans at most 20,000 directory entries, including non-session entries. Exceeding this budget returns `limit_exceeded`, not an arbitrary partial page. Metadata reads are bounded. Listing is not a frozen snapshot: concurrent creation, deletion, or updates can change later pages. Restart without `after` to refresh the list.

#### Durable replay

Replay is read-only and requires no attachment or writer lease. It can read a session held by another service. A conflicting durable append/rotation lock returns `session_busy`; retry later. The response payload is:

```json
{
  "events": [
    {"cursor":"<cursor-1>","kind":"message","role":"user","text":"Question","part":0,"last_part":true},
    {"cursor":"<cursor-2>","kind":"message","role":"assistant","text":"Partial answer","part":0,"last_part":true},
    {"cursor":"<cursor-3>","kind":"terminal","status":"cancelled"}
  ],
  "next_cursor": "<cursor-3>",
  "snapshot_cursor": "<cursor-3>",
  "truncated": false,
  "gap": false,
  "resync_required": false
}
```

The allowlisted projection contains user/assistant `message` items, `terminal` items with `completed`, `cancelled`, `failed`, or `compaction_required`, and compaction `summary` items. A summary has `text`, `part`, and `last_part`, but no `role`. Text is split at UTF-8 boundaries into at most 2,048 bytes per part; `part` starts at zero for each message or summary. Parts can span pages. Cancelled/failed terminal records preserve their accepted `assistant_text`, so replay includes the user and partial assistant messages, not just the terminal label.

Replay reads the recent 2 MiB window, at most 100,000 records, rather than exporting all history. When a valid compaction checkpoint is available in that window, its summary precedes the conversation after its cutoff. Older history may be outside the window. Tool arguments/results, provider records, reasoning, local diagnostics, recovery notes, display metadata, raw JSONL, malformed lines, storage paths, and sidecar internals are not separate frontend fields or events. Projected text uses the existing redaction policy; this is not a guarantee that arbitrary user-written secrets or paths cannot appear in conversational text.

Each page has at most the requested item count and a complete encoded payload of at most 16 KiB, including cursor fields and JSON framing. Its fields mean:

- Each event's `cursor` identifies the accepted projection prefix through that item. Save it only after accepting the item; pass it as replay `after` to continue without accepting the same item twice.
- `next_cursor` is the position after this page, also provided for an empty page. `snapshot_cursor` identifies the end observed for this request, not a retained server snapshot or subscription.
- `truncated:true` means more projected items remain in this snapshot. Continue with `after:next_cursor`. At the current end, `next_cursor` equals `snapshot_cursor`; poll that cursor later for new durable items.
- `resync_required:true` means `after` does not match the current projection: it may be invalid, from another session, or stale after compaction, rewind, corruption, or a moving tail window. The page contains no events and gives the current starting position in `next_cursor`. Discard the cached projection and replay without `after` (or from that starting cursor), following bounded pages.
- `gap:true` reports read/checkpoint diagnostics or mismatched session records, including bounded-window omissions. A usable cursor can coexist with a gap. Show incomplete history; a resync cannot recover records absent from the available window, and the flag may persist.

Cursors survive process restart and append-only changes **when the projected prefix stays unchanged**. They are not live `event_id`, `sequence`, activity, or turn IDs. In-progress durable text may be replaced or regrouped as a turn settles, making a previously accepted prefix stale. Live `turn.assistant_delta` and `turn.activity` events are transient output, not durable replay notifications. Use `turn.terminal.assistant_text` to settle live display, then poll replay to reconcile durable history; do not concatenate live deltas onto replay items. Activity is not replayed, so replay cannot fill live activity gaps.

#### Attachments, close, and conflicts

Create/open acquires an exclusive service writer lease, distinct from the shared retention lease. Reopening in the same process reuses the attachment; opening a turn-created attachment makes it explicit. Another service cannot attach or start a turn on it while held (`session_busy`). List/replay do not consume attachment slots. At most 16 explicit or implicit attachments are held per process.

Explicit attachments survive successful, cancelled, and failed turns until close or shutdown. A turn without an existing attachment acquires an implicit one; its lease is released after terminal output handling and worker cleanup, including failure/cancellation. Only one turn may be active per session. Reservations survive the terminal write attempt, not just worker completion.

Closing an idle attachment releases it immediately and returns `closed`. Closing during a turn marks it closing, requests cancellation, and returns `closing`; repeated closes remain `closing` until cleanup. New opens/turns on that closing attachment return `session_busy`. Wait for the existing turn's terminal event, then repeat close to observe `closed`; there is no separate session-closed event. Completion that wins the cancellation race can still report `completed`.

Close never deletes history or releases another process's lease. With no active local turn it is idempotent for any valid ID, including an unattached, missing, or previously removed session. EOF/output failure cancels and drains workers before releasing remaining attachments; output failure does not promise terminal delivery. Later processes may open the durable session again.

### `turn.start`

A start request uses the optional outer `session_id` and this payload:

```json
{
  "prompt": "Read the fixture and summarize it.",
  "options": {}
}
```

`prompt` must contain non-whitespace text. `options` is currently optional and must be an empty object; unknown option fields are rejected. Prompt, identity, nesting, and total payload limits apply before a turn is started.

Without `session_id`, the service creates a new durable session through the normal session manager. With an opaque existing `session_id`, it reuses this process's attachment or acquires an implicit one, then validates the existing bounded history before starting. Unsafe, unavailable, unreadable, conflicting, closing, or already-running sessions return a request error. Successful `session.open` or partial `session.replay` alone does not guarantee that history meets the stricter turn-admission read bounds.

The successful response contains the assigned identities:

```json
{
  "turn_id": "opaque-turn-1",
  "session_id": "opaque-session-1",
  "status": "accepted"
}
```

The response is followed by one `turn.started` event. Its payload contains `turn_id`, `session_id`, `status: "started"`, and `sequence: 0`.

After acceptance, the service runs the normal agent runtime: prompt preparation, configured provider selection, replay/context handling, configured built-in and MCP tools, skills, continuation behavior, cancellation checks, and durable session persistence. The service does not replace this path with a direct provider call. Alongside assistant deltas, `turn.activity` exposes selected runtime activity through the bounded projection below.

Each worker reloads current selection, reasoning, and fast settings before provider execution. Wait for `config.set` success before starting a dependent turn; changes do not retarget an already-running turn. See [catalogs and configuration](#catalogs-and-configuration-issue-467) for persistence and startup-resource boundaries.

### `turn.assistant_delta`

Each accepted assistant text delta is sent as an ordered event:

```json
{
  "turn_id": "opaque-turn-1",
  "sequence": 1,
  "text": "partial assistant text"
}
```

Deltas retain the runtime sink order. The service applies the advertised string and total-text bounds rather than silently truncating output.

### Structured activity (issue #468)

`turn.activity` uses the normal event envelope with the originating `turn.start` request ID and session ID. Its payload is:

```json
{
  "turn_id": "opaque-turn-1",
  "sequence": 2,
  "activity": {
    "activity_id": "<opaque-activity-id>",
    "kind": "lifecycle",
    "category": "subagent_task",
    "status": "running",
    "details_omitted": true
  }
}
```

Every activity has `activity_id` and `kind`. `parent_activity_id` is included only when supplied by a lifecycle start; otherwise it is omitted. IDs are namespace-prefixed SHA-256 hashes, not raw runtime names or paths. Scope activity display to the turn: IDs can recur across turns, and stream-level context/usage and reasoning updates reuse IDs. An activity ID is not a unique event ID.

The following fields are flattened into `activity`, not nested under a separate data field. Nullable fields are present as `null` when unavailable.

| `kind` | Fields and values |
| --- | --- |
| `lifecycle` | `category`: `tool`, `hook`, `context_injection`, `subagent_batch`, `subagent_task`, `assistant`, `diagnostic`, or `compaction` on start; `activity` on finish. `status`: `queued`, `running`, `writing`, `completed`, `failed`, or `cancelled`. `details_omitted`: always `true`. Correlate finishes by ID, not category. |
| `tool` | `tool_id`: hashed tool name; `tool_name`: allowlisted built-in name or `null`; `server_id`: hashed MCP server name or `null`; `transport`: `local`, `mcp`, or `unknown`; `status`: lifecycle status above (main-agent starts use `running`, results use `completed` or `failed`); `timed_out`: boolean when supplied by a main-agent result, otherwise `null`; `details_omitted`: always `true`. Results are status-only, with optional timeout state, never result content. |
| `reasoning_summary` | `text`: sanitized completed provider summary; `redacted`: whether sanitization changed it; `truncated`: whether the size bound removed text. Raw thinking and reasoning deltas are excluded. |
| `context` | `current_tokens`, `max_tokens`: counts; `reasoning_tokens`: count or `null`; `request_sequence`: runtime request counter; `source`: `fallback_estimate`, `tokenizer_estimate`, `fallback_projection`, `tokenizer_projection`, `last_provider_usage`, `provider_exact`, or `provider_partial`. |
| `usage` | `effective_input`, `output`, `cache_read`: token counts; `cache_known`: whether cache usage is known; `request_sequence`: runtime request counter; `final_usage`: boolean; `source`: `provider_request` or `subagent_aggregate` (whole-run child usage). These request counters are not live event sequences. |
| `compaction` | `status`: `triggered`, `running`, `completed`, `failed`, or `cancelled`; `current_tokens`, `max_tokens`: counts for triggered/completed, otherwise `null`; `replay_required`: `true` for completed/failed/cancelled, otherwise `false`. Refresh durable replay when requested. |

Subagent batches/tasks, their parent links, tool states, context counts, and aggregate usage are supported when emitted by the runtime. Activity delivery is best-effort, so clients must tolerate missing starts, updates, or finishes. Generic compaction lifecycle activity does not carry the richer fields of `kind:"compaction"`.

#### Bounds and privacy

`src/service/activity.rs` constructs this allowlisted projection; it does not serialize runtime display records. Tool arguments, commands, result bodies, raw metadata, arbitrary labels, child previews/output, provider payloads, and underlying error text are excluded. MCP tools expose hashed identities and status, not raw server/tool names, endpoints, or credentials.

Reasoning summaries enter only through the dedicated `provider_reasoning_summary` callback after the provider completion is marked as a verified provider summary. Generic thinking-completion events do not establish that provenance and are not projected. Summary text uses the existing display sanitizer and is capped at 2,048 UTF-8 bytes, without splitting a character. Input over 16,384 bytes is omitted as `text:""`, `truncated:true`, `redacted:false`. Sanitization is not a guarantee against arbitrary user-written secrets or paths; `redacted:false` is not a security certification.

General encoded payload/record limits still apply. Activity uses nonblocking sends to the bounded turn-event queue; overflow drops activity instead of waiting for queue capacity. Assistant text retains its acceptance and terminal-size checks and is not silently truncated or discarded as activity.

#### Activity capabilities

The `activity` object in initialization and capability responses currently contains:

```json
{
  "events": true,
  "approvals": false,
  "steering": false,
  "mcp_server_lifecycle": false,
  "mcp_tool_activity": true,
  "subagents": true,
  "raw_tool_output": false,
  "activity_replay": false,
  "max_summary_bytes": 2048
}
```

These are feature flags, not operation names for `requested_capabilities`. There are no approval or steering routes/events, nor MCP server connection/start/stop lifecycle events. MCP **tool** activity and subagent activity are supported; raw tool output and durable activity replay are not.

### `turn.cancel`

Send a request identifying an active turn:

```json
{
  "protocol_version": 1,
  "kind": "request",
  "request_id": "cancel-1",
  "method": "turn.cancel",
  "payload": {
    "turn_id": "opaque-turn-1"
  }
}
```

The successful response reports `status: "cancellation_requested"`. The active worker then observes cancellation through provider streaming, tools, continuation, and completion cleanup. A cancel for an unknown or already-terminal turn returns `unknown_turn`.

## Turn completion and ordering

Every started turn produces exactly one `turn.terminal` event, with one of these statuses:

- `completed` — the full turn completed;
- `cancelled` — explicit cancellation or stdio EOF stopped the turn; or
- `failed` — the runtime could not complete the turn for another reason.

The terminal payload always includes the authoritative assembled assistant text:

```json
{
  "turn_id": "opaque-turn-1",
  "session_id": "opaque-session-1",
  "status": "completed",
  "assistant_text": "The complete answer.",
  "sequence": 4,
  "activity_dropped": 1,
  "activity_replay_available": false,
  "replay_required": true
}
```

The service records the terminal outcome through the durable session boundary. Cancelled and failed turns retain the assistant text accepted before the stop, including partial text. Clients must use `assistant_text` as the final value; they must not require reconstructing it only by concatenating deltas.

For one turn, ordering is:

1. the `turn.start` acceptance response;
2. `turn.started`;
3. zero or more interleaved, ordered `turn.assistant_delta` and `turn.activity` events; and
4. exactly one `turn.terminal` event.

`sequence` is per-turn: started is `0`, each attempted activity or accepted assistant delta advances the shared counter, and terminal receives the next value. Child activity shares this order. Dropped activity consumes a sequence number. For example, started `0`, delta `1`, activity `2`, dropped activity `3`, terminal `4` produces the terminal payload above. Sequence gaps indicate omitted live progress, not missing durable conversation text. `event_id` remains an opaque envelope identity, not the ordering counter.

Terminal `activity_dropped` is the authoritative count of activity enqueue failures, including drops after the last delivered progress event. Intentionally excluded runtime event kinds are not counted. `activity_replay_available:false` means these gaps cannot be recovered through replay. `replay_required:true` directs clients to reconcile durable conversation/compaction state through `session.replay`, not reconstruct activity. Terminal closes activity admission, including late child callbacks.

No later event for that turn is emitted after `turn.terminal`. A cancellation response, when present, is a separate correlated response and does not replace the terminal event. If stdin closes, no cancellation response is possible, but the turn still resolves as cancelled before service shutdown when the output stream remains available.

## Errors and validation

Errors use stable codes and short fixed messages. Request data is never included in an error message.

| Code | Meaning |
| --- | --- |
| `invalid_json` | The record is not valid JSON. |
| `record_too_large` | The JSONL record exceeds the record limit. |
| `invalid_request` | The envelope is missing, has an unknown field, or has an invalid field type/value. |
| `payload_too_large` | The compact JSON payload exceeds its byte limit. |
| `limit_exceeded` | A protocol limit, session/catalog page limit, attachment cap, or list scan budget was exceeded. |
| `unsupported_version` | The request version or initialization version set is unsupported. |
| `unsupported_capability` | Initialization requested an unsupported capability. |
| `unsupported_operation` | The method is not implemented. |
| `not_initialized` | Initialization has not completed successfully. |
| `invalid_payload` | A known operation received an invalid payload shape. |
| `duplicate_request_id` | The request ID is already used by an in-flight request. |
| `too_many_active_turns` | The service has reached its active-turn limit. |
| `session_busy` | A conflicting writer, active turn, closing attachment, or busy durable replay snapshot prevents the operation. |
| `session_unavailable` | The requested session is missing, unsafe, unreadable, or outside the supported replay bounds. |
| `invalid_session_id` | A session ID or list continuation fails session-ID validation. Envelope validation may reject unsafe identity fields earlier. |
| `unknown_turn` | The turn ID is unknown or already terminal. |
| `auth_busy` | A login is already active or its callback has already been submitted. |
| `unknown_login` | The login ID is unknown or already terminal. |
| `provider_unavailable` | The provider does not support this auth operation. |
| `confirmation_required` | Logout needs explicit confirmation. |
| `configuration_unavailable` | Settings or their safe effective projection could not be read. |
| `configuration_busy` | A catalog/config request is running; retry after its response. |
| `catalog_refresh_failed` | Discovery or cache refresh failed; check provider readiness and retry. |
| `settings_write_failed` | The scoped settings update could not be validated/persisted; inspect local settings and retry. |
| `invalid_scope` | The scope is unknown or a setting requires global scope. |
| `invalid_selection` | The provider/model is unknown, unsafe, or disabled. |
| `unsupported_reasoning` | The recognized reasoning choice is unsupported for the target model. |
| `catalog_unavailable` | Selection/reasoning validation requires a usable catalog; refresh first. |
| `catalog_stale` | Selection/reasoning validation requires a fresh catalog; refresh first. |
| `internal_error` | A bounded service failure prevented handling the request. |

A rejected start request creates no worker and needs no terminal event. Once a turn or login start succeeds, its lifecycle ends through one terminal event. Errors on later callback or cancellation requests do not replace that active worker's terminal event.

Envelope and operation payloads reject unknown fields. Unsupported kinds, invalid types, malformed values, and unsupported methods fail with an error response rather than a panic. The adapter continues after request-level errors when framing can be recovered.

## Advertised limits

These are the current implementation's limits, returned in `initialize.payload.limits`, `initialize.payload.capabilities.limits`, and `capabilities.payload.limits`. They are fixed for the running process, not a promise that every package using protocol `1` has the same limit keys or values:

| Field | Value | Applies to |
| --- | ---: | --- |
| `max_record_bytes` | `65536` | One input record or encoded output line, including its newline at the stdio boundary. |
| `max_payload_bytes` | `32768` | Compact UTF-8 JSON bytes for one payload. |
| `max_error_message_bytes` | `512` | One UTF-8 error message. Current messages are shorter and fixed. |
| `max_id_bytes` | `128` | Request, session, turn, and event correlation IDs. |
| `max_name_bytes` | `64` | Methods, capabilities, and event names. |
| `max_string_bytes` | `16384` | Any JSON string or object key, including a prompt or assistant text value. |
| `max_json_depth` | `32` | JSON container nesting and validation work. |
| `max_array_items` | `64` | Items in any JSON array. |
| `max_object_fields` | `64` | Fields in any JSON object. |
| `max_negotiation_items` | `8` | Protocol-version and requested-capability lists during initialization. |
| `max_session_attachments` | `16` | Explicit and implicit attachments held by one service. |
| `max_session_page_items` | `32` | Maximum/default list or replay page item count. |
| `max_session_replay_page_bytes` | `16384` | Complete encoded replay payload, including events, cursors, flags, and JSON framing. |
| `max_session_replay_window_bytes` | `2097152` | Recent durable replay read window. |
| `max_session_list_scan_entries` | `20000` | Directory entries examined per list request. |
| `max_catalog_page_items` | `32` | Maximum/default provider or model catalog page item count. |

Request limits are checked before dispatch. Oversized input and assistant output are rejected rather than silently truncated; activity summaries use explicit truncation flags and activity queue overflow is reported through sequence gaps and the terminal counter. Session projections use explicit paging, text parts, and gap/resync flags. The service uses bounded input, turn-event, and provider-cleanup workers; cancellation cleanup has a bounded join grace period and a wall-clock limit for abandoned provider reads.

Limit evolution does not disable bounds checking. Validate required keys, positive integer values, units, and consistency between initialization's two limit objects; accept bounded new limit keys without counting known keys. `activity.max_summary_bytes` is a separate feature bound, not a seventeenth general limit. The eight-entry negotiation limit applies only to the two initialization input lists, not operation/event discovery arrays.

Keep local defensive limits before parsing initialization; never allocate based only on untrusted advertised numbers. For requests and configurable page sizes, use the smaller of the applicable server and local limits. For server output, a smaller local cap is safe only if the client can still consume all required output under the accepted profile: sending smaller prompts does not bound terminal text or every event. If a required server guarantee can exceed the client's supported record, payload, string, depth, collection, or feature bound, reject locally before mutation unless a documented request control actually constrains that output. Do not clamp an advertisement and pretend the server agreed. Output beyond an accepted bound is a protocol failure, not permission to truncate authoritative text or skip terminal/error records.

New keys and changed values are acceptable only when these checks still succeed and existing required meanings are preserved. Removing a required limit or changing its type/unit/meaning follows the required-semantic version/negotiation rule above.

## Client boundary

The client should depend only on this wire contract. It must not read `MC_HOME`, settings, auth files, session JSONL, provider payloads, or private runtime files to infer service state. Later service operations may provide other sanitized projections through this protocol.

The dispatcher is transport-neutral: the composition/adapter boundary injects advertised transport names, and stdio framing is the current adapter. Persistent sockets, reconnect transport, destructive session operations, client tools, approvals, steering, MCP server lifecycle, and frontend UI integration remain unsupported.

## Conformance and Companion handoff (#497)

The reusable consumer scenarios live in [`tests/fixtures/application_service/`](../../tests/fixtures/application_service/); its [README](../../tests/fixtures/application_service/README.md) defines fixture schema version, scenario IDs, expected outcomes, and opaque-ID binding. [`tests/service_conformance.rs`](../../tests/service_conformance.rs) is the reference harness. Keep fixture-schema, wire-protocol, and package versions distinct. Independent consumers, including Swift, should use the JSONL and expected behavioral outcomes without importing Rust implementation types.

Run the credential-free focused gate from the repository root:

```sh
cargo test --quiet --test service_conformance
```

Use the fixture README to distinguish tests driving the service from synthetic server streams testing client consumption. Only synthetic data, isolated temporary state, and injected/local fake dependencies belong in this gate; no user stores, credentials, or live provider/OAuth requests. Passing synthetic decoder scenarios alone is not evidence of runtime lifecycle behavior or signed-helper packaging.

[Companion #9](https://github.com/magimetal/magi-companion/issues/9) owns the Swift adapter and helper integration. Its handoff is:

- Replace exact output-key/count checks with the bounded extension rules above while retaining strict known types, required fields, enums, nullability, and correlation. Keep request generation strict.
- Add complete event-set preflight and typed activity capabilities, all six activity kinds, shared sequences, terminal drop/replay metadata, and authoritative text reconciliation. Preserve bounded, turn-local display and stale process/session/request rejection; activity is never durable replay.
- Run these consumer fixtures in the Companion test suite, including safe optional-field additions and rejection before mutation for unsupported events/bounds. Resolve any contract discrepancy here and in the shared fixtures, rather than silently relaxing the Swift decoder.
- Update parsing and the verified pinned, bundled, signed helper together. Record the selected backend revision and fixture-schema version in that integration; a package-version comparison or pin-only update is insufficient. Run Companion's own tests/build/signature checks from #9.

This backend handoff makes no Swift edits, advances no helper pin, and claims no Companion or signed-bundle verification. It does not promise that all historical frontends accept this server.

---

[Back to feature docs](README.md) · [Back to repository README](../../README.md)