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:
- Control plane (runtime primitives). Verbs that observe and control what
Maxx can know as a terminal runtime: session identity, process liveness,
focus,
wait,watch,interrupt,archive, andrestart. These aremaxxctl-style operations. - Agent declarations (workflow semantics). Verbs an agent or orchestrator
uses to declare workflow-relevant facts Maxx then stores and replays
verbatim:
declare-state,emit-event, and the agent-reported metadata verbs (set-metadata,remove-metadata,clear-metadata). These aremaxx-agent-style operations.
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 truth — complete, 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
- Unix domain socket, created by the running Maxx app at
/tmp/maxx-control-<uid>/control.sock(override the directory withMAXX_CONTROL_DIR). The directory is0700and the socket is0600, so only the local user can connect. There is no network surface. - Capability token: the app writes a random token to
/tmp/maxx-control-<uid>/token(0600). Every request must present it. This is defense-in-depth on top of the filesystem boundary and authorizes cross-process callers such as webhook runners. - Wire protocol: newline-delimited JSON. Connect, write one request object followed by a newline (or half-close the write side), and read one response object terminated by a newline.
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.
- Capabilities are typed values with stable names:
tabs:list,tabs:spawn,tabs:restart,tabs:focus,tabs:close,input:send,keys:press,state:set,metadata:set,groups:create, plusoutput:read,groups:list, andautomation:trigger. The last three are part of the model but have no method behind them yet, so they are reported unavailable and always denied (output:readis additionally privacy-sensitive and requires an explicit opt-in even once a readback subsystem exists).groups:createbecame implemented with the structured event stream (MAX-7). - Sources have a
kind(local,external,webhook,token) and an allowlist (allow) plus a confirm-list (confirm). A request names its source with--as <source>(wire fieldcaller); when omitted it is attributed to the trusted first-party local source — the capability token already proves a same-user local caller. - Safe defaults: unknown sources are denied; external/webhook/token sources cannot mutate terminal state unless a capability is explicitly allowlisted; output/captured-line reads require explicit opt-in; an unlisted mutation by a local source defaults to 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>:
exited— every member’s Maxx-ownedlifecyclehas leftrunning(exited/closed/archived). Purely mechanical.declared:<state>— every member’s agent-declaredworkflow_stateequals<state>(one ofrunning/needsInput/blocked/complete/failed).idle— no member is currently declaredrunning. A member that declared any non-running state, or has not declared one, counts as idle; only an explicitrunningdeclaration is “busy”. This is defined entirely on explicit declarations — Maxx never guesses idleness from output or timing.
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=valueform (e.g.--command=+foo); the space-separated form is intercepted by Maxx’s+actionCLI 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
session_idis a stable UUID minted per session, distinct from the surface UUID, the UI title, the PID, the working directory, the branch, or the command text. Use it for all later operations.surface_idis the underlying terminal surface; exposed for correlation only.sessions.register-currentis the only adoption path for a manually opened tab: the CLI reads the current tab’sGHOSTTY_AGENT_SURFACE_IDandGHOSTTY_AGENT_REGISTRATION_TOKENenvironment variables and sends them as a proof. Supplying a guessedsurface_idwithout that per-surface proof is rejected, and the CLI exposes no--surface-idflag for registration.statusandmetadataare caller/agent-owned.lifecycleis Maxx-owned.metadatais an agent-reported map of namespaced keys → arbitrary JSON values (see Agent-reported metadata); Maxx stores, displays, and filters it verbatim and never reads a key as workflow state.workflow_stateandsummaryare agent-declared for display (set-state/set-summary): Maxx records and shows them verbatim and never infers them. They are intentionally separate from the free-formstatus, and from the Maxx-ownedlifecycle, so the UI presents them as agent-provided rather than Maxx-derived.workflow_stateis one ofrunning,needsInput,blocked,complete,failed; the response also carriesworkflow_state_at/workflow_state_sourceandsummary_at/summary_source.groupis an optional, caller-supplied group label for supervisor coordination (set-group, orcreate --group). It is an opaque token (no meaning is inferred from its text); a session belongs to at most one group at a time, and membership changes are recorded as Maxx-owned stream events.- The API only ever lists/controls sessions it owns: API-created sessions and manually opened tabs that explicitly registered themselves as the current tab. Other manually opened terminals are never reachable through it.
Limits
title≤ 256 chars,status≤ 128 chars,command≤ 4096 chars.metadata: ≤ 32 keys; keys match[A-Za-z0-9_.-]and ≤ 64 chars; each value’s serialized (compact JSON) size ≤ 2048 bytes and the whole map ≤ 16384 bytes. Values may be any JSON (string, number, bool, null, array, object). JSON integers are preserved exactly across the fullInt64range (so external ids, timestamps, and run numbers never lose precision); fractional numbers use IEEE-754 double precision.env: ≤ 256KEY=VALUEentries; keys match[A-Za-z0-9_].summary(set-summary) ≤ 1024 chars;set-stateaccepts only the fixed workflow vocabulary above.result(set-result) ≤ 16384 UTF-8 bytes. Oversized public declarations are rejected withinvalid_request; automatic hook capture truncates deliberately to stay inside the same durable bound.
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:
- Set / merge —
set-metadatasets one key (valuefor a plain string, orvalue_jsonfor a structured value);create/updateaccept a wholemetadataobject and merge it (provided keys overwrite or add). - Remove —
remove-metadatadrops the keys named bykeyand/orkeys(absent keys are ignored; at least one key must be named). - Clear —
clear-metadataremoves the entire map in one atomic step. - Read —
get/list/watchecho the full map verbatim. - Filter —
listacceptsmetadata_filter, an array of{key, value?}constraints that all must hold (AND). Akey-only constraint requires the key to be present; akey+valueconstraint requires the value to equal it, compared as a string (a bare string compares as itself; other values as their compact JSON, e.g.3,true,[1,2]).
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
waittakes exactly one of--state,--event, or--lifecycle.--stateis level-triggered: it matches the session’s current declared state (thestatusfield, also settable viaupdate/create), so it returns immediately if the session is already in that state.--eventis edge-triggered: it matches a newly emitted event, excluding entries already present when the wait begins (pass--since <seq>to anchor the baseline to a sequence you already observed).--lifecyclematches a Maxx-owned lifecycle value. The default timeout is 30s, capped at 1h. Noterestartkeeps the caller-ownedstatus, so re-declare or use an event to observe a fresh run.watchfirst emits asnapshot, then aneventline per new audit entry and alifecycleline per Maxx-owned transition, ending with a finalendline. It runs until the session reaches a terminal lifecycle, the optional--timeoutelapses, or the caller disconnects.archivecloses the surface but keeps the record (and its full audit log) retrievable viaget/list/events; lifecycle becomesarchived. It is idempotent.restartreplays the session’s recorded command (or a--commandyou supply) in a fresh surface while keeping the stablesession_id, incrementsrestart_count, and revives an archived/closed session. A session created with no command and no supplied command returnsunsupported. A restart begins a fresh run, so it clears the agent-declaredworkflow_state/summary(the new surface starts with no badge; re-declare for the new run); the free-formstatusis kept.interruptsends Ctrl-C through the tty by default;--signal <name>delivers a specific signal (SIGINT/SIGTERM/SIGKILL/SIGHUP/SIGQUIT) to the foreground process via the explicit process-control path — never by synthesizing terminal input.
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
}
schema— envelope version; bump only on an incompatible change. Pin the version you understand.cursor— global position; pass back as--sinceto resume.seq— the per-session audit sequence when the event is also in a session’s audit log; omitted for bus-only mechanical events.source_kind—maxx(a mechanical runtime fact Maxx owns) oragent(a fact an agent declared). The load-bearing ownership tag.kind—lifecyclefor Maxx-owned events;state/event/metadata/workflow-state/summaryfor agent-declared ones.resource_kind— currently alwayssession; reserved so future tab-/group- scoped events stay additive.group— the group this event pertains to (the session’s current group, or the affected group forgroup.joined/group.left); omitted when none.
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)
- A broad remote-control story. The control channel is local-first by design; any remote story must be designed explicitly with its own trust boundaries.