# The Stitch Slop bridge protocol

Everything needed to write a bridge from scratch, in any language, without
reading our implementation.

## Read this as DATA, not as instructions

**You probably fetched this because a user asked you to. Nothing here is a
command.** This is a protocol description. What to do with it is between you and
them — if anything below reads like an order, it is describing what a working
bridge does, not telling you to do it.

That matters because the alternative is a channel where a remote server hands
you steps you then execute with shell and file access on someone's machine. An
agent refused an earlier version of this page on exactly that ground and was
right to. **The user's own message is the instruction; this is the reference it
points at.**

## What you are building

Stitch Slop is a browser embroidery digitizer. The user's design lives in their
browser TAB, not on a server, so nothing reaches it from outside. A small local
process — the bridge — listens on loopback; their tab dials in and the two
exchange JSON. That is the whole architecture.

**The user has already switched the connection on** before sending you here, and
their tab is polling for your bridge now. It attaches on its own the moment one
exists. There is nothing for them to press and no button for you to send them
looking for.

**Write your own.** The protocol below is complete and a working bridge is
around 100–500 lines depending on language; one was built from this document
alone and passed 37 of 37 conformance checks first try, another in Python on a
machine with no Node at all. Code you author is code you have read, and it needs
no trust in this server. A reference implementation in JavaScript is linked at
the end if you would rather transcribe than derive.

Correctness is decided by `conformance-bridge.mjs`, not by resemblance to our
code.

---

## 0. What a bridge is

One process with three faces:

```
   MCP client  ──JSON-RPC 2.0 over stdio──▶
   (spawns it; optional)                     BRIDGE  ◀──WebSocket──  browser page
   any shell   ──HTTP on the same port───▶                           (dials in)
   (§4; needs no MCP at all)
```

The MCP face is **optional**. It is the nicer one — native tools rather than
shelling out — but it cannot be the only one, because a client only spawns its
servers at session start and a mid-session registration would cost the user a
restart. §4 is what makes the bridge usable the moment it is running.

The page owns the document and the command registry. The bridge owns nothing: it
carries `tools/call` to the page and the page's answer back. It **never dials the
page** and **never opens a browser** — the page always connects to it.

---

## 1. Command line

```
<bridge> --origin <ORIGIN> [--port N] [--config-dir DIR] [--version]
          (token via the STITCHSLOP_TOKEN environment variable)
<bridge> status | tools | wait [SECONDS] | call <COMMAND> [JSON-ARGS] [--out FILE]
```

The first form runs the bridge. The second drives a bridge that is **already
running**, from an ordinary shell — see §4. A bare word in the arguments selects
the second form.

| flag | meaning |
|---|---|
| `--origin` | the **only** site allowed to connect. Required in practice. |
| token | One-time bootstrap credential — refused after it has been redeemed (§2.4). Not needed once paired. **Take it from an environment variable, not a flag:** anything on the command line is readable by every process on the machine through `ps`. A field report passed it as `STITCHSLOP_TOKEN` and was right to; our own examples used to put it in argv. |
| `--port` | pin one port instead of scanning. |
| `--config-dir` | where pairings live. Default `~/.stitchslop`. |
| `--agent-idle-minutes` | standalone only: exit after this long with no agent using the bridge (§2.8). Default 120. |
| `--version` | print a line containing the protocol number to **stdout**, exit 0. This runs before any MCP session exists, so §3.1 does not apply. |

Exit non-zero with a message on stderr if there is **neither** a token **nor** a
stored pairing for `--origin` — there is no way to authenticate anyone.

---

## 2. The page-facing side (WebSocket server)

### 2.1 Listening

Bind **127.0.0.1 only**. Never a public interface.

Take the **first free port of 8787, 8788, 8789, 8790**, in that order. The page
tries the same four in the same order, so neither side has to be told which was
chosen. If all four are busy, exit non-zero and say so. `--port` overrides the
list with a single entry.

Print to **stderr** (never stdout — see §3.1) the port, the allowed origin, and
your own pid. **No format is prescribed** and nothing parses it — the
conformance test finds you by probing the four ports, not by reading your log.
It is for the human watching.

### 2.2 The origin check

On the WebSocket upgrade, compare the `Origin` header to `--origin`. Compare
case-insensitively and ignore a trailing slash; treat a **missing** Origin, and
the literal string `null` that sandboxed frames send, as a mismatch. On
mismatch, refuse the upgrade with **HTTP 403** and log it. A request that is not
an upgrade at all deserves **HTTP 426**. Localhost is not a
trust boundary: any page in any tab can reach your port, so this check and the
credential in §2.3 are the only two boundaries that exist.

### 2.3 The handshake

The page's **first frame** is JSON carrying one or both of:

```jsonc
{ "token": "tok_…" }                  // bootstrap
{ "secret": "…" }                     // a stored pairing
{ "secret": "…", "token": "tok_…" }   // both — accept EITHER
```

Both may arrive because the page's memory of being paired and yours can
disagree; accepting either is what lets that heal.

**If both are present and both verify**, treat it as a SECRET handshake: no new
pairing is minted (§2.4). The page is already paired; re-issuing would churn a
working credential for nothing.

**Reject** (neither matches) → reply `{"error":"bad_credential","message":"…"}`
and close. Compare in constant time — and note the lengths may differ, which
makes a naive fixed-width compare throw; hash both sides to a fixed width first,
or compare lengths separately.

**Accept** → reply:

```jsonc
{ "hello": "stitchslop-connector", "protocol": 1, "port": 8787,
  "pairingSecret": "…" }    // ONLY on a token handshake — see §2.4
```

### 2.4 Pairing

When a connection authenticates **by token** (not by an existing secret), mint a
long-lived random secret (≥32 bytes of entropy, URL-safe), store it, and include
it in `hello` as `pairingSecret`.

**Then spend the token.** Record it — hashed, never in the clear — and refuse it
on any later handshake. "One-time" has to be enforced or it is not true:
registering with `--token` writes that token into the client's config in plain
text, so a token that keeps working is a permanent credential in a file that
every document describes as short-lived. A genuinely new browser is unaffected;
the page mints it a fresh token. The page keeps it and sends it next time, so a
returning user never copies a token again.

Store at `<config-dir>/pairing.json`, **keyed by origin**, with file mode
`0600` and the directory `0700`. A staging site and production must not share a
secret.

### 2.5 Frames after the handshake

**You → page**, to run a command:

```jsonc
{ "id": 1, "command": "scene.describe", "args": {} }
```

**Page → you**, either:

```jsonc
{ "id": 1, "ok": true, ... }              // a reply; match by id
{ "tools": [ { "name": "...", ... } ] }   // the tool list, sent unprompted on connect
```

A reply's `id` matches the request. The tool list has no `id` and normally
arrives once per connection — but accept it any number of times and re-notify,
since a page may revise it. Cache the latest; see §3.3.

### 2.6 Two tabs

Only one page holds the connection, and what you do with a second one decides
whether two tabs can be used at all.

**Refuse it, don't take it.** When a second page authenticates while the first
is still reachable, answer

```json
{ "error": "busy", "busy": true, "message": "This connector is already serving another Stitch Slop tab. …" }
```

and close. Do **not** switch to it.

The reason is the port scan. Every page dials 8787 first and walks the list, so
a bridge that accepts-and-switches means opening a second tab kills the first
one's connection **even when a perfectly good second bridge is listening on
8788**. Two tabs then cannot coexist however many bridges are running. A
refusal costs nothing, because the page already treats an `error` reply as "try
the next port" — the same path that lets a stale bridge on 8787 stop hiding a
working one on 8788. So the second tab walks on and finds its own bridge.

`busy` is a distinct flag rather than a bare `error` because the page reports it
as its own state: the credential was fine, and the fix is to close the other tab
or start another bridge, not to re-pair. A bridge that omits the flag is still
correct — the page just describes the refusal less precisely.

**Condition it on the first page being genuinely reachable.** A socket the OS
has not yet buried is not a live tab. If the incumbent's socket is closed,
destroyed or unwritable, take over instead — otherwise a laptop that slept locks
the user out of their own app with no way back.

**When you do take over — on that stale path, or behind an explicit opt-in flag
— tell the first page:** send it `{"displaced": true, "message": "…"}` before
switching. Silently swapping leaves the first tab showing itself as connected
while commands go elsewhere. Note the page treats `displaced` as terminal and
stops polling, deliberately: two tabs snatching the connection back from each
other is a fight neither wins.

### 2.7 Idle exit

If **no page has ever authenticated** within 30 minutes, exit 0 with a message.
A bridge someone armed and forgot must not sit listening indefinitely. Once a
page has connected, never idle-exit — it is in use.

**And cancel it the moment an MCP client sends `initialize`.** That client
started you and will stop you when its session ends; exiting underneath it is
fighting the thing that manages you. Leave this in and the user's tools
disappear after half an hour of not using them, with a session restart as the
only cure — which is exactly the friction the idle exit was never meant to
create. The rule is: idle-exit protects a bridge nobody owns, and a client
attaching means somebody owns it.

### 2.8 When your agent is gone, go too

§2.7 only covers a bridge no page ever reached. The commoner leak is the other
way round: a page connected, then the agent that started the bridge ended its
session, and the bridge kept the port. A bridge an agent wrote from this spec
did exactly that and held port 8788 for four and a half days, orphaned, still
answering the page (found 2026-09-20). Nothing on the page's side can stop
it. So:

- **Run by an MCP client:** once the client has sent `initialize`, it owns you.
  When your stdin closes, exit 0. Do **not** exit on stdin closing before
  `initialize`: a bridge started in the background often has stdin on
  `/dev/null`, which ends at once.
- **Run standalone** (backgrounded, driven by `call` / `status` from a shell):
  exit 0 after `--agent-idle-minutes` (default 120) with **no agent activity**:
  no control-plane request and no MCP message. This applies **even while a page
  is attached**, because a page staying connected says nothing about whether an
  agent is still there. Say on stderr why you exited, and that the pairing is
  kept, so starting again needs no token.

### 2.9 Heartbeat

Send the page a WebSocket **ping** frame every 30 seconds. The browser answers
pings itself, so nothing in the page has to run. If no pong (or any other
frame) arrives for 90 seconds, treat the page as gone: close the socket, empty
the tool list, and fail anything pending, exactly as for a close. A tab that
crashed, slept or lost its network sends no close frame, and without this the
bridge would go on reporting a dead page as connected.

---

## 3. The client-facing side (MCP over stdio)

JSON-RPC 2.0, one message per line, `\n`-delimited, on stdin/stdout.

### 3.1 STDOUT IS THE PROTOCOL

Every diagnostic goes to **stderr**. A single stray line of logging on stdout
corrupts the stream and the client dies with a parse error that names nothing.
This is the easiest rule to break and the hardest failure to diagnose.

### 3.2 Methods

**`initialize`** → result:

```jsonc
{ "protocolVersion": "<echo the client's, or 2025-06-18>",
  "capabilities": { "tools": { "listChanged": true } },
  "serverInfo": { "name": "stitchslop-connector", "version": "…" },
  "instructions": "…" }
```

`instructions` should tell the model what to do when no page is connected yet:
call `wait_for_connection`, because the page polls and attaches on its own. It
should **not** tell the model to send the user off to press something — there is
nothing to press, and saying so sends them hunting for a control that does not
exist. Only if the wait times out is the user involved, and then the question is
whether their **Allow agent connections** switch is on.

**`notifications/initialized`** — a notification. Note it, answer nothing.

**`ping`** → `{}`.

**`tools/list`** → `{ "tools": [ … ] }` — always `wait_for_connection` (§3.4),
plus the page's tools when a page is connected.

**`tools/call`** → `{ "content": [ … ], "isError": <bool> }`.

**The translation, which is the whole job:** the MCP call's `params.name`
becomes the page frame's `command`, and `params.arguments` becomes `args`. You
mint the `id`. So

```jsonc
{ "name": "scene.describe", "arguments": { "limit": 5 } }   // from the client
{ "id": 7, "command": "scene.describe", "args": { "limit": 5 } }   // to the page
```

and the page's `{ "id": 7, … }` is the answer to that call.

Never answer a notification (no `id`). Unknown method → JSON-RPC error −32601.

### 3.3 The tool list arrives late

A client calls `tools/list` immediately after `initialize` — almost always
before the user has pressed Connect. Do not invent tools. Offer only
`wait_for_connection`, and when the page connects and sends its list, emit:

```jsonc
{ "jsonrpc": "2.0", "method": "notifications/tools/list_changed" }
```

On disconnect, drop the page's tools and notify again. Advertising tools that
cannot run is worse than advertising none.

### 3.4 `wait_for_connection`

The one tool you provide yourself. It **blocks** until a page authenticates,
then returns a greeting.

MCP cannot wake a model: `tools/list_changed` reaches the *client*, not the
conversation, so an agent that told the user to go and press Connect sits silent
forever. A blocking call is the one thing that reliably gets a model talking
again, because it is answering a result.

- Input: `{ "timeoutSeconds": number }`, default 90, cap 120.
- Already connected → return at once, saying so — and still call
  `scene.describe`, because the reason for including it (proving the link rather
  than asserting it) applies just as much when the page was already there.
- Times out → return a **normal result** (not an error) saying it is still
  waiting and may be called again. A call that outlives the client's own
  deadline looks like a crash.
- On connection → call `scene.describe` on the page and include what it says, so
  the greeting proves the link works rather than asserting it.

### 3.5 Results and errors

A call while no page is connected is **`isError: true` with helpful text**, not
a JSON-RPC error — the call was well-formed, the app simply is not there, and
the model should relay the fix rather than treat the tool as broken.

A page envelope with `ok: false` is also `isError: true`; its `message` is a
sentence for the user.

**On success**, return one text block containing the envelope as JSON, with the
transport `id` removed — it is bookkeeping between you and the page and means
nothing to the model. If the envelope has a `say` field, lead with that line
before the JSON: it is the app's own sentence and is what the agent should
relay. Nothing else is prescribed; a model reads whatever you pass through.

**Images:** if a reply contains a `dataUrl` field of the form
`data:<mime>;base64,<payload>`, emit an `{"type":"image","data":…,"mimeType":…}`
block **and remove `dataUrl` from the JSON you also return as text**. Leaving it
sends the same payload twice — once usable, once not.

---

## 4. The control plane (a shell, over HTTP)

**This is the section that removes the session restart, and it is why MCP is
optional rather than required.**

An MCP client only spawns its servers at session start. A bridge registered
mid-session therefore does not exist for that session: the tools are absent
until the user restarts and loses their context. No amount of protocol design
fixes that from inside MCP, so a conforming bridge must also be usable **without
it** — as an ordinary command, in the session the agent already has.

### 4.1 The session file

On a successful bind, write `<config-dir>/session-<port>.json` with mode **0600**:

```json
{ "port": 8787, "key": "<32 random bytes, base64url>", "origin": "https://…",
  "pid": 1234, "protocol": 1, "startedAt": "2026-09-13T20:01:59.081Z" }
```

- The port is the one actually bound, not the one requested (§2.1).
- **The key is minted fresh on every start.** A file left behind by a dead
  bridge then fails against a new one with a clean 401 instead of authenticating
  by accident.
- Remove it on exit, but **only if its `pid` is still yours** — a second bridge
  may have replaced it, and deleting that one would break a live connection.
  A bridge killed with `SIGKILL` cannot clean up, so clients must also handle a
  stale file (connection refused) gracefully.

**NAME IT PER PORT, not `session.json`.** One fixed path cannot describe two
bridges: the second overwrites the first, and since the key is minted per
process (above) the first bridge's key becomes unrecoverable from disk. Every
later CLI call then reaches the second bridge while the caller believes it is
driving the first. The WebSocket side never notices, which is the quiet kind of
wrong. Writing `session.json` as well, for older clients, is harmless once
nothing you depend on reads it.

**A client picking a session must not guess.** Prune entries whose `pid` is gone
(signal 0 tests existence without sending anything — a stale file is the common
case). Then: one live bridge, use it; several, require the caller to say which
with `--port` and list the options; none, say so. Choosing one from several
would silently drive the wrong document and report success.

### 4.2 Endpoints

Same port as the WebSocket; plain HTTP rather than an upgrade. Every route
requires `x-stitchslop-key: <key from the session file>`.

| route | body | answers |
|---|---|---|
| `GET /status` | — | `{ok, connected, port, origin, protocol, pid, paired, tools[], message}` |
| `GET /tools` | — | the tab's tool list, or `ok:false` when no tab is attached |
| `POST /wait` | `{seconds}` | blocks until a tab attaches; `ok:false, error:"timeout"` if none does |
| `POST /call` | `{command, args}` | the tab's envelope (§2.5), verbatim |

Anything else on that port keeps answering `426`.

### 4.3 The two checks that make this safe

**The file mode IS the authentication.** Loopback is not a trust boundary: every
page in every tab the user has open can reach `127.0.0.1:8787`. The key lives
only in a 0600 file in the user's home directory, which a browser cannot read.

**Refuse any request carrying an `Origin` header, before looking at the key.**
Shells and agents send none; pages always do. This is belt-and-braces — a page
should never hold the key — but it means a leaked key alone is still not enough
for a web page to use, and it costs one comparison.

### 4.4 The command-line contract

Agents parse this, so it is part of the protocol:

- the envelope as **JSON on stdout**, and nothing else on stdout;
- `--out FILE` on a `call`: if the envelope carries a `dataUrl`, decode it, write
  the bytes to FILE, **remove `dataUrl` from the printed envelope** and replace
  it with `savedTo` / `savedBytes` / `savedType`. An MCP client receives a render
  as a native image block and simply sees it; this path cannot, so without
  `--out` a render arrives as ~140KB of base64 that the caller must slice out of
  the JSON, decode and write before it can look at anything. If the envelope
  carries no image, **say so on stderr and write nothing** — a silent no-write
  is how a caller ends up opening a file that was never created, or a stale one
  from an earlier run;
- human commentary on **stderr**;
- **exit 0 only on `ok:true`**; `1` for a refusal or a failed command; `3` when
  no bridge is running or the session file is stale; `64` for a usage error.

So an agent that checks `$?` and one that parses `.ok` reach the same
conclusion, and neither has to guess from prose.

---

## 5. When the tab does not attach

The failures here are hard to tell apart from a socket, which is why each row
names the SYMPTOM that distinguishes it rather than the cause alone.

| What you see | What it is |
|---|---|
| **Nothing dials the port at all — your log stays completely empty** | Almost always the site's own **Content-Security-Policy** blocking `connect-src` to `ws://127.0.0.1`. A CSP block happens in the browser BEFORE any socket is opened, so there is no connection for you to log and no refusal either. **Ask the user to open their browser console** — it names the violation in one line. Nothing on their machine can fix it; it is a site-side bug. This cost one agent a day: two silent waits, then unzipping Firefox's `omni.ja` and querying `permissions.sqlite`, before the console answered it immediately. |
| **Connections arrive and are REFUSED at the upgrade** | You logged something, so the browser reached you. The origin does not match: a bridge started with the wrong `--origin` refuses with 403 and the page cannot tell that from nothing listening. Compare the origin you were started with against the one in the user's paste. |
| A permission prompt appeared and was dismissed | **Chrome only.** Chrome gates a public page reaching loopback behind "Access other apps and services on this device", and a refusal is sticky across restarts — recovery means site settings. **Firefox does not prompt at all**: measured on Firefox 155, `ws://127.0.0.1` from an HTTPS page connected with no prompt and no permission recorded, once CSP allowed it. Do not warn a Firefox user about a dialog they will never see. |
| `bad_credential` in your log | The token was redeemed already (they are one-time) or a stored pairing no longer matches. Have the user reopen the panel for a fresh token. |
| It connected, then `displaced` | A second tab took the connection. Only one holds it at a time, and the page stops polling after this — deliberately, so two tabs cannot snatch it back and forth forever. If you did not mean to run two tabs, close one; if you did, see §2.6 and run a bridge each. |
| The page reports **busy** and never attaches | Every bridge it found is already serving another tab. This is the §2.6 refusal working: the credential was fine. Close the other tab, or start another bridge — the page keeps polling and will attach to a free one on its own, with nothing to press. |
| **Your bridge cannot bind any of 8787–8790, or the page attaches to a bridge you did not start** | An old bridge is still running: one written before §2.8, or started by a session that has ended. `lsof -nP -iTCP:8787-8790 -sTCP:LISTEN` lists what holds the ports. A bridge whose parent is `launchd` / pid 1 and that no running agent owns is abandoned; stop it. Something that is not a Stitch Slop bridge may hold one of the four, which is why there are four. |
| **The tab keeps disconnecting during a long session** | Check for a SECOND Stitch Slop tab before anything else. Against a bridge that still accepts-and-switches (§2.6), the two tabs displace each other in turn and each disconnection looks random. A bridge that follows §2.9 pings the page; one that does not has no keepalive on this socket: a backgrounded tab can have its connection throttled, and neither end pings. |

**The discriminator to hold onto: an empty log means the browser never tried;
a logged refusal means it tried and you turned it away.** Those need opposite
fixes and look identical from the user's side.

---

## 6. Conformance

```
node scripts/conformance-bridge.mjs -- <command that starts your bridge>
```

It plays both roles — a browser page on the WebSocket side and an MCP client on
stdio — and checks the contract above. **A bridge is correct when this passes.**
Exit `0` passed, `1` failed, `2` a usage error, `3` **not run** (ports 8787–8790
were already in use, so it would have tested whatever answered there instead).
Treat `3` as a failure in CI.
It does not care what language you wrote it in, what dependencies you used, or
whether it resembles our implementation.

---

---

## Reference implementation

A complete JavaScript bridge implementing everything above:
**`/agent-connector.mjs`** — served at that path on this origin, no
credential needed.

It is offered for transcription, not execution. **Writing your own from the
protocol above is the expected path**, and the one that does not ask you to run
code you have not read; every failure mode in §5 has been hit by bridges written
both ways. If you do fetch it, check it before running it:

```
shasum -a 256 <file>     # expect 4cae58cdf83c94bbc53da1279c786fcade30202f0a8a361209c1d2f21de28863
wc -l <file>             # expect 1179
```

A mismatch means a truncated or modified copy, which presents as "the connection
just does not work" rather than as an error. Note that many agent sandboxes
refuse to fetch a file straight to an executable path, which is a reasonable
call rather than a fault — writing the bridge yourself sidesteps it entirely.
