Surface protocol

Brief

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.

Technical

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#

FieldNeededWhat the window does
tdalwaysChecks the major version.
opnopresent (the default), update or retire.
idfor retireKeeps letters, digits and - _ . :, up to 96 characters. Left out, it's made from the kind and title.
kindexcept retireOne of the kinds below.
titlenoTrimmed and cut to 72 characters; empty gets a default for the kind.
modelexcept retireThe kind's content.
weightnoEffort, complexity, foundation, confidence; see the workbench.
sourcenofiles, command, reference. The source button opens the first file.
actionsnoVerbs added after the kind's own. They can add buttons, never remove one.
panenoAccepted 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#

KindNeeds in modelMay also haveShelfButtons
responselaymanbrief, technical, evidence, asks, next, doubts, escalation, any other keyoverviewcomment, ask
artifacthref (or path, url, open, file, link)mime, summary, any other noteartifactsopen, comment
markdownbodyartifactscomment
tablecolumnsrows; a null cell reads unavailableartifactscomment
architecturenodes, each with an idnode label, state, group; edges of from, to, labelartifactscomment, ask
changesethunks, each with a filerepository; hunk id, patchdecisionsaccept and reject per hunk, comment, approve
decisionquestionoptions (name, case, cost, one recommended), consequencesdecisionsapprove, reject, comment
questionquestion, optionsoptions as strings or {label, what_happens}; recommenddecisionsone 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 kind and model, and a missing title becomes the kind's default rather than keeping the old one.
  • retire with an id takes 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 as unclassified, 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_surface with the pane's pid and the document as surface. 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. A retire deletes that file.
command line
terminal-delight surface [file], reading stdin with no file, from inside a pane. It takes one document, or every ```td block 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 ```td or ```tdsp block 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":"…"}
ActionButtonDone by
approve, rejectapprove, rejectthe agent
accept_part, reject_partaccept, reject, per hunkthe agent
comment, ask_agentcomment, ask; Enter opens the composer firstthe agent
chooseone per question optionthe agent
open, open_source, copyopen, source, copythe window; nothing is sent
anything elseits name, with spaces for underscoresthe 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
title72 characters
id96 characters
file name64 characters of letters, digits, - and _
surfaces per pane64 in memory, 512 files on disk; the oldest go first
idle sessionsa session folder untouched for 30 days is removed
unclassified payload4096 bytes kept, 20 lines shown
table cell600 characters
changeset patch24 lines drawn per hunk

Refusals#

The MCP route checks strictly and says why:

  • missing `td` version field — this build speaks TDSP 0.4
  • unknown 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

  1. 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.