Surface protocol
An agent tells Terminal Delight what it made by sending a small JSON document called a surface: a reply, a decision it wants taken, a change to review, a file it wrote, a table, a diagram or a question. It says what the thing is. The window decides how it looks, and it lands on the pane's workbench.
{ "td": "0.4", "kind": "decision", "id": "cache-eviction",
"title": "Cache eviction policy",
"model": { "question": "LRU or TTL?",
"options": [ { "name": "LRU", "recommended": true,
"case": "3× fewer misses on the replay corpus.",
"cost": "A cold start is slower." },
{ "name": "TTL" } ] } }
The easiest way to send one is to write it as a .json file into the pane's own folder, which the window checks every second. It can also go through the MCP server, be piped to terminal-delight surface, or be printed in a ```td block in the agent's reply.
When a person presses approve on that card, one line is typed into the agent's terminal: [workbench:k7f2q9ax] approve on surface cache-eviction. The tag matches the pane's $TD_TAG, so the agent can tell its operator from text that only looks like one.
Technical has every field, kind, route and limit.
TDSP 0.4
The protocol's spec lives in the repository at docs/spec/td-surface-protocol.md. It has fallen behind the code in places, including the kinds, the shelves and its question example, and where the two differ this page follows the code.
Version#
td is a string, "0.4". Only the major number is compared: "0.1" and "0.9" are accepted, "1.0" is refused, and so is a missing or numeric td.
Envelope#
| Field | Needed | What the window does |
|---|---|---|
td | always | Checks the major version. |
op | no | present (the default), update or retire. |
id | for retire | Keeps letters, digits and - _ . :, up to 96 characters. Left out, it's made from the kind and title. |
kind | except retire | One of the kinds below. |
title | no | Trimmed and cut to 72 characters; empty gets a default for the kind. |
model | except retire | The kind's content. |
weight | no | Effort, complexity, foundation, confidence; see the workbench. |
source | no | files, command, reference. The source button opens the first file. |
actions | no | Verbs added after the kind's own. They can add buttons, never remove one. |
pane | no | Accepted and not used; the route decides the pane. |
Always send an id. Two surfaces of the same kind with no id and the same title, or no title, get the same made-up id, and the second replaces the first.
Kinds#
| Kind | Needs in model | May also have | Shelf | Buttons |
|---|---|---|---|---|
response | layman | brief, technical, evidence, asks, next, doubts, escalation, any other key | overview | comment, ask |
artifact | href (or path, url, open, file, link) | mime, summary, any other note | artifacts | open, comment |
markdown | body | artifacts | comment | |
table | columns | rows; a null cell reads unavailable | artifacts | comment |
architecture | nodes, each with an id | node label, state, group; edges of from, to, label | artifacts | comment, ask |
changeset | hunks, each with a file | repository; hunk id, patch | decisions | accept and reject per hunk, comment, approve |
decision | question | options (name, case, cost, one recommended), consequences | decisions | approve, reject, comment |
question | question, options | options as strings or {label, what_happens}; recommend | decisions | one per option |
A kind this build doesn't know is shown as unclassified, with its raw payload, rather than dropped. comment is the kind a person's own notes use; it isn't offered to agents. The workbench page has what each response register is for.
An artifact's location must be an absolute path or an http://, https:// or file:// address. A relative path is refused, because it would be read from the terminal's directory rather than the agent's, and so is any other scheme.
present, update, retire#
- present with a new id adds the surface; with an id already there, replaces it.
- update merges into the existing surface, or adds it if the id is new. It still needs
kindandmodel, and a missingtitlebecomes the kind's default rather than keeping the old one. - retire with an
idtakes the surface off the bench.
An arrival never changes the shelf a person is looking at and never selects anything. It's marked unseen unless its shelf is on screen. Each pane keeps its 64 newest surfaces.
Four routes#
- file
- Write the document to
$XDG_STATE_HOME/terminal-delight/surfaces/$TD_SESSION/$TD_PANE_ID/<name>.json(~/.local/state/…by default). The window reads new and changed files once a second, oldest first. A file that isn't valid JSON is skipped until it is. Valid JSON that isn't a valid surface lands asunclassified, so a mistake shows up. Write beside and rename into place so a half-written file is never read. The folder is also how a new window gets the bench back. - MCP
present_surfacewith the pane'spidand the document assurface. It needs the server on and writes on, checks the document strictly and returns the reason if it's refused, and saves it to the pane's folder on the way past. Aretiredeletes that file.- command line
terminal-delight surface [file], reading stdin with no file, from inside a pane. It takes one document, or every```tdblock in the input, and writes each to the pane's folder. Exit 0 if it wrote something, 2 if nothing was usable, 3 outside a pane.- transcript
- A
```tdor```tdspblock in the agent's own reply. The window reads the last 256 KiB of the agent's transcript, when it's sure which transcript is the pane's. Use this when nothing else is available.
terminal-delight surface --catalogue, or the surface_catalogue MCP tool, lists the kinds and actions this build handles. terminal-delight surface --derive <transcript.jsonl> shows what the window would read out of a transcript.
The line that comes back#
[workbench:<tag>] <action> on surface <id> · <target> — <comment>
Typed into the agent's terminal, followed by a return. · <target> appears when the action names a part, such as a hunk or an option; — <comment> when the person had written something in the composer. Line breaks and control characters become spaces. For example:
[workbench:k7f2q9ax] reject_part on surface change-847 · src/surface.rs#hunk-4 — Tube geometry shouldn't depend on terminal state.
[workbench:k7f2q9ax] choose on surface q1 · Coffee
Each is also appended to actions.jsonl in the pane's folder, with the comment as written:
{"td":"0.4","type":"action","surface":"change-847","action":"reject_part","target":"src/surface.rs#hunk-4","comment":"…"}
| Action | Button | Done by |
|---|---|---|
approve, reject | approve, reject | the agent |
accept_part, reject_part | accept, reject, per hunk | the agent |
comment, ask_agent | comment, ask; Enter opens the composer first | the agent |
choose | one per question option | the agent |
open, open_source, copy | open, source, copy | the window; nothing is sent |
| anything else | its name, with spaces for underscores | the agent |
open shows a local Markdown, HTML or image file in a floating document over the pane, and hands anything else to the desktop. An HTML artifact built as a decision brief takes notes there, and its notes go back to the agent.
The agent channel#
A second, smaller protocol, TDAC 0.2, carries what Claude Code's hooks see: your prompts, its questions, your answers and its replies. scripts/install-agent-hooks.sh installs the hooks (see Install). They append to inbound.jsonl in the pane's folder, and the window turns each question into a question card and each reply into a response if the agent sent none. When the bench is open, a question waits up to 540 seconds for an answer written to answers/<id>.json, so a person can answer it from the bench. bench.json in the same folder says whether the bench is open. Codex can't use this yet.
Limits#
| Limit | |
|---|---|
| title | 72 characters |
| id | 96 characters |
| file name | 64 characters of letters, digits, - and _ |
| surfaces per pane | 64 in memory, 512 files on disk; the oldest go first |
| idle sessions | a session folder untouched for 30 days is removed |
| unclassified payload | 4096 bytes kept, 20 lines shown |
| table cell | 600 characters |
| changeset patch | 24 lines drawn per hunk |
Refusals#
The MCP route checks strictly and says why:
missing `td` version field — this build speaks TDSP 0.4unknown kind "hologram" — this build renders response, artifact, …a response needs a `layman` — the plain-English explanation of the whole reply"report.html" is relative — it would resolve against the TERMINAL's directory, not yours. Send an absolute path or a full URL.`retire` needs the `id` of the surface to take off the bench
The file and transcript routes don't refuse. JSON they can't read as a surface lands as unclassified with the reason; a file that isn't JSON at all is left unread until it is.
Sources
- The envelope, kinds, actions and limits: surface.rs; the file and command-line routes: surfacefeed.rs; the MCP route: mcp.rs; the transcript route: derive.rs; the agent channel: channel.rs and td-agent-hooks; the spec: td-surface-protocol.md.