Paintless
Latest
/Reference

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.

FieldTypeNotes
selectorPathstringStable, human-readable CSS path from the root.
outerHTMLstringSanitized, truncated to 20 000 chars (shallow snapshot when larger).
computedStyleRecord<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 actionWhat happens
applyLeave the edits in the working tree. Nothing is committed — useful when you want to keep editing by hand.
commitCommit the changed files.
commit_and_deliverCommit, 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

EndpointAuthNotes
POST /api/requestsx-paintless-key when PAINTLESS_PROJECT_KEY is setBody is a bare ChangeRequest, not a message envelope. 201 { id, status }
GET /api/requests · GET /api/requests/:idadminAuthorization: Bearer <PAINTLESS_ADMIN_KEY>
POST /api/requests/:id/approveadminQueues the request for the runner
POST /api/requests/:id/rejectadmin
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 to http(s). Plain z.url() accepts javascript: and data: URLs.
  • safeHref(url) — returns the URL only if it is http(s), else '#'. Any URL you render into an href must 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 input

isExecutionResult(input) narrows a Destination.deliver argument to an ExecutionResult, so one destination can handle both a raw request (issue) and a result (PR).

Edit this page

Last updated: