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

Manifest schema

One TOML manifest is the complete description of an agent: how to recognize its pane, how its hook events map to states, which states its screen rules detect, and the screen rules themselves. Bundled agents ship as manifests in crates/tma-core/manifests/; a user manifest in ~/.config/tma/agents/ adds a new agent or shadows a bundled one by filename stem, with no code change.

State routing is normative and not manifest-overridable: a manifest maps its agent’s events and screens into the closed state vocabulary (idle, working, blocked, unknown); it cannot invent or remap a state. [details] carries token spellings only.

Top level

fieldrequiredtypemeaning
min_engine_versionyesversion stringThe minimum engine version this manifest needs (e.g. "0.1"). Missing components default to zero. A manifest that needs a newer engine is rejected with an upgrade error, checked before the strict parse so a newer-schema field never surfaces as a confusing “unknown field”.
[identity]yestableHow to recognize the pane.
[hooks]notablePresent marks the agent hook-capable. Absent is the screen-only floor.
[capture]yestableWhich states the screen rules reliably detect.
[[rules]]noarrayScreen rules.
[details]notableDetail-token alternate spellings.
[telemetry]notableMetric channels the agent exposes. Absent means it exposes none.

Unknown fields at any level are a parse error.

Manifests load per file. A user manifest that fails to parse (or whose rule regexes fail to compile) is skipped and the rest of the set still loads, so a typo in one file never costs you the bundled agents. The poll surfaces print one tma: skipping manifest <path>: <error> line to stderr; tma event stays silent (a hook must never speak); the daemon logs it and starts on what loaded; and tma doctor lists every skipped file with its error under agents:. A bundled manifest that fails is fatal — that is a build bug, not user input.

[identity]

fieldrequiredtypemeaning
process_namesyesarray of string#{pane_current_command} values that cheaply flag a candidate agent pane.
title_patternsnoarray of stringRegexes over #{pane_title} that narrow a generic process_names match. When non-empty, a pane is this agent only when a process_names entry matches AND the current title matches one of these patterns (or the flicker-stickiness hold is active). Empty (the default) leaves identity as process match alone. Patterns compile at engine build; an invalid pattern is a build-time error naming the file.

Comm truncation: why a name may need two spellings

process_names is matched against two different sources, and they do not always report the same string for the same process:

  • ps -eo comm, the process-tree walk. This is what decides the pane holds an agent at all. tma takes the first whitespace-separated token and basenames it.
  • #{pane_current_command}, the foreground check. This decides foreground_is_agent, and a false answer caps every screen verdict at unknown — the pane is still identified, but its capture evidence stops meaning anything.

Keep every entry to 15 characters or fewer, and list both spellings when the real name is longer. Fifteen is where the truncation lands: the Linux kernel’s comm field is 16 bytes including the terminator, and macOS’s libproc — which is where tmux gets #{pane_current_command} — cuts at the same width. On Linux both sources truncate, so one 15-character entry covers both. On macOS they diverge: ps reports the invoked path (untruncated, and the symlink you typed), while tmux reports the resolved binary’s name, truncated.

The bundled codex manifest is the worked example, and the divergence is why it carries two entries:

[identity]
process_names = ["codex", "codex-aarch64-a"]

Homebrew installs codex as codex-aarch64-apple-darwin behind a codex symlink. Launch it and the two sources disagree, verified on macOS:

$ ps -eo pid,comm | grep codex
13071 /opt/homebrew/bin/codex          ← basenames to `codex`: identifies the pane

$ tmux display -p '#{pane_current_command}'
codex-aarch64-a                          ← 15 chars of the resolved binary

Drop the second entry and codex panes are still found (the walk matches codex) but every screen rule is capped at unknown, because the foreground check compares codex-aarch64-a against a list that has no such name.

tma doctor flags the trap directly — an entry longer than 15 characters with no truncated sibling in the same list:

agents:  6 loaded, no issues
  - myagent: process_names entry "my-very-long-agent-binary" is longer than 15 chars, the width both
    macOS libproc and the Linux kernel truncate `comm` to, and no truncated spelling sits beside it —
    add "my-very-long-ag"

A long entry with its prefix already listed is not flagged: that is the codex shape, and it is correct.

[hooks]

Presence of this block marks the agent hook-capable.

fieldrequiredtypemeaning
coversnoarray of tokenWhich states and lifecycle the agent’s hooks report: any state token, plus the literal lifecycle. This is the first coverage gate.
[[hooks.map]]noarrayEvent-to-claim mappings.

[[hooks.map]]

fieldrequiredtypemeaning
eventyesstringAgent hook event name (e.g. Notification, SessionStart).
matchernostringOptional payload matcher regex (e.g. `permission_prompt
claimyestableThe claim this event raises: either a state claim { state = "...", detail = "..." } (detail optional) or a lifecycle claim { lifecycle = "start" } / { lifecycle = "end" }.
turn_endnoboolWhether this event MEANS a turn ended (false by default). Set it on the agent’s turn-end event and nowhere else. It is a property of the EVENT, not of its claim: the same state = "idle" is raised by screen rules too, where nothing ended. tma raises the done marker on a turn end even when the pane was already idle, which is the only way a second completion is signalled after the user cleared the first marker; an event that merely observes idleness (an idle-reminder notification) must leave it false, or a cleared marker would come straight back.

[capture]

fieldrequiredtypemeaning
visiblenoarray of stateThe states the agent’s screen rules reliably detect, evidence-backed. This is the second coverage gate that the coverage-aware decay reads.

[[rules]]

One screen rule. Higher priority wins when multiple rules match.

fieldrequiredtypemeaning
stateyesstateThe state this rule asserts on match.
detailnodetail tokenDetail to attach (e.g. permission for a permission prompt).
prioritynointegerHigher wins on multiple matches. Default 0.
regionyesstringWhere to look (see below).
matchyesmatcherThe text predicate (see below).
skip_state_updatenoboolThis screen shows history, not live state: freeze, do not restate. Default false.

region

valuemeaning
tail_lines(N)Match against the last N lines of the captured tail. Bottom-anchored agents use a small window that always fits the visible screen, so this never reads scrollback for them.
bottom_non_empty_lines(N)Match against the last N lines that end at the last line with content: trailing blank lines (blank after ANSI stripping) are discarded before the window is taken. Use this instead of tail_lines(N) for an agent that renders inline, where a session that has not yet filled the screen leaves blank rows below its chrome that would consume the whole window.
visibleMatch against the visible screen only: the last #{pane_height} lines of the captured tail, before any further scoping. This removes scrollback lines for agents whose chrome floats in the transcript, so a whole-screen rule cannot match a prior turn’s chrome out of scrollback on a short pane. When the height is unknown it degrades to the whole captured tail.
titleMatch against the pane title.

match

A screen matcher composes leaf text predicates. TOML is externally tagged:

formmeaning
{ contains = "x" }Substring match.
{ regex = "..." }Regex over the region.
{ line_regex = "..." }Regex applied per line.
{ last_matching_line = { selector = { ... }, predicate = { ... } } }Find the bottommost line matching selector, then apply predicate to that line alone. Added in tma 0.5.11; manifests using it must set min_engine_version = "0.5.11" or newer.
{ any = [ ... ] }Any child matches.
{ all = [ ... ] }All children match.
{ not = { ... } }The child does not match.

Regex strings are stored verbatim and compiled at match time.

[details]

Maps a canonical detail token to its alternate spellings, so a screen or hook that spells a detail differently still normalizes to the canonical token.

[details]
rate_limit = { aliases = ["ratelimited", "rate-limited"] }

Each key is the canonical token and aliases lists alternate spellings.

[telemetry]

One optional sub-table per metric. Only context exists today; a second metric would be an additive sibling rather than a rename.

fieldrequiredtypemeaning
[telemetry.context]notableHow tma obtains this agent’s context-window utilization percent. Absent means the agent has no gauge.

[telemetry.context]

fieldrequiredtypemeaning
channelyestokenThe transport shape: event (the agent pushes a payload to tma event --kind context), file-tail (tma reads a bounded, end-anchored slice of a file the agent writes), or screen (last-resort extraction). Any other value is a parse error naming the three.
formatyesstringThe compiled-in parser id, bytes in and metric out (claude-statusline-json, codex-rollout-jsonl, cursor-statusline-json, pi-context-json).

format is not user-authorable: a new one needs core code, so the loader accepts any string here and the intake refuses an unknown id at read time rather than failing the whole manifest. Declaring the block is what separates a gated refusal for a context-gated action (the channel exists, the metric has not landed yet) from a permanent no-coverage one. Which agent uses which channel is in Agent coverage.

Token rules

A detail token (a [details] key, an alias, a [[rules]] detail, or a [[hooks.map]] claim detail) must be a safe machine token: non-empty and drawn from lowercase a-z, digits, _, and - only. This rejects the format metacharacters (#, {, }, ,), whitespace, control bytes, and any non-ASCII glyph at the load boundary, so a corrupt token can never reach the render chain. A [details] key that collides with a state token is rejected, because state routing is normative and not manifest-overridable.

A full manifest

min_engine_version = "0.1"

[identity]
process_names = ["claude"]

[hooks]
covers = ["working", "blocked", "idle", "lifecycle"]

[[hooks.map]]
event = "Notification"
matcher = "permission_prompt|elicitation_dialog"
claim = { state = "blocked", detail = "permission" }

[[hooks.map]]
event = "Stop"
claim = { state = "idle" }
turn_end = true

[[hooks.map]]
event = "SessionStart"
claim = { lifecycle = "start" }

[[hooks.map]]
event = "SessionEnd"
claim = { lifecycle = "end" }

[capture]
visible = ["working", "idle", "blocked"]

[telemetry.context]
channel = "event"
format = "claude-statusline-json"

[[rules]]
state = "blocked"
detail = "permission"
priority = 100
region = "tail_lines(5)"
match = { any = [ { contains = "Do you want to proceed?" }, { regex = "❯\\s" } ] }

[[rules]]
state = "idle"
priority = 10
region = "tail_lines(50)"
skip_state_update = true
match = { all = [ { contains = "transcript" }, { not = { contains = "❯" } } ] }

[details]
rate_limit = { aliases = ["ratelimited", "rate-limited"] }