Important
Trayce is designed for trusted local networks. It provides reasonable defaults β token auth, rate limiting, CSP, bounded submission lifetime β without requiring complex setup.
Every incoming message passes through the same ordered pipeline. If a layer rejects, the request is dropped before reaching a handler.
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#fef2f2','primaryBorderColor':'#dc2626','primaryTextColor':'#111','lineColor':'#555','fontSize':'13px'}}}%%
flowchart LR
In([π‘ Incoming WS frame]) --> Transport["π Transport<br/>TLS if reverse-proxied<br/>localhost by default"]
Transport --> Auth["π Token auth<br/>timing-safe compare<br/>at WS handshake"]:::accent
Auth --> Size["π Size limits<br/>β€30 MB frame<br/>β€20 MB submission"]
Size --> Rate["β±οΈ Rate limit<br/>10 / min sliding<br/>per connection"]
Rate --> Schema["β
Zod validation<br/>discriminated unions<br/>at parse boundary"]
Schema --> Handler["π― Message handler"]
Handler --> Out([β
Accepted])
Auth -. 401 .-> Drop([β Dropped])
Size -. too large .-> Drop
Rate -. quota exceeded .-> Drop
Schema -. invalid payload .-> Drop
classDef accent fill:#fef2f2,stroke:#dc2626,stroke-width:2px,color:#111
A random token is generated on each server start via crypto.randomUUID. All WebSocket connections must present it as a query parameter; validation is timing-safe (crypto.timingSafeEqual) to prevent timing attacks.
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#fef2f2','primaryBorderColor':'#dc2626','primaryTextColor':'#111','actorTextColor':'#111','fontSize':'13px'}}}%%
sequenceDiagram
autonumber
participant U as π€ User
participant Srv as π₯οΈ Server
participant SF as π state.json
participant B as π Browser
participant Br as π Bridge
U->>Srv: bun run start
Srv->>Srv: Generate token<br/>(TRAYCE_TOKEN env takes priority)
Srv->>SF: Write { port, pid, token, url }
Srv-->>U: Print http://localhost:9740?token=abcβ¦
U->>B: Open URL (token in query)
B->>Srv: WS /canvas?token=abcβ¦
Srv->>Srv: timingSafeEqual(provided, expected)
Srv-->>B: β
101 Switching Protocols
Note over SF,Br: Bridge spawned by Claude Code
Br->>SF: Read state file
Br->>Srv: WS /bridge?token=abcβ¦
Srv-->>Br: β
101 Switching Protocols
Token priority:
TRAYCE_NO_AUTH=true β TRAYCE_TOKEN β auto-generate
(disables auth) (explicit) (default)
Tip
Pass TRAYCE_NO_AUTH=true on fully-trusted networks to skip token auth entirely. This is the default inside the container (see container deployment).
Submissions, canvas pushes, and permission requests are rate-limited to 10 per minute per connection using a sliding-window counter. Transcript and usage messages are unmetered.
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#fef2f2','primaryBorderColor':'#dc2626','primaryTextColor':'#111','fontSize':'13px'}}}%%
stateDiagram-v2
direction LR
[*] --> Empty
Empty --> Accumulating : message<br/>push(now)
Accumulating --> Accumulating : message<br/>expire(now-60s) Β· push(now)<br/>len β€ 10
Accumulating --> Throttled : len > 10<br/>emit error
Throttled --> Accumulating : oldest ts<br/>rolls off
Accumulating --> Empty : 60 s idle
Note
Limits are tracked per connection, not per IP. A single browser opening multiple tabs creates multiple buckets β this is intentional (browser IDs are independent) but worth knowing.
| Limit | Value | Enforced by |
|---|---|---|
| π Max WebSocket frame | 30 MB | Bun's WebSocket handler |
| πΌοΈ Max submission size | 20 MB | SubmitSchema + handler check |
πΌοΈ Max push_image size |
20 MB | Bridge check before base64 encoding |
Oversized payloads are rejected before reaching any handler β no partial processing, no disk write.
Submissions live on disk just long enough for Claude to read them. Automatic cleanup ensures nothing lingers.
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#fef2f2','primaryBorderColor':'#dc2626','primaryTextColor':'#111','fontSize':'13px'}}}%%
stateDiagram-v2
[*] --> Validated : submit { image, prompt }
Validated --> Written : save to disk<br/>mode 0o600
Written --> Routed : bridge notified<br/>with absolute path
Routed --> Read : Claude uses Read tool
Read --> Expired : > 1 hour old
Written --> Expired : > 1 hour old
Expired --> Deleted : 15 min cleanup cycle
Deleted --> [*]
Written --> Deleted : server shutdown<br/>removeAll()
| Stage | Detail |
|---|---|
| π Storage path | $TMPDIR/trayce/submissions/ (/tmp on Linux/macOS, %TEMP% on Windows) |
| π File mode | 0o600 β owner read/write only |
| β° TTL | 1 hour |
| π§Ή Cleanup interval | 15 minutes |
| π On shutdown | removeAll() wipes the directory |
Every HTTP response includes:
| Header | Value | Prevents |
|---|---|---|
π§± X-Content-Type-Options |
nosniff |
MIME sniffing attacks |
πͺ X-Frame-Options |
DENY |
Clickjacking via iframe embedding |
π Content-Security-Policy |
script-src 'self' 'sha256-β¦' 'unsafe-eval'; object-src 'none'; β¦ |
Inline script injection, external resource loads |
Note
The server computes SHA-256 hashes of inline <script> tags in the served index.html at startup and emits them in the CSP header. Any on*= attribute in the served HTML causes a fail-fast startup error β no stray inline handlers can slip through.
All browser-to-server and bridge-to-server messages are validated by Zod discriminated unions at the WebSocket parse boundary (shared/protocol-schema.ts). Invalid payloads are logged at warn level and dropped silently β they never reach a message handler.
Browser submissions additionally check:
- β
targetSessionIdcorresponds to a registered bridge - β
imageis non-empty base64 - β Combined prompt + image is under the 20 MB limit
- β Rate limit has not been exceeded for this connection
Warning
By default the server binds to 127.0.0.1 (localhost only). To expose on the local network, set TRAYCE_HOST=0.0.0.0. The auth token protects against unauthorized access β do not disable auth (TRAYCE_NO_AUTH=true) on a bound public interface.
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#fef2f2','primaryBorderColor':'#dc2626','primaryTextColor':'#111','fontSize':'13px'}}}%%
flowchart LR
A[TRAYCE_HOST unset] -->|default| B["127.0.0.1<br/>localhost only<br/>β
safe"]:::safe
C[TRAYCE_HOST=0.0.0.0] -->|trusted LAN| D["All interfaces<br/>+ token auth<br/>β
reasonable"]:::ok
C -->|public / no auth| E["All interfaces<br/>- no auth<br/>β dangerous"]:::danger
classDef safe fill:#ecfdf5,stroke:#059669,color:#064e3b
classDef ok fill:#fef2f2,stroke:#dc2626,color:#111
classDef danger fill:#fef2f2,stroke:#991b1b,stroke-width:2px,color:#7f1d1d
Trayce is not a zero-trust system. It is designed for a developer's own machine or a trusted local network. It does not defend against:
- π« Untrusted users on the same local network (if auth is disabled)
- π« Malicious MCP bridge code (the bridge is trusted β you install it yourself)
- π« A compromised Claude Code process (it has full filesystem access already)
- π« Browser-side XSS if you load unknown content in the same tab as the canvas
- π« Resource exhaustion beyond the rate / size limits above
If you need stronger isolation, run trayce in a container behind a reverse proxy with TLS and per-user auth.
- ποΈ Architecture & data flow β processes, flows, WebSocket protocol
- π¦ Container deployment β running in podman / docker