Automating DeviceTerm
August 10, 2026 · View on GitHub
Use the deviceterm CLI to control DeviceTerm itself: open and arrange tabs,
panes, and windows, inspect workspace state, drive other tabs from an
orchestrator tab, and wait on events. The caller can be a person at a prompt,
a script, an agent, or a program coordinating several agents; the commands
and the rules are the same.
Run these commands from a shell inside a DeviceTerm tab. The tab provides the session identity that authorizes them.
Driving the device inside a pane (touch, keys, buttons, accessibility) is
covered in USAGE.md. The JSON shapes, exit codes, and stability
promises behind every command here are defined in
INTEGRATION.md.
Contents
- Understand Tabs, Sessions, and Authority
- Control the Workspace
- Discover State
- Orchestrate Other Tabs
- Wait on Events
Understand Tabs, Sessions, and Authority
Authority has three tiers. Device panes are contained to the session that currently owns them. Workspace commands reach any public tab the caller can see. Reading another tab's contents or typing into it requires a live orchestration grant that only the GUI can issue.
The grant sits where it does because capture and input reach another
session's contents: what its terminal shows and what runs in it. Workspace
commands touch arrangement and lifecycle, never contents, and the structure
of public tabs is deliberately shared. A tab that should be untouchable opts
out of every tier with tab set-private.
Know Your Session
DeviceTerm injects a session identity into every terminal pane's shell:
DEVICETERM_SESSION holds the session id, DEVICETERM_SESSION_CAP holds the
session credential, and companion variables locate the daemon socket and the
per-session shim directory. The CLI reads them transparently; no command
takes a credential flag or operand.
A terminal split is its own session. The GUI treats the tab as one workspace, but each terminal pane carries a separate CLI identity with its own credential.
Device panes are linked to an owning session, and every pane-targeted command is authorized against the current ownership. Your session drives only its own panes; a pane owned by a sibling terminal pane is refused with the same error as an unknown pane, even inside your own tab.
The session's role is readable without a daemon round-trip:
echo "$DEVICETERM_SESSION_ROLE"
Trust the Terminal, Not the Token
The capability in DEVICETERM_SESSION_CAP is one authentication factor, not
proof of origin. It is inherited environment, readable by any process running
under your uid, so possession alone establishes nothing. On every scoped
request the daemon also checks the caller's kernel provenance: its POSIX
session, controlling terminal, and session-leader start time must match the
terminal the session is bound to.
What earns trust is running in the session's terminal, not descending from
its shell. Everything you start in the tab shares that terminal and may
control the session, which is why the cap is deliberately visible to child
processes; don't strip it from a subprocess environment. A helper that
detaches from the terminal (setsid, a daemonized process) stops
authenticating even though the shell is its ancestor, and a process elsewhere
that copied the cap is refused.
Escalate Only Through the GUI
Cross-tab input and capture require a live orchestration grant, and only the GUI issues one, when a person opens an orchestrator tab. There is no CLI verb for escalation, and constructing the raw request by hand does not work: the daemon refuses it from anything but the validated GUI.
A role string such as "orchestrator" is descriptive metadata. Without a
live grant, cross-tab input and capture fail with error.role_violation
even when DEVICETERM_SESSION_ROLE says orchestrator.
Open an Orchestrator Tab covers the grant lifecycle.
Control the Workspace
Open Tabs, Panes, and Windows
Create workspace surfaces from a script or agent:
deviceterm tab open --cwd "$PWD" --cmd 'make test'
deviceterm pane open --terminal --cwd "$PWD"
deviceterm window open
tab open mints a fresh tab, pane open --terminal splits the current tab
with another terminal pane, and window open mints a new window holding one
fresh tab. --cmd is typed into the new shell after attach, so the command
runs once and the shell stays interactive.
A success receipt means the GUI accepted the mutation for asynchronous processing; it does not prove the change completed, and it does not return the new session id. Confirm the outcome with the list commands; the event stream cannot confirm it, because another session's lifecycle events are not delivered to yours. Receipt shapes are defined in workspace receipts.
Arrange, Select, and Close Surfaces
Reorder, retitle, focus, and close surfaces by reference:
deviceterm tab move --tab abc123 --to 0
deviceterm tab rename "auth-feature"
deviceterm tab select --tab abc123
deviceterm window focus --window 2
deviceterm tab close --mode shutdown
tab move also accepts --to-window <ref> to move the tab to another
window. tab close and window close take --mode <detach|shutdown> to
decide what happens to owned Simulators, the same decision the GUI close
prompt offers.
These verbs reach any public tab visible to the caller, not only your own. A
tab close --tab <ref> naming another session's public tab closes it,
ending whatever was running there; mark a tab private when other sessions
should not be able to touch it.
pane close and pane info resolve Simulator panes only. Close a
physical-device pane in the GUI.
pane rename and pane move are not implemented; see
unsupported workspace verbs.
Discover State
List Tabs, Panes, Windows, and Devices
deviceterm tabs list
deviceterm panes list
deviceterm windows list
deviceterm devices list
tabs list returns one row per live terminal session, so a split tab
produces several rows. It shows public sessions plus your own private ones.
tabs current prints only the caller's row.
panes list returns the device panes owned by the calling session.
windows list returns the caller's own window; add --all for every window
visible to the caller.
devices list reports DeviceTerm-owned booted Simulators and connected
physical devices. An externally booted Simulator stays absent until you
attach it; see device roster rows.
Pass --json to any list for the machine-readable row shapes defined in
Discovery and State.
Check Health With doctor
deviceterm doctor
doctor checks the session environment, the xcrun shim, the daemon socket
and handshake, session authentication, linked device panes, and the methods
the daemon admits for this session. Use --json in a script and branch on
the exit status. The report shape and check names are defined in
the doctor report.
Diagnose Version Skew
After an upgrade, confirm the live daemon and the bundled CLI agree:
deviceterm version --json
Compare the daemon and rpcWire fields. A missing daemon field means the
version probe did not complete; it does not prove that no daemon is
reachable. Field semantics and a ready-made check are in
the version report.
Orchestrate Other Tabs
Open an Orchestrator Tab
Open the tab with Shell ▸ Open Orchestrator Tab or ⇧⌘T.
The GUI issues that tab's terminal session a live orchestration grant. The
grant lives in daemon memory and is checked on every cross-tab request; it is
revoked when the tab closes, when the issuing GUI connection is lost, or when
the session ends. An ordinary tab receives error.role_violation for
cross-tab capture and input, and the CLI cannot grant authority to itself.
Send Input to Another Tab
List the visible sessions and target one by short id:
deviceterm tabs list
TARGET_TAB="abc123"
deviceterm tab send-input --tab "$TARGET_TAB" 'make test\n'
Replace abc123 with a short id printed by tabs list.
Instant input is dispatched before the command returns. With --type-delay <ms>, typing is animated one character at a time and the command returns as
soon as the typing is enqueued, so it may still be running. Neither result
confirms that the target shell executed anything. Receipt fields and pacing
limits are defined in send input.
Capture Another Tab
deviceterm tab capture --tab "$TARGET_TAB"
The capture is the target's currently visible terminal viewport; scrollback
is not included. Human output is the raw text, so a redirect saves the
screen; --json wraps it as {text}. See
capture a viewport.
Make a Tab Private
Mark the current tab private when other sessions should not see or control it:
deviceterm tab set-private true
Every terminal session in the tab changes together. Other sessions cannot list the private tab or its panes, resolve its references, capture it, or send input to it; your own sessions keep access. Orchestration grants do not bypass privacy, so an orchestrator tab cannot capture or type into a tab after that target becomes private.
Only a tab the caller owns a terminal in can be flipped. The receipt's
committed field distinguishes a confirmed change from one the GUI is still
converging on; see set privacy.
Wait on Events
Choose Polling or Events
deviceterm events streams the current session's pane transitions and
session close, plus global Simulator boot and shutdown transitions, one JSON
object per line:
deviceterm events
Treat the list commands as the source of current truth and the stream as a low-latency signal to refresh that truth. Event shapes, ordering, and loss behavior are defined in Events.
An external Simulator can emit boot and shutdown events while staying absent
from devices list; use xcrun simctl when you need its metadata.
Subscribe Before Triggering Work
The stream has no replay or durable journal. Events published before the subscription is established are not delivered later.
This sequence can miss the rendering transition and wait forever:
xcrun simctl boot "$UDID"
deviceterm events \
| jq --unbuffered \
'select(.type == "pane.stateChanged" and .state == "rendering")'
Starting the subscriber first reduces the race, but the CLI does not emit a public readiness record. A fixed delay cannot prove that the subscription is active.
The following recipes run simctl boot synchronously. Their deadline begins
after that command returns, so it bounds the rendering wait but does not bound
a stalled boot command. Apply a command timeout appropriate to your automation
environment if that failure mode must also be bounded.
Poll for the Final State
Use current-state polling when you only need the final state:
UDID="<simulator-udid>"
if ! xcrun simctl boot "$UDID"; then
printf 'failed to boot Simulator %s\n' "$UDID" >&2
exit 1
fi
deadline=$(( $(date +%s) + 30 ))
while ! deviceterm panes list --json \
| jq -e --arg udid "$UDID" \
'any(.[];
(.udid | ascii_downcase) == ($udid | ascii_downcase)
and .state == "rendering"
)' \
>/dev/null; do
if [ "$(date +%s)" -ge "$deadline" ]; then
printf 'timed out waiting for Simulator %s\n' "$UDID" >&2
exit 1
fi
sleep 0.2
done
Combine Events With Current State
For lower latency without an indefinite rendering wait, start the subscriber first and poll the same target as a fallback:
UDID="<simulator-udid>"
EVENT_FILE="$(mktemp -t deviceterm-events)"
deviceterm events > "$EVENT_FILE" &
EVENTS_PID=$!
cleanup_events() {
trap - EXIT HUP INT TERM
kill "$EVENTS_PID" 2>/dev/null || true
wait "$EVENTS_PID" 2>/dev/null || true
rm -f "$EVENT_FILE"
}
trap cleanup_events EXIT
trap 'exit 1' HUP INT TERM
if ! xcrun simctl boot "$UDID"; then
printf 'failed to boot Simulator %s\n' "$UDID" >&2
exit 1
fi
DEADLINE=$(( $(date +%s) + 30 ))
while ! jq -e -s --arg udid "$UDID" \
'any(.[];
.type == "pane.stateChanged"
and (((.udid? // "") | ascii_downcase) == ($udid | ascii_downcase))
and .state == "rendering"
)' \
"$EVENT_FILE" >/dev/null 2>&1; do
deviceterm panes list --json \
| jq -e --arg udid "$UDID" \
'any(.[];
(.udid | ascii_downcase) == ($udid | ascii_downcase)
and .state == "rendering"
)' \
>/dev/null && break
if [ "$(date +%s)" -ge "$DEADLINE" ]; then
printf 'timed out waiting for Simulator %s to render\n' "$UDID" >&2
exit 1
fi
sleep 0.2
done
cleanup_events
The state query covers an event that fired before subscription readiness. It also provides the recovery path if the daemon restarts and closes the stream.
The cleanup function stops the whole subscriber process and waits for it. The
deadline bounds the rendering wait after simctl boot returns.