- HTML Clay
- Docs
- Programs
- The wire CLI
The wire CLI
The htmlclay wire subcommands, the contract a handler program obeys, and how a request travels between a page and a local program.
The htmlclay wire subcommands connect an open page to a command you started yourself. This page covers the CLI, the contract a handler program obeys, the request states and timeouts, and what keeps the channel safe. The page side of a request, and the frames each side sends, are documented on the ClayJS wire protocol page.
The wire#
The wire is a per-file control channel between an open page and one program you started
yourself. The page sends a small request, the program edits the .htmlclay file, and the edit
reaches the page the same way any other file change does. No HTML travels on the wire in
either direction. The file is the only thing the two sides share.
This is the part of HTML Clay written for agents. An agent reading this file is the intended handler.
Requirements: HTML Clay 1.6.0 or later with the file already open in a browser during the
current app run, and ClayJS 0.6.0 or later with the wire plugin. Load sync alongside it,
because without live sync the page never sees the change the program made and a finished request
reports done several seconds late rather than the moment the text appears.
<script src="https://clayjs.com/v1/clay.js?plugins=sync,wire"></script>
Five minutes#
Open the file in a browser first. Then attach a program to it:
htmlclay wire serve ~/notes/page.htmlclay -- ./my-agent.sh
The CLI answers with one line and waits:
[wire] attached as handler to http://htmlclay.localhost:1212 (/Users/you/notes/page.htmlclay)
my-agent.sh runs once per request. The request envelope arrives on stdin and the file’s path
in the environment:
#!/bin/bash
echo "working on it" # becomes a status line the page can show
node rewrite.js "$HTMLCLAY_WIRE_FILE" # edit the file however you like
From the page:
const request = clay.wire.send(
{ instruction: "make the intro shorter", target: "#intro" },
{ text: "make the intro shorter" }
);
clay.wire.on(state => console.log(state.state, state.text));
const outcome = await request.done; // { state: "done" | "error" | "cancelled", ... }
The payload is yours. HTML Clay passes it through untouched and never looks inside it.
The CLI#
htmlclay wire serve <file> -- <cmd> [args...] run <cmd> for every request
htmlclay wire listen <file> [--handler] print frames as JSON lines
htmlclay wire send <file> --type <type> ... send one frame, payload on stdin
htmlclay wire where <file> print the origin serving <file>
Flags may sit on either side of the file argument. Exactly one file operand is required.
| Subcommand | Flags |
|---|---|
serve | --origin <url>, --port <n> |
listen | --handler, --origin <url>, --port <n> |
send | --type <type>, --id <id>, --text <text>, --origin <url>, --port <n> |
where | --origin <url>, --port <n> |
serve requires a literal -- with at least one argument after it; without one it prints usage
and exits 1. It always takes the handler role, so it has no --handler flag.
--origin <url> names an HTML Clay site’s address as the browser shows it, such as
http://htmlclay.localhost:1212. Use it to reach a file whose folder HTML Clay remembers no
origin for, such as a file loose in your home directory: copy the address out of the page’s own
address bar. --port <n> names a Hyperclay Local origin, which has no hostname; HTML Clay’s own port (1212)
looks the file’s hostname up on that port instead. Passing both is a usage error. Otherwise the CLI discovers the origin from siteSlugs in config.json, trying
ancestor anchors first, shortest first, then any legacy per-folder port the config still remembers.
listen is an observer unless you pass --handler. The handler slot is exclusive, one program
per file, and it also keeps HTML Clay watching the file while no tab is open on it, so an edit
made while you are away is still versioned and still appears when you come back. --handler on
listen takes that slot without running any child command.
send --type must start with wire/. The payload is read from stdin and must be valid JSON:
printf '%s' '{"instruction":"make the intro shorter"}' |
htmlclay wire send ~/notes/page.htmlclay \
--type wire/request \
--text "make the intro shorter"
It prints the server’s acknowledgement.
where prints one JSON line:
{"file":"/Users/you/notes/page.htmlclay","origin":"http://htmlclay.localhost:1212","anchor":"/Users/you/notes","state":"live"}
anchor is the literal string --origin or --port when one of those was passed. state is live
or refused, and a refused answer carries a status field.
Discovery reads HTML Clay’s remembered hostnames first, then the folders Hyperclay Local publishes for the same purpose. When both apps serve the same file, the command cannot tell which origin you meant, so it exits 8 and asks for --origin or --port.
serve uses raw mode unless you pass --protocol=jsonl:
htmlclay wire serve <file> [--protocol=jsonl] -- <cmd> [args...]
run <cmd> for every request
Exit codes:
| Exit | Means |
|---|---|
| 0 | fine |
| 1 | bad arguments, unknown subcommand, or a path that cannot be resolved |
| 2 | HTML Clay is not running, or has never served this file’s folder |
| 3 | the address is held by the recovery page, so nothing is open at this origin |
| 4 | no site is serving that file, so open it first |
| 5 | another program holds the handler slot |
| 6 | refused (403, 429, 503) |
| 7 | the send was accepted and nothing was attached to take it |
| 8 | both HTML Clay and Hyperclay Local serve that file, so pass --origin or --port |
Frames go to stdout, one JSON object per line. Everything else goes to stderr.
The handler contract#
wire serve <file> -- <cmd> runs <cmd> once per request.
In: the whole request envelope as JSON on stdin, plus two environment variables.
{
"v": 1,
"type": "wire/request",
"id": "request-id",
"from": "page",
"file": "/absolute/path/to/page.htmlclay",
"text": "make the intro shorter",
"payload": {
"instruction": "make the intro shorter",
"target": "#intro"
}
}
| Variable | Value |
|---|---|
HTMLCLAY_WIRE_FILE | absolute, symlink-resolved path of the file to edit |
HTMLCLAY_WIRE_ID | this request’s id |
Out: every line the program prints on stdout becomes a wire/status frame the page can
display. Exiting 0 becomes wire/done; any other exit code becomes wire/error.
Cancel: the process is sent SIGTERM (on Windows there is no deliverable SIGTERM, so it is
killed). A handler that wants to finish its write can trap it. After the cancel signal the child
gets 5 seconds before exec forces it down.
On the CLI’s own stderr: the handler’s stderr, each line prefixed with the short request id
(the first 8 characters), and then one line per request saying how it ended: done, error: …,
or cancelled. That last line matters when something goes wrong, because a request that ends
with no terminal frame looks exactly like one whose frame was posted and lost downstream, and
the two have nothing in common. The line is printed after the frame goes out, so if the line is
there, the CLI did its part. A request refused before the handler ever ran says so on the same
line: refused: …, or already running; ignoring the repeat.
At most 8 requests run concurrently in one wire serve. Past that a request is refused with a
terminal frame rather than queued, so the page stops spinning.
The program edits the file directly. There is no “return the new HTML” path, on purpose: the file is the state, and anything that wrote HTML back through the wire would be a second, competing writer.
Request states and timeouts#
| State | Means |
|---|---|
sent | posted, waiting for the program to take it |
acked | the program has it, and every status line rearms the clock |
landing | the program finished writing, the page is waiting to see the bytes |
done | the change is on the page |
error | the program failed, nothing was attached, or it went silent |
cancelled | you took it back |
done means visible, not merely finished. The program’s exit says the file is written; the
page then waits up to 4 seconds for live sync to deliver it before reporting done. A page with
no sync plugin still reports done, just later.
Page-side timeouts, so a stuck program cannot hang the page: 15 seconds to acknowledge, then 120 seconds of silence. Every frame rearms the clock, so a program that prints what it is doing can work for as long as it likes. A silent one gets two minutes. This is why both of the example handlers print while they work.
The CLI’s own stream has a 90 second idle timeout, and the server sends a keepalive comment every 25 seconds, so three missed keepalives end the stream and it reconnects.
The page API#
clay.wire exists only when the plugin is loaded, so check for it.
clay.wire.send(payload, opts) // returns { id, state, done, cancel() }
clay.wire.cancel(id) // true if it was still open
clay.wire.get(id) // one snapshot, or undefined
clay.wire.list() // every request this page knows about
clay.wire.isBusy() // is anything in flight
clay.wire.on(fn) // subscribe; returns its own unsubscribe
opts takes { id, type, text }, all optional. text is a plain line for a program that wants
one without parsing the payload. id lets you supply your own request id; reusing a live one is
refused as an outcome rather than thrown. type defaults to wire/request.
A snapshot is { id, type, state, text, error, startedAt }.
send returns immediately. handle.done resolves with the final snapshot and never rejects:
a failed request is an outcome to render, not an exception to catch.
clay.wire.on(fn) passes the snapshot as the first argument and, as the second, the inbound
frame when one caused the update. Today that means wire/status and nothing else. state.text
is sticky across later states, so the frame is the only way to tell a new status line from the
same line arriving again.
The DOM event clay:wire-state carries the same snapshot on document, for code that would
rather not hold a subscription.
Sending saves the page first. The program is about to read the file, so it has to read what you are looking at. Autosave is then suspended until the request ends, so the page cannot write over the program mid-edit. Both are automatic.
Status UI must never become document content#
Everything visible in a malleable page can be serialized, so wire status UI has to be built at runtime and marked before it is connected:
const status = document.createElement("p");
status.setAttribute("clay", "no-save no-snapshot no-watch");
document.getElementById("ask").after(status);
clay.wire.on(state => {
status.textContent = state.error || state.text || state.state;
});
Never author a no-save node into the file by hand. A node marked no-save is left out of
the document that gets written, so the first save deletes it permanently. Injecting it at runtime
means it comes back on every load without ever becoming saved state. Some hosts read the
attribute form rather than the clay list, so set both when you want to be portable:
for (const t of ["no-save", "no-snapshot", "no-watch"]) node.setAttribute(t, "");
The HTTP protocol underneath#
Two routes, both under /_/wire/.
GET /_/wire/subscribe?file=<absolute path>[&role=handler]
POST /_/wire/send
GET /_/wire/subscribe returns text/event-stream with Cache-Control: no-store,
Connection: keep-alive, X-Accel-Buffering: no. Each frame is id: <seq> then data: <json>.
The keepalive is a bare comment line. Last-Event-ID (header, or a lastEventId query
parameter) resumes and replays retained terminal frames past that sequence; a fresh subscription
replays nothing.
A browser never names a path: its file comes from the page’s own URL, sent as the Page-URL
header or a page-url query parameter, and any supplied file is discarded. A local process
has no page, so it names the absolute path, which is then validated for containment in the home
directory and must already be registered with this site.
POST /_/wire/send requires Content-Type: application/json. The body is the envelope shown
under the handler contract: seven fields, no more. v, file, and from are stamped by the
server and any client-supplied value is discarded, which is why a browser cannot address another
file by writing a different path into its envelope. from is exactly one of "process" or
"page". payload is opaque raw JSON that the server never inspects.
The response is:
{"ok": true, "delivered": 1, "observers": 0}
delivered counts handler queues only. It is how a page distinguishes “nobody was listening”
from a timeout, and it is what wire send turns into exit code 7.
Frame types in use: wire/request, wire/ack, wire/status, wire/done, wire/error,
wire/cancel. The router validates only that a type starts with wire/ and is at most 64
bytes; it does not enforce a closed set. wire/done and wire/error are the only two the
server itself treats specially, as terminal frames. The first terminal frame for an id wins, and
a later one does not overwrite it.
Errors from POST /_/wire/send, all {"ok":false,"error":"…"}:
| Status | Message |
|---|---|
| 415 | expected application/json |
| 413 | wire frame too large |
| 400 | could not read body, invalid JSON, invalid type, invalid id |
| 404 | unknown file |
Errors from GET /_/wire/subscribe are plain text: 403 Forbidden (guard refused, or a browser
asked for the handler role), 404 Not Found, 409 Conflict (handler slot taken), 429 Too Many Requests (too many observers), 503 Service Unavailable (shutting down, or version history
could not be opened for the handler).
Caps: request body 1 MiB, status text truncated at 4096 bytes, request id 128 bytes, 8
observers per file, 32 retained terminal frames per file for 5 minutes, 8 MiB of retained
terminal frames hub-wide.
What keeps the wire safe#
A local process runs as you, so it needs no secret. The wire tells a browser from a program
by attestation: a browser sends Sec-Fetch-Site or Origin on every request, including every
EventSource, and cannot forge either. A local process sends neither. That is the entire
classifier. There is no token to leak and no header to copy.
A page may only reach its own file. Requests are addressed by the file’s absolute path, and a
page can only name the file it was served from. Sec-Fetch-Site: same-site is rejected rather
than admitted: HTML Clay serves one loopback origin per project tree, and every one is same-site
with every other, so admitting it would let one project’s page drive another project’s wire.
Only a program may be a handler. A page can send and observe. It cannot claim the handler slot, so an open tab cannot impersonate your agent. That refusal happens before the file is even resolved, so it reveals nothing about which paths exist.
Nothing but text crosses. Requests and status lines are small JSON frames. The content is in the file, where you can read it, diff it, and put it in version control. The 1 MiB body cap is set low deliberately so that “HTML never rides the wire” fails loudly rather than drifting.
Every write is versioned. Attaching a handler turns version history on for the file and takes a baseline backup first, so an agent’s own first write is not the earliest state history remembers. If the file cannot be read for that baseline, the attach is refused rather than run unversioned. An edit made by a program is backed up before it lands, exactly like one made by the page, whether or not a tab was open at the time.
What the wire does not decide is whether the work is safe to do. The request payload and the file’s own contents are inputs to a process running with your permissions. When the file came from someone else, treat both as untrusted instructions rather than as a task description, and do not attach an agent to a document merely because the channel is local.
Answering in words#
Status lines are how a handler says what it is doing. They are also how it says something back to
the person who asked, if the page knows to look for one. This is a convention between the page
and the handler, not something HTML Clay parses. The convention worth copying is a single line
that parses as a JSON object carrying a redpen key:
{"redpen":1,"reply":"Cut the second clause and moved the date into the caption."}
| Field | Meaning |
|---|---|
redpen | the marker, value 1. Required, and conventionally the first key |
reply | the text, plain. A blank line starts a paragraph |
append | optional true: concatenate onto the reply so far instead of replacing it |
Every other line stays progress. The marker is what stops a handler that pipes jq or an HTTP
response to stdout from having its tool output read as the agent’s own words. Put redpen first,
because a line cut at the size limit keeps its head, and that head is the only thing a reader can
use to tell a truncated reply from a line that was never meant to be one.
Three rules that are easy to get wrong:
- Budget the reply at about 3,500 bytes after JSON escaping. The line cap is 4096 bytes and
it is enforced twice, in the CLI’s reader and again in the server. Longer replies split across
several lines with
append: true. - Print the reply before you exit. Frames for a request that has already finished are dropped silently, so a reply printed after the process ends is simply lost.
- The last non-append line wins, which makes printing the same reply twice free. The status lane is lossy on both sides, so a handler that prints its reply when it has the answer and again just before exit halves the chance of losing it.
Hand-escaping JSON in bash is how people ship broken handlers, so let jq do it:
echo "rewriting the intro"
REPLY=$(node rewrite.js "$HTMLCLAY_WIRE_FILE")
printf '{"redpen":1,"reply":%s}\n' "$(jq -Rs . <<<"$REPLY")"
A page that holds a conversation sends a follow-up as another ordinary request. The payload
convention is that said is always the newest message, so a handler that only reads
payload.said keeps working with no change; the exchanges so far ride alongside it as thread,
a list of {you, agent} pairs, appearing only from the second message onwards. Both keys live
inside payload, which the server never inspects.
A complete working page#
This is the file from /docs/guide/let-a-program-edit/, verbatim in substance. Save it with a
.htmlclay extension and open it before attaching a handler.
<!DOCTYPE html>
<html lang="en" autosave>
<head>
<meta charset="utf-8">
<title>Robot notes</title>
</head>
<body>
<h1 editable>Robot notes</h1>
<form id="ask">
<input id="said" placeholder="Ask for a change" autocomplete="off">
<button>Send</button>
</form>
<ul id="log">
<li>Lines the robot adds will show up here.</li>
</ul>
<div id="notes" editable>
<p>This part is yours. Click and type.</p>
</div>
<script src="https://clayjs.com/v1/clay.js?plugins=sync,wire"></script>
<script>
clay.ready.then(function () {
var ask = document.getElementById("ask");
var box = document.getElementById("said");
// Built here, never typed into the file: everything you can see
// in the page is what gets saved.
var status = document.createElement("p");
status.setAttribute("clay", "no-save no-snapshot no-watch");
ask.after(status);
clay.wire.on(function (request) {
status.textContent = request.error
|| request.text
|| request.state;
});
ask.addEventListener("submit", function (event) {
event.preventDefault();
var words = box.value.trim();
if (!words) return;
box.value = "";
status.textContent = "sent";
clay.wire.send({ said: words }, { text: words });
});
});
</script>
</body>
</html>
A handler with nothing clever in it, to prove the loop:
#!/bin/bash
echo "working on it"
NOW=$(date "+%H:%M:%S")
LINE="<li>The robot was here at $NOW.</li>"
perl -pi -e "s|</ul>|$LINE\n </ul>|" "$HTMLCLAY_WIRE_FILE"
echo "added a line at $NOW"
htmlclay wire serve robot-notes.htmlclay -- bash robot.sh
An AI handler is the same three rules with a different program. This one prints a heartbeat every five seconds so the silence timeout never fires, and sends the agent’s own chatter to the terminal rather than the page:
#!/bin/bash
REQUEST=$(cat)
claude --permission-mode acceptEdits -p "The file at
$HTMLCLAY_WIRE_FILE is an HTML page I have open on my screen.
This request came from inside the page: $REQUEST. Make the change
it asks for by editing that file directly. Leave everything else in
the file exactly as it is, and do not create any other files." 1>&2 &
agent=$!
while kill -0 "$agent" 2>/dev/null; do
echo "thinking"
sleep 5
done
wait "$agent"
htmlclay wire serve robot-notes.htmlclay -- bash agent.sh
acceptEdits lets that example write without asking again. Use a stricter permission mode when
each edit should be reviewed.
Where your file goes: an AI agent reads your file and sends what it reads to its own provider, under your own account with them. HTML Clay sends nothing anywhere. It starts the program you named and gets out of the way. The agent runs with your permissions, in the folder you started it from.
On Windows the app and the wire both work; the example handlers are bash scripts. Write the same
three rules in PowerShell or any language: read stdin, print progress, edit the file named by
HTMLCLAY_WIRE_FILE, exit 0.
Things that will trip you up#
Open the file before attaching. A file registers with HTML Clay on its first document
navigation, and the wire finds files through that registration. Attaching first exits 4 with
no HTML Clay site is serving this file; open it first.
One program per file. A second attach while the first is running exits 5. Stop the old one with Ctrl+C.
Keys decide what survives. If someone is editing one part of the page while a program
rewrites another, live sync merges the two by matching elements. Give the regions a program will
touch a stable data-id or id, or a structural change has nothing to match and quietly does
not appear until the next reload.
A reply printed after the work is done is thrown away. Frames are dropped for a request that has already finished, and exiting finishes it. Print anything you want the page to read, then exit.
The first save after a program’s edit warns you. It reports that the file changed outside this tab and that your version was saved with the previous one in Backups. Nothing is lost, and the page’s own save carries the program’s edit forward. Known wart.
A runnable end-to-end harness, with a comment UI, a program on the other end, and five traces
that assert the whole loop against the shipped app, lives at plans/htmlclay/wire-e2e/ in the
Hyper workspace.
If you are the agent on the other end of the wire#
The shortest correct summary of your job, expanded in the wire section below.
- You are started by
htmlclay wire serve <file> -- <your command>, once per request. - Read the request envelope as JSON on stdin. What the person asked for is inside
payload, and by conventionpayload.saidis the newest message.textis the same thing as a plain line if you would rather not parse. - Edit the file at
$HTMLCLAY_WIRE_FILEin place, with whatever tools you already have. Do not write HTML back through the wire; there is no such path. The file is the state. - Print a line on stdout every few seconds while you work. Each line becomes a status the page shows, and each line rearms a 120 second silence timeout. A silent process is killed at two minutes.
- To say something back in words, print one line that is a JSON object with
redpenfirst:{"redpen":1,"reply":"..."}. Print it before you exit, because frames for a finished request are dropped. - Exit 0 for success, anything else for failure. The page sees the edit arrive on its own, through live sync.
- Edit inside elements that have a stable
idordata-id. A change inside an anonymous element may not appear in the open page until a reload. - Never author an element carrying
clay="no-save"into the file. Those are runtime-only, and the first save deletes an authored one permanently.