Maxx Control API

The Maxx Control API is a local, token-authenticated control surface that lets trusted scripts, local automation, and webhook runners outside an existing Maxx tab create and manage Maxx tabs/sessions safely.

Maxx is the terminal-native runtime/control plane: it provides explicit, observable control over terminal tabs and sessions. It is not a workflow brain. External orchestrators decide what should happen and speak to Maxx through explicit API calls.

The control-plane / agent-declaration boundary

The API surface splits cleanly into two halves, and that split is the load- bearing design constraint:

Maxx never originates or interprets the semantic facts; it only records the ones an agent declares and reports the runtime facts it can directly observe. Both halves share the same maxx +control CLI and socket here, but the conceptual boundary is preserved by the verb groups (and could be split into two binaries without changing the protocol).

No-inference guarantee

The API never scrapes or regexes terminal output and never guesses workflow semantics from process names, branch names, paths, idle time, or similar signals. Every meaningful action comes from an explicit API request and declared metadata. The only Maxx-owned lifecycle signal is derived from explicit session state — whether the surface still exists and whether its child process has exited (a kernel-reported fact) — never from terminal contents.

Put another way, Maxx shows only mechanical facts (things it owns or observes as a terminal runtime) and agent-declared facts (workflow meaning declared through an explicit API call, hook event, or metadata field). It never derives workflow truthcomplete, blocked, tests passed, PR created — from incidental signals. This is the no-inference rule; the canonical statement, the allowed/prohibited sources, and the tests that lock it down live in no-inference rule.

Transport & trust boundary

Capability policy

On top of the token gate, every request is evaluated against a typed capability policy before any side effect runs. A request resolves to a caller source and a requested capability; the policy returns allow, deny, or confirm.

Decisions are no-inference: they depend only on the explicit caller, capability, and target — never on terminal output, captured lines, process names, branch names, paths, or idle time.

Built-in sources

Source Kind May…
local-cli (default) local every implemented capability, no confirmation.
local-prompt local read freely; confirm every mutation.
trusted-automation webhook tabs:spawn and state:set only.
readonly-external external tabs:list only; no mutation, no output read.

These built-ins are all subsets of local-cli, so claiming one via --as only reduces a caller’s privileges. User-configured sources are added beside these built-ins; built-in ids are reserved and cannot be replaced by config.

Configured policy sources

Maxx loads additional policy sources from JSON before constructing the ControlSessionRegistry, so every Control API request is enforced against the active configured policy from startup. The default file is:

~/Library/Application Support/com.scottmcpherson.maxx/control-policy.json

Debug builds use their bundle id in the same Application Support location. Set MAXX_CONTROL_POLICY_FILE=/path/to/control-policy.json to point a dev/test instance at a specific file.

Example source for grouped Linear webhook launches:

{
  "version": 1,
  "sources": [
    {
      "id": "linear-webhook",
      "kind": "webhook",
      "allow": ["tabs:spawn", "groups:create", "state:set"],
      "confirm": [],
      "confirm_scope": "always"
    }
  ]
}

Config is fail-closed: missing config uses the built-ins; invalid JSON, unsupported versions, reserved/duplicate ids, unknown capabilities, and ambiguous allow/confirm overlap are rejected and Maxx falls back to the safe built-in policy. The file is bounded before and after read, and it carries no secrets.

Confirmation

When the policy returns confirm, the response is ok:false with code confirmation_required and a human-readable prompt that names the source, the requested action, the target, and the consequence. Re-send the request with --confirm (wire field confirm: true) to approve it. A source configured with once_per_source is prompted only on its first use of a capability for the lifetime of the app/control session.

Diagnostics

policy check evaluates a (source, capability, target) and reports the decision without performing any action — useful for debugging an integration’s permissions:

maxx +control policy check --as readonly-external --capability tabs:close
maxx +control policy check --as readonly-external --capability output:read
maxx +control policy check --capability tabs:spawn   # the default local source
maxx +control policy check --as linear-webhook --capability groups:create
maxx +control policy sources
maxx +control policy validate --config ./control-policy.json

Every allow/deny/confirm decision (including policy check) is written to the unified log under the ControlPolicy category with the source, capability, target, and reason — never the request params.

Metadata writes are gated by metadata:set: sessions.set-metadata, sessions.remove-metadata, sessions.clear-metadata, and a metadata-only sessions.update all require it. A sessions.update that also sets status is gated by state:set instead (status is the same state field declare-state writes and wait --state matches; it is the stronger gate when both are present), so neither field is a way around the other’s capability.

Create-time metadata is the deliberate exception. A metadata object passed to sessions.create rides under tabs:spawn, not metadata:set. It is part of the atomic spawn request — an explicit caller declaration captured as the tab is created (e.g. a connector attaching connector.* provenance) — not the post-create agent-metadata write surface that metadata:set gates. So a webhook source allowed to tabs:spawn but not metadata:set can still stamp provenance at create time, yet cannot mutate metadata afterward. The metadata is still validated and stored verbatim; it arrives in the watch snapshot rather than as a separate audit entry.

CLI

The app CLI ships a +control action that handles the token and socket for you.

maxx +control sessions create \
  --title "Run release checks" \
  --cwd /path/to/repo \
  --command "zig build test -Dtest-filter=release" \
  --metadata workflow=release-checks \
  --metadata request_id=abc123

# From inside an already-open Maxx tab, register the current tab only.
parent=$(maxx +control sessions register-current \
  | jq -r .result.session.session_id)

# Create a split pane inside an existing session's tab instead of a new tab.
# `--split-target` names the session whose surface is split (required);
# `--split-direction right|down|left|up` picks the edge (default: right).
# Focus stays on the target pane unless `--focus` is passed.
maxx +control sessions create \
  --title "codex worker" \
  --location split --split-target "$parent" --split-direction right \
  --command "codex"

maxx +control sessions get <session_id>
maxx +control sessions list
maxx +control sessions update <session_id> --status waiting_for_review
maxx +control sessions action <session_id> --action focus
maxx +control sessions action <session_id> --action input --input 'echo hi'
maxx +control sessions action <session_id> --action submit --input 'echo hi'
maxx +control sessions cancel <session_id>

input is paste-only. Use submit when the line must execute immediately; it pastes the supplied input and then sends an Enter key press/release.

Lifecycle control (maxxctl half)

maxx +control sessions wait <session_id> --state tests:passed --timeout 5m
maxx +control sessions wait <session_id> --event pr.merged --timeout 30s --since 12
maxx +control sessions wait <session_id> --lifecycle exited --timeout 10m
maxx +control sessions watch <session_id> --json
maxx +control sessions action <session_id> --action interrupt --signal SIGTERM
maxx +control sessions archive <session_id> --reason "run complete"
maxx +control sessions restart <session_id> --last-command
maxx +control sessions restart <session_id> --command "zig build test"
maxx +control sessions events <session_id> --since 0

Agent declarations (maxx-agent half)

maxx +control sessions declare-state <session_id> --state tests:passed --message "all green" --source ci-agent
maxx +control sessions emit-event <session_id> --event pr.opened --payload-json '{"pr":123}'

Agent-reported metadata

Namespaced key → arbitrary JSON value an agent attaches to a session. Maxx stores, displays (a metadata chip on the tab), and filters it verbatim, and never interprets a key as workflow state.

# Set/merge one key (string value, or a structured value via --value-json).
maxx +control sessions set-metadata <session_id> --key linear.issue --value MAX-4
maxx +control sessions set-metadata <session_id> --key pr.url --value https://github.com/org/repo/pull/456
maxx +control sessions set-metadata <session_id> --key cleanup.command --value 'git worktree remove ../wt'
maxx +control sessions set-metadata <session_id> --key run --value-json '{"id":"run_abc","attempts":[1,2]}'

# Merge several string keys at once via create/update.
maxx +control sessions update <session_id> --metadata repo=org/repo --metadata branch=codex/agent-metadata-api

# Remove one or more keys, or clear them all.
maxx +control sessions remove-metadata <session_id> --key branch --key run
maxx +control sessions clear-metadata <session_id>

# List only sessions whose metadata matches every filter (key present, or key=value).
maxx +control sessions list --filter repo=org/repo --filter linear.issue

Agent-declared workflow state (displayed)

A small, validated workflow state an agent declares for human-facing display: Maxx shows it as a badge on the tab and a one-line summary. This is distinct from the free-form declare-state above (machine coordination for wait) and from the Maxx-owned lifecycle (process liveness).

maxx +control sessions set-state <session_id> --state running
maxx +control sessions set-state <session_id> --state needsInput --source release-agent
maxx +control sessions set-state <session_id> --state blocked
maxx +control sessions set-state <session_id> --state complete
maxx +control sessions set-state <session_id> --state failed
maxx +control sessions set-summary <session_id> --summary "Waiting on user confirmation for release notes wording."

set-state accepts exactly one of running, needsInput, blocked, complete, or failed; any other value is rejected with invalid_request and the current declared state is left unchanged. set-summary is independent of set-state, so an agent can update the displayed text without changing status. Both record an audit entry and are surfaced in get / list / watch.

Agent-declared result (child answer retrieval)

set-result stores a bounded answer/result text on the session so a supervisor can retrieve a child agent’s final response by session_id without reading scrollback or asking the child again.

maxx +control sessions set-result <session_id> --result "Use parser branch B."
maxx +control sessions get <session_id> | jq .result.session.result
maxx +control sessions clear-result <session_id>

The session view includes result, result_at, and result_source once declared. summary remains the short display line; result is the child answer payload. set-result records a kind: result audit entry, and clear-result records result.cleared. Results are persisted with the session record and kept when archived for inspection. A session restart clears the previous run’s result together with the previous run’s workflow badge and summary. Public result writes are gated by state:set; result reads are part of sessions.get / sessions.list and are gated by tabs:list.

Maxx can also declare results from structured agent hook transcripts when the hook provides a transcript path and final-answer record. Automatic capture is handled by a per-runtime transcript adapter table: each adapter names the .jsonl roots a runtime writes to and how to pull a final-answer field from one transcript record. Built-in adapters cover Codex (~/.codex/sessions, ~/.codex/archived_sessions) and Claude Code (~/.claude/projects). A runtime with no adapter — e.g. a cross-provider agent that writes no structured transcript — is never inferred from output; it simply has no automatic capture and reports its answer with an explicit set-result. Adding auto-capture for another runtime that does write a structured transcript is one adapter entry, no other code changes. Capture reads CLI transcript JSON from an explicit hook payload, not terminal scrollback, and only captures answer text.

Declared result schema (structured-output contract)

A caller can declare a JSON-Schema (subset) contract for a session’s result. When set, every set-result — over the API or from an automatic transcript capture — must supply a result that parses as JSON and satisfies the schema, or it is rejected with invalid_request and the previous result is left untouched. This is the cross-provider equivalent of a structured-output contract: a parent declares the shape it expects a child’s answer to take, and Maxx enforces it on the declared value. It is a validation contract the agent declares, never a heuristic applied to terminal output.

# Declare a contract at create time, or on an existing session.
maxx +control sessions create --command "codex exec 'review'" \
  --result-schema '{"type":"object","required":["verdict"],
                    "properties":{"verdict":{"type":"string"},
                                  "confidence":{"type":"number"}}}'
maxx +control sessions set-result-schema <session_id> --result-schema '{"type":"array"}'

# A conforming result is accepted; a non-conforming one is rejected.
maxx +control sessions set-result <session_id> --result '{"verdict":"ship","confidence":0.9}'
maxx +control sessions set-result <session_id> --result 'looks good'   # invalid_request

maxx +control sessions clear-result-schema <session_id>

Supported keywords (all optional; unknown keywords are ignored so the subset can grow): type (object, array, string, number, integer, boolean, null), required (property-name array), properties (name → sub-schema), and items (array element sub-schema). The schema is validated when declared, so a session never carries a contract Maxx cannot enforce. The contract is a session property retained across restart (unlike result, which is per-run) and independent of clear-result; it appears in the session view as result_schema. Declaring/clearing it is gated by state:set (the same declared-fact gate as set-result).

Agent type and parent (persisted)

An agent declares its type explicitly; Maxx stores it verbatim and persists it across restarts (MAX-5). A session can also be created under an explicit parent.

maxx +control sessions set-agent-type <session_id> --agent-type claude-code
maxx +control sessions create --command "zig build test" --agent-type codex
maxx +control sessions create --command "zig build" --parent <parent_session_id>

--agent-type is validated as an opaque token (Maxx never derives meaning from its text) and recorded with a source and timestamp. --parent must reference a known session_id; the persisted edge is mechanical, never inferred.

Agent profiles (cross-provider subagents)

A profile is a named bundle of create-time inputs — command, agent_type, env, and metadata — so a caller can spawn a well-known agent (Claude Code, Codex, an OpenRouter-backed model, a local runner, …) by name rather than repeating its full invocation. This is the substrate for defining custom subagents as any provider/model: the “brain” is whatever CLI the profile’s command launches; Maxx just spawns and supervises it.

Profiles are a user-authored, read-only config file — Maxx never writes it and the API never mutates it. Location: MAXX_PROFILES_FILE, else ~/Library/Application Support/<bundle-id>/agent-profiles.json. Either shape is accepted — a versioned envelope or a bare name → profile map:

{
  "version": 1,
  "profiles": {
    "kimi-reviewer": {
      "command": "kimi --model kimi-3 exec 'review the diff'",
      "agent_type": "kimi",
      "env": ["OPENROUTER_API_KEY=sk-..."],
      "metadata": { "role": "reviewer" }
    }
  }
}
# Spawn a session from a profile. Explicit flags override the profile's fields.
maxx +control sessions create --profile kimi-reviewer
maxx +control sessions create --profile kimi-reviewer --command "kimi --model kimi-3 exec 'other task'"

# Discover available profiles (env values are never returned — only key names).
maxx +control profiles list

An unknown --profile name is invalid_request (never a silent fall-through to a bare tab). Explicit caller-supplied fields always override the profile’s; env entries merge by key with the caller winning. Profile env may hold secrets, so — like every create-time --env — it is applied to the spawned process but never persisted to the session registry, and profiles.list returns only env key names, never values. profiles.list is gated by its own read capability, profiles:list.

Parent-child tab groups (MAX-6)

Parent/child and group relationships are explicit metadata so supervisor agents can group visible tabs and answer relationship queries — Maxx tracks the edges and the display/query mechanics, never the workflow meaning. The edge is the persisted parent_id from MAX-5; MAX-6 adds the update path, validation, queries, and a tab badge.

# Attach a child under a parent after creation (the update counterpart to
# `create --parent`). An empty --parent clears the edge.
maxx +control sessions set-parent <child_session_id> --parent <parent_session_id>
maxx +control sessions set-parent <child_session_id> --parent ""   # detach

# Group-aware queries (composable with each other and --filter):
maxx +control sessions list --parent <parent_session_id>   # a tab's children
maxx +control sessions list --group release                # a group's members
maxx +control sessions get <child_session_id> | jq .result.session.parent_id   # a tab's parent
# Siblings = the children of a tab's own parent.

set-parent validates the edge so the graph stays sound: a missing parent is not_found, and the session itself or any edge that would form a cycle is invalid_request. Re-setting the same parent is a no-op (no event, no updated_at bump). The edge is gated by groups:create — an association edge, the same gate as set-group and create --parent — enforced before any session/parent lookup, so a denied caller cannot use it as a session-existence oracle. A set-parent change records a Maxx-owned parent.set / parent.cleared mechanical event on the structured stream (alongside group.joined / group.left).

The parent_id edge is durable mechanical history: closing a parent does not rewrite its children. A child of a closed parent is still returned by list --parent <closedParent>, carrying its own lifecycle, so a supervisor gets “active visible children” by filtering the list on each child’s lifecycle — Maxx never reacts to a lifecycle by mutating a relationship.

In the UI, a grouped or child tab shows a small relationship badge (a group-label chip and/or a “child” indicator) on its surface, alongside the MAX-3 state badge and MAX-4 metadata chip. A plain ungrouped tab with no parent shows nothing and behaves exactly as any other tab — selection, close, reorder, and focus are untouched.

Structured event stream (stream / event)

A cross-resource, cursor-addressed event bus for supervisor agents. Where sessions watch/wait follow one session, stream watch/wait follow tab, session, and group activity together, with a process-wide monotonic cursor so a supervisor can resume after a dropped connection (and is told, via a reset, when its cursor predates what Maxx still retains). Maxx emits its own mechanical lifecycle events (create/focus/close/process-exit and group membership changes); agents declare workflow-relevant events explicitly. Maxx never infers either.

# Group sessions for coordination (also accepted as `sessions create --group`).
maxx +control sessions set-group <session_id> --group release
maxx +control sessions set-group <session_id>            # (no --group) leaves the group

# Stream every event as newline-delimited JSON, filtered and resumable.
maxx +control stream watch
maxx +control stream watch --group release
maxx +control stream watch --session <session_id>
maxx +control stream watch --tab <surface_id>
maxx +control stream watch --since <cursor> --timeout 10m

# Block until a specific event is observed on the stream (optionally filtered).
maxx +control stream wait --group release --event deploy.done --timeout 30m
maxx +control stream wait --session <session_id> --event tests.green

# Block until every member of a group satisfies a condition.
maxx +control stream wait --group release --all exited
maxx +control stream wait --group release --all idle
maxx +control stream wait --group release --all declared:complete

# Declare a structured event (shorthand for `sessions emit-event`).
maxx +control event emit --session <session_id> --type declared.status --json '{"step":3,"of":7}'

All of these are subject to the capability policy above: stream watch / stream wait are gated by tabs:list (the same read capability as observing sessions), enforced before the long-lived stream begins; sessions set-group and sessions create --group are gated by groups:create (a create --group needs both tabs:spawn and groups:create); and event emit is gated by state:set like the other declaration verbs. A --as <source> / --confirm applies to these exactly as to any other request.

stream watch first prints a hello line carrying the current cursor and the envelope schema, then one {"type":"event","event":{…}} line per matching event, and a final {"type":"end"} when a single-session filter’s session ends (otherwise it runs until --timeout or the caller disconnects). --since <cursor> replays retained events after that cursor; if the cursor predates the retained window, the hello line carries "reset": true and a "dropped_through" cursor so the supervisor knows a gap occurred rather than silently missing it.

stream wait prints one response whose result.outcome is matched, timeout, or ended; on a --event match it also carries the stream_event envelope, and on a --group --all match the satisfying member sessions. --all takes idle, exited, or declared:<state>:

The raw JSON response is printed to stdout. Exit codes are stable so scripts can branch on them:

Exit Meaning
0 Success, or wait observed its condition (matched).
1 Generic error (transport, usage, validation).
2 wait timed out before the condition held.
3 Missing target — no session with that id (not_found).
4 wait target ended (session became terminal) before matching.
5 Unsupported operation for this session (e.g. nothing to restart).
6 Confirmation required — re-send with --confirm to approve.

wait blocks until its condition holds, then prints a single response whose result.outcome is matched, timeout, or ended. watch streams one JSON object per line (snapshot, then event / lifecycle, then a final end) until the session ends or the caller disconnects; pass --timeout to cap it. Durations accept ms/s/m/h suffixes (a bare number is seconds).

A flag value that begins with + must use the --flag=value form (e.g. --command=+foo); the space-separated form is intercepted by Maxx’s +action CLI detection. The socket protocol (and the Python client below) has no such restriction.

Methods

The method field mirrors the proposed REST shape:

Method REST equivalent Purpose
sessions.create POST /control/v1/sessions Create a tab/session from explicit inputs.
sessions.register-current POST /control/v1/sessions/current Register the caller’s current live tab using its per-surface proof.
sessions.get GET /control/v1/sessions/{id} Explicit lifecycle state + declared metadata.
sessions.list GET /control/v1/sessions List control sessions; optional metadata_filter, parent/group filters.
sessions.update PATCH /control/v1/sessions/{id} Update caller-owned status/metadata only (metadata merges).
sessions.action POST /control/v1/sessions/{id}/actions focus, input, submit, interrupt (signal), cancel, close.
sessions.wait GET /control/v1/sessions/{id}/wait Block on a state/event/lifecycle until matched or timeout.
sessions.watch GET /control/v1/sessions/{id}/events Stream lifecycle/event changes (newline-delimited).
sessions.archive POST /control/v1/sessions/{id}/archive Close the surface, retain the record.
sessions.restart POST /control/v1/sessions/{id}/restart Replay the recorded/supplied command in a fresh surface.
sessions.events GET /control/v1/sessions/{id}/log Read the audit log (declared states/events + lifecycle).
sessions.declare-state PUT /control/v1/sessions/{id}/state Agent declares a lifecycle state (audited).
sessions.emit-event POST /control/v1/sessions/{id}/emit Agent emits a named event with optional JSON payload.
sessions.set-metadata PUT /control/v1/sessions/{id}/meta Agent sets/merges one metadata key (value or value_json).
sessions.remove-metadata DELETE /control/v1/sessions/{id}/meta Agent removes one or more metadata keys (key/keys).
sessions.clear-metadata DELETE /control/v1/sessions/{id}/meta Agent clears all metadata for the session.
sessions.set-state PUT /control/v1/sessions/{id}/workflow-state Agent declares a validated workflow state for display.
sessions.set-summary PUT /control/v1/sessions/{id}/summary Agent sets the human-readable summary shown with the state.
sessions.set-result PUT /control/v1/sessions/{id}/result Agent sets the bounded child-answer result returned in the session view.
sessions.clear-result DELETE /control/v1/sessions/{id}/result Agent clears the current result.
sessions.set-agent-type PUT /control/v1/sessions/{id}/agent-type Agent declares its type (e.g. claude-code); persisted, never inferred.
sessions.set-parent PUT /control/v1/sessions/{id}/parent Set/clear the parent edge after creation; rejects self/missing/cycle (MAX-6).
sessions.set-group PUT /control/v1/sessions/{id}/group Set/clear group membership (Maxx-owned membership event).
stream.watch GET /control/v1/stream Stream the cross-resource event bus (filtered, resumable).
stream.wait GET /control/v1/stream/wait Block on a stream event or a group-wide condition.
policy.check GET /control/v1/policy/check Evaluate a (source, capability, target); report allow/deny/confirm, no side effect.

Audit entries

declare-state, emit-event, the metadata mutations (set-metadata / remove-metadata / clear-metadata, and the metadata merge in update), set-state, set-summary, set-result, and clear-result append to a per-session, append-only audit log. Each entry is fully auditable and carries a monotonic seq, a kind (state / event / metadata / workflow-state / summary / result, plus lifecycle for the archive / restart actions Maxx records itself), the declared name (the affected metadata key, or * for a clear; a metadata removal/clear also carries message removed/cleared), the source (agent-supplied, or maxx for runtime entries), the created_at timestamp, the session_id and surface_id, and the foreground pid observed at record time. wait, watch, and events all read from this one log — never from terminal output.

Request

{
  "token": "<capability token>",
  "method": "sessions.create",
  "params": {
    "title": "Run release checks",
    "cwd": "/path/to/repo",
    "command": "zig build test",
    "env": ["CI=1"],
    "metadata": { "workflow": "release-checks", "request_id": "abc123" },
    "status": "created",
    "location": "tab"
  }
}

location is tab (default), window, or split. A split create places the new surface as a pane inside an existing control session’s tab and takes two extra params: split_target (required — the session id whose live surface is split; the id must name a session this registry owns this run, so canceled, archived, and restored-from-a-previous-run records are rejected as already_ended) and split_direction (right default, down, left, up). Both are rejected on tab/window creates so a typo cannot silently degrade a split into a plain tab. The pane is an ordinary session: its own session_id, surface_id, lifecycle, declarations, and events. Without focus: true, keyboard focus stays on the target pane. On restart, a split session respawns as a plain tab: its original neighbor surface may be gone, and picking a substitute would be inference.

Response

{
  "ok": true,
  "result": {
    "session": {
      "session_id": "2B0E…",
      "surface_id": "9F1C…",
      "title": "Run release checks",
      "command": "zig build test",
      "cwd": "/path/to/repo",
      "status": "created",
      "lifecycle": "running",
      "metadata": { "workflow": "release-checks", "request_id": "abc123" },
      "created_at": "2026-06-14T12:00:00Z",
      "pid": 41234
    }
  }
}

Errors are predictable and documented:

{
  "ok": false,
  "error": { "code": "not_found", "message": "no session with id …" }
}
Code Meaning
invalid_request Malformed input, bad limits, or a disallowed update field.
unauthorized Missing/wrong token, or the policy denied this capability for the source.
confirmation_required The policy requires confirmation; re-send with confirm: true.
not_found No control session with that id.
already_ended The session was canceled or its surface no longer exists.
unsupported_action Unknown action name.
unsupported Operation not supported for this session (e.g. no command to restart).
internal Unexpected server-side failure.

Identifiers & ownership

Limits

sessions.update uses merge semantics for metadata (provided keys overwrite or add) and only accepts status/metadata — any attempt to set server-owned fields is rejected with invalid_request. sessions.action cancel/close is idempotent.

Agent-reported metadata

metadata is a generic, agent-owned store: a map of namespaced keys to arbitrary JSON values an agent or orchestrator attaches to a session so Maxx can store, display, and filter it — without inferring any of it. Maxx does not scrape terminal output, parse process names, or read branch names/paths/idle time to populate it; every entry comes from an explicit API call. No key is treated as authoritative workflow state, and unknown keys round-trip verbatim with no normalization, so new keys need no app change to appear in get / list / watch and in the metadata chip on the tab.

Representative keys: linear.issue, pr.url, repo, branch, run.id, cleanup.command — but any [A-Za-z0-9_.-] key is accepted.

Operations:

Every post-create metadata mutation records a metadata-kind audit entry per affected key — set-metadata, the per-key merges from update, remove-metadata (each carries message removed), and clear-metadata (one entry, name *, message cleared) — so a watch/events consumer observes the change no matter which verb made it. (Metadata supplied at create time arrives in the watch snapshot instead.) Each mutation also pushes the updated map to the live surface atomically, so the UI/filtering never observe a partially-applied change.

Persistence. Metadata is scoped to the session record’s lifetime in the registry. It survives archive and restart (a restart keeps the stable session_id, so the reported metadata stays attached — unlike the per-run workflow_state / summary badge, which a restart clears). It is removed only by an explicit remove-metadata / clear-metadata, or when the session record itself is gone. There is no separate session export/restore mechanism, so no stale metadata is ever restored from a previous run.

Lifecycle, wait, watch, archive, restart

The persistent session registry (MAX-5)

The session registry is durable: it survives an app restart so Maxx keeps a mechanical view of sessions and the facts agents declared on them. This persists the registry; it does not persist or revive the terminal processes, which die with the app.

What is persisted. Each record stores its stable session_id and surface_id, the optional parent_id (a persisted parent association), group, the declared agent_type, title, command, cwd, caller-owned status, the agent-reported metadata, the agent-declared workflow_state / summary (with their sources/timestamps), the mechanical lifecycle flags (archived, restart_count, last-observed lifecycle), the audit log, and the created_at / updated_at / last_seen_at timestamps. updated_at is bumped on any change; last_seen_at is the last time Maxx mechanically observed the surface still existing (used for retention). The create-time env map is deliberately not persisted: it can carry secrets (API tokens passed only to the spawned agent), so it lives only in memory for the current run and never gets a plaintext at-rest copy. (A restored session’s restart therefore re-spawns with the ambient environment, not the original --env overrides.)

Where. A single versioned JSON document, registry.json, in the same per-user control directory as the socket and token (honoring MAXX_CONTROL_DIR), written 0600 inside the 0700 directory — the registry can carry agent-declared metadata, so it gets the same private-to-the-user hardening. It survives an app restart and is cleared on reboot along with the rest of the runtime directory (no live session survives a reboot either). Writes are atomic (temp file + rename) and happen on every mutation, plus a flush on app termination.

Read safety. The control directory lives in world-writable /tmp, so the registry is read only after the server validates that directory as ours, a real directory (not a symlink), and 0700 with no group/other access — the same check that gates the token and socket. The registry is loaded after that check, never in a constructor that runs before it, so a file another local user planted in an insecure directory is never decoded. The read is also size-capped (16 MiB, far above any retention-bounded registry) so a corrupt or oversized file is not slurped whole at launch. An oversized file is preserved, not overwritten: its schema version sorts after the large sessions array so it cannot be read cheaply, and this build never writes an oversized file (saves trim to fit), so an oversized file may be a newer build’s — a downgrade run must not clobber it. Writes are gated symmetrically: the registry persists nothing until that post-validation load has run, so a flush on a startup that refused the directory (an insecure/symlinked MAXX_CONTROL_DIR) cannot write a snapshot into — or clobber a registry in — the refused directory.

Restart rehydration. On launch the registry loads its records. A restored record is detached from any live surface: it reads as closed with no pid and is flagged restored: true (a mechanical fact about this run, not an inference about the work). The detachment is a safety boundary — with macOS window restoration a freshly rebuilt, user-owned surface can reuse a persisted surface_id, and the control API must never adopt, observe, signal, or close a surface it did not create or explicitly register this run — so a restored record resolves no surface and its actions return already_ended until it is restarted. A restored record is fully listable/readable via get / list / events, and — because its command is persisted — is restart-able: a restart spawns a fresh surface, revives the record (restored clears, lifecycle returns to running), and increments restart_count.

Declared fields. agent_type is an explicit agent self-declaration (set-agent-type, or --agent-type at create), stored verbatim like all declared facts and never inferred from the command, process name, branch, path, or title. Either path records the declaration in the session audit log (events / watch) with its source, so the create-time declaration is as visible and durable as a later set-agent-type — a supervisor never has to wait for a second request to learn it. parent is supplied at create time — or updated later with set-parent (MAX-6) — as a known session_id; the edge is verified (not_found for an unknown id, invalid_request for a non-UUID, the session itself, or a cycle) and persisted, but never inferred from naming or spawn order. Both set-agent-type and a --group/--parent association are gated by the same capabilities as the other declaration/group verbs (state:set and groups:create). The groups:create check runs before the parent id is resolved, so a caller that lacks it gets unauthorized whether or not the id exists — the parent lookup never becomes a session-existence oracle.

Retention. Deterministic and bounded so the file cannot grow without limit. The age cutoff retires only records observed terminal (closed or archived): a record older than 14 days (by max(updated_at, last_seen_at)) is dropped, but a record that may still be live (last-observed lifecycle running/exited) is never aged out, since its last_seen_at can lag during a long idle stretch and dropping it would lose a still-existing tab’s record. A count cap (newest 500) is a hard backstop applied to everything, and each record’s persisted audit log is bounded to its most recent events (newest 1000) so one chatty session cannot grow the file past the read cap. The same policy runs on save and on load. Because many chatty sessions could still collectively exceed the byte budget, a save whose encoded snapshot would exceed the read cap keeps trimming audit events (newest kept) until it fits — so persistence never stalls; only the oldest audit history of the busiest sessions is dropped. Only if it still cannot fit with every audit event removed is the write skipped and the last readable file preserved (logged; never a silent unreadable write or data loss). The trimming also bounds the events fed to the first encode up front (count-based), so even a registry that would encode to gigabytes never materializes that blob in memory on the save path. The live in-memory audit log is never trimmed; only the on-disk copy is bounded.

No inference on load. Rehydration replays exactly what was stored and nothing more. A record whose mechanical fields (command, cwd, title) happen to read like completion signals never comes back with a guessed workflow_state, summary, or agent_type — only explicitly declared facts survive, verbatim. See no-inference rule.

The structured event stream contract (MAX-7)

Maxx is the visible terminal-native runtime/control plane, not the workflow brain. The event stream reflects that split exactly: Maxx emits the mechanical runtime facts it owns, agents declare the workflow facts they own, and the envelope tags every event with which is which (source_kind). A supervisor composes coordination from these explicit events and never has to scrape terminal output, match process or branch names, or time idle gaps.

The event bus

Every event is appended to an in-memory, append-only bus with a process-wide monotonic cursor (starting at 1; stable for the run, never reused, survives session restarts). The bus is bounded (default 10,000 events) with oldest-first eviction; a --since cursor that predates the retained window is reported as a retention miss (reset/dropped_through) rather than silently skipped. The bus is a superset of the per-session audit logs: every per-session audit entry appears on it, plus the Maxx-owned mechanical events below that have no per-session entry. Durable history is intentionally out of scope for v1, but the cursor contract does not preclude adding it later. The cursor is in-memory and resets when the Maxx app restarts (the bus is not persisted); a --since from a previous run is therefore beyond the current cursor and is reported as a reset (the stream replays what it retains rather than blocking past the stale cursor).

The envelope

Each stream.watch event message and each stream.wait --event match carries a schema-versioned envelope:

{
  "schema": 1,
  "cursor": 42,
  "seq": 3,
  "source_kind": "agent",
  "kind": "event",
  "name": "deploy.done",
  "source": "release-agent",
  "message": null,
  "payload": { "version": "1.4.0" },
  "created_at": "2026-06-14T12:00:00Z",
  "resource_kind": "session",
  "session_id": "2B0E…",
  "surface_id": "9F1C…",
  "group": "release",
  "pid": 41234
}

Post-create metadata mutations — set-metadata, remove-metadata, clear-metadata, and the metadata merge in sessions.update — flow onto this stream as kind: metadata events that carry the affected key in name and reuse the generic message/payload fields. The envelope adds no metadata-value-specific fields, so it stays uniform and workflow-neutral: a supervisor learns which key changed from the stream and reads the full map verbatim via get / list / watch.

Create-time metadata does not produce a stream event. A metadata object supplied to sessions.create is stored on the session and pushed to the surface, but the only events a create emits are the mechanical created (and optional group.joined) below — there is no kind: metadata event for it. So a supervisor must not stream.watch/stream.wait for a create-time connector.* metadata event: that metadata is available immediately from the create response, sessions.get, sessions.list, and the per-session watch snapshot, while a grouped connector launch is observed on the stream via created + group.joined.

Events Maxx owns (source_kind: maxx, kind: lifecycle)

name When
created A session/tab was created (its command, if any, was started).
registered The caller’s current live tab self-registered as a session.
focused A session was focused via the API.
closed A session was canceled/closed, or its surface vanished.
exited The session’s child process exited (kernel-reported).
archived A session was archived.
restarted A session’s command was restarted in a fresh surface.
group.joined A session joined a group (message/group name the group).
group.left A session left a group.
parent.set A session’s parent edge was set (message is the parent id).
parent.cleared A session’s parent edge was cleared.

These derive only from explicit API actions and kernel-reported process/surface state — never from terminal output, process names, branch names, paths, tab titles, prompts, or idle time. created covers command start; exited covers command/process exit. Process-exit and surface-vanished events are reconciled when the stream (or a get/list/events read) next observes the kernel state.

Events agents own (source_kind: agent)

declare-state, emit-event, set-metadata, set-state, set-summary, and set-result / clear-result (see above) flow onto the stream verbatim. Maxx validates the envelope (name characters, payload is well-formed JSON within the size limit, source length) and routes it, but assigns no meaning to the agent’s type/payload.

Example supervisor flow

A supervisor launches a batch of jobs as a group, follows progress without reading any terminal text, and blocks until they all finish:

G=release-2026-06-14
for repo in a b c; do
  id=$(maxx +control sessions create --command "./ci.sh $repo" --group "$G" \
        | jq -r .result.session.session_id)
  # each job declares its own milestones:
  #   maxx +control event emit --session "$id" --type tests.green
  #   maxx +control sessions set-state "$id" --state complete   (or failed)
done

# Follow everything in the group as structured JSON (resumable via --since):
maxx +control stream watch --group "$G" &

# Block until every job's process has exited, then inspect declared outcomes:
maxx +control stream wait --group "$G" --all exited --timeout 1h
maxx +control sessions list | jq '.result.sessions[] | {id:.session_id, state:.workflow_state}'

Nothing here inspects terminal contents: coordination rides entirely on Maxx’s mechanical exited events and the agents’ explicit set-state declarations.

Not in scope (yet)