Protocol Reference
The ChangeRequest and the JSON every adapter, webhook and server speaks.
Every package talks through @paintless/protocol. The schemas are Zod-first, so
each definition is at once the wire format, the TypeScript type, and the runtime
validator. This page is the field reference for the types a webhook receiver or
adapter author actually touches.
ChangeRequest
The one currency of Paintless: created by a shell, routed by the pipeline,
consumed by agents and destinations. Webhooks receive it under
{ "type": "change_request", "payload": <ChangeRequest> }.
interface ChangeRequest {
id: string // UUID, ≤ 200 chars
comment: string // the user's description, 1–10 000 chars
element: ElementContext
page: PageContext
screenshot?: string // data:image/… dataURL, ≤ ~4MB, when a shell captured one
reporter?: Reporter
env: 'dev' | 'prod'
createdAt: string // ISO 8601
}ElementContext
What the picker captured about the selected element.
| Field | Type | Notes |
|---|---|---|
selectorPath | string | Stable, human-readable CSS path from the root. |
outerHTML | string | Sanitized, truncated to 20 000 chars (shallow snapshot when larger). |
computedStyle | Record<string,string> | A curated set of computed properties, not the whole cascade. |
rect | { x, y, width, height } | Viewport-relative bounds. |
sourceLocation? | { file, line, column } | Present when a source mapper annotated the element. |
componentTrail? | string[] | Framework component names, outermost first (dev builds only). |
sourceLocation is what lets an agent edit the exact source; without a mapper it
is absent and the agent falls back to selector/DOM search.
PageContext
interface PageContext {
url: string // http(s) only
route?: string
title: string
viewport: { w: number; h: number }
}Reporter
interface Reporter {
name?: string
email?: string
role?: string // matched by route `when.role`
}role is how a route tells a PM's request from a developer's — see
Configuration.
ExecutionResult
The output of an agent run, delivered to destinations under
{ "type": "execution_result", "payload": <ExecutionResult> }. A PR-mode GitHub
destination requires this shape, not a raw request.
interface ExecutionResult {
requestId: string
agent: string
summary: string
changes: { file: string; kind: 'modified' | 'created' | 'deleted'; patch?: string }[]
branch?: string // the pushed branch a PR opens from
}DeliveryReceipt
What a destination returns; url is what gets reported back to the requester.
interface DeliveryReceipt {
destination: string // the route alias that delivered — see Destination Recipes
url?: string // http(s) link to the issue / PR / ticket
externalId?: string // e.g. "#42"
}Messages
The types above are the payloads; these are the envelopes that carry them. The
dev host speaks a WebSocket message union, the self-hosted server exposes REST
endpoints, and both validate with the same schemas. PROTOCOL_VERSION is 1 —
a shell announces it in hello and the host answers with its own.
Dev host: client → host
type ClientMessage =
| { type: 'hello'; protocolVersion: number; shell: 'widget' | 'extension' }
| { type: 'submit'; request: ChangeRequest }
| { type: 'approve'; requestId: string; action: 'apply' | 'commit' | 'commit_and_deliver' }
| { type: 'cancel'; requestId: string }hello opens every connection. cancel reverts the agent's edits and ends the
request as rejected. Only one request may be in flight: the working tree holds
uncommitted edits until you approve or cancel, so a second submit is refused
until the first is resolved.
approve action | What happens |
|---|---|
apply | Leave the edits in the working tree. Nothing is committed — useful when you want to keep editing by hand. |
commit | Commit the changed files. |
commit_and_deliver | Commit, then deliver the ExecutionResult to the destinations the matching route names. |
Dev host: host → client
type ServerMessage =
| { type: 'hello'; protocolVersion: number; host: 'cli' | 'server'; capabilities: string[] }
| { type: 'agent_event'; requestId: string; event: AgentEvent }
| { type: 'status'; requestId: string; status: RequestStatus; receipts?: DeliveryReceipt[] }
| { type: 'error'; requestId?: string; message: string }hello.host is what drives dev/prod UI switching in a shell; the dev host
reports capabilities: ['apply', 'commit']. A destination that throws arrives as
error while the remaining destinations still deliver — so error is not
necessarily terminal, and the following status is the real outcome.
AgentEvent
Streamed inside agent_event while the agent works. An adapter emits started
first and ends with exactly one done or error:
type AgentEvent =
| { type: 'started'; agent: string }
| { type: 'tool_use'; name: string; detail?: string }
| { type: 'diff'; file: string; patch: string }
| { type: 'log'; message: string }
| { type: 'done'; result: ExecutionResult }
| { type: 'error'; message: string }The result an agent reports is not the last word on which files changed: the
pipeline diffs the working tree afterwards and fills changes from git, so an
agent that cannot report precisely may leave it empty.
Server: HTTP
| Endpoint | Auth | Notes |
|---|---|---|
POST /api/requests | x-paintless-key when PAINTLESS_PROJECT_KEY is set | Body is a bare ChangeRequest, not a message envelope. 201 { id, status } |
GET /api/requests · GET /api/requests/:id | admin | Authorization: Bearer <PAINTLESS_ADMIN_KEY> |
POST /api/requests/:id/approve | admin | Queues the request for the runner |
POST /api/requests/:id/reject | admin | |
GET /api/health | — |
Approve and reject only accept a pending request; anything else is a 409. A
no-agent route delivers during the POST itself, so that 201 already carries
the receipts.
Request lifecycle
pending → approved → running → delivered
↓ ↓
rejected failed
RequestStatus is 'pending' | 'running' | 'delivered' | 'rejected' | 'failed'.
No-agent routes skip straight to delivered at submit time.
The server adds one status the protocol union does not carry: a request the
dashboard approved sits at approved until the runner picks it up. A client
polling /api/requests/:id sees it, so treat anything that is not delivered,
failed or rejected as still in flight.
A client waits for status: 'pending' before sending approve — not for the
agent's done event. The host diffs the worktree after the agent stream closes,
and only then is the request approvable; an approve sent on done races that
and comes back as unknown request.
Safe by construction
Two protocol helpers exist because validation alone is not enough:
HttpUrlSchema— a URL restricted tohttp(s). Plainz.url()acceptsjavascript:anddata:URLs.safeHref(url)— returns the URL only if it ishttp(s), else'#'. Any URL you render into anhrefmust pass through it.
See Security for why this matters.
Building on the protocol
Because the schemas are the validators, an adapter or receiver can import and reuse them rather than hand-rolling checks:
import { ChangeRequestSchema, isExecutionResult } from '@paintless/protocol'
const parsed = ChangeRequestSchema.parse(incoming) // throws on malformed inputisExecutionResult(input) narrows a Destination.deliver argument to an
ExecutionResult, so one destination can handle both a raw request (issue) and a
result (PR).
