Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Pane options and JSON contracts

tma keeps all shared state in tmux pane options (user options, pane-scoped). There is no socket and none is needed: any tmux show-options -p or #{@agent_state} format string reads the current verdict straight from tmux’s own option store, and tma ls --json gives the same data as a stable structured document. This page is the contract for both: the pane-option schema and the JSON schemas.

The stamp grammar

Option values are machine tokens, never glyphs. State is one of the closed vocabulary idle, working, blocked, unknown. Glyph and color rendering happens only in the surfaces, and is configurable.

@agent_detail is a lowercase machine token ([a-z0-9_-]) qualifying why the pane is in that state. Unlike state, this vocabulary is open and unstable until 1.0: an agent manifest may emit a token this engine has never heard of, and it round-trips intact rather than erroring. Read it defensively — match the tokens you know and degrade gracefully on the rest. What the bundled manifests emit today:

tokenstatemeaning
permissionblockeda tool-use permission prompt: approving grants the one action in front of the user
planblockeda plan-approval dialog. Its affirmative option grants every following action, so tma act approve deliberately does not resolve here
trustblockeda workspace-trust gate. Its affirmative option grants the whole folder, so neither approve nor deny resolves here
questionblockedthe agent asked you something and is waiting on the answer (OpenCode’s question tool). There is nothing to grant, so approve and deny do not resolve here either: answering means picking one of the options on screen
rate_limitworking or blockeda usage-limit wait. The state is the whole point of the pair: working/rate_limit is the agent waiting out its own limit and resuming by itself, blocked/rate_limit is a wait that halted and needs you (a keypress, or a fresh prompt). A wait --until blocked that also reads the detail can tell “needs the clock” from “needs permission”

tma-core additionally declares error, background and compacting as constants; no bundled manifest emits those three yet.

Every @agent_*_at value is epoch milliseconds (13 digits today), not seconds. Millisecond resolution is what keeps two episodes opening in the same wall-clock second distinguishable.

@agent_attention is a presence flag: the literal value 1 when set, and the option is absent otherwise. It is compared against @agent_since by the ordered-input clear, which is why the raise instant has to stay write-once. Writers order a chained stamp so @agent_stamped_at is written last; a reader that sees stamped_at older than since or evidence_at caught a chained write mid-flight and should treat the tuple as in-progress.

One exception keeps that rule from latching. A chained stamp commits in milliseconds, so a since more than 2 seconds ahead of stamped_at is not a write in flight: it is a backward wall-clock step (a suspend, an NTP correction) that stranded the write-once since in the future. Such a tuple reads as settled, and the next publish rewrites since rather than holding it, so a stepped clock costs one stale transition time instead of a pane that re-captures every cycle for the rest of the session.

Pane-option schema

The store carries provenance (@agent_source, @agent_evidence_at) so a stateless producer can rank a stamped hook claim above its own fresh capture. Pane-scoped options describe one agent pane; window-scoped and server-scoped options carry rollups and hints.

optionscopesemantics
@agent_name, @agent_pidpaneidentity (pid: process-group leader found by the walk)
@agent_state, @agent_detailpanethe verdict (state) and its detail token. A tool other than tma that writes these on the same pane implements the @agent_state contract, which is what the two producers have to agree on
@agent_sourcepaneprovenance of the current state: hook / capture / process. activity is a legacy value still accepted on read; nothing produces it any more (a viewport hash change stopped being state evidence)
@agent_evidence_atpaneepoch ms of the evidence behind the current state
@agent_sincepaneepoch ms of the state transition, written once by the first producer to record it and never rewritten while state is unchanged (the one exception is a value stranded ahead of @agent_stamped_at by a backward clock step)
@agent_stamped_atpaneper-pane freshness marker, written last in a chained stamp
@agent_hashpanescheduling only, and its value is not interpreted: a hash of the last captured viewport whose PRESENCE means “this pane’s screen has been read at least once”, which is all any reader checks before reusing a stored stamp. Nothing compares two hashes, a changed one makes no claim about state, and the algorithm is not part of this contract. Treat it as a boolean; absent on a pane detected purely from hooks
@agent_attentionpanepresentation flag: value 1 when set, option absent otherwise. Cleared by the focus hooks (the pane arrived at, the pane departed) and by the poll cycle when a client displaying the pane was typed into after @agent_since
@agent_notified_atpanenotification-episode marker, written only by the notifier
@agent_turn_atpaneepoch ms of the last hook event that meant “a turn ended” and raised the done marker (Stop, codex’s notify, pi’s agent_settled). It exists because @agent_since is write-once per state run and so cannot move when a second completion lands on a pane that never left idle; the notify dedup and wait --since compare against the later of the two. Written only by the hook intake, and only when the marker was down, so one turn end reported on two channels records one turn. Absent on a pane that has never had one, which leaves both comparisons reading @agent_since alone
@agent_sessionpaneowning agent session id from hook registration; the subagent guard compares incoming event session ids against this
@agent_transcriptpanepath to the agent’s own transcript file, as the hook payload’s transcript_path named it (Claude Code, Codex, Gemini and Cursor carry one; OpenCode and pi do not, and a pane detected from the screen alone never gets one). Stamped beside @agent_session under the same guard, rewritten only when a payload names a different file, never cleared on an ordinary edge, and removed with the rest of the tuple on session end. Claude’s SubagentStop agent_transcript_path is deliberately not read: one session spawns many subagents and one option cannot hold them
@agent_subagentspanespace-separated live subagent session ids; SubagentStart appends, SubagentStop removes; bookkeeping only, never top-level state. While it is non-empty, only an event whose session matches @agent_session may write state; an event that cannot be attributed (either side missing) is ignored
@agent_context_pctpanecontext-utilization metric percent (integer 0100), or absent when the agent has no telemetry coverage or the channel reported no window (a null-clear); written only by the context intake under the evidence-time write guard, never part of the state tuple
@agent_context_atpaneepoch ms of the evidence behind @agent_context_pct; written last in the context mini-chain and advanced even by a null-clear, so a reordered stale push cannot walk the gauge backward (not older acceptance)
@agent_tokenspanetokens currently in the agent’s context window: the absolute @agent_context_pct is a percent of, written in the same guarded chain. Absent for an agent whose channel reports no count tma can call a footprint (see Agent coverage) and cleared by any observation that carries none, so a stale count never sits beside a fresh gauge. A level, never a cumulative spend: tma stamps no usage totals, and the one cost it does stamp (@agent_cost_usd) is the vendor’s own live figure for the current session
@agent_tokens_atpaneepoch ms of the evidence behind @agent_tokens, set and cleared with it under the same guard. It equals @agent_context_at whenever a count is present — one observation stamps both — and exists so a reader that wants only the count can age it without reading the gauge’s marker
@agent_context_notified_atpanethe context_high notify marker: a present/absent armed flag (absent = armed, present = already fired), never the state lane’s @agent_notified_at; its value is an epoch ms for debuggability only, not a comparison basis. Written only by the context-high notifier, guarded set-from-absent so concurrent firers resolve to one bell; cleared (rearmed) when the gauge dips below threshold - 10
@agent_stall_notified_atpanethe stall notify marker, the same present/absent armed flag as @agent_context_notified_at and just as separate from the state lane’s @agent_notified_at: absent = armed, present = already fired for the current working run. Its value is the epoch ms of the fire, written by the stall notifier under the same guarded set-from-absent; cleared (rearmed) once the pane leaves working
@agent_quota_pctpaneaccount rate-limit utilization percent (integer 0100) for the window closest to exhausted, or absent when the channel reported no rate_limits block. Account-wide, not per-pane: every pane signed into the same account carries the same figure, so several rows showing it is correct and adding them up means nothing. Written by the same intake that stamps @agent_context_pct, on its own guarded chain
@agent_quota_windowpanewhich window @agent_quota_pct measures: 5h / 7d / spend (Claude) or primary / secondary (Codex). Highest percent wins, and a tie takes the shorter window; without this token the percent is unreadable, since 80% of five hours and 80% of a week are different facts. Set and cleared with the percent
@agent_quota_resets_atpaneepoch ms at which @agent_quota_window resets, absent when the channel stated none. Both vendors publish seconds (Claude resets_at, Codex resets_at, or an older relative resets_in_seconds); the conversion happens in the parser, never in a consumer, so this option is ms like every other instant here
@agent_quota_atpaneepoch ms of the evidence behind the quota trio and @agent_cost_usd; written last in the quota mini-chain and the not older arbitration basis, exactly as @agent_context_at is for the gauge. Its own marker, so a quiet context gauge never gates a fresh quota push
@agent_cost_usdpanethe agent’s own reported cost for the CURRENT session, a string with two decimals (3.50). Absent for a channel that publishes none (Codex’s rollout carries no cost). It is the vendor’s live estimate for one session, not a total tma computed and not a price table; tma reports which pane, right now and aggregates nothing across sessions or over time (ccusage is what answers “how much since Monday”)
@agent_modelpanebest-effort model-name label the context intake reads from the payload it already has: the Codex rollout window’s model record, or the Claude statusline’s model.id (an object, so the registration path’s top-level-string read cannot reach it); never load-bearing for a gauge, it only feeds tma doctor’s recognized-model line (a model no [telemetry.windows] entry names). Plain-set, cleared on deregister, absent when no model record sat in the tail
@agent_permission_requestpanethe id of the permission decision the pane is waiting on. Two producers write it: OpenCode’s plugin publishes the server’s own request id on a permission.asked edge (ownership-filtered against @agent_session), and a claude pane whose hook reply lane is on mints one in the PermissionRequest hook, the same 16 hex digits @agent_pending_call carries, since claude’s payload names no request of its own. Cleared on the edges that end the prompt (a working/idle transition, or a permission.replied); the action broker reads it to answer an [api] permission-reply op or to find the parked hook request, and an empty value refuses the API op requires-unmet. The broker also clears it itself once its own reply lands (a 2xx, or a written hook verdict), so a spent id does not read as a pending request until the agent’s next event; it leaves the option alone on a 404, which may already name a newer request
@agent_question_requestpanethe id of the mid-turn question the pane is waiting on: OpenCode’s que_*, published by its plugin on a question.asked edge (ownership-filtered against @agent_session) and cleared on the edges that end it (a working/idle transition, or a question.replied / question.answered / question.rejected). Its own option rather than a second use of @agent_permission_request, because the two are different channels with different endpoints: a permission-reply POSTed at a question id would quote a request the server is not holding, and the reverse. The broker reads it to answer a question-reply or question-reject op, refuses requires-unmet on an empty value, and clears it itself once its own 2xx lands
@agent_pending_tool, @agent_pending_callpanethe tool name and call id of the permission decision a blocked pane is waiting on, stamped from Claude Code’s PermissionRequest hook (tool_name / tool_use_id). Set together with @agent_pending_summary and cleared together on every edge that ends the prompt: the pane leaving blocked (a PostToolUse/Stop working-or-idle stamp), and SessionEnd, which removes the whole tuple. Absent on an agent with no such hook. On a build whose payload omits tool_use_id (Claude Code 2.1.261 does, while its PreToolUse and PostToolUse for the same call still send one) the call id is minted instead: 16 hex digits over the session id, the prompt id, the tool name and the tool input, so it stays the same across repeated fires of one call and differs for the next call. A minted id is a value to compare, not a handle to give back to the agent
@agent_pending_summarypanea one-line summary of that call, derived from the hook’s tool_input: the command for Bash, the file path for Edit/Write/Read, otherwise the first string-valued field. Agent-supplied text, capped at 120 bytes with control characters stripped and a marking a truncation. Treat it the way you treat a pane title: it is not a machine token, and tma deliberately keeps it out of the notification payload, the notify audit line, and every TMA_* env var, so a summary can never reach a [notify] command sink or a third-party push carrier. It is here and on the JSON rows, both of which stay on your machine
@agent_api_endpointpanethe OpenCode server base URL, stamped at registration by the plugin from its serving address; the broker’s permission-reply endpoint, with a [api.opencode] api_base config fallback (neither present refuses requires-unmet)
@agent_ignorepaneyou set this one. Any non-empty value takes the pane out of detection: no identity, no capture, no row, and a stamp left from before it was set is cleared on the next cycle. tmux set-option -p @agent_ignore 1 in the pane (add -t <pane> from elsewhere), tmux set-option -pu @agent_ignore to undo. tma never writes or clears it; tma doctor lists every pane carrying it
@agent_mute_untilpanenotification mute deadline in epoch ms: while it is ahead of the clock the pane fires no notification of any kind (state triggers and context_high alike), and every other lane is untouched — it is still detected, stamped, and counted. tma mute --for 30m writes now + the window, a bare tma mute writes the far-future sentinel 99999999999999 (indefinite), and tma mute --clear unsets it. Living in the store is what makes a mute survive a tma or daemon restart
@agent_actionpanesingle-flight action lock: value <expiry_ms>:<nonce>:<pid>:<name>, acquired and reclaimed by a server-side conditional write on the leading expiry, released nonce-conditionally, self-healing via the embedded expiry; written only by the action broker
@agent_act_repeatpaneconsecutive-fire run for the act audit log: value <episode_ms>:<action>:<count>, written by the action broker under the held @agent_action lock on the path that is about to have an effect. A new episode or a different action restarts the run; the third consecutive fire warns on stderr and lands as repeat in the act audit log. Never read by the gate
@agent_summarywindowrollup, a pure function of the sibling panes’ options; space-separated <state>:<count> in the fixed order blocked working idle unknown, zero-count states omitted, empty or absent when the window has no agents (e.g. blocked:1 working:2)
@agent_session_summarysessionthe same rollup grammar over every agent pane in the session, written by the same writers under the same guards. A distinct key rather than @agent_summary at session scope because a pane-context format read falls back pane → window → session: one shared name would make an agentless window render its session’s counts
@tma_last_pollserverhint only; the per-pane @agent_stamped_at is authoritative for freshness
@tma_watch_pidpane (on the watcher’s own pane)the focus-nudge target; tma watch advertises its pid here on its own $TMUX_PANE at startup and unsets it on every quit path, so it dies with the pane
@tma_window_name_origwindowthe window’s name before [daemon] window_names first renamed it. Present exactly while tma owns the name, and its presence is what licenses the restore
@tma_window_autorename_origwindowthe window-scope automatic-rename saved beside it, since rename-window turns that option off. The sentinel - records “was not set at window scope”, so the restore unsets rather than pinning an inherited value
@tma_window_name_lastwindowthe last name tma wrote. A current #{window_name} that differs means you renamed the window yourself, and tma stops renaming it until the next restore

Values that depend on a previous value (the write-once @agent_since, the notification dedup, the hook-versus-capture arbitration) are governed by server-side conditional writes (set-option -pF), which expand formats in the target pane’s context atomically at write time. Everything else is last-writer-wins over deterministic values, which converges.

Reading these from your own bar, prompt, or script is a supported first-class use; Read agent state from a status bar or script covers the read forms and the freshness rule that goes with them.

tma ls --json

A versioned, additive-only document. The top level is { "schema": 1, "agents": [ ... ] }; each element of agents is one agent row with this exact key set:

keytypemeaning
panestringtmux pane id (e.g. %5)
agentstringagent name
statestringidle / working / blocked / unknown
detailstring or nulldetail token, null when none. Open vocabulary — read defensively
sincenumberepoch ms of the last state transition (original unsuffixed key, kept for compatibility)
since_msnumberthe same value as since; names the unit, preferred in new consumers
episode_msnumberepoch ms of the instant wait --since compares against: the later of since_ms and @agent_turn_at. Equal to since_ms until a second completion lands on a pane that never left idle, and the value a supervisor loop feeds back as its next --since (see Drive a supervisor loop)
locatorstringsession:window.pane
titlestringpane title
attentionbooleantrue when the pane still carries @agent_attention
donebooleantrue when the pane is idle and carries @agent_attention: finished with output nobody has reviewed
sessionstring or nullowning agent session id, null when the pane never registered one
transcriptstring or nullpath to the agent’s transcript file (@agent_transcript), null when no hook payload ever named one
permission_requeststring or nullthe id of the permission decision the pane is waiting on (@agent_permission_request), null when none is outstanding or nothing on that pane publishes an id (OpenCode’s plugin does; a claude pane does while its hook reply lane is on). A consumer answering the prompt must quote this id, but a match is necessary and not sufficient: it is no proof the request is still open, since the agent’s next event and tma’s own successful reply both clear the option
stamped_at_msnumber or nullepoch ms of this pane’s last stamp (@agent_stamped_at), the freshness anchor for the whole tuple: compare it against your own clock to decide whether the row is stale before acting on it. null for a pane nothing has stamped yet
contextnumber or nullcontext-utilization percent (0100), null when the agent has no telemetry coverage or the channel reported no window
context_at_msnumber or nullepoch ms of the evidence behind context, null when context is
mutedbooleantrue when the pane’s @agent_mute_until is still ahead of the clock, so its notifications are suppressed (tma mute). A resolved boolean, not the deadline: the row is rendered at a known instant, and this is the only question a consumer asks
tokensnumber or nulltokens currently in the context window (the absolute context is a percent of), null when the agent’s channel reports no count tma can call a footprint. Aged by context_at_ms, which is its evidence time too. Never a spend total
quotaobject or nullthe account rate-limit reading: { pct, window, resets_at_ms }. null when the pane’s channel reports no rate_limits block (API-key auth, or before the agent’s first API response). pct is 0100 for the window closest to exhausted, window names which one (5h / 7d / spend / primary / secondary, read defensively, the vocabulary grows with the channels tma parses), and resets_at_ms is epoch ms or null. Nested rather than three flat keys because the three are one fact. Account-wide, so identical values on several rows are not duplicates
cost_usdnumber or nullthe agent’s own reported cost for this session, two decimals, null when its channel publishes none. A live per-session figure, never a running total across sessions
repostring or nullthe pane’s git repo name (basename of the git common dir’s parent, so worktrees share their origin’s name), null when the pane’s cwd resolves to no repo
branchstring or nullthe pane’s checked-out branch (the literal HEAD for a detached head), null when repo is
worktreeboolean or nullfalse for a resolved main checkout, true for a linked worktree, null exactly when repo is
pending_toolstring or nullthe tool name of the permission decision the pane is waiting on (@agent_pending_tool), null when nothing is pending
pending_callstring or nullthat call’s id, null exactly when pending_tool is (an agent whose hook carries no id stamps "")
pending_summarystring or nullone line of at most 120 bytes describing the call, where it was truncated, null exactly when pending_tool is. Agent-supplied text (a command line, a path): treat it as you treat title. It is deliberately absent from the notification payload, the notify audit line, and every TMA_* env var, so it never leaves the machine through a [notify] command sink
serverstringthe tmux server this row was observed on: its own #{socket_path} (e.g. /private/tmp/tmux-501/default)
hoststringthe hostname of the machine that observed it

The “done” surface is state == "idle" and attention == true; done carries that conjunction precomputed from the one definition the whole tool shares (it is also what wait --until done and --state done mean), so consumers stop re-deriving it. The state token itself is never mangled — it stays idle. All of attention, done, session, transcript, permission_request, stamped_at_ms, context, context_at_ms, muted, tokens, quota, cost_usd, repo, branch, worktree, pending_tool, pending_call, pending_summary, server, and host are additive, so the schema stays 1 (a new key never bumps it, including the keys nested inside quota); render an absent context, tokens, quota or cost_usd as absence (no gauge, no count, no quota, no cost), never as 0. The repo/branch/worktree keys are best-effort: the resolver memoizes one bounded git call per unique cwd and degrades every field to null on any failure, so a consumer treats them as hints, never guarantees.

The row writer has a second, title-free protocol surface for consumers that are not on this machine, and title is the only key that differs between the two: a pane title is agent-supplied text, kept off a remote surface for the reason pending_summary is kept out of the notification payload. Both surfaces are written by one function, so every other key, its order, and its null handling are the same on both, and a key added to either is added to both. Nothing tma ships today emits the protocol form; tma ls --json, tma wait --json and tma subscribe are the local surface and carry title exactly as documented above.

server and host: merging rows from more than one place

A pane id is unique within one tmux server and nothing more. Collect tma ls --json from your laptop and from a build box and both sets will contain a %5, with no way to tell them apart — the same is true of two servers on one machine (tmux -L work and tmux -L scratch number panes independently). The server/host pair is what makes a merged set addressable: (host, server, pane) is the key you want, and either alone is not enough.

server is the server’s own #{socket_path}, which is what the daemon already keys its per-server socket and lock on, so it is stable for the life of the server and identifies it however you addressed it: --socket-name work, --socket-path /tmp/tmux-501/work, and an invocation from inside that server all report the same value. The path rather than a hash of it, because an operator reading a merged log can tell /private/tmp/tmux-501/default from a tmate socket at a glance.

Both are resolved once per invocation — one tmux call and one uname — and repeated onto every row, since a line-oriented consumer that filters agents down to one element must not lose the provenance with it. A long-lived tma subscribe resolves them once for the life of the stream.

tma wait --json

The single matched agent row as one schema-1 object: the top-level schema key plus the same row fields as an ls --json element (pane, agent, state, detail, since, since_ms, episode_ms, locator, title, attention, done, session, transcript, permission_request, stamped_at_ms, context, context_at_ms, muted, tokens, quota, cost_usd, repo, branch, worktree, pending_tool, pending_call, pending_summary, server, host). It shares the serialization with ls --json, so the two can never disagree on keys, order, or null handling.

The fleet targets satisfy a SET of panes, so wait --all --json and wait --count <n> --json emit the ls --json document instead — { "schema": 1, "agents": [ ... ] }, one element per satisfied row, with those same row keys. A consumer parses one shape whether it listed or waited; the single-object form stays the single-pane targets’ emission (--pane, --agent, --any).

Notification hook payload

When a [notify] command fires, it receives one JSON object on stdin. It carries metadata only, never captured screen content. The exact top-level key set:

keytypemeaning
schemanumberpayload schema version (2); kept the first key so a reader sees it up front
agentstringagent name
panestringtmux pane id
statestringthe landed state (blocked, or idle for a completion)
detailstring or nulldetail token, null when none. Open vocabulary — read defensively
sessionstring or nullowning agent session id, null when none
locatorstringsession:window.pane
titlestringpane title. Absent unless [notify] include_title = true — see below
repostringrepo name resolved from the pane’s working directory, "" when it is not a checkout
branchstringbranch name (the literal HEAD when detached), "" when unresolved
since_msnumberage of the episode when the notification fired (now - episode_ms); a hook’s own direct fire reads 0, the daemon’s reads its dispatch latency
episode_msnumberepoch ms of the episode this fire belongs to: max(@agent_since, @agent_turn_at), the same instant the row’s episode_ms reports. Absolute, so two fires for one episode carry the same value and a sink can collapse on it (apns-collapse-id and friends); since_ms is the age of this instant and cannot be compared for equality against a stored stamp
context_pctnumber or nullthe pane’s stored context-window utilization percent, null when the agent reports none

The pane title is not sent

The pane title is the one field whose content the pane’s own program controls, and it routinely holds a branch name, a repo path or a prompt fragment. notify.command pipes this payload to whatever you configured — ntfy, Pushover, an Apple Shortcut — so the title would reach that service’s operator on every fire. Since schema 2 it is omitted by default.

[notify] include_title = true puts it back, and it governs all three carriers together: the payload’s title key, the TMA_TITLE environment variable, and the notify.log audit line. The host-local display-message baseline is not a carrier and always shows the title.

The standing rule for this payload: no field enters it that is not safe world-readable. Its writer is also the audit log’s writer, and that log is the file most likely to end up pasted into an issue.

The same values are also exported as environment variables (TMA_AGENT, TMA_PANE, TMA_STATE, TMA_LOCATOR, TMA_SINCE_MS, TMA_EPISODE_MS, plus TMA_TITLE when include_title is on, TMA_DETAIL, TMA_SESSION, TMA_REPO, TMA_BRANCH and TMA_CONTEXT_PCT when they have a value), so a hook reads whichever is more convenient. Unlike the JSON, a value with nothing to report is an unset variable rather than an empty string.

The daemon and the daemonless tma event path build this payload through one shared builder, so the same transition yields the same object either way.

The [notify] log file (see Configuration) holds the same object per line, with one extra key: at, the fire time in epoch milliseconds, written directly after schema. A detached action’s completion is logged the same way, as its own payload (below) plus at; a completion line carries action and outcome where a state line carries state, which is how a reader tells the two apart.

tma act JSON result

The result of firing one action, a schema-1 object with this exact key set. See tma act for the verb and its exit codes.

keytypemeaning
schemanumberpayload schema version (1)
actionstringthe action name
panestringthe target pane id
outcomestringthe closed outcome token (below)
exit_codenumberthe process exit code this outcome maps to; for an exited outcome it is the exec child’s own code
reasonstring or nullthe refusal reason token when outcome is refused, which target went away when outcome is vanished, null otherwise
cachedbooleanpresent only with --slot: false on the dispatch that fired, true when this is a replay of that slot’s stored receipt and nothing was sent

tma act --all --json wraps those same objects: { "schema": 1, "results": [ ... ] }, one element per resolved target in the order they were fired, each with the exact key set above. The envelope appears whenever --all is passed, even for a single matched pane, so a consumer’s parse never depends on the match count. The process exit code is the worst element’s (see Fan-out); the per-element exit_code stays each target’s own.

outcome is authoritative and closed: sent (keys delivered), replied (an API-channel answer delivered over HTTP, a 2xx), exited (a synchronous exec child finished; exit_code is its code), spawned (a detached supervisor launched), timeout (a synchronous child killed at timeout_ms), refused (reason carries which gate), vanished (tmux reports the pane gone, or an API target answered/withdrawn between gate and act — a 404; reason carries which), error (broker runtime failure: an unreachable API server, or a tmux command the server refused, whose stderr rides in the message). reason is one of gated, requires-unmet, wrong-agent, no-coverage, episode-changed, request-gone (all exit 4) and locked (exit 5). On a vanished outcome (both exit 3) it is instead pane-gone (tmux says the pane is gone) or request-gone (the API server answered 404: the permission request was already answered or withdrawn, on a pane that is still there).

A --slot dispatch carries one additive key, cached, and the replay’s object is the fire’s object with cached flipped: same outcome, same reason, same exit_code, which is the point of a receipt. An error replayed from the ledger reads reason fired-unknown, a token that appears on no live fire: the broker could not prove the keystroke did not land, so the ledger records the doubt rather than inviting a second one. The ledger and its own document are in tma receipts.

episode-changed and request-gone are the binder refusals: the fire carried --expect-episode-ms or --expect-permission-request and the pane, read under the action lock, no longer matches what the caller saw (see Binding a dispatch to the pane you saw). Note that request-gone is deliberately one token across two outcomes: refused means the pane stopped carrying the id the caller quoted, vanished means the server answered 404 for it. Read outcome to tell them apart.

tma act list document

A schema-1 document enumerating the loaded actions from tma act --list --json: { "schema": 1, "actions": [ ... ] }. Each action carries this exact key set, and with --pane two more.

keytypemeaning
namestringthe action name (also its file stem)
labelstringthe human label
kindstringkeys, text, or exec
agentsarray of stringthe agents this action applies to (empty means all, for an exec action). For a keys action this is the union of its [keys] and [api] transport agents (no per-transport surface in v1: a deck does not care how the answer travels), and for a text action its [text] agents
whenobject or nullthe gate, or null when the action is always fireable for its agents
fireablebooleanpresent only with --pane: whether the action can fire on that pane right now
reasonstring or nullpresent only with --pane: the refusal reason token when not fireable, null when fireable

The when object carries state (array of state tokens), detail (array of detail tokens), context_pct_min, and context_pct_max (number or null each).

Detached-action completion payload

A detached (detach = true) action fires one completion notification through the [notify] command when its child exits. It is its own contract, distinct from the notification hook payload (a completion has no state, and its pane may already be gone). The exact top-level key set:

keytypemeaning
schemanumberpayload schema version (1)
actionstringthe action name
panestringthe target pane id
agentstringthe agent name
outcomestringthe outcome token (exited / timeout / error)
exit_codenumber or nullthe child’s exit code for exited, null for a deadline kill or spawn failure
locatorstring or nullsession:window.pane, null when the pane is already gone
lock_release_failedbooleanpresent only as true, when the supervisor’s nonce-conditional clear of @agent_action failed; absent on the ordinary release. Additive, so the schema stays 1. A dead pane’s failing option write correlates with a null locator

The same values reach the command as environment variables: TMA_ACTION, TMA_PANE, TMA_AGENT, TMA_OUTCOME, plus TMA_EXIT_CODE and TMA_LOCATOR when they have a value. As on the state path, a value with nothing to report is an unset variable rather than an empty string, so ${TMA_EXIT_CODE:-} is how a hook tells a deadline kill from a child that exited. There is no env mirror of lock_release_failed; read it from the JSON.

A completion rides the same sinks a state notification does: the display-message baseline, the opted-in bell/osc tty sinks, and the [notify] log audit line. A hook that cannot start, or that exits non-zero, updates the same failure marker tma doctor reports.

tma doctor --json

The whole diagnosis as one schema-1 object. It is grouped rather than flat: each check is its own sub-object, so a consumer reads .daemon.alive rather than guessing which prefix belongs to what. Top level:

keytypemeaning
schemanumberpayload schema version (1)
daemonobjectalive (boolean), socket (string, null when the server was unreachable and no socket could be keyed), version (string or null), version_matches (boolean, null when there is no reported version to compare against this build)
ambient_driverobjectpolling (boolean: the server option @tma_last_poll carries a non-zero timestamp), last_poll_age_ms (number, null when it does not)
clientsobjectattached (number of attached clients)
status_optionobjectenabled (boolean: the server’s global status)
mouseobjectbindings_installed (boolean), enabled (boolean: the server’s mouse option). Both true is the working state; installed-without-mouse is the warning
watchobjectrunning (boolean), watchers (number of panes advertising @tma_watch_pid)
wrapperobjectpath (string), present (boolean) for the tma-hook wrapper
notifyobjectlast_failure: null, or an object of at (epoch ms), reason, command
tmux_hooksarrayone object per checked hook: hook (name), present (boolean), hook_state (present / drifted / wiped / missing)
manifestsobjectok (number loaded) and issues, an array of { file, problem }
process_name_issuesarray{ agent, name, comm_max } per process_names entry past the truncation width
process_walkobjectok (boolean: the ps walk ran) and error (string or null). With ok: false the agents array holds only panes a hook registered
nested_multiplexersarray{ pane, locator, command } per pane running an inner multiplexer client
remote_panesarray{ pane, locator, command, stamped } per pane behind a remote shell; stamped says whether it still carries a held @agent_* stamp
ignored_panesarray{ pane, locator, value } per pane carrying @agent_ignore, with the value you set
stamp_issuesarray{ pane, locator, problem } per pane carrying an @agent_* option that does not decode, or an @agent_state no tma write produced (outside the closed set, or set with no @agent_stamped_at)
agentsarrayone object per agent pane (below)
actionsobjectok (number loaded) and issues, an array of { file, problem }

Each agents element carries this exact key set:

keytypemeaning
pane, agent, locatorstringthe pane, its agent name, and session:window.pane
statestring or nullthe stamped state token, null when the pane has none
sourcestring or nullprovenance of that state (hook / capture / process; activity is legacy, read-only)
evidence_age_msnumber or nullage of the evidence behind it
hook_statusstringwired, incomplete, not_installed, hookless, or no_adapter. Wiring reached through another program’s config (codex’s notify chained onward) reports wired; the chain is named in the text report
hooks_wiredbooleantrue only for wired, so a consumer needs no token table for the common question
modelstring or nullthe best-effort @agent_model label
window_coveredboolean or nullwhether [telemetry.windows] names that model; null when there is no model, and also when the pane’s context channel carries its own window (every shipped one does), where that table is never read
endpoint_okboolean or nullwhether the pane’s API endpoint answered; null when the agent has no API lane
hook_demotedbooleanregistered through a hook but currently running on capture evidence: output kept arriving that its hooks did not account for. A working hook claim accounts for output until capture contradicts it, so a long tool call does not set this
tiernumberthe effective tier (3 / 2 / 1)
tier_reasonstring or nullwhy it is not higher; null at the top of what its manifest supports

remote_panes and ignored_panes are additive, so the schema stays 1.

tma debug explain --json

One pane’s identity, rule evaluation, and verdict as a schema-1 object. Absent optional fields are an explicit null, never dropped.

keytypemeaning
schemanumberpayload schema version (1)
pane, locator, command, titlestringthe pane id, session:window.pane, #{pane_current_command}, and the pane title
agentstringthe resolved agent name, or unknown
identity_sourcestring or nullobserved (the process walk found it) or registered (a hook claimed the pane); null when nothing identified it
out_of_scopestring or nullthe foreground command that put the pane out of scope (a remote shell, an inner multiplexer)
out_of_scope_kindstring or nullwhich category that command falls under
registered_behindstring or nullthe boundary a live registration outranks: the pane is in scope, but its agent runs where no capture reaches
registered_behind_kindstring or nullthat boundary’s category
ignoredbooleanthe pane carries @agent_ignore, which is why an otherwise recognizable pane reports no agent and no verdict
foreground_is_agentbooleanwhether the foreground command is the agent itself; false caps every screen verdict at unknown
scrolled, history_viewbooleanthe pane is scrolled back, and the screen is showing history rather than live state
evidencearray{ source, claim, at, meta } per evidence record the fold saw
rulesarray{ index, matched, state, detail, priority, region, skip_state_update } per screen rule evaluated; empty when no rules ran
verdictobject or nullthe fold’s result, null when nothing was evaluated (an ignored or unidentified pane)

The verdict object carries state, detail (string or null), action (publish or hold), may_override, set_attention, episode_reset, and winning_evidence, itself { source, at, label }.

ignored is additive, so the schema stays 1.

Additive-schema discipline

All the JSON contracts on this page share one rule: additive changes (a new key) keep the schema at 1; a breaking change (renaming or removing a key) bumps schema and the exact-key-set drift tests with it. A consumer can branch on schema rather than guess. Each serialization site has a test pinning its exact key set, so a silent drift cannot ship.