Author a custom action
An action is a guarded thing tma does to an agent pane: send a key sequence, or
run a command with the pane’s context in its environment. You declare one in a
TOML file, dry-run it until it looks right, then fire it from any surface. This
guide writes an exec action that asks a live Claude session for a progress
summary; for the full field list see
Action manifest schema.
Drop the manifest
User actions live in ~/.config/tma/actions/. The file stem is the action name,
so summarize.toml is tma act summarize:
# ~/.config/tma/actions/summarize.toml
min_engine_version = "0.1"
name = "summarize"
label = "Summarize progress"
kind = "exec"
agents = ["claude"]
when = { state = ["working", "idle"] }
requires = ["session"]
confirm = true
command = "~/.config/tma/actions/summarize.sh"
agents limits it to Claude panes. when gates it to working or idle.
requires = ["session"] refuses cleanly if the pane never registered an agent
session id, so the script never runs against an empty TMA_SESSION_ID.
Write the command
The command is passed to sh -c verbatim. It learns which pane it serves only
through environment variables, never through the command string, so there is no
quoting injection from a pane title. Fork the session so the user’s live TUI is
untouched:
#!/bin/sh
# ~/.config/tma/actions/summarize.sh (chmod +x)
set -eu
claude -p --resume "$TMA_SESSION_ID" --fork-session \
"Summarize progress and list open questions" \
| tma-notify-or-your-own-sink
The available variables are TMA_PANE, TMA_AGENT, TMA_STATE, TMA_DETAIL,
TMA_SESSION_ID, TMA_CWD, TMA_PID, TMA_LOCATOR, TMA_TITLE, and
TMA_ACTION, plus the caller’s --arg values (below). Quote every one of them: "$TMA_TITLE", not $TMA_TITLE. A title
is text the agent printed, so an unquoted expansion re-parses hostile input. The
command runs in tma’s own working directory, not the pane’s; a script that needs
the agent’s directory uses TMA_CWD explicitly (cd "$TMA_CWD"). The security
model
explains what the env transport does and does not protect.
Dry-run, then fire
--dry-run resolves everything and executes nothing: the context env with each
value’s age, the gate verdict, and the command that would run. It is to actions
what tma debug explain is to detection.
$ tma act summarize --pane %5 --dry-run
action: summarize
pane: %5
agent: claude
gate: fireable
effect: command: ~/.config/tma/actions/summarize.sh
context:
TMA_SESSION_ID 0d1a... (1200 ms old)
TMA_CWD /home/you/proj (live)
Edit, dry-run, fire, with no rebuild. When it looks right, fire it. confirm = true means a non-interactive fire needs --yes:
$ tma act summarize --pane %5 --yes
Fire past the gate: --force
--force skips the when gate, and only the when gate. It is for the case
where you know better than the stamp: the pane reads working because a hook
missed its Stop, and you want to act anyway rather than wait for the next cycle
to correct it.
$ tma act summarize --pane %5 --force --yes
Everything else still holds. requires is still checked, so an action needing a
session id still refuses requires-unmet (exit 4) on a pane that never registered
one. The single-flight lock is still taken, so --force cannot run two copies at
once. Identity still applies: an action declaring agents = ["claude"] still
refuses on a codex pane. --force is not --yes, either; a confirm action needs
both.
One thing --force skips that is easy to miss: an action that reaches the pane
with keystrokes (keys or text) normally re-verifies a stale pane on demand
before gating. Under --force there is no gate to verify for, so no
re-verification happens and the keys go out against whatever the pane looks like
now.
Pass a value in: --arg
Some actions need a payload from the caller, not just the pane’s context. --arg
carries one (repeat it for more), and it travels the same way everything else
does — as environment, never spliced into command:
# ~/.config/tma/actions/queue-next.toml
min_engine_version = "0.1"
name = "queue-next"
label = "Queue the next task"
kind = "exec"
agents = ["claude"]
when = { state = ["idle"] }
requires = ["session"]
confirm = true
command = "~/.config/tma/actions/queue-next.sh"
#!/bin/sh
# ~/.config/tma/actions/queue-next.sh (chmod +x)
set -eu
[ -n "${TMA_ARG:-}" ] || { echo "queue-next needs --arg <task>" >&2; exit 2; }
# The value is data: it reaches the agent as an argument, never as shell source.
tmux send-keys -t "$TMA_PANE" -l -- "$TMA_ARG"
tmux send-keys -t "$TMA_PANE" Enter
The script gets TMA_ARG (the first value), TMA_ARG_1..N and TMA_ARG_COUNT
when several were passed, and nothing at all when none was. A value that contains
$(reboot) stays those nine characters: nothing expands it, because nothing ever
builds a command string out of it. Quote it anyway ("$TMA_ARG"), as you would
TMA_TITLE.
keys actions refuse --arg (exit 2) on purpose. A keys sequence lives in the
manifest, which is what makes it reviewable, so there is nowhere for a value to
go.
To type a caller’s line into a live session, reach for
kind = "text"
before writing a script like the one above. It keeps the wrapping keys, the
agents and the gate in the manifest, delivers the string with send-keys -l --,
and applies the payload rules (no control bytes, no leading / or !) that a
hand-rolled script has to remember for itself. The bundled steer action is one.
Driving it from a wait loop is the orchestrator shape:
#!/bin/sh
set -eu
pane=%5
since=0
while read -r task; do
row=$(tma wait --pane "$pane" --until idle --since "$since" --json --timeout 900) || exit $?
since=$(printf '%s' "$row" | sed 's/.*"episode_ms":\([0-9]*\).*/\1/')
tma act queue-next --pane "$pane" --arg "$task" --yes
done < tasks.txt
--since is what keeps that loop honest: without it the second wait returns on
the same idle episode it just fed. See
Block a script on agent state.
Fire on a whole fleet
--all turns the selector flags into the
target set instead of a uniqueness requirement, firing on each matched pane in
turn. Dry-run it first: with --all, --dry-run prints the resolved targets and
what each verdict would be, which is the blast radius before it happens.
$ tma act summarize --all --repo tmux-agents --dry-run
targets: 3
%1 claude would fire
%4 claude refused: gated
%7 claude refused: locked
$ tma act summarize --all --repo tmux-agents --yes
Each target runs the full broker sequence on its own — its own single-flight
lock, its own gate re-verification at fire time — so one pane’s refusal neither
skips nor weakens the others. The confirmation is asked once for the batch, and
the process exits with the worst target’s code, so && still means “all of them
acted”.
The broker re-verifies the pane is still a Claude pane in a gated state, acquires
a single-flight lock so a double-press cannot run two summaries at once, spawns
the command, and releases the lock. Exit 0 means the child exited 0; a gate
refusal is exit 4, a held lock exit 5. The full table is in
tma act.
Bound the run: timeout_ms
A synchronous exec action runs under a deadline. timeout_ms is it, in
milliseconds, defaulting to 30000. A child still alive at the deadline is killed
and the action ends with outcome timeout and exit 124, the same code
timeout(1) uses, so a caller branches on it the way it already branches on a
tma wait timeout.
timeout_ms = 120000 # two minutes for a slow summary
Set it to what the command genuinely needs. The value also sets the single-flight
lock’s expiry (the deadline plus a few seconds of slack), so an over-generous
timeout on a command that hangs leaves the pane locked for that long against other
fires, and a too-tight one turns a slow success into a 124. detach = true uses
detach_timeout_ms instead; both are in the action manifest
schema.
Long-running actions
An SDK call can take minutes. Set detach = true and the broker returns
immediately (exit 0, outcome spawned) while a tma-owned supervisor holds the
lock, kills the process group at detach_timeout_ms, and fires a completion
notification through your [notify] command when the child exits. A detached
action is fire-and-forget: its exit code says nothing about the child’s outcome,
which arrives on the completion payload instead. Use a synchronous action (the
default) when a script needs to branch on the result.
Retry an approve safely
A script that fires an action over a network has a problem a local one does not:
when the response never arrives, it cannot tell whether the action ran. Firing
again is a guess, and on approve it is the expensive kind.
Give the dispatch an id and tma will run it at most once:
#!/bin/sh
# Approve the prompt on %5, and survive losing the answer.
episode=$(tma ls --json | jq -r '.agents[] | select(.pane == "%5") | .episode_ms')
slot="approve:%5:$episode"
tma act approve --pane %5 --slot "$slot" --device "$(hostname)" --json
Run that line twice and the pane receives the keystroke once. The second run
prints the first run’s receipt with "cached": true, exits with the first run’s
code, and sends nothing. The id is the whole identity, so build it from what
makes this dispatch this dispatch: the action, the pane, and the episode you read
off the row. A new episode is a new prompt, so it earns a new slot.
If the connection died before you saw any answer at all, ask instead of firing:
tma receipts --slot "$slot" --json
An empty result means the dispatch never reached the host, so it is safe to send it. A receipt means it did, and tells you what it ended as.
Two behaviours make this safe to build on. A locked refusal (exit 5) releases
the slot, because a lock held by another invocation is the one refusal a retry
fixes. Everything else writes a terminal receipt, including a broker error,
whose receipt records fired-unknown: tma cannot prove that keystroke did not
land, so it will not send a second one on your behalf. Receipts live for 24 hours
in a per-host ledger, so a retry from a different connection, or a different
device, still lands on the same slot. The flags and the ledger’s rules are in
tma act --slot.
Recommend confirm for anything that writes
tma cannot inspect what your script does. Set confirm = true for any action
that injects into a live session or mutates a repo, so a stray keypress or a
script cannot fire it unattended. It costs one --yes (or one menu keystroke) and
is the honest place to declare “this one is not idempotent”.
Control surfaces
Every surface fires the same verb, so an action is written once and reachable four
ways: a tma act shell line, the tmux menu (tma act --menu, wired to a key by
tma install-keys), a keybinding, and a
hardware deck. Nothing works on a deck that a keyboard-only tmux user cannot reach
through the menu; if a flow needs hardware, it is a bug.
Both live surfaces carry a triage key that opens the menu for the agent under the
cursor rather than the pane you are standing in, so a screenful of blocked agents
is answered from one place: a in tma watch, and tab in the picker (whose
every printable key belongs to the fuzzy query). Two things follow from
the menu being computed fresh, on the target pane. If nothing is fireable there
right now, no menu opens at all, and tma act --list --pane <id> says why. And
the menu is handed to tmux rather than run as a child of the dashboard, so it
outlives the surface that asked for it, which is what lets the popup-hosted
picker offer the key: a tmux menu replaces the popup on screen and the action
still fires.
The menu opens over your client and captures your keystrokes until you pick an
entry or dismiss it; the target pane keeps running underneath, untouched. That
focus steal is deliberate and the menu never refuses to open on a working
pane: interrupting a working agent is the flagship menu use, and the entries
already show only what is fireable right now. If you were mid-sentence into the
pane when you opened the menu, finish the menu first — keys you type go to it,
not the pane.
A surface stays a dumb reader on the act path exactly as on the read path. A deck
or plugin enumerates actions with tma act --list --json --pane %N and renders
them: fireable ones lit, gated ones dark, each carrying a reason so the plugin
can gray-out-temporarily (gated) versus gray-out-permanently (no-coverage).
It then shells out to tma act <name> --pane %N and contains zero policy. The
document’s exact key set is in
Pane options and JSON contracts.
To know when to re-render, spawn tma subscribe instead of running your own
polling timer: see Stream state changes.