Skip to content

Maia CLI, model tool, and JSON reference

Status: Implemented and Source candidate commands under Commands today are verified against apps/cli/src (the command tree in apps/cli/src/command-tree/) on 2026-10-03, as maia help --json reports them. Everything else is Planned for the ROADMAP slice named beside it (S5, S12–S21, R1, R7, R8, R10a, R11, MN0–MN5, T2, RL2–RL4).

This file owns command grammar, the Maia Agent surfaces (launch flags, interactive commands, model tools, voice tool) and the R11 capability disposition. Contracts own identifiers, schemas, preview and confirmation, receipts, events and generation. Operations owns observability and recovery. The CLI README is the package overview.

The package's library entry exports makeCli and runCli, which return an Effect of the exit code (decision); the maia binary runs that Effect on one runtime per process.

Naming: container commands address the organizational Workspace; workspace commands address an execution context. R1b renames them to workspace and environment through a versioned migration; no current command changes meaning before then.

Contract rules

  1. packages/protocol owns every operation ID, schema, error, risk class, authority scope, event and receipt policy. Each CLI usage maps to one registered operation. Each command is authored once, as a leaf of a command-tree fragment in its feature's tree/ scope (for example apps/cli/src/session/tree/), with its usage, availability, operations and kind; apps/cli/src/command-tree/ holds the grammar, help and suggestions. The typed tree test checks every leaf against the inventory in apps/cli/src/testing/node-operation-registry.ts, which the compiler holds exhaustive over the protocol methods and SDK operations, and scripts/quality/registry-parity.mjs checks the tree's usages against the protocol feature descriptors (decision).
  2. CLI, SDK, MCP, voice and model tools project the same operation-owned data.
  3. Lists are bounded and cursor-paginated: --limit, --after <cursor>, and nextCursor in the result. Cursors are opaque. A list that merges Nodes gives each Node's cursor in result.nodes.
  4. Consequential mutations use exact IDs, expected revisions, a command ID (idempotency key), preview and human confirmation where the operation requires it, and a receipt. Only setup, node install and node remove take --yes. After an uncertain result, reconcile with maia receipt show <command-id> and retry with the same command ID. A consequential mutation that needs a preview is two-phase: --dry-run previews (with --input-stdin where the request is JSON on stdin), and --confirm-stdin confirms the preview piped back.
  5. Identity comes from the authenticated transport; no flag sets the actor (receipt list --actor only filters, by exact stored actor ID, D-138).
  6. Output never contains credentials, prompt bodies, hidden reasoning, raw audio or raw provider payloads. A read made to show content shows the bodies it reads: session attach, session history read, a read with --include-content, and the planned maia dashboard Session view (decision). Printed command equivalents, --plain output and receipts never carry a body. A JSON preview carries its confirmation proof for the person who confirms; human text and every other result omit it.
  7. Large or sensitive input uses bounded stdin flags (for example --input-stdin, --prompt-stdin, --body-stdin, --api-key-stdin), never arguments.
  8. Lists, search and observation follow the coordinator visibility contract: an omitted filter never widens scope, and no mutation targets all.
  9. There is no public Fleet, Workflow, Bot or Subagent command root, and no rules command (AGENTS.md loads through context; decision). Session-local tasks exist only as the Agent task tool and /task (decision).
  10. Manual CLI controls never need an LLM or Jev (recovery).

Global grammar

maia [--json] [--profile <name>] [--node <name>] [--no-input] <node-backed command> ...
maia [--json] help [<command>...]
maia [--json] <command>... (-h | --help)
maia [--json] version
maia completion <bash|zsh|fish>
maia [--json] doctor
maia agent ...

Commands are scope-first: maia <scope> [<sub-scope>] <verb> [<id>] [flags], for example maia session turn send --session <session-id> .... The ID of the resource a command is about is positional (session show <session-id>); the ID of a parent or related resource is a flag (--workspace, --run, --session, --project). The earlier paths (approve, turn, monitor, recovery, *-preview and *-confirm, and the rest) are retired with no alias (decision). An unknown or retired path prints a short Did you mean 'maia ...'? and runs nothing.

  • Global options (--json, --profile, --node, --no-input, -h/--help) may appear once each, anywhere before --; none is parsed under maia agent. Everything after -- belongs to the command, --json included. -h and --help are never an option's value.
  • --profile <name> selects an existing local Maia Node profile for that invocation only (default default). It is not a configuration or execution profile: those are --execution-profile (workspace execution set) and, for a remote Node, --remote-profile (node install, node register). version, completion, maia agent and model-provider login reject it; help ignores it.
  • --node <name> routes a Node-backed command, maia mcp included, to a remote Node the local Node registered (maia node install or maia node register), through one SSH stdio bridge per invocation. It takes one exact registered name (no prefix, no all), needs the Node ready and fails closed (node_unavailable, exit 69) without falling back to the local Node; a mutation previews and confirms against that Node. maia node install|register|list|remove rejects it, and so does a Session credential (role_denied). maia --node <name> login codex|claude runs the pinned vendor sign-in on that host over ssh -t; a registered Node has none pinned, so it refuses. --node local names this machine's Node, so no remote Node takes the name local. A name no registered Node has is a usage error (invalid_argument, exit 64) that names it. An attached command (--attached: session inbox check, session inbox ack, session message send) speaks for its Session on this machine's channel and refuses --node the same way (exit 64). context show --attached reads the attached Session's own context, so --node names no other Node for it. A parity test holds every other Node-backed command to it.
  • Without --node, the commands below address every Node (D-175): this machine's and each registered remote Node, four at a time and 12 s each, as maia node list asks them. Every other command acts on this machine's Node, as before; so does a command under a Session credential or MAIA_NODE, and every command when no remote Node is registered (or this machine's Node keeps no registry). Which Node takes an ID follows client-core's routing rule, the one Desktop uses.
    • An exact ID selects the one Node that holds it, for a read and a mutation alike:

      • a Project ID: project show, project guard show|set (--confirm-stdin by the piped preview's projectId), routine create (both phases, by the input's template.projectId), and --project on project context record|list, workspace list, routine list, session list and session create;
      • a Workspace ID: workspace show|select, workspace execution show|set|clear, workspace membership attach, and --workspace on terminal create|list and session create (session create takes one of --project and --workspace; both refuse with exit 64 before any Node is asked, with --node too);
      • a terminal ID: terminal show|read|resize|signal|close, terminal controller acquire|release, terminal input write, and --terminal on terminal events list and terminal controller request create|show;
      • a Routine ID: routine show|pause|resume|disable|run (--confirm-stdin by the piped preview's routineId);
      • a Session ID: session show|wait|rename|start|stop|resume|archive|diff|attach, session history read (a claude: or codex: ID reads this machine), session turn queue, and --session on session turn send, session events list and session approval list;
      • an approval ID: session approval show|approve|deny|answer. The decision still needs a person at an interactive terminal; the CLI reads the request on its holder and answers it there. It asks the Nodes first, so a caller that is no person meets a silent or ambiguous Node's refusal before its own;
      • a turn ID: session turn show|wait|cancel|interrupt. turn wait routes by its first turn ID, so the turns it waits for share one Node;
      • a command ID: receipt show. A command ID is the caller's own key, not a Node-made ID, so every well-formed one asks every Node; two Nodes can hold the same key, and then the command refuses as below.

      session attach follows a Session on a remote Node, routed or named with --node, over the SSH bridge's attach stream. Detaching (or the CLI's end) closes the stream and its Node socket; the Session runs on. A lost link or a stream the bridge ended as lost reattaches from the view's cursor. Replay recovery shows every item of one epoch once; any snapshot (after a Node restart, or when a replay no longer reaches back far enough) can print text again, as a local attach does. Any other failure ends the command at once with its own code (role_denied 77, resource_not_found 66). A bridge built before streams (an older Node) refuses the subscribe and exits 65; the CLI then fails with command_not_implemented (exit 64) and names session turn wait and session turn show --include-content instead. A Session credential still cannot address another Node with --node.

      The CLI asks the Nodes on every command and keeps no answer. Text output prints node: <name> (holds <id>) on stderr; JSON adds a resolved entry with "argument": "--node", the Node as id and the ID as reference. ID prefixes and names still resolve on this machine's Node, and the command acts there; with remote Nodes registered, the CLI first reads the registry, and a not-found then adds the line (JSON: hint) "Names and prefixes resolve on this machine's Node; pass --node <name> or use an exact ID." A list scoped this way reads the holder alone and keeps its own shape. project register and workspace repository attach name a path, and a path names a machine, so they act on this machine's Node unless --node names another; so do project create|rename and the Session commands not named above.

    • It never guesses: an ID two Nodes hold is node_ambiguous (exit 64), an ID no Node holds resource_not_found (exit 66), and an ID a Node that did not answer could hold is node_unavailable (exit 69, retryable), each naming the Nodes. The JSON error adds nodes, each Node's state: holds, absent or unavailable. --node settles any of them. A remote Node that is not ready gives its state, and an install_failed or remove_failed one names maia node remove <name>.

    • project list, routine list without --project, session list --all and session approval list without --session with remote Nodes merge every Node (approvals: every Node's pending requests before the resolved ones --all adds, each text entry led by its Node): each row carries its node, text output adds a NODE column, and result.nodes gives each Node's nextCursor or error. A Node that fails shows as its own error, labeled with its Node (text: error: <node>: <message>), beside the others' rows, with "ok": false, that failure as the top-level error, and its exit code. --limit bounds each Node's page. A cursor pages one Node, so there --after needs --node. A list on one Node, --node included, keeps its own shape: no labels, nextCursor at the top.

  • --no-input forbids prompts: a command that would ask a person fails with exit 64 and names its flags.
  • MAIA_PROFILE, MAIA_NODE and MAIA_NO_INPUT stand in for --profile, --node and --no-input on the commands that take them; a flag wins. With MAIA_NODE set and no --json, the command first prints node: <name> (MAIA_NODE) on stderr.
  • --timeout and --stall-after take a duration: 30s, 10m or 1h.
  • maia agent forwards every following token to the Agent unchanged.
  • Arguments outside maia agent are bounded: at most 32 tokens, 4 KiB per token, 16 KiB in total.
  • maia help prints the root commands by section; maia help <command>... and -h or --help anywhere print one command: a group lists its subcommands, a leaf its usage, typed arguments, an example and its operation. --help after a leaf's arguments still shows that leaf. Help never reaches a Node. version and --version print the version; maia version --json reports {command, name, version, protocol, commit?}, with commit only in a build made with MAIA_CLI_BUILD_COMMIT. -v is not an option. maia completion <bash|zsh|fish> prints a shell completion script.
  • Every resource argument accepts a name or an ID prefix as well as an ID (References and defaults). A list returns 20 items per page unless its usage shows another range.
  • Inside a Codex or Claude Code Session the Node launched, MAIA_SESSION_CREDENTIAL is that Session's own credential and MAIA_NODE_ORIGIN the Node's exact loopback origin. With MAIA_SESSION_CREDENTIAL set, every Node-backed command, maia mcp included, acts as that Session within its role (worker or orchestrator): it never reads the operator's profile, descriptor or bearer, and --profile changes nothing. A missing or invalid MAIA_NODE_ORIGIN fails the command with configuration_invalid (exit 70), with no fallback. An operation outside the role, and any --attached operation, fails with role_denied (exit 77).

JSON output and exit codes

With --json, success writes one line to stdout. result.command names the command (project.register, turn.wait, ...); the rest is that command's projection, usually the operation result under result.result:

{
  "ok": true,
  "result": { "command": "version", "name": "maia", "version": "<version>" }
}

A failure writes one line to stderr; recovery appears when a mutation's outcome is unknown, and a usage error (exit 64) adds the nearest command's usage lines and a hint with an example and the help to run. (maia doctor is the exception: it always writes its check result to stdout, a failed one included.) The exit code carries a command's outcome: maia doctor with a failed check, or session turn wait with a turn that failed or did not settle, prints "ok": true with the result (result.result.ok, or each turn's state and satisfied) and exits 70, 74 or 76. A session create task batch with a refused or unknown item prints "ok": false with the same result (every item, a Session that failed to start with its error.failure code and message) on stdout and exits 70 or 76. Its top-level error is the first failed item's: the failure code and message when the Node gave one, else the stopped step's code. A merged list with a failed Node does the same with that Node's error, so error.code is set whenever ok is false.

{
  "ok": false,
  "error": {
    "code": "outcome_unknown",
    "message": "Local request outcome unknown.",
    "retryable": false
  },
  "recovery": { "commandId": "command_01..." }
}

When the CLI resolved a reference or a default for the command (see References and defaults), the success line also carries resolved: one entry per argument with its argument (the option or positional placeholder), exact id, kind, label when the resource has a name or title, match (name, prefix, path for the current folder or selection for the selected Workspace) and the typed reference, absent for a default.

{
  "ok": true,
  "resolved": [
    {
      "argument": "--project",
      "id": "project_01...",
      "kind": "project",
      "label": "maiaos",
      "match": "path"
    }
  ],
  "result": { "command": "session.list", "result": { "items": [] } }
}

Error codes are lowercase protocol codes (invalid_argument, resource_not_found, saturated, ...). Without --json, errors print maia: <message>, then the usage and hint of a usage error or, for an unknown outcome, the maia receipt show command to run.

The CLI owns two codes of its own, for what it decides before or instead of a Node (no protocol operation returns them):

CodeExitMeaning
node_unavailable69No Node answered: the local Node is not running, a remote Node is not ready or its bridge failed, or a Node that could hold an exact ID did not answer
node_ambiguous64Two Nodes hold an exact ID; --node <name> chooses one
ExitMeaning
0success
64usage error: invalid argument, unknown command or an ID on two Nodes
66resource not found
69Maia Node unavailable, an unsupported Node.js runtime, or no terminal
70other failure, a waited turn that failed, or a failed doctor check
74deadline exceeded, or a session wait or session turn wait timed out
75temporary: saturated, held, a full guard or retryable; try again later
76outcome unknown: reconcile by command ID
77authentication failed, forbidden context override or role_denied
130interrupted by a signal; nothing more is printed

session wait exits 74 when its timeout ends before the Session reaches the state. session turn wait exits 70 when a turn it waited for failed, 74 at the deadline and 76 when a turn's outcome is unknown. maia doctor exits 70 when a check fails and names the next command beside each failure.

maia agent exits 2 on any error it reports itself, and 70 when the Agent payload cannot load.

Agent contract

LLM agents and scripts can rely on these; human text may change, they may not. maia help --json returns the same rules as its contract block, with every command in commands.

  • Pass --json and read result; failures are one JSON line on stderr with the exit codes above.
  • Discover commands with maia help --json: every group and leaf with its summary, kind, availability, usage, example and operations. A command's own --help --json (for example maia session start --help --json) adds typed arguments: type, choices, integer range, required and repeatable. In a usage, <x> is a value, [x] optional, (a | b) one of, <a|b> a choice, <1..100> an integer range and x... repeatable.
  • The CLI prompts only when stdin and stderr are terminals and neither --json nor --no-input (MAIA_NO_INPUT=1) is present; otherwise a command that needs a person fails with exit 64 and names its flags.
  • A two-phase command previews with --dry-run (or --input-stdin) and confirms that preview from stdin with --confirm-stdin, for example maia --json hold place --input-stdin --dry-run < in.json | maia hold place --confirm-stdin.
  • Durations are 30s, 10m or 1h.
  • A mutation that guards a revision takes the current --expected-revision (or a named --expected-*-revision). Pass a --command-id you choose: after exit 76, run maia receipt show <command-id>, then retry with the same command ID, and the Node replays the first result. The CLI itself retries a saturated answer whose saturation is admission (the Node's request slots were full) four times with short jittered backoff and the same request body before it exits 75; an account refusal (an account's Session slots or vendor limit) exits 75 at once.
  • Lists take --limit and --after <cursor>; pass nextCursor as --after, and with a merged list's result.nodes entry, its node as --node.
  • An explicit flag wins over its environment variable (MAIA_PROFILE, MAIA_NODE, MAIA_NO_INPUT), which wins over the default.
  • A resource argument takes a full ID, a unique ID prefix or a unique name or Session title; an ambiguous one exits 64 listing the candidates, and resolved says what the CLI resolved and how (next section).

References and defaults

Every positional reference and scope option a command's usage names as a Session, Project, Workspace, Connection or Container (<session-id>, --project <project-id>, <connection>, ...) takes:

  • a full ID (session_ and 32 hexadecimal digits), sent as typed with no lookup, so a Session's own credential, which cannot make one, still works;
  • a unique ID prefix of at least 4 hexadecimal digits, with or without the tag (3fa9, session_3fa9);
  • or a unique name: a Project, Workspace, Container or Connection name, or a Session title. A name that matches no resource exactly may still match one that differs only in letter case.

Before the command runs, the CLI asks the Node (reference.resolve, one read per distinct reference, at most 32) and sends the exact ID it returns, also for --name=value, so a mutation binds the resource the person named and a two-phase preview shows that ID. A compare-and-set guard (--expected-*) is never resolved: it takes the ID an earlier read returned. A Project resolves first, then a default scope, and a Session or Workspace then resolves inside that Project. More than one match exits 64 and lists up to eight candidates with their names; no match exits 66 and the message names the kind; an answer of another kind exits 70. The CLI never guesses. Option errors still exit 64 before any request.

A command that requires --project or --workspace and gets neither uses, in order:

  1. the registered Workspace checkout or Project repository containing the current folder (the CLI sends the folder's path; the Node maps it, through symbolic links, and the CLI never reads Git);
  2. the selected Workspace (maia workspace select), or its Project;
  3. otherwise it exits 64 and names the flags to pass.

An attached command (--attached) speaks for its Session through this machine's invocation channel and takes exact IDs as typed.

The same order applies to a mutation (session create without --workspace creates in the folder's Workspace); the CLI says which default it used on stderr and in resolved, so pass the flag to choose another. The default option goes before any --.

When the folder's Project has several Workspaces and none is selected, a command that also takes --project (session create) falls back to the Project; another lists the candidates. With --node, the CLI sends no folder: the remote Node uses its selected Workspace. In human output each resolution prints one line on stderr, for example --project: project_01... (maiaos) from the current folder.

Commands today

maia help --json lists every command with each usage's availability (implemented or source_implemented_candidate), and a command's --help --json describes it alone. A source candidate is callable from a source build but release-disabled. Below, … stands for maia [--json] [--profile <name>].

Implemented

… status
… node status
… node monitor
… project register <path> [--name <name>] [--command-id <id>]
… project list [--limit <1..100>] [--after <cursor>]
… project show <project-id>
… project context record --project <project-id> --body-stdin [--command-id <id>]
… project context list --project <project-id> [--limit <1..100>] [--after <cursor>]
… project context show <note-id>
… workspace list --project <project-id> [--limit <1..100>] [--after <cursor>]
… workspace show <workspace-id>
… workspace repository attach <path> --workspace <workspace-id> --expected-revision <revision> [--name <name>] [--command-id <id>]
… session create (--workspace <workspace-id> | --project <project-id>) [--command-id <id>] [--role <worker|orchestrator>] [--connection <connection-id> [--executor <codex-cli|claude-code>] [--model <id>] | --executor <codex-cli|claude-code> | --agent <agent-id> | --agent-definition <key> --agent-definition-version <version> --agent-definition-digest <sha256>] [--effort <low|medium|high|xhigh|max|ultra>] [--base <branch-tag-or-commit> | --host-authority [--permission <mode>]] [--cwd <path>] [--title <text> | --name <text>] [--label <key=value>...] [--message <text> | --prompt-stdin | --from-file <path>]
… session list (--project <project-id> | --all) [--state <state>[,<state>...]] [--label <key=value>...] [--attention] [--settled [--idle-for <duration>]] [--limit <1..100>] [--after <cursor>]
… session show <session-id>
… session events list --session <session-id> [--limit <1..32>] [--after <session-cursor>]
… session observe [<session-id>...] [--stall-after <duration>]
… receipt list [--limit <1..32>] [--after <cursor>] [--actor <actor-id>] [--method <method>] [--since <recorded-at>] [--until <recorded-at>]
… receipt show <command-id>
… events read [--kind <kind>]... [--since <cursor>] [--subject <type>:<id>] [--limit <1..100>]

Plus help, version, completion, doctor and the Agent launch grammar.

  • session events list and terminal events list (for an unavailable terminal; a live terminal reports an evicted range as a gap event) add oldestRetainedCursor to their JSON when a prune removed events after --after (D-191) and lead the text with earlier history pruned; oldest retained cursor N. receipt show adds eventsPruned and, in text, an events-pruned column.
  • events read (source candidate) prints one page of the Node's public events after --since; see events.
  • node status reports Node readiness with storage and process health; --json returns a compact projection, including admissionCaps with maximumActiveSessions and defaultAccountSessions. Set MAIA_NODE_MAX_ACTIVE_SESSIONS (default 8) and MAIA_NODE_DEFAULT_ACCOUNT_SESSIONS (default 2) in the Node environment before startup; each must be an integer in 1..64, otherwise startup fails with configuration_invalid. The account default applies to new Connections only; explicit account limits and existing records keep their values. Restarting with a lower Node cap stops no Session and admits no new one until the count falls below it. maia status is a shortcut for it. Session create, start and resume also use a startup-configured host headroom gate (Source candidate), shared by scheduled starts. It defaults to MAIA_NODE_HEADROOM_ENABLED=true, MAIA_NODE_MAX_LOAD_PER_CPU=1.0, MAIA_NODE_MIN_MEMORY_AVAILABLE_PERCENT=10 and MAIA_NODE_MIN_DISK_FREE_PERCENT=10. Equality passes; load is the one-minute host load divided by logical CPU count, memory is Linux MemAvailable (macOS: memory available to the Node process), and disk is available blocks on the Node's state volume. Thresholds must be finite and nonnegative; percentages cannot exceed 100. Invalid values fail config load. Set the enable switch to false before startup to disable this gate. A refusal is retryable saturated with cpu|memory|disk and measured/required values; unavailable measurements fail closed. Receipt replay preserves its result. See Node admission settings. When the Node reports journal retention, the human output adds an events: line (bytes over the limit, and checkpoint blocked when a reader holds the log) and the compact projection carries storage.events. On Linux, a Node outside a delegated cgroup reports process resources as cgroup_delegation_unavailable with next step run_node_in_delegated_cgroup, because every launch would fail closed. A Linux Node on a kernel below Landlock ABI 6 (Linux 6.12) reports landlock_scoped_unavailable with next step upgrade_kernel for the same reason. A failed probe reports it too. A Linux Node also lists what the vendors' own sandboxes need from its host, one line each with the owner's fix when it is missing: bubblewrap and socat (found or missing on the Node's PATH) and user namespaces (allowed, no_nesting or restricted); --json carries them as executorHost (prerequisites).
  • node monitor prints a loopback monitor URL carrying a short-lived bootstrap token.
  • doctor is a read-only check of this installation, the Node and the worker accounts. Before the product CLI loads it checks the bootstrap, the exact Node.js runtime, the CLI payload and the Agent payload; a failed one reports next: reinstall Maia. Each check carries a scope: machine (the bootstrap, runtime, payload and CLI checks, labeled "(this machine)" in text) or node. doctor --node <name> runs the node checks on that Node and adds node to the result. A sound installation then checks the CLI, the Node, and for each installed vendor CLI its account and approved executable, for Codex the Node's worker prompt check (codex-cli.worker-prompt from connection detect: a failure reads skills.* keys no longer honored by Codex <version> with next: update Maia, and an inconclusive check passes with probe inconclusive), and that a worker is ready. Each failed check names the next command to run (maia setup, maia login codex, maia integration plan codex, ...). It reads no Project configuration, loads no extension, calls no model and repairs nothing. A failed check exits 70, and --json still writes the checks to stdout.
  • completion <bash|zsh|fish> prints a shell completion script generated from the command tree.
  • version --json reports {command, name, version, protocol, commit?}.
  • project context stores user-authored notes that outlive Sessions (coordinator).
  • session create with --message <text>, --prompt-stdin or --from-file <path> creates, starts and sends the first turn of each Session through the SDK's createSessions: one Session for the message or standard input, one per non-empty line of the file (at most 32 tasks of at most 16 KiB each; the file at most 524,352 bytes). A refused line sends nothing and exits 64. The batch runs in the named Workspace, or with --project in the Project's first ready Workspace, its main checkout first. Text output for one task prints labeled lines: stage (sent, or started without a task, refused, unknown), session, turn, error (<operation>:<code>) and, for an unknown task only, the command ID to reconcile with receipt show; a line with no value is left out. Several tasks print a table with the task's line number, the same columns and - for none. Receipt IDs are in JSON only. JSON returns session.create.tasks with items and workspaceId; each item carries the Node's receipts by step (create, start, send). A failed session.create, session.start or turn.send error carries its commandId, refused or unknown; only an unknown item also carries recovery.commandId to reconcile with receipt show. It exits 0 when every item settled, 76 when one is unknown and 70 otherwise. The CLI runs a batch once: running the command again starts a new batch with new Sessions, so reconcile an unknown item instead. Only an SDK caller that keeps the outcomes retries in place. --command-id names the one Session of --message or --prompt-stdin (its start and first turn take <id>.start and <id>.send) and is refused with --from-file, as are --title, --name and --label (each Session of a file takes its task's first line). The Agent definition flags do not apply.
  • session create --label <key>=<value> (repeatable) puts up to 8 labels on a new Session, such as owner=ci-bot, lane=e1 or goal=fix-build. A key is a lower-case name of up to 32 characters (a-z, digits, _, -; it starts with a letter); a value is 1 to 64 printable ASCII characters without spaces or commas. Labels are set at creation, are part of the command's identity (a retry with the same command ID must repeat them), stay with the Session across Node restarts, and show in every list: never put a secret in one. JSON session list and session show carry labels (absent when a Session has none); the text list adds a LABELS column of key=value pairs (- when none) and a LAST ACTIVE column, which is the Session's updatedAt (when its record last changed, not when its last turn ran); human session show prints labels and updated too. A prompted create (--message, --prompt-stdin) keeps its labels.
  • session list --label <key>=<value> (repeatable) keeps the Sessions that carry every given label, with the exact value, before the page is cut. The SDK takes the same labels object on session.list.
  • One manager per Project and owner (D-201): session create --role orchestrator --label owner=<name> (also with --host-authority) and session adopt --role orchestrator --label owner=<name> create a Project's manager. A top-level orchestrator needs the owner label. While another top-level orchestrator of the same Project and owner is not archived, the Node refuses with manager_exists (exit 70, not retryable) and names that Session; reattach to it, resume it or archive it first. A stopped manager keeps its slot; the slot frees when session archive finishes, and an archived Session cannot start or resume. A manager's default title is <project> · <owner>; --title (alias --name) on create or adopt wins. session create --host-authority takes no role except orchestrator with an owner label. A manager created without --title or --name is titled <project name> · <owner>. An orchestrator child is not a manager. The guarantee holds per Node database, and owner is a label the caller asserts, not a login.
  • session list --all lists every Session of the Node in creation order; --state keeps the named states and --attention the Sessions that need a person. JSON carries the Node's attention value of each listed Session that has one; text output and session observe print it as a column (- when none), and text output adds the title.
  • A list command's text output is a plain table: a header row, then one row per item with columns aligned by two spaces and no borders, then next: --after <cursor> when another page follows (a merged list prints next: --node <name> --after <cursor> per Node). Approval, question, terminal, model and inbox reads keep their own text; scripts use --json. session create, session show, session start, session stop, session resume, session turn send, cancel, interrupt, show and wait, project show and project register print one object as aligned label: value lines instead, leaving out a field with no value.
  • session create --agent <agent-id> starts the Session from an Agent definition at its current revision and fixes the harness, Connection, model, effort and instruction, so --connection, --executor, --model and --effort are refused with exit 64. --base still applies. The Session keeps the Agent ID and revision it started from.
  • session create options --connection, --executor and the Agent definition flags are mutually exclusive, except that a gateway --connection takes --executor (required) and --model (optional, an id from connection models list).
    • --connection binds one account, by name or ID; --executor lets the Node pick the least-used account that can take the Session (rules, decision). The binding is final. When no account fits, the CLI exits 75 (saturated: every account busy, limited or used up), 70 (configuration_invalid: every account disabled or signed out) or 66 (resource_not_found: no account of that kind).
    • The three Agent definition flags are all or none. Key and version are exact string tokens; the digest is sha256: plus 64 lowercase hex characters. They bind metadata only, never activation or provider authority; the Node rejects unknown references.
  • session create --effort <level> sets the reasoning effort of a vendor executor Session and needs --connection or --executor. Claude Code takes low, medium, high, xhigh and max; Codex also takes ultra (a model may support fewer). The level reaches the vendor unchanged, and an unsupported level is refused with exit 64 or 70, never mapped to another. A Session without it keeps the vendor default. Maia agent Sessions take no effort.
  • On a Linux Node, session create (and so a Routine fire or an Agent) refuses, exit 70 before it records anything, an executor whose own sandbox the host cannot run: host_bubblewrap_missing, host_socat_missing or host_user_namespaces_restricted, with a message that names the package or the sysctl and profile the owner changes. Codex needs them at any access; Claude Code only with --base (prerequisites).
  • session create --host-authority (D-198, pending owner) opts one Codex or Claude Code Session out of the Maia sandbox. The harness runs in the Workspace's checkout (or --cwd) as the Node's user, with that user's network, git push, gh, installs and vendor configuration. Host admission skips the Linux sandbox prerequisites above, since no vendor sandbox runs. --permission <mode> passes the vendor's own permission mode or approval policy unchanged (the values are SESSION_PERMISSION_MODES in the protocol). Without it Claude Code uses its own configured default, and Codex uses the fixed approval policy on-request, which overrides the user's Codex configuration. Maia answers every command-approval request Codex emits with a decline, and commands that need no approval still run with host authority. --permission without --host-authority is refused. The flag needs --connection or --executor and refuses --base and --agent; --role orchestrator records the role and the manager slot (D-201) but grants no credential, since the Session runs outside the Maia sandbox. The Node's owner must start it with MAIA_HOST_AUTHORITY=enabled: otherwise create, start and resume of such a Session fail with host_authority_disabled, and node status shows host authority: disabled. A Session credential or an external coordinator cannot request it (role_denied). session show, session list (a CONTAINMENT column) and --json report containment: none for it and - for a sandboxed Session; session show and --json also report the stored permission mode (such as bypassPermissions). It runs on the user's own vendor configuration, so it binds a system-default account: the Node picks one granted to the Project, and a named Maia-managed account (or a gateway) is refused at create. Claude Code and Codex host authority are source candidates. Codex accepts the policies untrusted, on-request and never.
  • session create --role sets what the Session's own credential reaches: worker (the default) or orchestrator (Session roles and Project guards).
  • session observe reads live runtime state for every running Session or for 1–32 named IDs: runtime (codex-cli, claude-code, pi), running turn and elapsed time, native activity (record count, last record kind, idle time, declined vendor requests) and process usage (CPU time, RSS, process count; an unmeasurable value is absent, never zero). Health is idle between turns, working while records arrive, and stalled after --stall-after of silence (10s to 24h, default 3m). It never returns prompt or model text. Durable Session truth stays in session show and session events list. A Session that needs the orchestrator carries one attention value, most urgent first: failed, approval_pending, question (an unanswered recorded question), turn_failed (a running Session whose latest turn failed or ended with an unknown outcome, until a later turn is sent or the Session stops; a turn a stop ended raises nothing), stalled or settled_unreviewed (a Run ledger dispatch whose turns ended and nobody settled); a requested Session without a live runtime still reports it.

Source candidates (release-disabled)

Remote Nodes

The remote Nodes this Node installed or registered over SSH (S14, decision, operations). node install previews (probe and plan), asks for confirmation and waits until the Node is ready (--node-runtime defaults to the Node.js running this command when that is linux-x64 24.22.1; otherwise the flag is required); node register records a Node already running on the host: it greets that Node over the bridge --node uses, through the launcher --launcher names or install's layout under the remote HOME, previews what it records and records it ready once confirmed (decision); node list adds the reachability this CLI observes now, for every ready host at once (its text STATE shows unreachable or stale for a ready host that did not answer as recorded, never ready): the release, lifecycle, pressure and tree use (used/capacity) the Node greeted with, its pid and latency, or the reason the host failed, and one failing host never fails the others (decision); node remove uninstalls, and --purge also deletes the profile's state; for a registered Node it forgets the record and leaves the Node running. SSH never accepts an unknown or changed host key for the destination: trust it once with ssh <host> first. A failing host shows one fixed reason, never what ssh printed, and a failed install step shows no raw command diagnostic from the host (validated facts such as paths still appear, and login shows its own ssh -t session) (limits). install, register and remove are two-phase: at a terminal they preview and ask y/N, --yes skips the question for a script (install and remove only), --dry-run only previews, and --confirm-stdin confirms a preview piped back. --json prints one lossless result.

… node install <host> --release <maia-node.release.json> [--node-runtime <node>] [--name <name>] [--root <dir>] [--remote-profile <name>] [--keep <2..8>] [--codex-bin <path>] [--claude-bin <path>] [--command-id <id>] [--dry-run | --confirm-stdin | --yes]
… node list [--all]
… node register <host> [--launcher <path>] [--name <name>] [--remote-profile <name>] [--command-id <id>] [--dry-run | --confirm-stdin]
… node remove <node> [--purge] [--command-id <id>] [--dry-run | --confirm-stdin | --yes]

First-owner bootstrap

Creates the first User, local Device, CLI client, team Workspace and owner membership of an empty local Node (specification). It is local only: it takes --profile but refuses --node, and it has no --yes, --dry-run or stdin confirmation, because only a person at the controlling terminal can confirm.

… node bootstrap --owner-name <display-name> --workspace-name <name> [--command-id <id>]
  • The command starts an empty maia-node for the profile (found on PATH) with a private bootstrap channel, previews, prints every identity the apply creates, then asks for YES <preview-id> on /dev/tty (POSIX only). Standard input is never read. A missing or redirected terminal exits 64 before a Node starts; any other answer exits 1 with Cancelled; nothing changed.
  • A Node already running for the profile, or one that already has an owner, refuses with bootstrap_channel_invalid or bootstrap_already_completed and exits 70. Bootstrap is never repeated.
  • --json prints one line, {"ok":true,"result":{"command":"team-workspace.bootstrap.apply","result":<apply result>}}. The apply result carries the created IDs, role: "owner", delivery (new or duplicate) and the receipt; it carries no proof, credential or path. Text output prints the same fields as tab-separated rows.
  • After an unknown outcome the CLI reads receipt show <command-id> on the same profile and returns the stored result. It sends one identical retry only when no receipt exists. An owned Node without the receipt exits 70 with bootstrap_integrity_failed. Any inconclusive retry (saturated, node_unavailable, deadline_exceeded, a transport failure or an unrecognised code) keeps the unknown outcome: exit 76 with the recovery command, because the first apply may still be running or may have committed. Only a success, a replay or a typed bootstrap refusal (bootstrap_already_completed, bootstrap_authority_unavailable, bootstrap_binding_mismatch, bootstrap_confirmation_required, bootstrap_preview_expired) replaces it.
  • The command stops its Node when it ends. When the outcome stays unknown, the error names the profile and the Node to start before reading the receipt: maia-node --desktop --profile <name>, then maia --profile <name> receipt show <command-id>. With --json the failure carries recovery: {"commandId":"<id>","profile":"<name>"}.

Login, Connections, and models

Worker accounts, model providers and approved vendor executables (v1 vendor executors, S2a; decision). The model catalog itself is planned with R10 (Providers).

… login
… login <claude|codex> [--name <name>] [--device-code] [--max-sessions <1..64>]
… login <model-provider> [--api-key-stdin]
… login gateway --name <name> --base-url <url> --api-key-stdin [--max-sessions <1..64>] [--command-id <id>]
… connection create --name <name> --executor <codex-cli|claude-code> --root <maia-managed|system-default> [--max-sessions <1..64>] [--command-id <id>]
… connection list [--limit <1..100>] [--after <cursor>]
… connection show <connection>
… connection detect [--refresh]
… connection update-policy set <claude-code|codex-cli> <off|notify> [--command-id <id>] [--dry-run | --confirm-stdin | --yes]
… connection login start <connection> [--device-code] [--command-id <id>]
… connection usage show <connection> [--refresh]
… connection models list --connection <connection>
… connection capacity set <connection> --max-sessions <1..64> --expected-revision <revision> [--command-id <id>]
… connection enable <connection> --expected-revision <revision> [--command-id <id>]
… connection disable <connection> --expected-revision <revision> [--command-id <id>]
… connection grant list [--connection <connection>] [--project <project-id> | --container <container-id>] [--state <active|revoked>] [--limit <1..100>] [--after <cursor>]
… connection grant set <connection> (--project <project-id> | --container <container-id> | --confirm-stdin) [--revoke] [--command-id <id>] [--dry-run]
… connection pool list [--project <project-id> | --container <container-id>] [--limit <1..100>] [--after <cursor>]
… connection pool set (--project <project-id> | --container <container-id> | --confirm-stdin) --mode <off|rotate|single> [<connection>...] [--command-id <id>] [--dry-run]
… setup [--max-sessions <1..64>] [--command-id <id>] [--dry-run | --confirm-stdin | --yes]
… integration plan <codex|claude-code>
… integration status <codex|claude-code>
… integration apply <codex|claude-code> --plan <plan-id> [--command-id <id>]
… integration remove <codex|claude-code> [--command-id <id>]
… provider pool list
  • maia login is the one sign-in (decision; credential rules in Providers; an example follows the sign-in bullets). At a terminal it lists worker accounts and model providers and offers a menu; without a terminal or with --json it prints the list and changes nothing.

  • login claude|codex proposes the next free name (codex-1, codex-2, ...), creates the Maia-managed account, runs the vendor's own sign-in for its private root and shows its usage; an existing name signs in again. Without a terminal, login codex needs --name and returns a device code. Claude Code signs in only from a terminal.

  • login <model-provider> runs Pi's sign-in for a Maia Agent model provider (for example vercel-ai-gateway, anthropic, openai-codex). --api-key-stdin reads the key from stdin and never replaces a stored subscription. maia agent login opens an interactive Agent without a session file for Pi's own /login.

  • Headless sign-in: a shell is headless over SSH (SSH_CONNECTION, SSH_CLIENT or SSH_TTY set) or, on Linux and BSD, without DISPLAY or WAYLAND_DISPLAY. Codex then uses its device code (--device-code forces it); Claude Code prints a link and waits for the pasted code.

  • connection usage show shows the last usage snapshot: plan, 5-hour and weekly windows (percent used, reset time) and reset credits. --refresh reads the vendor now. connection login start asks the Node to start a Codex sign-in, for a Node on another machine; to sign in from this terminal use login <claude|codex> --name <name>. connection capacity set changes an account's --max-sessions.

  • login gateway stores a gateway Connection: the base URL (https://, or http:// to a loopback host, without credentials, query or trailing slash) and the API key read from stdin. The key never appears in output; leave /v1 out of the URL. After a failure that names a command id, rerun with --command-id <id> to finish the same gateway. connection models list lists the model ids the gateway serves. A gateway has no usage or sign-in; connection disable removes it reversibly.

  • connection grant set offers a Connection to one Project or to one Workspace (--container, the organizational Workspace), or takes the offer back with --revoke; a Session binds only a granted account, and a refusal names this command. connection pool set names the accounts a scope's Sessions use: --mode single with one account, --mode rotate with one or more (at most 32, each already granted to that scope), --mode off with none to defer to the next scope. Both previews show the current and the proposed state; a person confirms at the prompt, or pipe --json --dry-run into --confirm-stdin (decision). A confirm that loses its answer names the --command-id to reconcile with receipt show.

  • Sign-in example:

    maia connection detect         # the installed claude and codex as the Node sees them
    maia setup [--max-sessions 4]  # use them as they are: system-default accounts
    maia login                     # list worker accounts and model providers
    maia login claude              # add or re-sign a Claude Code account
    maia login codex [--name codex-2] [--device-code]
    maia login <model-provider>    # a model provider from Pi's catalog
    maia connection usage show codex-1 [--refresh]
    printf %s "$GATEWAY_API_KEY" | maia login gateway --name <name> \
      --base-url <url> --api-key-stdin
    maia connection models list --connection <name>
    maia session create --workspace <workspace-id> --connection <name> \
      --executor claude-code --model <model-id>
    

    Maia never reads or stores a vendor account's credential: login claude and login codex run the vendor's own sign-in for a Maia-managed account root. Model providers store one credential per provider in Maia's auth.json. A person approves vendor executables with integration plan and integration apply --plan <plan-id>.

  • connection detect (MCP connection_detect) shows what the Node sees of the installed Claude Code and Codex CLIs: path, version, whether its own PATH finds them, the sign-in kind (vendor-login with the vendor's method, custom-endpoint with the endpoint host and whether a helper or a key variable supplies the credential, none or unknown), the system-default account and whether the executable is approved. It reads no credential and shows no account identity (decision). For Codex, --json (and maia doctor) also reports workerPrompt: the Node runs codex debug prompt-input twice (no model call, network or sign-in), each in its own temporary HOME and CODEX_HOME holding one sentinel Skill, once plain and once with the keys every Codex worker launches with. ok when the plain render shows the sentinel, the Skills instructions and the multi-agent role and the worker render exits 0 with none of them; failed when the worker render still shows one or exits non-zero (a Codex release that renamed or rejects the keys); inconclusive when the plain render fails or lacks one, a render times out, or a signal ended the worker render (an exit of 128 or more), which says nothing about the keys (decision). connection detect runs the renders; setup does not, since its preview and confirmation never read the result.

  • connection detect also reports, per installed CLI, how current it is (decision): whether the approved build launches (yes when the installed build is the approved one, else unknown), the newest version the registry last named, the update policy and a proposal. --refresh first asks the public npm registry (bounded, advisory, a proxy for the vendor's own channel); a CLI the registry does not answer for keeps its last answer, and stderr names it. The Node holds the answer in memory, so a restart clears it until the next --refresh. A proposal is a command a person runs: the vendor's update command (claude update, codex update) once a newer version exists, or maia integration plan <vendor> when a newer build is installed but not approved. Maia runs no vendor updater. The CLI asks for these facts (upkeep: true); a client that does not ask gets the result it always got.

  • connection update-policy set <claude-code|codex-cli> <off|notify> previews the change, asks (or takes --yes) and records it with a receipt naming the authenticated actor. notify is the default; off keeps the facts and drops the proposal. It needs the CLI's executable approved. A manual integration apply keeps it and integration remove ends it. Setting the policy a CLI already has changes nothing. auto is Planned.

  • setup uses them as they are: it previews, for each usable CLI, the system-default account it creates (claude or codex, else claude-system or codex-system; --max-sessions sets its cap, default 2) and the executable it approves, asks (or takes --yes), then confirms that exact preview. --dry-run only previews and --confirm-stdin confirms a preview piped back; the Node's preview repeats the --max-sessions it was given, so the piped confirmation carries it. Accounts and approvals that exist show as such, with their Session cap; an existing account keeps that cap when --max-sessions differs, and a note (notes in --json) names connection capacity set to change it; a skipped CLI says why. --json prints { preview, result } and needs --yes; with --json, connection detect and setup mark each binary path pathScope: "node-local", a path on the Node's machine. When no installed CLI is usable, setup fails with resource_not_found (exit 66) and names each CLI's reason.

  • integration plan <codex|claude-code> records the exact executable path, identity and SHA-256 that integration apply <vendor> --plan <plan-id> makes active. A changed binary fails closed until planned and applied again. For Claude Code, plan approves a Maia-owned copy of the resolved claude binary. remove also retires an authorization fenced as cleanup_unknown; integration status then names that step (after a crash).

  • provider pool list shows configured provider pools without credentials.

Sessions, turns and the coordinator MCP server

Vendor executor Sessions (S3, S4a, S4c, S6), coding Sessions (S5) and the live item stream (S12b).

… session rename <session-id> --title <text> --expected-revision <revision> [--command-id <id>]
… session diff <session-id> [--stat]
… session archive <session-id> --expected-revision <revision> [--summary <text>] [--transcript <path>] [--command-id <id>]
… session attach <session-id> [--after <epoch>:<sequence>] [--no-follow]
… session start <session-id> --expected-revision <revision> [--command-id <id>] [--wait [--timeout <duration>]]
… session stop <session-id> --expected-revision <revision> [--command-id <id>]
… session resume <session-id> --expected-revision <revision> [--command-id <id>]
… session wait <session-id> [--until <state>[,<state>...]] [--activity <activity>[,<activity>...]] [--timeout <duration>]
… session turn send --session <session-id> --prompt-stdin --expected-revision <revision> [--wait] [--command-id <id>]
… session turn show (<turn-id> | --session <session-id> --latest) [--include-content]
… session turn wait <turn-id>... [--any] [--timeout <duration>]
… session turn queue <session-id>
… session turn cancel <turn-id> --expected-turn-revision <revision> [--command-id <id>]
… session turn interrupt <turn-id> --expected-session-revision <revision> [--command-id <id>]
… session agent-permission set (--input-stdin --dry-run | --confirm-stdin)
… mcp [--worker]
  • A Session has a title: session create --title (or its alias --name; both together exit 64), else the first line of its first turn (whitespace collapsed, at most 80 characters with an ellipsis). session rename changes it at the Session's revision with a receipt and a session.renamed event. Titles need not be unique; a title that names one Session works as a reference, and session list shows it.
  • Every Session mutation takes the Session's revision, session turn interrupt included (as --expected-session-revision): session show returns it, and so does session turn show as sessionRevision for the Session's newest turn. A stale revision fails with exit 75, and the message names the current one.
  • A Session holds at most 8 pending turns, the running one included. session turn send queues by default and dispatches in send order; the 9th fails with turn_queue_full (exit 70, not retryable). session turn queue lists the running turn and the queued ones in dispatch order as a table (POSITION STATE TURN REVISION INPUT); a queued entry shows its position and a one-line input preview, and an empty queue prints a message. session turn cancel ends a queued turn at the turn's own revision (from queue); a turn that already left the queue fails with turn_not_queued, so use session turn interrupt for it. A cancelled turn reads failed with reason turn_rejected.
  • A script that drives a Session sends and waits in one step: session turn send --session <ref> --prompt-stdin --expected-revision <revision> --wait. The Node compares the revision, so a change in between fails with exit 75 and the message names the revision to retry at; retry with the same --command-id so the Node replays the receipt instead of sending twice. --wait then waits until the turn's outcome, at most 24h (the turn wait maximum; exit 74 after that), and exits as session turn wait does (0 settled, 70 failed, 74 not settled, 76 outcome unknown); the JSON result adds wait, the turn.wait result. Without --wait the command returns once the turn is queued. session wait waits for a Session state, not for a turn: use session turn wait <turn-id> (or --wait) to know a turn is done. A terminal ID or an unknown Session ID fails with resource_not_found (exit 66). To read the last result without keeping the turn ID, session turn show --session <ref> --latest --include-content shows the Session's newest turn that ran: running or settled, never a queued turn or one that failed before it ran (cancelled, rejected by the runtime or cut by a stop), so such a follow-up leaves the previous result in place (its latestTurnId, on session show and session list). A Session with no such turn gives an empty result (no turn field) and exit 0. latestTurnId is an optional field added to Session responses: a reader that rejects unknown fields must ignore it or update.
  • Steering a running turn is Planned (S26b, D-144).
  • Stop, resume and wait semantics are in vendor executors. CLI specifics: session start --wait then polls until the Session runs or stops, by default at most two minutes (--timeout <duration>, 1s to 1h, needs --wait), and prints its state: exit 0 when running, 70 when failed, stopping or stopped, 74 at the deadline. session stop waits at most 3 seconds for runtime teardown; session resume exits 75 while the previous runtime is still stopping; session turn wait defaults to --timeout 10m (1s to 24h), exits 0 when the turns settled, 70 when a waited turn failed, 74 when the turns have not settled and 76 when a turn's outcome is unknown, and never returns turn text (session turn show --include-content does). session list and session show (also with --json) report each Session's status (what to do about it: starting, working, compacting, rate_limited, idle, question, approval, error, done, ended or unknown; events), and while it is question or approval the pending prompt (session show prints it with its options). They also report each Session's activity (stopped, waiting_approval, working or idle) and its newest turn that ran as latestTurnState (beside latestTurnId; absent until a turn runs). A Session is done when its activity is idle and its status is done (its newest attempt completed, not an attempt a runtime refused to start); the text output prints done for it. session wait <id> --activity idle,waiting_approval,stopped returns when the Session is no longer working, and the reached latestTurnState tells how the newest turn that ran ended. session wait blocks until the Session is in the --until state. Name several states with a comma list or by repeating --until, and it returns when the Session is in any of them, so an agent writes session wait <id> --until idle --until failed and reads the state reached in the output. Waiting on one state keeps waiting if the Session ends in another. It exits 0 when a state is reached, 74 when --timeout (default 10m, 1s to 24h) ends first, with the current Session printed either way; an unknown state exits 64 and an unknown Session 66. It repeats bounded Node waits inside one deadline, so it needs no polling and its total stays within the timeout. session show, session turn show and session turn wait add a failed: <code>: <message> line for a failure with a reason, and session events list appends the reason and detail to session.runtime.failed and turn.failed. session turn show prints a completed turn's tokens, with its model calls when the runtime counted them (Codex from its token-usage notifications; Claude Code from its result's num_turns, the SDK's count of model round-trips in the turn). It adds a context: line, the input tokens of the turn's last model call against the model's context window and as a percentage, when the runtime reported both (Codex: last and modelContextWindow; Claude Code: the last main-thread call and the result's modelUsage window for that model, never a guess when the model has no entry). The turn's tokenUsage carries them as the optional contextTokens and contextWindow. Maia's decoders reject unknown fields, so upgrade the client and the Node together: an older client refuses a turn that carries them, and a downgraded Node cannot read the stored usage of such a turn. Usage is stored only for a completed turn, so a running, failed or interrupted turn shows no context, and an older turn has none: read it between turns, with session turn show --session <ref> --latest.
  • session create --cwd <path> starts a Codex or Claude Code Session in a folder of its Workspace. A relative path resolves against the Workspace root, or against the Session's worktree with --base, never against the client's working directory: the Node may be remote, and the worktree path does not exist before the Session does. Example: session create --executor codex-cli --base main --cwd packages/sdk. An absolute path must lie inside the Workspace root; with --base it is refused, because the client cannot know the worktree path. The path has no .. segment and at most 4096 bytes. --cwd needs --connection or --executor; --agent refuses it. The Node checks the folder when the Session starts, with every symlink followed: it must exist, be a directory and stay inside the root, else the start fails closed (path_invalid, with no path in the message) and the Session is failed. The Node re-checks it on every start and resume, and again right before the launch, so a folder swapped in between fails closed. Claude Code starts in the folder and keeps the whole Workspace or worktree as its sandbox. Codex CLI starts its thread in the folder; its :workspace_roots follow the thread's working directory and Codex 0.163 has no separate setting, so with Codex the write sandbox is the start directory, never wider than the root. Claude keeps the Workspace or worktree root.
  • session create --base makes a coding Session: the Node creates its own worktree Workspace from that ref on branch maia/<workspace-id> and the worker may write inside it (coding Sessions). session diff prints one line per changed file and a total, then the patch (at most 48 KiB) unless --stat; session archive prints the Session, state, revision, branch, snapshot and the time the Node prunes the worktree. A Session without a worktree (a read-only or failed one) has nothing to keep: session archive stops it, ends its runtime and prints the line no worktree to keep. That archive is final too: session start and session resume refuse the Session, and a manager's slot frees with it (D-201). session archive --summary <text> (up to 2 KiB of UTF-8, written by the caller) and --transcript <path> (a path the caller names, up to 4 KiB; the Node records the path and never reads or stores transcript content) are bound into the command, kept in its receipt, replayed with it and returned as summary and transcriptPath in the JSON result. A retry must repeat them. The Node guesses no vendor transcript path.
  • session list --settled [--idle-for <duration>] keeps the Sessions that are not archived and are stopped, failed, done (idle with a completed latest turn, settled at once), or idle for longer than the duration (30s, 10m, 24h; default 24h, at most 720h). A Session is idle since its newest turn changed, or since its record changed when it has no turn, so a rename never postpones retirement. The filter runs before the page is cut; each JSON item carries settledReason (stopped, failed or idle) and the text table a SETTLED column. Retire a settled Session with session archive.
  • session attach follows one Session's item stream (design): the replay or snapshot, then each message, reasoning summary, command, tool call, file change, error and token usage as it streams, deltas included. It reads; turns still go through session turn send. Text output prints ## turn lines, user>/assistant>/thinking> items, $ <command> with its output and [exit <code>], and a tokens: line with cached tokens and, when counted, model calls. Each line of Session text that Maia breaks prints indented two spaces; terminal soft wrap can still put text at column 0, and copied output carries the indent. --json gives the exact text: it writes each session.attach frame as one NDJSON line, and every record carries epoch and sequence. --after <epoch>:<sequence> resumes after a record; a different epoch (the Node restarted) or a gap over 1,000 records or 8 MiB restarts from a snapshot. An item that started before that record prints when it completes, not as it streams. --no-follow exits 0 at the synchronized frame. On resync (a slow terminal) or a lost connection the command reattaches after its cursor; five reattaches without progress exit 69. An unknown Session exits 66. Codex and Claude Code Sessions stream today; Pi Sessions show turn boundaries only.
  • session agent-permission set is a two-phase, human-only Agent grant: --input-stdin --dry-run previews and --confirm-stdin confirms; it enables no bound worker.
  • maia mcp --worker requires the Session credential and loopback origin, never discovers an operator profile, and exposes exactly the tools of its credential's role (an orchestrator's add its child tools; an unreadable role gets the worker's). Either Session variable also selects this surface for plain maia mcp; incomplete variables fail closed. Coding vendor workers receive this server in launch configuration outside their tool sandbox.
  • maia mcp serves the coordinator tools of the protocol registry over stdio for Claude Code or Codex (its server test initializes with MCP protocol 2025-06-18). An invalid argument names the failing field in the error message. It exposes no human-only operation except turn.send, and reaches the Node as the coordinator principal, with --node too (the SSH stdio bridge carries the mark): the Node admits the coordinator tool operations and refuses every other one with role_denied, so a coordinator answers an approval only where its Project grants it (D-199) and never confirms a preview (details).
  • Without --node, plain maia mcp addresses every Node as the commands above do (D-175), with the same CLI code and one SSH stdio bridge per Node for the whole MCP session. With no remote Node registered, every call goes to this machine's Node as before.
    • A tool that names an exact ID runs on the one Node that holds it, read or mutation: a Session ID (session_show|start|stop|resume|archive, session_rename|diff|events_list, turn_send, turn_queue_list, approval_list, and session_history_read for a Maia Session), a turn ID (turn_show, turn_interrupt, turn_cancel), a Project ID (project_show, project_guard_show, workspace_list, reference_resolve, session_list, routine_list), a Workspace ID (workspace_show, and session_create), an approval, Routine or command ID (approval_show, routine_show, receipt_get). The server first checks the arguments with the protocol's request schema, as a Node does, so an invalid call refuses (invalid_argument) before any Node is asked. It then asks each Node with that kind's show tool; two holders (node_ambiguous), a Node that could hold the ID and did not answer (node_unavailable) or no holder (resource_not_found) refuse before the call is sent. An ID list (turn_wait, session_observe) runs on the Node that holds every ID; IDs on different Nodes refuse with invalid_argument.
    • Without an ID, project_list, session_list, routine_list, approval_list and session_observe merge every Node's page: each item names its node, and nodes has one entry per Node with its next cursor or its error. In a merged session_observe, the entry of a Node that answered also carries that Node's own observedAt and stallAfterSeconds; a failed Node's entry has neither. limit bounds each Node's page; session_observe takes no limit. A Node's failure fails the call (isError) and result keeps the other Nodes' rows. With more than one Node, afterCursor refuses (invalid_argument): page one Node with maia --node <name> mcp. A call that one Node answers, because only one Node exists or one Node holds every ID it names, keeps the protocol's own result shape.
    • Everything else stays on this machine's Node: a session_create without an exact Workspace ID, connections, port leases, vendor discovery, node_status, containers, and the Agent and Run ledger tools, whose IDs no route kind covers yet. Reach another Node's with maia --node <name> mcp.
    • Each Node gets every routed call, its holder check included, as the coordinator, so that Node's own coordinator gate decides it: no tool, role or operation is added, and Routine mutations stay outside the coordinator tools. maia mcp --worker and a Session credential keep every call on the Session's own Node.

Approvals and worker questions

What running workers wait on a person for (S1, S4b; decision).

… session approval list [--session <session-id>] [--all] [--limit <1..50>]
… session approval show <approval-id>
… session approval approve <approval-id> [--for-session] [--as-coordinator] [--command-id <id>]
… session approval deny <approval-id> [--reason <text>] [--as-coordinator] [--command-id <id>]
… session approval answer <approval-id> (--text <answer> | --option <option-id>) [--as-coordinator] [--command-id <id>]
… session answer <session-id> <answer> --prompt <approval-id> [--as-coordinator] [--command-id <id>]
  • session approval list and show map to approval.list and approval.show, also the MCP tools approval_list and approval_show; list shows pending requests, newest first, and --all adds resolved ones. Each request names its Session, turn, kind (command, file_change, tool, question), expiry and the exact text a person decides on: the command and its folder, a patch's files with line counts and the digest of every diff, a tool call with its whole input, or a question with its options.
  • session approval approve, deny and answer map to approval.respond. They are a person's: they refuse to run without an interactive terminal (an agent's shell or a pipe has none), and the Node refuses every worker Session credential. Two exceptions. A person sets the first on the Project guard (D-199, proposed; project guard set --approval-coordinator none|answer|decide): --as-coordinator sends the request as the coordinator principal, with no terminal and no [y/N], and the Node answers role_denied unless the Session's Project grants it (answer: questions only; decide: also approve and deny); the same grant opens the approval_respond MCP tool. The Node checks the grant when it commits the answer, so a revocation refuses every later answer and leaves one already committed. The receipt names coordinator:actor_local_user and the guard revision that admitted it. MAIA_NODE_HEADLESS_APPROVALS=off at Node start turns every grant off for that run, and node.status reports it. The second is an orchestrator Session: it may answer a question of its direct child without a terminal through its approval_list, approval_show and approval_respond tools, never approve or deny (D-186). Each first shows the request, then sends its requestDigest, so the Node applies the decision only to the request the person read; approve also asks a person [y/N]. --for-session approves the same request for the Session until its worker restarts, where the vendor offers it (Codex). --option answers with that option's label.
  • session answer answers the prompt a Session waits on, the one session show prints under prompt:. It runs the same show, confirm and approval.respond path as session approval, so it adds no authority: a person, the Project's coordinator grant and an orchestrator's own child are the callers allowed above, and the Node checks each. A question takes the text (or an option id, sent as its label) as its answer, so the literal approve answers a question; a command, file or tool request takes approve or deny. --prompt <approval-id> is required: it names the prompt that session show printed, so an answer meant for one prompt never lands on the one that replaced it. The prompt must belong to the Session, and the Node refuses it once it is resolved, expired or replaced (approval_closed, approval_expired, approval_request_changed). A retry repeats --prompt with the original --command-id, and the Node replays the receipt. A Session whose status is unknown gets no answer: the command reads the Session first and stops. --as-coordinator is optional.
  • A decision is final: a retry with the same command id replays its receipt; any other decision on a resolved request is approval_closed, after the expiry approval_expired, and on a changed request approval_request_changed.

Port leases

Advisory port leases (S16 port kind).

… port lease acquire --purpose <label> [--session <session-id>] [--count <1..8>] [--preferred <port>] [--command-id <id>]
… port lease renew <lease-id> --expected-revision <revision> [--command-id <id>]
… port lease release <lease-id> [--command-id <id>]
… port lease list [--session <session-id>] [--limit <1..100>] [--after <cursor>]
  • One to one with port.lease.acquire, renew, release and list (the port lease leaves), also served as the MCP tools port_lease_acquire, port_lease_renew, port_lease_release and port_lease_list.
  • A lease holds 1 to 8 TCP ports from the Node's range (MAIA_PORT_RANGE, default 41000-41999) for an exact Session or, without --session, for the caller. A port is granted after a loopback connect and bind probe finds it free; --preferred is a hint inside the range.
  • Leases expire after 15 minutes unless renewed at their exact revision, end when their Session stops or fails, and are reconciled at Node start. release is idempotent. Codex and Claude Code Sessions holding leases at launch get MAIA_PORTS=<port>,<port>; nothing else in their environment changes.
  • Leases are advisory (decision): the Node never restricts binds, blocks a Session over ports or signals a process. list reports per port whether something listens on it now (probes[].inUse; an asterisk in text output). Typed failures: port_pool_exhausted (retryable), port_lease_not_found, port_lease_expired, port_lease_conflict.

Session roles and Project guards

Session-bound worker credentials and fan-out guards (S18).

… project guard show <project-id>
… project guard set (<project-id> --expected-revision <revision> --max-depth <1..4> --max-live-children <1..64> --max-messages-per-minute <1..600> --max-leases-per-session <1..64> [--approval-coordinator <none|answer|decide>] [--command-id <id>] [--dry-run] | --confirm-stdin)
  • project guard show is project.guard.show (also the MCP tool project_guard_show). project guard set also takes --approval-coordinator <none|answer|decide>: the grant that lets a coordinator answer a Project's pending approvals without a terminal (D-199, proposed); omitted, it keeps its level. It is two-phase over project.guard.preview and project.guard.confirm, both human-only: no MCP tool, no automatic confirmation. With the limits it previews and, at a terminal, asks y/N; --dry-run only previews; --confirm-stdin confirms a preview piped back.
  • show prints the source (default at revision 0 until a person confirms new guards, then project), the revision and the four limits; JSON output is the lossless result. Defaults: depth 1 (Sessions an operator creates cannot create children), 8 live children per parent, 60 messages per Session per minute and 8 port leases per Session.
  • The preview takes all four limits at the guard's exact revision; --command-id defaults to a fresh one. It returns the current guard, the proposed limits, inputDigest, proof and a 30-second expiry. Human text shows the current and proposed lines and omits the proof; JSON output carries it.
  • --confirm-stdin reads that --json --dry-run preview from stdin (at most 64 KiB), for example maia --json project guard set <project-id> ... --dry-run | maia project guard set --confirm-stdin. It replaces the guard and returns it with its receipt (target type project_guard). An unknown outcome names the command ID for maia receipt show.
  • The Node enforces the guards when a Session creates a child, sends a message or leases a port. guard_live_children_exceeded, guard_messages_per_minute_exceeded and guard_leases_per_session_exceeded are retryable (exit 75). guard_depth_exceeded is not (exit 70): a person must raise --max-depth.
  • A Session's role comes from session create --role. With its own credential (see Global grammar) a worker reaches context.show, node.status and its own port leases; an orchestrator also reaches project.guard.show and session.create in its Workspace, and session.show, session.start, turn.send, turn.show and session.stop on its own child Sessions, at most 2 levels deep (D-151). turn.show reads a turn through its Session's link. The Node checks the child link per request: a Session that is not its child fails with role_denied like anything else (exit 77). A Connection's Session cap still bounds children: the one past it fails saturated. A coding child (--base-ref) gets its worktree only after the guards admit it, and its worktree receipts name the parent Session.

Emergency holds

Emergency holds (S20) on exact Projects and accounts.

… hold list [--state <active|released>] [--limit <1..100>] [--after <cursor>]
… hold place (--input-stdin --dry-run | --confirm-stdin)
… hold release (--input-stdin --dry-run | --confirm-stdin)
  • hold list is hold.list. hold place and hold release are two-phase leaves over hold.place.preview and hold.place.confirm, and hold.release.preview and hold.release.confirm. All four are human-only: no MCP tool, no automatic confirmation. The request arrives on stdin, so these leaves never prompt: --input-stdin --dry-run previews and --confirm-stdin confirms that preview piped back, for example maia --json hold place --input-stdin --dry-run < in.json | maia hold place --confirm-stdin. Without --dry-run or --confirm-stdin the command exits 64 and names them.
  • hold place --input-stdin takes strict JSON { commandId, reason, running, targets } on stdin (8 KiB, 32 chunks). targets lists 1 to 64 exact, existing { "kind": "project" | "connection", "id": ... } entries; there is no wildcard and no all. reason is a 1–128 character label. running is keep (reject new work) or stop (also stop the Sessions running on the targets). The preview returns the Node holdId, the Sessions now starting, running or recovering on the targets, inputDigest, proof and a 30-second expiry; for stop the digest binds that Session list.
  • Confirmation takes the preview (its input plus inputDigest and proof) and returns the hold, its receipt (target type hold), stoppedSessionIds and the delivery status. It fails with exit 75 and places no hold when a Session started on the targets after the preview. When a stopped runtime has not ended within 3 seconds, it fails with retryable saturated (exit 75) although the hold is placed; repeat the same confirmation to wait for it.
  • hold release --input-stdin takes { commandId, holdId, expectedRevision }; confirmation adds inputDigest and proof and returns the released hold (revision 2) with its receipt. A released hold stays listed as evidence; Sessions it stopped stay stopped until session resume.
  • While a hold is active, session create, session start, session resume, session turn send, run create, Session spawn and the voice relay on a held target fail with error code held and exit 75; the message names the hold and the target. session create --executor skips held accounts. JSON preview output carries the confirmation proof; human text omits it. Design, measurements and deferred targets (Node, capability, peer): operations.

Scheduled Sessions

Routines (S30): a cron expression and an IANA time zone fire one Session from a stored template and send its first turn (decision, contracts).

… routine create (--input-stdin --dry-run | --confirm-stdin)
… routine list [--project <project-id>] [--state <active|paused|disabled>] [--limit <1..100>] [--after <cursor>]
… routine show <routine-id>
… routine pause <routine-id> --expected-revision <revision> [--command-id <id>]
… routine resume <routine-id> --expected-revision <revision> [--command-id <id>]
… routine disable <routine-id> --expected-revision <revision> [--command-id <id>] [--dry-run | --confirm-stdin]
… routine run <routine-id> --expected-revision <revision> [--command-id <id>] [--dry-run | --confirm-stdin]
  • routine create is two-phase over routine.create.preview and routine.create.confirm, human-only like hold place: the request arrives on stdin, so it never prompts. --input-stdin --dry-run previews and --confirm-stdin confirms that preview piped back. Without either flag the command exits 64 and names them. A preview expires after 2 minutes.
  • routine create --input-stdin takes strict JSON { commandId, name, paused?, schedule: { expression, timezone }, template: { projectId, prompt, executor?, connectionId?, model? } } (8 KiB). expression has five POSIX cron fields. template names an executor or a connectionId; model needs a connectionId. With "paused": true the routine starts paused with no next fire, so it fires after a routine resume and not before; the preview binds the choice and --confirm-stdin must repeat it. A paused routine counts toward the Project's limit of 20. The preview returns the routine ID, the next 3 fire times, inputDigest, proof and the expiry. The Node refuses with routine_schedule_invalid, routine_interval_too_short (a gap under 15 minutes), routine_limit_reached (20 active or paused routines in the Project) or configuration_invalid (a template its Connection can never serve, such as a gateway connectionId without executor); each exits 70 and changes nothing.
  • routine pause, resume and disable are compare-and-set on --expected-revision and record a receipt. Pause stops fires; resume continues from the next slot after now and never replays slots paused over, so resuming a batch of routines fires none of them at once. The last column of each result is the next fire (- while the routine does not fire).
  • routine disable is final, so it is two-phase over routine.disable.preview and routine.disable.confirm, like project guard set. The Node previews the routine; a person at a terminal reads its name and revision and confirms, or --dry-run previews and --confirm-stdin confirms that preview piped back (with --json). Without a flag and without a terminal the command exits 64 and names the flags. A preview expires after 2 minutes. Create a new routine to schedule again.
  • routine run fires an active routine once now, as its schedule would. It starts a Session, so it is two-phase over routine.run.preview and routine.run.confirm, with the flags, prompt and stdin of routine disable. The confirmation records a launching fire with trigger manual and a receipt, and the Node's next tick launches it through the same path, holds, guards and Session cap. That tick judges it again: a routine that is not active then, a hold or a disabled Connection fails it with its reason. The output is the fire ID, outcome, routine, receipt and delivery; routine show follows the fire. The same --command-id and proof return the first fire. A refused preview or confirmation records nothing and exits 75 with routine_busy while the previous fire's first turn is unsettled, or with held; 70 with routine_not_active (paused or disabled) or connection_disabled; 66 for an unknown routine. It never moves the next fire.
  • routine list and routine show also reach coordinators as the MCP tools routine_list and routine_show; every change is human-only.
  • routine show lists the newest 20 fires, newest first: scheduled time, trigger (cron or manual), outcome (started, skipped, failed, or launching while the Session is created), typed reason, Session, first-turn state, missed slots folded into a catch-up and, when the first turn failed, its cause: the failure reason (for example runtime_failed) with the runtime's code and message when it gave them. When the fire's Session failed to start, the cause is that Session's code and message, such as why Claude Code did not initialize. A fire skipped while the previous first turn is unsettled has reason overlap. A failed fire has reason held, connection_disabled, routine_not_active (a manual fire whose routine is not active at the claim), saturated (the Node has no free active Session slot), workspace_unavailable, launch_failed or outcome_unknown; the next tick is the retry. session show on the fire's Session says why a launch failed.
  • Fires run on the Node's clock, checked every 5 seconds. Slots missed while the Node was down run once at start. The Session starts through the same path as session create, session start and session turn send, so holds, guards and Connection state apply. A Node allows 8 active Sessions, so each tick stops the Session of a settled fire, whatever its outcome, once its turns are finished. The Session keeps its history and diff, and session resume wakes it. A Session a person has sent another turn stays running.

Webhooks

Source candidate. Signed outbound webhooks for Node events (E12, EV3). The delivery contract is in events.

… webhooks list [--limit <1..100>] [--after <cursor>]
… webhooks deliveries [--webhook <webhook-id>] [--failed] [--limit <1..100>] [--after <cursor>]
… webhooks add (--url <http-url> --secret-env <VARIABLE> --event <kind>... [--label <key=value>...] [--project <project-id>] [--node-id <node-id>] [--allow-private] [--command-id <id>] [--dry-run] | --confirm-stdin)
… webhooks test (<webhook-id> --expected-revision <revision> [--command-id <id>] [--dry-run] | --confirm-stdin)
… webhooks remove (<webhook-id> --expected-revision <revision> [--command-id <id>] [--dry-run] | --confirm-stdin)
… webhooks redeliver (<delivery-id> --expected-revision <revision> [--command-id <id>] [--dry-run] | --confirm-stdin)
  • webhooks list is webhooks.list and webhooks deliveries is webhooks.deliveries; neither is an MCP tool. add, test, remove and redeliver are four leaves over one two-phase pair, webhooks.change.preview and webhooks.change.confirm, operator-only: an attached Session credential is refused, and there is no automatic confirmation. At a terminal a change previews and asks y/N; --dry-run only previews; --confirm-stdin confirms a --json --dry-run preview piped back to the leaf that made it. A preview of another action exits 64 before any request.
  • --event and --label repeat. --label team=core filters on label.team. --secret-env names an environment variable of the Node; the CLI never reads, prints or stores the secret.
  • A change is bound to its action, input, actor, hook revision, destination and --command-id, and expires 30 seconds after the preview. A removal preview states how many deliveries the removal deletes. test and redeliver send to an external receiver, so a send already transmitted is uncertain.

Agent definitions

Versioned Agent definitions of a Project (S49, D-120).

… agents list --project <project-id> [--state <active|disabled>] [--limit <1..100>] [--after <agent-id>]
… agents show <agent-id> [--revision <revision>]
… agents detect
… agents create (--project <project-id> --name <text> --harness <claude-code|codex-cli|maia-agent|pi> --accountable <actor-id> --instruction <text> [--connection <connection-id>] [--model <id>] [--effort <low|medium|high|xhigh|max|ultra>] [--manager <agent-id>] [--home-session <session-id>] [--command-id <id>] [--dry-run] | --confirm-stdin)
… agents revise (<agent-id> --expected-revision <revision> <the create options but --project> [--dry-run] | --confirm-stdin)
… agents disable (<agent-id> --expected-revision <revision> [--command-id <id>] [--dry-run] | --confirm-stdin)
  • agents list is agents.list and agents show is agents.show; both are also MCP tools (agents_list, agents_show). create, revise and disable are three leaves over one two-phase pair, agents.change.preview and agents.change.confirm, both human-only: no MCP tool, no automatic confirmation. A Session credential gets role_denied (exit 77). At a terminal a change previews and asks y/N; --dry-run only previews; --confirm-stdin confirms a --json --dry-run preview piped back (at most 64 KiB) to the leaf that made it, for example maia --json agents disable <agent-id> --expected-revision 1 --dry-run | maia agents disable --confirm-stdin; a preview of another change exits 64 before any request.
  • agents detect (agents.detect, Source candidate, AI1, D-190) reads the machine, not a Project's definitions: which of Claude Code and Codex the Node finds, with their versions, and the folders at user scope that hold installed Skills. Project scope is not part of this slice (AI1c). It works on the Node --node names, one at a time, and is not an MCP tool. The Node reads each version with --version and lists user Skills through the pinned skills CLI (skills list -g --json), the exact 1.7.0 release the Node bundles as a dependency (D-193), run by the Node's own runtime from the real home, with the worker read denials, a small environment, a 4 s deadline over the whole read (the SDK gives up at 5 s) and a 256 KiB stream bound. The pinned CLI cuts a pipe at 64 KiB, so about 320 Skills or more read as invalid_output, and a heavily loaded host can reach the 4 s deadline (timeout) on the first read; more than 8 distinct folders for one harness read as output_too_large, and a folder whose shown path passes 512 characters is dropped. Detection never downloads or installs anything, and a skills binary in a Project never runs. Maia keeps no table of harness folders: a folder appears only once the pinned CLI lists an installed Skill in it, so "folders": [] means none reported, not unsupported. Output carries no Skill name, binary path or account; home shows as ~, relative to the real home. The user scope is ok or unavailable with a reason (failed, timeout, output_too_large, invalid_output, unsupported off Linux; failed also on Linux below 6.12, where hard containment is unavailable, see operations). An unavailable scope keeps the result, prints ok: false with the reason as error.code under --json, and exits 70. A Session credential gets authentication_failed.
  • A change is bound to its exact input, actor and --command-id (fresh by default), and expires 30 seconds after the preview. revise and disable name the revision they expect; a revision that moved since the preview, or an expired or altered preview, fails with storage_conflict and changes nothing. Confirmation returns the Agent and a receipt (target type agent, method agents.change.confirm). An unknown outcome names the command ID for maia receipt show.
  • A definition holds the name, harness, accountable actor, instruction (at most 16 KiB), optional Connection, model and effort, and an optional manager Agent and home Session. The accountable actor is the actor making the change (configuration_invalid otherwise): the Node knows no other actor to hold accountable. Placement is local; remote placement is refused. revise replaces the whole definition and bumps the revision; the Project, the ID and earlier revisions stay readable with show --revision.
  • disable is final: a disabled Agent stays listed as evidence, takes no new Session and cannot be revised. show and list print the headline (ID, state, revision, harness, name); show adds the digest, the spec and the instruction. Human text omits the proof; JSON output carries it.
  • Fallback routes and Skill references are not part of a definition yet: they join it with agent reliability (S60a) and the Skill catalog (S22), which are the first readers of them. session create --agent starts claude-code and codex-cli Agents; a maia-agent or pi Agent exits 70 (configuration_invalid). The instruction reaches Claude Code as its system prompt and Codex as developer instructions.

Session discovery and adoption

Find the Claude Code and Codex sessions on this machine, read their past messages and adopt an ended one as a Maia Session (S11, decision).

… import scan [--source claude,codex] [--since <days>d] [--root <path>] [--running] [--adopted | --not-adopted] [--include-content] [--limit <1..100>] [--after <cursor>]
… session adopt (<claude|codex>:<native-id> --workspace <workspace-id> [--attest-ended] [--host-authority [--permission <mode>]] [--role <worker|orchestrator>] [--label <key=value>...] [--title <title>] [--command-id <id>] [--dry-run] | --confirm-stdin)
… session import (--from <file> [--dry-run] | --confirm-stdin)
… session history read <session-id|claude:<id>|codex:<id>> [--offset <n>] [--limit <1..50>]
  • One to one with discovery.sessions.list, session.adopt.preview, session.adopt.confirm and session.history.read. session adopt is one two-phase leaf: --dry-run only previews, --confirm-stdin confirms a preview piped back, and at a terminal it previews and asks y/N. The two reads are also coordinator MCP tools (discovery_sessions_list, session_history_read); the preview and confirmation are human-only. A Maia-launched worker gets authentication_failed for all four.
  • scan lists metadata: native ID, directory, Git repository and branch, last activity, model when known, live, ended or unknown with its evidence, and the store that lists the session. Sessions are grouped by Git common directory, so a linked worktree joins its repository; a directory that no longer exists is reported as orphaned. Titles, first prompts and previews come from message content and appear only with --include-content. The window defaults to 30 days; pages default to 50 sessions. The list holds every session in the stores, interactive or started by a program (Claude Code headless and SDK runs, Codex exec runs), running or ended. Each item carries two facts. liveness is adoption's conservative check (D-078): a Claude Code session whose sessions/<pid>.json record exists is live, even when a crash left the record behind, so adopt refuses rather than risk a second resume. observed is what a live process shows: running when that record names a process that is Claude Code (read from /proc), not-running for a stale or absent record. For Codex it is running when a Codex process holds the thread's rollout file open (the kernel reports the open files, so arguments cannot fake it; a process of another user shows none) or was started with resume <thread-id> or exec resume <thread-id>; an ID elsewhere in the arguments selects nothing. A process counts as Claude Code or Codex when it is that command, or the script a node or bun runs; vim claude is neither. Any other Codex thread is unknown. A host without a readable process table, or a table Maia read only in part (a process it could not read, more than 8,192 processes), also reports unknown, never not-running. --running keeps the running sessions and leaves out unknown ones, so an empty --running result does not prove that no Codex process is running. --adopted and --not-adopted keep the sessions Maia adopted or did not, so a hygiene check can ask for "running and not adopted". Filters apply before paging. Each store reads at most 3,000 sessions of the window per call, newest first, counting the ones Maia leaves out (its own Sessions, workers, sessions without a directory). Sessions share one order: newest first and, at one time, by native ID. When a store holds more, the call keeps the first 3,000, names the store in truncatedSources (the text output prints a truncated line) and ends its page with a nextCursor after the last session read, even when the page holds nothing; the next call continues there, so following the cursor reaches every session, even when thousands share one millisecond. A call also reads at most 10,000 rows per store, counting the earlier ones a cursor skips; a cursor deeper than that ends the call truncated with no nextCursor. Maia's own Sessions and Codex worker threads are counted, not listed. A store Maia cannot read (an unapproved codex, for example) reports a source error and the other still answers.
  • adopt needs an ended session, a system-default Connection for the vendor (maia connection create --name claude --executor claude-code --root system-default) and a Workspace of the same repository. The Session runs in the Workspace's primary checkout: original when the session ran there, relocated otherwise (Codex only; Claude Code resumes a session from its own directory). Claude Code is live while its sessions/<pid>.json record exists; Codex keeps no liveness Maia can read, so a person passes --attest-ended after closing it elsewhere. The confirmation checks liveness and the claim again and creates a stopped Session bound to the native ID; maia session resume continues the native session. A native session is adopted at most once. --host-authority (optionally --permission <mode>, as for session create) binds the adopted Session to host authority (D-198): maia session resume then continues the native session as the Node's user with no Maia sandbox, using the user's own vendor configuration. Claude Code only; it needs an operator, the MAIA_HOST_AUTHORITY=enabled switch at Node start, and shows containment: none like a created host Session. The mode is part of the preview digest and cannot change after adoption.
  • session import adopts many sessions after one review. It composes session.adopt.preview and session.adopt.confirm once per entry; it adds no operation, store or migration. --from <file> names a JSON file { "entries": [ ... ] } of 1 to 64 entries and at most 65,536 bytes, each { "harness": "claude-code"|"codex-cli", "nativeId", "workspaceId", "attestEnded"?, "hostAuthority"?: { "permission"? }, "title"?, "labels"?, "role"? }. title, labels and role mean what they mean for session adopt (an orchestrator needs an owner label and takes the Project's manager slot, D-201); each is bound into the entry's preview digest. The caller resolves each Workspace; the CLI does no directory matching. --dry-run previews every entry, mints one command ID per entry and prints an artifact of at most 65,536 bytes (the stdin bound; a larger one fails, so split the file). Each entry shows its Workspace, account, working directory, host authority with any permission mode, and any title, labels and role. --confirm-stdin confirms exactly the previews in that artifact and never rereads the file; they expire after 15 minutes. If a confirmation answers a different command ID, input digest, executor, native ID or account than its preview, the entry ends unknown and the rest are not-attempted. Each entry ends as adopted, skipped (the native session already belongs to a Session, found at preview or at a competing confirmation), refused (with the Node's reason, for example Codex with hostAuthority, D-198), unknown (the answer may have committed; the rest are not-attempted; run maia receipt show <command-id> before importing again) or not-attempted. A rerun after success adopts nothing. The exit code is non-zero unless every entry is adopted or skipped; then --json prints ok: false with the first failure's error (adoption_refused, or outcome_unknown for an unknown entry) and the full result listing every entry.
  • session.adopt.preview answers { alreadyAdopted: { sessionId } }, with no preview ID and no change, when the native session already belongs to a Session; session adopt keeps its refusal text. A confirmation that loses the claim to another adoption fails with already_adopted (HTTP 409).
  • Planned (S11b, D-171): scans run on their own, every origin but Maia's own is onboarded for its owner, an ended session is claimed without adopt, and a running one is observed through a managed hook. adopt stays the manual claim until then.
  • Previews expire after 15 minutes; repeating a confirmation with the same preview ID replays its receipt. Repeating adopt with the same --command-id and input replays the open preview under a new preview ID (the Node keeps only a digest of each ID, so the earlier one stops working); the same command ID with other input, or after confirmation, fails with storage_conflict, as does every repeat that overlaps an earlier one still replaying. history returns user and assistant text and one-line tool summaries, at most 50 messages of 4,000 characters per page; the Node keeps none of it.

Run ledger

The S19 Run ledger over executor Sessions (coordination).

… run ledger create --workspace <workspace-id> --graph-stdin [--command-id <id>]
… run ledger list --workspace <workspace-id> [--limit <1..100>] [--after <cursor>]
… run ledger task list --run <run-id> [--ready]
… run ledger task show <task-id> --run <run-id>
… run ledger task dispatch <task-id> --run <run-id> --session <session-id> --expected-task-revision <revision> --expected-session-revision <revision> [--command-id <id>]
… run ledger task settle <task-id> --run <run-id> --session <session-id> --outcome <succeeded|failed> --expected-task-revision <revision> --evidence-stdin [--turn <turn-id>] [--command-id <id>]
  • One to one with run.ledger.create, list, task.list, task.show, task.dispatch and task.settle, also served as the MCP tools run_ledger_*. The graph on stdin is { "tasks": [{ "clientKey", "dependsOn", "objective" }] } (64 KiB); evidence on stdin is 1 to 8192 bytes.
  • dispatch queues the Task's objective as a turn on a running Codex or Claude Code Session of the Run's Workspace and returns that turn; settle needs the turn ended, and succeeded needs it completed. A third failed dispatch stops the Task. Text output prints one line per Run and Task: state, revision, failed dispatches and the last dispatch as state:session:turn.
  • Typed failures: run_task_not_ready, run_task_not_active and run_turn_unsettled (retryable, exit 75); a changed revision is a retryable conflict.

Messages and Runs

Registered; S17 completes them. run create and its siblings are the local-v1 terminal Run, separate from the Run ledger above. A launched Session reaches the three session message and session inbox operations as the worker MCP tools session_message_send, session_inbox_check and session_inbox_ack (same parameters, no sender field: the Node names the Session from its credential, and today the recipient must share the sender's execution workspace (checkout); Project reach is planned, D-166). The CLI leaves below stay the operator's path with --attached; inside a Session they fail with role_denied.

… session message send --session <recipient-session-id> --attached --subject <text> --body-stdin [--command-id <id>]
… session inbox check --attached [--limit <1..16>]
… session inbox ack <delivery-id> --attached --expected-mailbox-revision <revision> [--command-id <id>]
… run create --workspace <workspace-id> --terminal <terminal-id> --graph-stdin [--command-id <id>]
… run list --workspace <workspace-id> [--limit <1..100>] [--after <cursor>]
… run show <run-id>
… run task list --run <run-id> [--limit <1..32>] [--after <cursor>]
… run task show <task-id> --run <run-id>
… run inbox check --run <run-id> --owner <run|dispatch> --owner-id <id> --coordinator-epoch 1 [--limit <1..16>]
… run inbox ack <delivery-id> --run <run-id> --owner <run|dispatch> --owner-id <id> --expected-mailbox-revision <revision> --coordinator-epoch 1 [--command-id <id>]
… run question open --run <run-id> --dispatch <dispatch-id> --subject <text> --body-stdin --expected-run-revision <revision> --expected-dispatch-revision <revision> --coordinator-epoch 1 [--command-id <id>]
… run question reply <question-id> --run <run-id> --dispatch <dispatch-id> --answer-stdin --expected-run-revision <revision> --expected-dispatch-revision <revision> --expected-question-revision <revision> --coordinator-epoch 1 [--command-id <id>]
… run question cancel <question-id> --run <run-id> --dispatch <dispatch-id> --expected-run-revision <revision> --expected-dispatch-revision <revision> --expected-question-revision <revision> --coordinator-epoch 1 [--command-id <id>]
… run dispatch show --run <run-id>
… run dispatch settle-terminal <dispatch-id> --run <run-id> --expected-run-revision <revision> --expected-dispatch-revision <revision> --coordinator-epoch 1 [--command-id <id>]

Dashboard

Source candidate, the renderer foundation of D-161 (D1a0). The command draws one static frame; the Tree it will show arrives in a later version.

… dashboard [--plain]
  • dashboard needs a terminal on standard input and output, else it exits 64 and names --plain. It draws on the alternate screen a status line naming the CLI profile and one line saying the Tree arrives in a later version; a terminal below 80x24 gets one line stating that size instead. q and Ctrl-C quit with exit 0. It makes no Node request.
  • --plain prints the same two lines once, reads no input and exits 0 with or without a terminal. With --json it prints {command, profile}.
  • Under MAIA_SESSION_CREDENTIAL it exits 77 before anything else, in every mode.
  • SIGINT, SIGTERM and SIGHUP end it with exit 130 after the terminal is restored; SIGTSTP or Ctrl-Z restores it and sends SIGTSTP to its process group, as the terminal's suspend key does. A shell with job control stops the job, and once it continues the job the dashboard enters the screen again and draws it whole. Where no shell has job control and the kernel drops the stop, it enters again at once. A stop that nothing continues would strand the dashboard, so in a Maia terminal pane, which sets MAIA_TERMINAL_PANE=1 (a flag only, never identity or authority), and on Windows Ctrl-Z and SIGTSTP do nothing. Raw mode turns the terminal's signal keys off, so the dashboard reads Ctrl-Z itself, in the legacy and Kitty keyboard encodings. The CLI records each terminal mode it enables (alternate screen, cursor, bracketed paste, keyboard protocol) and resets exactly those on every exit. A failure inside the renderer exits 70 with the terminal restored. A missing @earendil-works/pi-tui exits 70 with next: reinstall Maia.

Workspaces, Projects, views and questions

Candidates for R0, R1 and R1a.

… container list [--limit <1..100>] [--after <cursor>]
… container show <container-id> [--limit <1..100>] [--after <cursor>]
… container index list [--limit <1..500>] [--after <cursor>]
… container create (--input-stdin --dry-run | --confirm-stdin)
… container rename (--input-stdin --dry-run | --confirm-stdin)
… project create (--input-stdin --dry-run | --confirm-stdin)
… project rename (--input-stdin --dry-run | --confirm-stdin)
… reference resolve <connection|container|project|session|workspace> [<reference>] [--project <project-id>]
… workspace context show
… workspace membership attach <workspace-id> --project <project-id> --repository <repository-id> --expected-workspace-revision <revision> --expected-context-revision <revision> [--command-id <id>]
… workspace select <workspace-id> --project <project-id> --repository <repository-id> --expected-active-workspace-id <workspace-id> --expected-active-context-revision <revision> --expected-workspace-revision <revision> --expected-context-revision <revision> [--command-id <id>]
… context show [--attached]
… presentation catalog show
… presentation workspace-directory show --input-stdin
… presentation project-list show --input-stdin
… presentation session-list show --input-stdin
… session interaction create --input-stdin
… session interaction list --session <session-id> --project <project-id> [--status <pending|answered|cancelled|expired>] [--limit <1..100>] [--after <cursor>]
… session interaction show <interaction-id>
… session interaction respond --input-stdin
… session interaction cancel --input-stdin
  • reference resolve <kind> <reference> prints the exact ID of a name, ID prefix or title, or every candidate when more than one matches (exit 0); without a reference, a project or workspace kind prints the current folder's default (the selected Workspace's when no registered folder contains it). --project narrows a Session or Workspace reference. JSON returns the Node's reference.resolve answer.
  • container and project create and rename are two-phase leaves. They take strict JSON on stdin (8 KiB, 32 chunks), so they never prompt: --input-stdin --dry-run previews and --confirm-stdin confirms the preview piped back, which carries its inputDigest and proof. For example maia --json project create --input-stdin --dry-run < in.json | maia project create --confirm-stdin. Inputs and rules: containers, Project metadata.
  • container index list pages every container with its Projects and each Project's repository path; --limit counts rows (a Project, or a container with none) and a container a page cuts continues on the next page. Text prints one line per container with its Project count, then one indented line per Project.
  • presentation reads return catalog-bound view specifications (presentation runtime).
  • session interaction records an external question and its answer (128 KiB, 32 chunks per input); an answer is data, never approval to act. A question or selection attaches to an idle Session and a person answers it. Inputs are strict JSON on stdin. After an uncertain result, reuse the same command ID and reconcile with maia receipt show. Shape in Interaction and response and the interaction contract.

Execution environments and terminals

… workspace execution show <workspace-id>
… workspace execution set <workspace-id> (--execution-profile <profile-id> | --policy <policy-id>) --expected-revision <revision> [--command-id <id>]
… workspace execution clear <workspace-id> --expected-revision <revision> [--command-id <id>]
… execution catalog show
… execution command run <instance-id> --expected-revision <revision> [--command-id <id>] -- <absolute-executable> [arguments...]
… execution file read <instance-id> <relative-path> --expected-revision <revision> [--command-id <id>]
… execution checkpoint create --instance <instance-id> --expected-instance-revision <revision> [--command-id <id>]
… execution checkpoint show <checkpoint-id>
… execution worktree create --project <project-id> --repository <repository-id> --base <base-revision> --expected-project-revision <revision> [--command-id <id>]
… execution worktree import <root-path> --project <project-id> --repository <repository-id> --expected-project-revision <revision> [--command-id <id>]
… execution worktree status <workspace-id>
… execution worktree reconcile <operation-id> --expected-operation-revision <revision> [--command-id <id>]
… execution worktree remove plan <workspace-id>
… execution worktree remove apply <workspace-id> --plan <plan-id> --plan-digest <digest> --expected-workspace-revision <revision> [--command-id <id>]
… execution placement create (--session <session-id> --expected-session-revision <revision> | --attempt <attempt-id> --run <run-id> --task <task-id> --expected-attempt-revision <revision>) --instance <instance-id> --expected-workspace-revision <revision> --expected-instance-revision <revision> [--command-id <id>]
… execution placement show (--session <session-id> | --attempt <attempt-id>)
… execution relocation plan <session-id> --expected-session-revision <revision> --expected-placement-revision <revision> --expected-epoch <epoch>
… execution relocation apply <session-id> --plan <plan-id> --plan-digest <digest> --expected-session-revision <revision> --expected-placement-revision <revision> --expected-epoch <epoch> [--command-id <id>]
… execution relocation show <relocation-id>
… execution relocation reconcile <relocation-id> --expected-operation-revision <revision> [--command-id <id>]
… terminal create --workspace <workspace-id> [--interactive --columns <2..500> --rows <2..300>] [--command-id <id>] [-- <program> [arguments...]]
… terminal list --workspace <workspace-id> [--limit <1..100>] [--after <cursor>]
… terminal show <terminal-id>
… terminal read <terminal-id> [--limit <1..8>] [--after <chunk-cursor>]
… terminal events list --terminal <terminal-id> [--limit <1..32>] [--after <terminal-cursor>]
… terminal controller request create --terminal <terminal-id> --terminal-generation <1..65535> --lease-ms <1000..30000> [--command-id <id>]
… terminal controller request show <controller-request-id> --terminal <terminal-id> --terminal-generation <1..65535>
… terminal controller acquire <terminal-id> --request <controller-request-id> --terminal-generation <1..65535> --expected-revision <revision> [--command-id <id>]

An interactive terminal runs on macOS and Linux, in the Workspace's checkout: the Project's root, or its worktree. The Node's terminal list decides what it starts:

  • Shell. The account's login shell must be on the list (/bin/bash, /bin/zsh, /usr/bin/bash, /usr/bin/zsh) and present on the host. It starts as <shell> -l, so it reads its login profile. SHELL names it.
  • Program. A name after -- runs in place of the shell: claude or codex. The login shell starts it, as below, so the profile and rc files set PATH first. Each argument stays one argument; no shell parses it. Every token after -- belongs to the program, --json included, so put --json before --.
  • Anything else is refused, and nothing starts:
    • an unlisted program fails with terminal_program_not_admitted, exit 77, before the Node records anything;
    • an unlisted login shell fails the same way, once the Node has recorded the request;
    • a name that is not a plain lowercase command name, such as /bin/sh or Codex, fails validation, exit 64.
  • No terminal on this Node. When the Node records the Terminal as unavailable (terminal_containment_unsupported, or platform_unsupported off macOS and Linux), terminal create fails with that code, ok: false, exit 69. The message names the Terminal and its receipt.

For example, this opens Codex in a Workspace, and the Node starts the second line:

maia --json terminal create --workspace <workspace-id> --interactive -- codex --model <model>
<shell> -l -i -c 'exec "$0" "$@"' codex --model <model>

terminal read pages output the Node holds in memory and never writes to SQLite (D-175). The buffer keeps at most 8 MiB and 50,000 lines per terminal, and 128 MiB and 131,072 chunks across terminals, and drops the oldest chunks first. A Node restart discards it. When output after --after is gone, evicted or lost with a restart, the page holds what is left and carries gap (reason: cursor_expired, recovery: resync-required, oldestAvailableCursor); human output leads with a gap row. A cursor past the head fails with storage_conflict. Cursors keep counting across a restart: each terminal generation numbers its chunks in its own cursor range, so a cursor from before a restart gets the gap, never storage_conflict, and never repeats. The upgrade that removed stored output also deletes it from every migration-backups/ file and rewrites them, keeping the rest so a rollback still works. Planned: when terminal.session.delete ships (it is unsupported today), delete frees the terminal's output buffer entry, and a test proves its bytes and per-terminal state are gone.

The remaining terminal mutations each require the controller fence --controller-id <id> --controller-epoch <epoch> --controller-revision <revision> --controller-expires-at <timestamp> --terminal-generation <1..65535> --expected-revision <revision> [--command-id <id>]:

… terminal controller release <terminal-id> <fence>
… terminal input write <terminal-id> (--input-stdin | [--text <text>] [--keys <names>]) --input-id <id> --input-sequence <1..2147483647> <fence>
… terminal resize <terminal-id> --columns <2..500> --rows <2..300> <fence>
… terminal signal <terminal-id> --signal <hangup|interrupt|terminate> <fence>
… terminal close <terminal-id> <fence>

terminal input write takes its bytes from --input-stdin, or from typed input: --text <text> (literal UTF-8) and --keys "<name> ..." (whitespace-separated key names), each at most once, sent in the order they appear on the command line, and never with --input-stdin. Example: --text "npm test" --keys Enter sends npm test, then CR; --keys Enter --text abc sends CR, then abc. The CLI turns the keys into bytes; the Node sees the same terminal.input.write as before. Key names are case-sensitive:

  • Enter (CR), Tab, Escape, Backspace (DEL), Space, Delete, Insert;
  • Up, Down, Left, Right, Home, End, PageUp, PageDown, sent as normal-mode xterm sequences (ESC [ A); a program in application cursor mode needs --input-stdin;
  • F1..F12;
  • C-a..C-z (the control byte 0x01..0x1a);
  • M-<char> (ESC, then the character).

An unknown name, or more than 32768 bytes in all, exits 64. For text that interleaves text, keys and text again, or any other byte, use --input-stdin.

Planned command families

Each family lands through protocol → SDK → Node → CLI → MCP with its slice. The owner document holds the design; this table is the index.

SliceCommandsDesign
S12session attach <session-id>: live renderer; /detach closes only this clientLive Session item stream
S13config.resolve, config.plan, config.apply, config.drift, config.importHarness configuration (decision)
S14node install <host> over SSHROADMAP S14
S15port lease list attribution of listening sockets to Sessions (--port <n>, --unleased); host and agent metricsShared resources
S16lock kind (wave 2): lock acquire <key> [--ttl] [--reason] [--wait], lock renew, lock release, lock listShared resources (decision)
S17enables the registered session message send, session inbox, run inbox aboveSafety (decision)
S20hold and release of one exact Project, Connection, Node or capability; capability.revokeKill switches (decision)
S21process start, process list, process logs, process stop over execution.process.*ROADMAP S21
TM7terminal input write and terminal read for an agent's own terminals, and a bounded terminal wait (a pattern or the exit)D-175
TM4terminal attach <terminal-id>: the Rust client, one full-screen paneD-175
TM5–TM6the multiplexer: panes, tabs, and Workspace and Project viewsROADMAP TM
TM9routine run <id>; node rename, node disable, node enableROADMAP TM
R1--schema[=input|output] on registered commandsSchema-only inspection
R1, S12observe reads and streams (below)Live observability contract
R8routine (below)Routine model
R10aevaluation operations; names are frozen with protocol, SDK, CLI and agent parityShared semantic evaluation
R11Agent launch flags, interactive commands and model toolsMaia Agent surface reference
MN0–MN5Execution Node enrollment, drain and handoff (below); MN0 freezes the bounds and the wire schemas; each later slice freezes its own startup mode, operation IDs, grammar and fixturesROADMAP MN0–MN5
T2telemetry status|query|level|purge; export in T7Local telemetry (decision)
RL2node.hello handshake before any other operationCompatibility handshake (decision)
RL3remote install <host>Install
RL4update [--version], node update --host <host>Updates
Latera2a (below), after S17A2A boundary (decision)

node backup|restore (below) and the maintainer contract generate|check|fixtures verify (contracts) are target grammar without a roadmap slice yet.

Live observability

Planned: R1 bounded reads and S12 streams over the observation plane. Today session observe and session events list are the implemented reads; node monitor prints a monitor URL.

maia observe health [--watch]
maia observe feed [--domain <agent|swarm|node|provider|voice|a2a|security|storage|scheduler>] [--importance <authoritative|diagnostic>] [--project <id>] [--session <id>] [--event-type <type>] [--include-content] [--after <cursor>] [--follow]
maia observe sessions [--project <id>] [--state <state>] [--watch]
maia observe session <session-id> [--after <cursor>] [--follow]
maia observe activations [--session <id>] [--state <state>] [--watch]
maia observe effects [--project <id>] [--state <state>] [--watch]
maia observe routines [--project <id>] [--watch]
maia observe queue [--project <id>] [--watch]
maia observe nodes [--watch]
maia observe providers [--connection <id>] [--watch]
maia observe voice [--session <id>] [--watch]
maia observe a2a [--project <id>] [--watch]
maia observe pipeline [--processor <id>] [--partition <0..31>] [--watch]
maia observe trace verify [--project <id>] [--deep]
  • --json returns one bounded snapshot. --watch and --follow stream with --after, --heartbeat-ms <1000..30000> and --max-buffered <1..1000>, using the frames and cursors in contracts. A retention gap returns event_cursor_expired with a fresh snapshot cursor; exceeding --max-buffered ends the stream with stream_buffer_exceeded. Records are never dropped silently.
  • observe feed is the one cross-domain stream. Filters intersect with the caller's grants and owner discovery ceiling; content is redacted unless --include-content is used with a separate content grant. Clients use one aggregate feed, not one connection per Session.
  • observe pipeline is operator-only and exposes partitions, checkpoints, lag, gaps and subscribers without payload content.
  • observe session is the causal turn timeline used by terminal, Desktop and mobile views (below).

Live Session timeline

maia observe session <id> --json returns the Session, its current activation and attempt, a bounded causal timeline, an evidence watermark and coverage. The timeline always carries lifecycle and correlation evidence, so a renderer can diagnose a stuck turn without reading private content; content grants may add redacted previews or artifact references. Planned data:

{
  "session": {
    "agentSessionId": "session_01...",
    "lifecycleStatus": "running",
    "revision": 13
  },
  "activation": {
    "activationId": "activation_01...",
    "state": "running",
    "reservationId": "reservation_01..."
  },
  "attempt": {
    "attemptId": "attempt_01...",
    "number": 1,
    "nodeId": "node_01...",
    "workerId": "worker_01...",
    "assignmentEpoch": 4,
    "lastHeartbeatAt": "2026-09-01T12:00:04.000Z"
  },
  "timeline": [
    {
      "kind": "message.accepted",
      "eventId": "event_01...",
      "messageId": "message_01...",
      "ownerSequence": 249,
      "correlationId": "correlation_01...",
      "occurredAt": "2026-09-01T12:00:00.000Z"
    },
    {
      "kind": "tool.completed",
      "eventId": "event_02...",
      "toolCallId": "tool-call_01...",
      "operationId": "workspace.file.read",
      "effectId": null,
      "receiptId": null,
      "durationMs": 18,
      "ownerSequence": 250,
      "occurredAt": "2026-09-01T12:00:01.000Z"
    }
  ],
  "evidenceWatermark": {
    "eventSequence": 250,
    "jsonlProjectedSequence": 250,
    "lagRecords": 0,
    "state": "live"
  },
  "coverage": { "requiredEdges": 8, "observedEdges": 8, "missing": [] }
}

Backup and restore

No roadmap slice yet; rules in operations.

maia node backup preview
maia node backup create <preview-id> --confirmation-token-stdin --command-id <id>
maia node backup list [--limit <1..100>] [--after <cursor>]
maia node backup verify <backup-id-or-path>
maia node restore verify <backup-path>
maia node restore apply <restore-preview-id> --confirmation-token-stdin --command-id <id>

Routines, Execution Nodes and A2A

R8 Routines. S30 ships the schedule commands (Scheduled Sessions); the rest stays Planned with S50:

maia routine validate <routine-ref-or-path> [--project <project-id>]
maia routine trigger <routine-ref> --project <project-id> --expected-revision <n> [--command-id <id>]
maia routine ticks <routine-ref> --project <project-id> [--state <state>] [--limit <1..100>] [--after <cursor>]
maia routine reconcile <tick-id> --expected-revision <n> --command-id <id>

MN1–MN4 Execution Nodes (draft spelling; requires verified owner or admin authority and a separate machine grant, not the local bearer):

maia node list [--state <state>] [--limit <1..100>] [--after <cursor>]
maia node enroll preview --pairing-code-stdin --node-evidence-stdin
maia node enroll apply <preview-id> --confirmation-token-stdin --command-id <id>
maia node drain <node-id> --expected-revision <n> --command-id <id>
maia node handoff preview <session-id> --to-node <node-id> --expected-assignment-revision <n>
maia node handoff apply <preview-id> --confirmation-token-stdin --command-id <id>

The first remote qualification is read-only; remote coding waits for a qualified containment adapter, not CLI placement.

A2A peers and tasks (after S17). Each accepted A2A task maps to one durable Maia task with both identities kept; peers get no lease or mutation authority (decision):

maia a2a peer list [--project <id>] [--limit <1..100>] [--after <cursor>]
maia a2a peer enroll --project <id> --agent-card <https-url> --policy-stdin --expected-project-revision <n> --command-id <id>
maia a2a peer revoke <peer-id> --expected-revision <n> --command-id <id>
maia a2a task list [--project <id>] [--state <state>] [--limit <1..100>] [--after <cursor>]
maia a2a task show <a2a-task-id>
maia a2a task reconcile <a2a-task-id> --expected-revision <n> --command-id <id>

Cross-owner coordinator work uses parent-mediated delegation only; there is no peer bridge command (decision).

Maia Agent surface reference

maia agent is Pi rebranded for Maia: standalone Pi interaction and extension semantics, with Maia owning the command prefix, state namespace, safety boundary and durable services (decision). Pi extensions are the only Agent plugin model: one extension can register tools, slash commands, flags, shortcuts, renderers, providers and lifecycle hooks.

The Agent has three separate surfaces:

SurfaceCallerSyntaxContract owner
process launchshell, user, process supervisormaia agent ... flagsCLI parser and activation schema
interactive commandperson inside an Agent session/command ...command registry projecting protocol or UI operations
model toolLLM onlyJSON Schema tool callprotocol tool-action registry

A launch flag never becomes a model tool, a slash command never enters the model's tool schema, and typing raw tool JSON never calls a tool.

Agent launch flags

Observed Maia Agent implementation today

maia agent [--continue] [-e|--extension <path>] [-ne|--no-extensions] [--model <provider/model>] [--thinking <level>] [extension flags] [--] [prompt]
maia agent --print [--no-session] [-e|--extension <path>] [-ne|--no-extensions] [--model <provider/model>] [--thinking <level>] [extension flags] [--] <prompt>
maia agent --json-events [--no-session] [-e|--extension <path>] [-ne|--no-extensions] [--model <provider/model>] [--thinking <level>] [extension flags] [--] <prompt>
maia agent extension digest <path>
maia agent login

Short forms: -c (--continue), -p (--print), -h, -v. --verbose is also accepted.

  • -ne/--no-extensions skips discovered executable extensions without resolving configured Project extension paths; explicit -e paths still load, and reviewed Maia kernel extensions, Skills and AGENTS.md stay.
  • Loaded extensions run in Pi's native runtime: their tools join the model tool list, their slash commands join the interactive registry, and their long flags reach the extension. Explicit paths resolve against the launch directory; Project-discovered extensions are digest-pinned. Maia never reads ~/.pi or calls an installed pi.
  • Model tools today: Pi's built-in read, bash, edit and write (Pi's default active set; its grep, find and ls are not active) and Maia's task, goal and process extension tools. Their details objects are ad-hoc, not the target contract below. A session that activates find or grep gets 80 hits by default and at most 200, and above 80 only with a path or glob. Known gap, observed through the runtime entry in a test home: when they run, Pi looks for fd and rg in ~/.pi/agent/bin before PATH, because Pi fixes that directory when it loads, before Maia binds its agent directory (R11 AG1).
  • Slash commands today, standalone agent only: /goal [status|show], /goal <objective> or /goal rewrite <objective>, /goal pause|resume|done|abandon, and /tasks [open|all], which changes nothing. They act on the same Session ledgers as the goal and task tools without a revision check. /goal raw and /goal clear are rejected, and a verb with extra words, such as /goal pause the deploy, is refused rather than read as a new goal. Pi's native commands apply. The managed worker registers no slash command.

Target launch flags (R11 AG1)

The standalone Pi launch grammar under the maia agent prefix, adding Maia flags only where Pi has none.

maia agent
  [--provider <provider>] [--model <model>] [--thinking <level>]
  [--continue | --resume | --session <id-or-path>]
  [--print | --mode <text|json|rpc>] [--no-session]
  [--tools <names> | --no-tools | --no-builtin-tools]
  [-e | --extension <path>] [-ne | --no-extensions]
  [--skill <path>] [--prompt-template <path>] [--theme <path>]
  [extension-defined flags] [@files...] [messages...]

Local mode is an in-process Session whose files are not Node receipts. Durable work uses Node Sessions and S12 session attach. --tools can narrow active tools but never grants an operation that policy denied.

Interactive user commands: human only

Planned (R11). With --json, an operation command returns the target envelope with meta.surface set to agent-command. A local UI command never mutates the Node or the workspace and returns maia.agent-command.v1 JSON without a receipt. A command that needs a mutation must project a registered operation.

CommandKindOperation or data
/help [command] [--json]local UIcommand names, usage, availability, schema digests
/status [--json]operationlocal activation or status.show projection
/session [--json]operation/readexact Session identity, mode, revision, usage, context digest
/model list [--json]operation/readmodel.list bounded catalog
/model use <connection-id> <model-id> [--json]local narrowingallowed model for the next local activation; no operation ID
/thinking <level> [--json]local activationeffective thinking level and activation revision
/login <provider> --name <name> [--json]operationconnection.login; secrets stay outside command output
/logout <connection-id> [--json]operationconnection.logout, confirmed when required
/connection list|show|test [<id>] [--json]operation/readredacted connection.* projection
/tools [--json]local activationregistered tools, actions, source, grant, availability, schema digest
/context inspect|lint [path], /context explain <source-id>operation/readcontext.* manifest, diagnostics or source metadata
/skills [--json]operation/readeligible Skill metadata only
/skill load <skill-ref> [--json]operation/local contextexact Skill digest, measurements, next-turn disposition
/task <create,list,get,update,archive-completed> … [--json]operationsession.task.* result or receipt
/goal <show,rewrite,update,verify,pause,resume,done,abandon> … [--json]operationsession.goal.* result or receipt
/process <start,list,output,write,stop,clear> … [--json]operationexecution.process.* result; absent without an execution profile
/agent <start,list,inspect,send,wait,stop> … [--json]operationsession.* result or receipt
/inbox <list,read> … [--json]operation/readbounded Session messages with thread, reply and delivery fields
/interaction <list,show,respond,cancel> … [--json]operationpending questions; respond answers questions only
/mcp <status,search,describe> … [--json]operation/readallowlisted MCP metadata; configuration stays outside the Session
/memory <list,search,get> … [--json]operation/readauthorized bounded memory references or content
/doctor <check,advise> … [--json]operationdeterministic diagnostics or maintenance proposals
/trace <tail,verify> … [--json]operation/readauthorized redacted trace records or integrity
/compact [focus] [--json]operation/local contextcompaction digest, retained range, token and byte measurements
/reload [--json]operation/previewcandidate config/context snapshot and diagnostics; never silent admission
/interrupt [--json]operationcancellation request or acknowledgement, never invented completion
/copylocal UIcopies the latest allowed assistant text
/detachattached renderer onlysession.attachment.close (S12); the durable Session continues
/export <path> [--json]operationredacted Session export manifest and receipt; create-new only
/newlocal UIreplaces the local Session with a fresh local identity
/quitlocal UIends the process after bounded cleanup

Commands do not mirror every model action: the model can call web_search while a person asks in prose, and provider or MCP configuration stays in the outer CLI. Native Pi and extension slash commands remain available locally.

Interactive commands use the attached or local Session implicitly and never take an actor, Project, workspace, Connection secret or attachment ID from model text. Mutating forms derive a command ID when omitted and use the observed revision; a stale revision is shown, never retried silently.

/task create --subject <text> [--description-stdin] [--active-form <text>]
/task list [--status <status>]
/task get <task-id>
/task update <task-id> [--subject <text>] [--description-stdin] [--active-form <text>] [--status <status>] --expected-revision <n>
/task archive-completed --expected-ledger-revision <n>

/goal show
/goal rewrite --objective-stdin [--next-stdin] --expected-revision <n|null>
/goal update [--next-stdin] [--success-stdin] [--constraints-stdin] --expected-revision <n>
/goal verify --evidence-stdin --expected-revision <n>
/goal pause|resume|done|abandon --expected-revision <n>

/process start --argv-stdin [--cwd <relative-path>] [--stdin <pipe|closed>]
/process list
/process output <process-id> [--after <cursor>]
/process write <process-id> --input-stdin --expected-revision <n>
/process stop <process-id> --expected-revision <n>
/process clear <process-id> --expected-revision <n>

/agent start <agent-ref> --instruction-stdin [--max-wall-time-ms <n>] [--max-tokens <n>] [--max-output-bytes <n>]
/agent list [--relation <child|parent|peer>]
/agent inspect <session-id>
/agent send <session-id> --behavior <ask-question|assign-work|reply|report-progress> [--thread <id>] [--reply-to <message-id>] --message-stdin --expected-revision <n>
/agent wait <session-id>... [--after-revision <n>] [--timeout-ms <n>]
/agent stop <session-id> --expected-revision <n>

/interaction list [--state <pending|answered|cancelled|expired>]
/interaction show <interaction-id>
/interaction respond <interaction-id> --answer-stdin --expected-revision <n>
/interaction cancel <interaction-id> --expected-revision <n>
/inbox list [--thread <id>] [--state <state>]
/inbox read <message-id>

/compact [--focus-stdin]
/reload
/interrupt
/export <path>

Text fields are at most 16 KiB; argv has 1–128 elements and 32 KiB in total; lists return at most 100 items. /reload only compiles a candidate and shows its diff; admission is a separate previewed operation. /compact writes a compaction record with source range, retained summary digest, model/tokenizer, token counts, trigger and resulting context digest.

Model-tool envelope

Planned (R11). Pi requires content blocks for the model; Maia puts the canonical result in details. The text block is a short summary; automation reads details.

{
  "content": [{ "type": "text", "text": "Session session_01... started." }],
  "details": {
    "schemaVersion": "maia.tool-result.v1",
    "requestId": "req_01...",
    "operationId": "session.start",
    "status": "accepted",
    "data": { "agentSession": {} },
    "meta": { "pagination": null, "warnings": [] },
    "interaction": null,
    "receipt": {},
    "error": null
  }
}
type ToolResultV1 =
  | {
      schemaVersion: "maia.tool-result.v1";
      requestId: string;
      operationId: string;
      status: "ok" | "accepted" | "running";
      data: unknown;
      meta: ToolResultMetaV1;
      interaction: null;
      receipt: ReceiptV1 | null;
      error: null;
    }
  | {
      schemaVersion: "maia.tool-result.v1";
      requestId: string;
      operationId: string;
      status: "blocked";
      data: null;
      meta: ToolResultMetaV1;
      interaction: {
        interactionId: string;
        kind: "confirmation" | "question" | "authority" | "outcome-unknown";
        expiresAt: string | null;
        resumeOperationId: string | null;
      };
      receipt: ReceiptV1 | null;
      error: null;
    }
  | {
      schemaVersion: "maia.tool-result.v1";
      requestId: string;
      operationId: string;
      status: "error";
      data: null;
      meta: ToolResultMetaV1;
      interaction: null;
      receipt: ReceiptV1 | null;
      error: MaiaErrorV1;
    };

interface ToolResultMetaV1 {
  readonly pagination: {
    readonly limit: number;
    readonly nextCursor: string | null;
    readonly hasMore: boolean;
  } | null;
  readonly warnings: ReadonlyArray<string>;
}

Each operation replaces unknown with its output schema. blocked never overloads error and always names how or whether the operation resumes. Pagination lives only in meta.pagination. ReceiptV1 is the receipt contract.

Error example:

{
  "schemaVersion": "maia.tool-result.v1",
  "requestId": "req_edit_conflict",
  "operationId": "workspace.file.edit",
  "status": "error",
  "data": null,
  "meta": { "pagination": null, "warnings": [] },
  "interaction": null,
  "receipt": null,
  "error": {
    "code": "content_digest_mismatch",
    "message": "The file changed after the edit was prepared.",
    "retryable": false,
    "details": {
      "path": "src/a.ts",
      "expectedDigest": "sha256:old",
      "observedDigest": "sha256:new"
    }
  }
}

Error details has a schema per code. Raw provider, MCP and web error bodies are never serialized.

Model-tool catalogue: LLM only

Planned (R11 AG2–AG3). Names, parameters and error codes follow D-172. Only tools granted to the exact activation are registered; optional adapters are absent by default. Tool schemas and descriptions are stable within an activation and included in its context manifest digest.

Common rules:

  • Mutations require idempotencyKey; compare-and-set mutations also require expectedRevision or an exact content digest. Error and blocked results never encode success in text.
  • Paths are workspace-relative unless the activation grants an absolute root.
  • Limits below are protocol maxima; Agent, Project or operator policy may lower them.
  • One action maps to exactly one operation, except maia__tool, which the Node routes by the target tool's admitted risk class; there are no wildcard operation IDs. Each row generates closed <operation-id>.input.v1 and <operation-id>.output.v1 schemas.
  • Authorization scope is the operation ID plus the activation's Project, Session, workspace grant and any exact target or Connection ID in the input. No row allows an omitted cross-Project target.
  • read operations have no receipt; a mutation requires idempotency, emits the named event and returns a receipt.
  • Availability is core, execution-profile, web-adapter, mcp-adapter, memory-adapter or maintenance-agent.

Error sets:

  • R: invalid_input, capability_unavailable, unauthorized_scope, hard_limit_exceeded, timeout, interrupted, integrity_failure, internal_error.
  • C: R plus revision_conflict and idempotency_conflict.
  • F: C plus content_digest_mismatch and path_outside_workspace.
  • E: C plus external_unavailable and outcome_unknown.
  • X: R plus external_unavailable and outcome_unknown, for external reads.
Tool actionOperation IDComplete inputSuccess dataContract: owner; risk; errors; event; availability
read_fileworkspace.file.readpath; offset? integer at least 0; limit? 1..2000path, content or artifactRef, startLine, endLine, truncated, digestworkspace; read; R; none; core
write_fileworkspace.file.writecreate mode: mode=create, path, content at most 1 MiB, idempotencyKey; replace mode: same plus mode=replace, expectedDigestpath, created, byteSize, priorDigest, resultDigest, revisionworkspace; mutation; F; workspace.file.written; core
edit_fileworkspace.file.editpath; edits 1..128 entries with oldText, newText, replaceAll?; expectedDigest; idempotencyKeypath, appliedEdits, diff, firstChangedLine, priorDigest, resultDigest, revisionworkspace; mutation; F; workspace.file.edited; core
grepworkspace.search.greppattern; path? default .; glob?; ignoreCase?; literal?; context? 0..20; limit? 1..200, default 50; outputMode? files_with_matches (default), content or countfile paths for files_with_matches, path/line/column/preview matches for content, or match counts for count; truncated, matchLimitReachedworkspace; read; R; none; core
globworkspace.search.findpattern; path? default .; limit? 1..500paths, truncated, resultLimitReachedworkspace; read; R; none; core
list_directoryworkspace.directory.listpath? default .; limit? 1..500; cursor? opaqueentries with name/path/kind, truncated; pagination in metaworkspace; read; R; none; core
run_commandexecution.shell.runargv 1..128 strings; cwd?; timeoutMs? 1..600000; maxOutputBytes? 1024..1048576; idempotencyKeyexecutionId, exitCode, signal, stdout, stderr, truncated, durationMsexecution; mutation; E; execution.shell.completed; execution-profile
start_processexecution.process.startargv 1..128 strings; cwd?; name? at most 120; timeoutMs? 1..600000; maxOutputBytes? 1024..1048576; expectedSessionRevision; idempotencyKeyprocess with ID/name/lifecycle/revision/outputCursorexecution; mutation; E; execution.process.started; execution-profile
list_processesexecution.process.listlifecycleStatus? from starting/running/exited/stopped/failed/outcome-unknown; limit? 1..100; cursor? opaqueprocesses; pagination in metaexecution; read; R; none; execution-profile
read_process_outputexecution.process.outputprocessId; afterCursor? opaque; limit? 1..200 chunks; maxBytes? 1024..1048576processId, chunks, nextOutputCursor, truncated, revisionexecution; read; R; none; execution-profile
write_process_inputexecution.process.writeprocessId; input at most 64 KiB; end?; expectedRevision; idempotencyKeyprocessId, acceptedBytes, inputClosed, revisionexecution; mutation; E; execution.process.input-written; execution-profile
stop_processexecution.process.stopprocessId; expectedRevision; idempotencyKeyprocessId, lifecycleStatus, revisionexecution; mutation; E; execution.process.stop-requested; execution-profile
clear_processexecution.process.clearprocessId; expectedRevision; idempotencyKeyprocessId, clearedThroughCursor, revisionexecution; mutation; C; execution.process.output-cleared; execution-profile
create_tasksession.task.createsubject at most 240; description? at most 4096; activeForm? at most 160; expectedLedgerRevision; idempotencyKeytask, ledgerRevisionSession work; mutation; C; session.task.created; core
list_taskssession.task.listlifecycleStatus?; includeArchived?; limit? 1..100; cursor? opaqueagentSessionId, tasks, ledgerRevision; pagination in metaSession work; read; R; none; core
get_tasksession.task.gettaskIdtask, ledgerRevisionSession work; read; R; none; core
update_tasksession.task.updatetaskId; one or more of subject?, description?, activeForm?, lifecycleStatus? from pending/in-progress/blocked/completed/cancelled; expectedRevision; expectedLedgerRevision; idempotencyKeytask, ledgerRevisionSession work; mutation; C; session.task.updated; core
archive_completed_taskssession.task.archive-completedexpectedLedgerRevision; idempotencyKeyarchivedTaskIds, ledgerRevisionSession work; mutation; C; session.tasks.archived; core
show_goalsession.goal.showno fieldsgoal or lifecycleStatus=noneSession goal; read; R; none; core
rewrite_goalsession.goal.rewriteobjective at most 2048; next? at most 1024; success? up to 16 strings; constraints? up to 16 strings; expectedRevision number or null; idempotencyKeygoalSession goal; mutation; C; session.goal.rewritten; core
update_goalsession.goal.updateone or more of objective?, next?, success?, constraints?; expectedRevision; idempotencyKeygoalSession goal; mutation; C; session.goal.updated; core
verify_goalsession.goal.verifyevidenceRefs 1..32 strings; expectedRevision; idempotencyKeygoal, verificationSession goal; mutation; C; session.goal.verified; core
pause_goalsession.goal.pauseexpectedRevision; idempotencyKeygoalSession goal; mutation; C; session.goal.paused; core
resume_goalsession.goal.resumeexpectedRevision; idempotencyKeygoalSession goal; mutation; C; session.goal.resumed; core
complete_goalsession.goal.doneexpectedRevision; idempotencyKeygoalSession goal; mutation; C; session.goal.completed; core
abandon_goalsession.goal.abandonexpectedRevision; idempotencyKeygoalSession goal; mutation; C; session.goal.abandoned; core
start_agentsession.startagentRef; instruction at most 16 KiB; expectedParentRevision; expectedTreeRevision; narrowing limits? with maxWallTimeMs 1..1800000, maxTokens 1..1000000, maxOutputBytes 1024..1048576; exact resultContractRef?; idempotencyKeyagentSessionSession; mutation; E; session.created; core
list_agentssession.listrelation? one of child/parent/peer; lifecycleStatus?; limit? 1..100; cursor? opaqueagentSessions; pagination in metaSession; read; R; none; core
inspect_agentsession.showagentSessionIdagentSessionSession; read; R; none; core
message_agentsession.sendagentSessionId; behavior one of ask_question, assign_work, reply, or report_progress; threadId?; replyTo?; message at most 16 KiB; expectedRevision; idempotencyKeymessageId, agentSessionId, acceptedRevision, wakeDispositionSession; mutation; E; session.message.accepted; core
wait_for_agentsession.waitagentSessionIds 1..8; afterRevisions? exact ID-to-revision map; timeoutMs? 0..120000changed, timedOut, observedAtSession; read; R; none; core
stop_agentsession.stopagentSessionId; expectedRevision; idempotencyKeyagentSessionId, lifecycleStatus, revisionSession; mutation; E; session.stop-requested; core
ask_questioninteraction.createkind one of question/selection; subject at most 240; body at most 4096; options? 2..8 entries with stable ID/label; answerSchemaRef?; expiresAt?; expectedSessionRevision; idempotencyKeyblocked result with interactioninteraction; mutation; C; interaction.created; core
show_interactioninteraction.showinteractionIdinteraction without secret proofinteraction; read; R; none; core
respond_to_interactioninteraction.respondinteractionId; typed answer; wakeDisposition one of resume-waiting-session/record-only; expectedRevision; idempotencyKeyinteraction, responseDigest, wakeDispositioninteraction; mutation; C; interaction.answered; core
cancel_interactioninteraction.cancelinteractionId; expectedRevision; idempotencyKeyinteractionId, lifecycleStatus, revisioninteraction; mutation; C; interaction.cancelled; core
inspect_contextcontext.inspectpathScope?contextManifestcontext compiler; read; R; none; core
lint_contextcontext.lintpathScope?diagnostics, valid, contextManifestDigestcontext compiler; read; R; none; core
explain_contextcontext.explainsourceIdsource metadata, measurements, dispositioncontext compiler; read; R; none; core
show_context_usagecontext.usageno fieldscontextManifestDigest, bytes, estimatedTokens, hardLimitBytes, sourcescontext compiler; read; R; none; core
list_skillsskill.listeligibleOnly? true; limit? 1..100; cursor? opaqueSkill metadata only; pagination in metaSkill loader; read; R; none; core
maia__skillskill.loadname (the qualified Skill identity); pathScope?; expectedActivationRevision; idempotencyKeyskillRef, digest, byteSize, loadDisposition, activationRevisionSkill loader; mutation; C; context.skill-selected; core
show_skill_statusskill.statusskillRefSkill metadata, eligibility, digest, measurementsSkill loader; read; R; none; core
web_searchintegration.web.searchqueries 1..4; mode? one of search/answer; limitPerQuery? 1..10; allowedDomains?; blockedDomains?; publishedAfter?; publishedBefore?results with fetchable URLs, truncated, citationsweb adapter; external read; X; integration.web.observed; web-adapter
web_fetchintegration.web.fetchurl; maxBytes? 1024..262144one document with URL, title, content or artifact, digest and truncated; citationsweb adapter; external read; X; integration.web.observed; web-adapter
show_mcp_statusintegration.mcp.statusno fieldsbounded server statusesMCP adapter; read; R; none; mcp-adapter
maia__searchintegration.mcp.searchquery; serverIds?; limit? 1..50bounded server/tool matchesMCP adapter; read; R; none; mcp-adapter
describe_toolintegration.mcp.describename (<connection>__<tool>)safe metadata, serverRevision, toolSchemaDigest, riskPolicyDigest, exact call variantMCP adapter; read; R; none; mcp-adapter
maia__tool (read)integration.mcp.call.readname (<connection>__<tool>); input JSON object at most 64 KiB and depth 16; serverRevision; toolSchemaDigest; riskPolicyDigestname, classified bounded result, pinned digestsMCP adapter; external read; X; integration.mcp.read-completed; mcp-adapter
maia__tool (mutation)integration.mcp.call.mutationname (<connection>__<tool>); input JSON object at most 64 KiB and depth 16; expectedServerRevision; toolSchemaDigest; riskPolicyDigest; idempotencyKeyblocked preview, then classified bounded result with pinned digestsMCP adapter; mutation; E; integration.mcp.mutation-completed; mcp-adapter
list_memoriesmemory.listscope one of current-session/current-project/project; projectId?; limit? 1..100; cursor? opaquememories, truncated; pagination in metamemory; read; R; none; memory-adapter
search_memoriesmemory.searchscope; projectId?; query at most 4096; roles? from user/assistant/tool/system-summary; tags? up to 16 strings of 64 chars; limit? 1..20; cursor? opaquememories, truncated; pagination in metamemory; read; R; none; memory-adapter
get_memorymemory.getmemoryRefmemory with exact scope, classification, provenance and bounded contentmemory; read; R; none; memory-adapter
run_doctordoctor.checksnapshotDigest; scope one of project/agent/routine/contextvalid, diagnostics, snapshotDigestmaintenance; privileged read; R; none; maintenance-agent
query_tracetrace.queryfilters with projectId, optional agent/session/routine IDs, time range and classification; limit? 1..100; cursor? opaqueredacted records; pagination in metamaintenance; privileged read; R; none; maintenance-agent
list_evaluationseval.listsubjectId; limit? 1..100; cursor? opaqueevaluations; pagination in metamaintenance; privileged read; R; none; maintenance-agent
propose_changemaintenance.proposal.createkind; rationale at most 4096; suggestedChanges 1..32 non-executable records; evidenceRefs 1..64; idempotencyKeyproposalmaintenance; proposal mutation; C; maintenance.proposal.created; maintenance-agent
  • rewrite_goal's expectedRevision is null only when no goal exists.
  • start_agent uses the calling Session as parent and cannot override workspace, Connection, model or authority. Limits only narrow the resolved Agent policy; resultContractRef must resolve to a registered, namespaced output schema. Long work is delegated with start_agent.
  • respond_to_interaction is available to authenticated people and to delegated Agents granted that exact operation, never to a voice model.
  • describe_tool resolves one immutable call variant from operator policy and fresh capability evidence. Both call variants reject drift in server revision, schema digest or risk-policy digest. A mutation maia__tool call first returns a blocked operation.preview; only an authenticated person can confirm it, and the model never receives the proof. A tool without a trusted static risk annotation is unavailable, never downgraded to read.
  • run_command takes argv, never an interpolated shell string. The process tools are Session-owned background execution, not a durable service. The task tools are a small planning ledger without dependencies, not a child Agent or scheduler.
  • Local mode implements the same schemas in process, keeps a local receipt ledger for mutations and has no direct-mutation escape hatch. Paths are canonicalized with symlink containment; output caps and deadlines are mandatory. Project instructions and Skills cannot register or grant tools.

Task and goal records (one Session owns them; tasks do not execute, allocate capacity, grant authority or form a dependency graph; a Session has at most one non-terminal goal, and done needs verification evidence or an explicit unverified-completion policy):

{
  "task": {
    "taskId": "task_01...",
    "agentSessionId": "session_01...",
    "subject": "Verify the Agent tool contract",
    "description": "Check every tool schema and result example.",
    "activeForm": "Verifying Agent tools",
    "lifecycleStatus": "in-progress",
    "revision": 3,
    "createdAt": "2026-09-01T10:00:00.000Z",
    "updatedAt": "2026-09-01T12:00:00.000Z"
  },
  "goal": {
    "agentSessionId": "session_01...",
    "objective": "Deliver implementation-ready Maia documentation",
    "next": "Implement the first protocol vertical slice",
    "success": ["Every command and tool has one checked contract"],
    "constraints": ["Keep one native Agent harness"],
    "lifecycleStatus": "active",
    "verification": { "lifecycleStatus": "not-run", "evidenceRefs": [] },
    "revision": 2,
    "updatedAt": "2026-09-01T12:00:00.000Z"
  }
}

Voice tool surface

Source candidate: the realtime voice model gets one tool, maia, with two actions (protocol):

  • ask with an utterance (at most 4 KiB) prepares an exact message for trusted on-screen confirmation. The model receives a proof-free confirmation-required result; the trusted client confirms and the Node relays it (voice.pi.relay, reconciled by voice.pi.relay.reconcile).
  • status returns one bounded Node snapshot. Agent output in it remains untrusted data.

A trusted Desktop projection may instead route ask through the managed-Agent preview (voice.managed-turn.preview, confirmed with voice.managed-turn.confirm), and status then reports that turn's phase and up to 2,048 bytes of Agent text. Calls are idempotent per scope and tool-call ID and capped at 16 per scope. Neither action confirms, executes generic operations or grants shell, filesystem, network or credential authority.

Planned (R7, voice contract; decision): ask binds to the exact authorized Session the person is viewing (global, Workspace or Project coordinator, worker or ordinary Session), with one client-turn identity shared by typed send and voice finalization. The Node binds principal, owner and target, never the model. Speech can request a confirmation ceremony but never satisfy it; the authenticated client confirms. Text stays the default composer, and no voice capability is needed for any text or CLI control.

Shared JSON shapes

Target result envelope

Planned for R11 operation-backed interactive commands, and the shape the other surfaces converge on. The outer CLI keeps its current output; moving it has no roadmap slice (decision, pending owner).

{
  "schemaVersion": "maia.cli.v1",
  "operationId": "session.task.create",
  "requestId": "req_01...",
  "ok": true,
  "data": { "task": {}, "ledgerRevision": 1 },
  "meta": {
    "generatedAt": "2026-09-01T12:00:00.000Z",
    "surface": "agent-command",
    "pagination": null,
    "warnings": []
  },
  "receipt": { "operationId": "session.task.create", "outcome": "accepted" },
  "error": null
}
  • ok: true requires error: null; ok: false requires data: null and an error.
  • receipt is non-null only when the registry declares a receipt.
  • meta.surface is cli, agent-command, model-tool, voice-tool, sdk or http; it never changes the operation-owned data.
  • meta.pagination is always present ({ limit, nextCursor, hasMore } or null).
  • data never repeats the operation ID, transport fields, receipt or error.

Interaction and response

Source candidate (R1a): the projection maia.interaction/v1 (packages/protocol/src/interaction):

{
  "schemaVersion": "maia.interaction/v1",
  "interactionId": "interaction_01...",
  "kind": "selection",
  "projectId": "project_01...",
  "sessionId": "session_01...",
  "activationId": null,
  "requestingActorId": "actor_01...",
  "origin": { "kind": "external-cli" },
  "subject": "Show routine progress updates?",
  "body": "Choose whether routine progress appears in Desktop.",
  "options": [
    { "optionId": "yes", "label": "Yes" },
    { "optionId": "no", "label": "No" }
  ],
  "answerSchemaDigest": "sha256:...",
  "lifecycleStatus": "pending",
  "expiresAt": null,
  "revision": "1",
  "createdAt": "2026-10-01T12:00:00.000Z",
  "answeredAt": null,
  "wakeDisposition": "record-only"
}
  • A question has no options and takes a text answer; a selection has 2–8 frozen options.
  • lifecycleStatus is pending, answered, cancelled or expired. Revision is "1" while pending or expired and "2" after the single answer or cancellation.
  • List, create and cancel results never contain the answer. show and respond add answer ({ "kind": "text", "value": ... } or { "kind": "selection", "optionIds": [...] }), respondingActorId and responseDigest for the authorized principal.
  • An answer is recorded data, never approval. Consequential actions use the operation's own preview and human confirmation.
  • The planned R11 model tool ask_question is the activation-bound form: it returns a blocked tool result, and its answer may set wakeDisposition to resume-waiting-session.

Overlap rules

The contract check (maintainer workflow in contracts) fails when:

  • a CLI usage or tool name appears twice in one activation;
  • a command or tool action maps to zero or several operations, except maia__tool's declared read or mutation routing by admitted risk class;
  • an operation has more than one durable owner;
  • an output field has different schemas on different surfaces;
  • a list DTO embeds another aggregate's full mutable state;
  • a command returns a credential or secret-class field;
  • data, meta, receipt or error overlap;
  • a mutation lacks risk, authority, idempotency or receipt declarations;
  • a paginated operation lacks a bound or cursor schema;
  • a model or voice tool exposes an operation not granted to that activation.

Reserved field names:

FieldOnly meaning
lifecycleStatusdomain aggregate lifecycle
health / degradedcomponent health observation
availabilityprovider, model or executor capacity
revisioncompare-and-set revision of one aggregate
eventSequenceordered event position
observedAttime an external projection was observed
createdAt / updatedAtpersisted aggregate timestamps
nextCursoropaque pagination continuation

New canonical DTOs do not use the ambiguous top-level names id, state, status, timestamp or cursor.

Agent Codex and Pi Config capability disposition

The single feature and action map for replacing the daily Pi harness with Maia (R11). The migration plan owns inventory, provenance, qualification and reversible cutover. Every required source workflow needs a verified Maia equivalent or a deviation the user accepted; a rejection code is not parity. Legacy names never become Maia operation IDs.

AG0 builds one closed migration record per observed source action (tools, commands, flags, shortcuts, background hooks): {sourceExtension, sourceAction, disposition, targetOperationId?, rejectionCode?, replacement?}, with exactly one of targetOperationId or rejectionCode. Stable rejection codes: MAIA_WORKFLOW_DSL_UNSUPPORTED, MAIA_TASK_GRAPH_UNSUPPORTED, MAIA_AMBIENT_CONFIG_MUTATION_UNSUPPORTED, MAIA_AUTOMATIC_MEMORY_INJECTION_UNSUPPORTED and MAIA_EXTERNAL_EXECUTOR_GUARANTEE_UNAVAILABLE. The contract check fails on an unmapped source action. Agent definitions, prompts, Skills, package resources, UI preferences, shortcuts and unexplained top-level helpers are classified the same way; nothing is imported by directory recursion.

The table covers Pi's built-ins and the 33 observed pi-* extensions, then names that appear in older Pi setups but not in the observed source.

SourceMaia dispositionRequired proof
Pi built-ins read, bash, edit, write, grep, find, lswrap read/edit/write/grep/find/ls as read_file, edit_file, write_file, grep, glob and list_directory; replace bash with bounded argv run_command under an execution profileexact input/result contract, containment, mutation receipts, output limits
pi-safetytyped tool authorization and execution/workspace policy outside prompts; never a prompt or deletion tripwire in place of containmentdestructive and escape adversarial tests
pi-agents, pi-swarmagent tools (start_agent, message_agent and their siblings) creating normal Maia Sessions under Node/Swarm authority; message_agent replaces steer; optional exact result contract; task-owned independent reviewrecursion, budget, lineage, structured result, cancellation, worktree-policy tests
pi-tasks (task, TaskCreate/List/Get/Update/Batch/Output/Stop/Execute, /tasks, /plan-gate)Session-local task; execution becomes start_agent; approvals become interactions or operation previews; no hub, graph or task schedulerledger recovery, CAS, lifecycle, source-action diagnostics
pi-goal, /goalone-goal-per-Session goal tool and /goal; raw and clear are rejected in favor of rewrite and terminal transitionslifecycle, revision, evidence, terminal-state tests
pi-interactions (/inbox)protocol interactions plus the Session inbox; questions stay distinct from confirmation proof; private session links become durable session.send with thread, reply and behavior fieldsreplay, expiry, actor/device binding, idempotent delivery, outcome-unknown tests
pi-a2a (/a2a)Node A2A v1 ingress and egress with Swarm admission; never internal lease or task truthidentity mapping, tenant authorization, artifact and update tests
pi-claims, pi-worktreeexact cooperative reservations (S16 leases) and explicit checkout create/list/remove (S5); neither is OS containmentownership and recovery tests
pi-daemon (shell, process, /daemon, /ps*), pi-rpc (rpc_session)bounded run_command and Session process tools; durable services are Node operator operations; rpc_session becomes the out-of-process Maia Agent worker and public session.*profile enforcement, process-tree cleanup, crash/restart, replay, interrupt tests
pi-crons, pi-routines (/crons, /loop, /routine)Routines with manual or cron start-or-wake (R8); no looping model process or workflow graphtick uniqueness, DST, downtime catch-up, queue tests
pi-context (usage, consumption, status, restore, history, doctor, prune)context.inspect, context.lint, context.explain, context.usage over exact Node history; restore and prune wait for an explicit retention operationmanifest accounting, budget, compaction, retention tests
pi-custom-compactorbounded context compiler and protocol-owned compaction recorddeterministic retained range, injection framing, token/byte evidence
pi-memory (list, search, get, list-projects, /memory)optional read-only memory.list/search/get; ambient history injection rejected; R4 adds scoped learning separatelyscope/export authorization, bounded manifests; PF-06/DF-06 for R4
pi-config (list, get, describe, sources, set, unset, edit, reload)outer configuration operations (S13 config.resolve, plan, apply, drift, import); edits create candidates and /reload only previews oneclosed schemas, no hidden grant, admission and CAS tests
pi-model-policytyped Connection and model selection order with explicit fallback policypre-dispatch, no-output and outcome-unknown conformance
pi-codex-pool (/codex)named Connections; session create --executor picks the least-used account; S2 adds a recorded connectionPool rotating at Session admission; no private usage endpoints or quota bypassmulti-account isolation, refresh, logout race, rotation, limit, cross-Workspace denial tests
pi-vercel-policyModel Provider policy and Vercel Gateway adapter; no provider-specific command rootcapability evidence, payload redaction, fallback tests
pi-exa (/web-search, web_search, web_fetch), pi-searchprovider-neutral optional web_search and web_fetch; glob and grep with hard boundsdomain/date/URL policy, citation, injection, rg/fd absence, truncation tests
pi-mcp (status, search, describe, connect, call, doctor, history, reload, config, /mcp)the Agent gets allowlisted status, search, describe and call; server configuration and lifecycle stay with the operator CLI and Nodeannotation risk, secret redaction, reconnect, malicious-result tests
pi-prompt, pi-prompt-conformanceoptional client helper plus deterministic context and contract tests; never required for kernel correctness or instruction authorityno hidden prompt source, snapshot digest tests
pi-projects (/projects), pi-cd (/cd)project.* operations; no ambient model cwd: path scope is explicit per activation and tool callexact root and admission, cwd confusion, containment tests
pi-session-chrome, pi-session-title, pi-session-scratch, pi-terminal-ui (/dashboard)Session metadata, owned disposable scratch and replaceable SDK projections; the old UI is not ported, a minimal PiTuiRenderer renders SDK Session eventsno authority in titles or status; renderer parity fixture
pi-events (/events), pi-telemetryNode ordered events through session events list, session observe and R1 observe; redacted events, traces and evaluation evidence (T-slices)cursor gap, backpressure, reconnect, consent, retention, secret-scan tests
Not in the observed source: pi-subagents, pi-session-links, pi-fleet, pi-workflows, pi-cursor, pi-rlmsubagents and session links as pi-agents and pi-interactions; multi-Node orchestration as Projects, Sessions, Routines, queues and receipts; no workflow DSL; Cursor as external executor Sessions (session.send, session.stop); RLM exec rejected until a sandbox, authority and receipt design existsexplicit unsupported diagnostics; no duplicate dispatch

R4 coordinator learning adds scoped automatic knowledge, memory and Skill authoring in Node-owned records. Ambient Pi-history injection stays rejected (MAIA_AUTOMATIC_MEMORY_INJECTION_UNSUPPORTED), and the read-only memory-adapter tools are not write authority or an R4 prerequisite.

System-prompt and Skill injection disposition

Maia allows the narrowest injection point that works:

Source behaviorMaia rule
tool description, snippet or guidelinesonly for registered granted tools; included in the tool-manifest and context digest
on-demand Skillonly by exact eligible Skill ID through skill.load; the body is budgeted and recorded
root instructionsone root AGENTS.md at Session start; no rules/ tree
nested instructionsdisabled by default; when enabled, nearest exact path only for one turn
APPEND_SYSTEM.md, arbitrary SYSTEM.md, full extension README/catalognever discovered or loaded
dynamic goal and task contextone bounded active goal plus a hot task summary, digest-framed; full ledgers load only through tools
automatic memoryambient injection rejected; R4 scoped retrieval uses Node admission and bounded untrusted context manifests
Orchestration or RLM dynamic system promptsrejected: those runtimes do not exist in Maia Agent
peer, A2A or Cursor messagesuntrusted conversation data, never system authority
trace or evaluation materialuntrusted evaluator input, never operational Agent context
extension or provider history rewriterejected unless a protocol operation owns and audits it

Prompt contributors have stable owner IDs, byte and token caps, exact digests, one insertion point and idempotent compilation. A contributor cannot change tool grants, actor authority, confirmation, Connection choice or protocol policy.

Imported from Maia OS docs at 3dc3e07