Skip to content

OSC 26 — Terminal Agent Protocol

Proposal

The Terminal Agent Protocol (TAP) is a proposed, vendor-neutral OSC sequence — not yet a ratified standard. Otty ships the reference implementation, and the spec lives in the open at github.com/Otty-sh/osc-26 so other terminals can adopt the same number and wire format. Feedback and cross-terminal implementations are welcome.

SequenceOSC 26 ; <Key>=<Value> [ ; <Key>=<Value> ]... ST
StatusProposal (v1)
Otty support✓ (gated by the Terminal Agent Protocol setting, on by default)

Why this protocol exists

A coding agent — Claude Code, Codex, OpenCode, Aider, and the rest — runs as a plain TUI inside a pane. The terminal would love to show what it's doing: a status light on the tab (thinking / awaiting approval / done), the task-list progress, a session title, a fork this conversation action. But the bytes on the PTY are just a screen render; the terminal has no structured channel to learn any of it. Today terminals resort to matching process names or reverse-engineering each agent's private files — fragile, and it breaks on every upstream version bump.

TAP gives the agent a small, declarative way to announce its own metadata: identity, session, live status, task progress, and how to resume or fork. One key/value escape sequence, understood by any terminal, replaces a pile of per-agent guesswork.

It is deliberately a declarative, one-way protocol (the agent announces state; the terminal renders and acts on its own schedule).

Sibling to OSC 88

TAP is the live-state half of a pair:

  • OSC 88 — TRP answers "how do I relaunch you after the terminal cold-restarts?" — universal, any TUI.
  • OSC 26 — TAP answers "who are you and what are you doing right now?" — agent-specific.

An agent typically emits both: TRP to survive a reboot, TAP for live status. The two are designed to compose (see Resume & fork).

Quick start — for an agent

Emit one sequence whenever your state changes. The Otty CLI hides the encoding:

sh
# A Claude Code hook announces status + task progress
otty agent:set --code-agent claude --session "$SID" \
  --status running --detail before-tool-call \
  --project "$PWD" --task-progress 1/4

# Awaiting a yes/no approval — drives the tab's attention indicator
otty agent:set --status awaiting-approval --detail edit-file

# Done — clears the live state
otty agent:set --status finished

Raw escape sequence (values that can contain spaces / UTF-8 / ; are base64):

sh
printf '\e]26;CodeAgent=claude;Status=running;Detail=before-tool-call;TaskProgress=1/4;SessionId=%s\a' \
  "$(printf 'a1b2c3d4' | base64)"

Write to the controlling tty

Emit to /dev/tty, not stdout — an agent's stdout is usually captured by its runner. Inside tmux / Zellij, wrap the bytes in the multiplexer's passthrough envelope so they reach the outer terminal.

Quick start — for a terminal

  1. Parse OSC 26. Split the payload on ;; each token is Key=Value (split on the first =). PascalCase keys are protocol-defined; UserVar:-prefixed keys are application vars. Base64-decode free-form values. Ignore unknown keys (forward-compat) and ignore the whole sequence if you don't implement TAP.
  2. Store a per-pane Key → Value map: last-write-wins; an empty value clears a key. One sequence may set many keys (atomic) or just the ones that changed.
  3. RenderStatus → tab indicator; Detail → tooltip; TaskList / TaskProgress → progress UI; SessionTitle → title hint; group panes by SessionId.
  4. Offer actionsMethodFork → a fork session command; MethodResume → resume (derive an OSC 88 arm, or defer to an explicit one).
  5. Stay safe — the only executable fields are MethodResume / MethodFork; gate them exactly like TRP. All display fields are untrusted text — sanitize before rendering (see Security).

Wire format

OSC 26 ; <Key>=<Value> [ ; <Key>=<Value> ]... ST
  • OSC = ESC ]; ST = ESC \ (preferred) or BEL (\a). ; separates params.
  • Keys are PascalCase for protocol-defined fields (they read like constants), or UserVar:<name> for application-defined vars. Key=Value splits on the first =.
  • Values: free-form text (anything that can hold spaces, ;, =, UTF-8) is base64(UTF-8) — base64's alphabet excludes ;, so it can never break the framing. Stable tokens, enums, fractions and integers (Status, Detail, TaskProgress, Version, CodeAgent) are sent literally. Each field below is marked b64 or literal.
  • An empty value clears that key (Status= removes it).
  • Unknown keys MUST be ignored.

Keys

Required

KeyValueMeaning
CodeAgentliteralAgent kind — claude / codex / opencode / aider / … . The one required key; its presence is what marks the pane as agent-driven.

Identity & session

KeyValueMeaning
SessionIdb64Opaque session id. Groups panes that share a session; dedups in open-quickly.
SessionTitleb64Tab title hint (lower priority than OSC 0/2).
ProjectFolderb64Project root / working directory.
WorkTreeb64Git worktree path, when distinct from ProjectFolder.
Modeb64Free-form mode / model label for display (e.g. plan, opus).

Live state

KeyValueMeaning
Statusliteral enumCoarse, closed state machine — see below.
Detailb64 / tokenOpen refinement of Status — an opaque display string. The spec defines the key, not the vocabulary.

Status is the one closed enum:

StatusMeaning
idleReady, waiting for the user, nothing pending.
runningActively processing.
awaiting-approvalBlocked on an explicit y/n approval (drives attention UI).
awaiting-inputBlocked on free-form user input.
errorThe last operation failed.
finishedThe agent ended cleanly.

A terminal MAY synthesize a down state when the process vanishes without sending finished. down is terminal-internal and never appears on the wire.

Detail carries the why/what behind a Status and is intentionally open-vocabulary — a consumer treats it as an opaque string for display (it MAY special-case values it recognizes for an icon, but MUST NOT branch logic on it). Illustrative values only: thinking, before-tool-call, post-tool-call, streaming, compacting, api-fail, rate-limited, edit-file. So an API failure is simply Status=error ; Detail=api-fail.

Tasks (the agent's plan / todo progress — e.g. Claude Code's task list)

KeyValueMeaning
TaskListb64Ordered task labels, newline-separated.
TaskProgressliteraldone/total, e.g. 1/4. Convention: items [0, done) are complete, item [done] is in-progress, the rest pending.

Resume & fork (declarative capability hints)

KeyValueMeaning
MethodResumeb64Argument string to resume this agent's session. MAY contain {SessionId} / {ProjectFolder} placeholders. The terminal runs CodeAgent + + the expanded string.
MethodForkb64Argument string to fork / branch the session into a new pane (same placeholder expansion). Used by a fork from here action.

Extension & meta

KeyValueMeaning
UserVar:<name>b64Arbitrary agent-defined variable — an open namespace (à la OSC 1337 SetUserVar). Never collides with the PascalCase keys above.
VersionliteralProtocol version (default 1).

Example

sh
# Claude Code, mid-run, 1 of 4 tasks done, working a project worktree
printf '\e]26;CodeAgent=claude;Version=1;Status=running;Detail=before-tool-call;TaskProgress=1/4;SessionId=YTFiMmMzZDQ=;SessionTitle=Rml4IGxvZ2luIGJ1Zw==;ProjectFolder=L1VzZXJzL21lL3Byb2o=;TaskList=QWRkIGF1dGgKRml4IGxvZ2luIGJ1ZwpXcml0ZSB0ZXN0cwpTaGlw;MethodResume=LS1yZXN1bWUge1Nlc3Npb25JZH0=;MethodFork=LS1mb3JrIHtTZXNzaW9uSWR9\a'
#  SessionId=a1b2c3d4  SessionTitle="Fix login bug"  ProjectFolder=/Users/me/proj
#  TaskList="Add auth\nFix login bug\nWrite tests\nShip"
#  MethodResume="--resume {SessionId}"  MethodFork="--fork {SessionId}"

# Blocked on approval
printf '\e]26;Status=awaiting-approval;Detail=edit-file\a'

# Clean exit — withdraw live state
printf '\e]26;Status=finished\a'

Resume & fork

Cold-restart resume is OSC 88's job; TAP only carries the agent's declaration of how it resumes. They compose:

  • If the agent emits an explicit OSC 88 ; arm, that is the armed cold-restart — it wins.
  • If the agent emits only MethodResume, the terminal MAY derive a TRP arm: cmd = CodeAgent, args = expand(MethodResume), cwd = ProjectFolder — subject to the same TRP verification gate. This lets a TAP-only agent still be resumed.
  • MethodFork is TAP-only — forking spawns a new pane as a live action; it is not a cold-restart and has no TRP equivalent.

Placeholders {SessionId} and {ProjectFolder} are expanded from the current key map, so one declaration works across agents with different resume grammars (--resume <id> vs resume <id> vs --continue).

Relationship to OSC 9;4 (progress)

TaskProgress is real progress, so a terminal that also implements OSC 9;4 SHOULD mirror the mappable subset to get native progress UI (taskbar / progress bar) for free — TAP stays the source of truth, 9;4 is a compatibility shim:

TAPmirror to OSC 9;4
TaskProgress=d/tOSC 9;4;1;<round(100·d/t)>
Status=errorOSC 9;4;2
Status=finishedOSC 9;4;0 (clear)
Status=running (no TaskProgress)OSC 9;4;3 (indeterminate)

The agent status itself (e.g. awaiting-approval) deliberately does not live in 9;4 — that enum is a progress model and has no room for an attention state. Status belongs to TAP; only genuine progress is mirrored.

Security

TAP is mostly declarative display state (low risk), with two exceptions and one caveat:

  • MethodResume / MethodFork are executed. Treat them exactly like a TRP arm: a conforming terminal MUST gate them behind a verification mechanism (recommended: process-identity verification against the pane's process tree), MUST surface execution as a user-visible reversible action, and SHOULD honor a per-binary deny-list. See OSC 88 § Security.
  • Display fields are untrusted text. SessionTitle, Detail, TaskList, Mode, UserVar:* are attacker-influenceable (a cat of a crafted file can emit them). Strip control characters / escape sequences before rendering, so a SessionTitle cannot smuggle its own OSC.
  • Never make a security decision from a declarative fieldCodeAgent, SessionId, Status are hints for UI, not authentication.

Otty