> Parlane integration kit — the full protocol contract, inlined verbatim > from docs/06-integration-kit.md at build time. Short index: https://parlane.ai/llms.txt > Authoritative JSON Schemas: https://parlane.ai/spec/.schema.json > Quickstart for humans: https://parlane.ai/docs/integrate # Integration Kit — Parlane **Status:** v1.0 — the protocol contract of record. Authored to reconcile with the published JSON Schemas in `spec/` and their fixtures. **Reads with:** 01-architecture.md §3/§4/§5 (surfaces & transport), 03-brd-ui-spec.md §8 (component catalog), 00-master-plan.md D8/D10/D13/D14. **Machine-readable contract:** `spec/manifest.schema.json`, `spec/ui-document.schema.json`, `spec/action.schema.json`, `spec/push.schema.json` (JSON Schema draft 2020-12), with valid + broken fixtures in `spec/examples/`. > **Brand identifiers are FROZEN** (name locked 2026-07-23, D6). Every brand-derived > identifier in this document — the display name **Parlane**, the well-known path > **`/.well-known/parlane.json`**, the MCP resource prefix **`parlane://`**, and the > URL scheme **`parlane://`** — is sourced from `packages/brand/brand.json` > (`displayName`, `wellKnownPath`, `mcpResourcePrefix`, `urlScheme`) and is now part of > the protocol contract of record: changing any of them is a breaking protocol change. > Code still never hardcodes a brand string — read it from the brand module > (CLAUDE.md rule 8). --- ## 1. Protocol-first philosophy (D8) Parlane has **no SDK**. The entire integration surface is a set of open JSON Schemas that any AI can generate documents against from these docs alone. A developer makes their app "Parlane-ready" by pasting the prompt snippet (§7) into their own coding agent; the agent emits a manifest, dashboards, and tool handlers directly. There is nothing to install, and optional helper libraries — *if they ever exist* — must never become required. Three consequences shape every decision in this kit: 1. **The developer's AI authors the interface.** The human downloads Parlane and connects once. From then on, all configuration — new dashboards, new widgets, copy changes — flows through the live connection as JSON the developer's agent produces. "Ask your agent to add a battery widget to the dashboard" works with no app-side change, no redeploy. *The app your agent builds itself.* 2. **The server owns intelligence and state; the app owns rendering, native integration, and secrets.** Dashboards are declarative *data*, never executable code (App Store posture, D12). 3. **Two transports, one contract.** MCP (streamable HTTP) is primary; a minimal REST fallback (§5) covers non-MCP backends. Both ship in v1 (D10). The two are behind one app-side `AppConnection` adapter, so the document shapes in `spec/` are identical regardless of transport — MCP is a mapping over the same manifest / UI document / action / push objects. The rest of this document is the human-readable companion to `spec/`. **When this prose and a schema disagree, the schema wins** — the schemas are the published contract, validated in CI against the fixtures. --- ## 2. The App Manifest **Schema:** `spec/manifest.schema.json` · **Examples:** `manifest-minimal.json`, `manifest-full.json` Served at the well-known path (`/.well-known/parlane.json`) over HTTPS, or returned by the MCP manifest resource (§6). It declares identity and which surfaces the backend supports. **Every field is optional**; the client applies the defaults below. Surface visibility is driven by the manifest declaration alone — the client does NOT infer undeclared surfaces from MCP capability discovery (a v1 non-feature; declare what you serve). An empty object `{}` is a valid manifest. ### 2.1 Top-level fields | Field | Type | Default | Meaning | |---|---|---|---| | `version` | integer ≥ 1 | `1` | Manifest/protocol schema version. Additive changes (e.g. server TTS) do **not** bump it; only breaking changes do. | | `name` | string | host of the URL | Display name of the connected app. | | `icon` | string (uri) | generated initials | Absolute https URL to a square icon. | | `accent` | string `^#[0-9a-fA-F]{6}$` | neutral system accent | Hex color that tints this app's chrome. Superseded by `theme.colors.accent` when a theme sets it. | | `description` | string ≤ 500 | — | Shown on the connect/consent screen. | | `theme` | object | client defaults | Server-driven skin for **this app's own screens** (D15). See §2.3. | | `surfaces` | object | none advertised | The surface declarations below. | ### 2.2 Surfaces All surface keys are optional; omit a surface to leave it unadvertised. The client hides tabs for undeclared surfaces — declare only what you serve, but see the chat note below. - **`chat`** — `{ endpoint: "mcp"|"rest" = "mcp", attachments: boolean = false }`. Streaming text, markdown, tool-activity, and (if `attachments`) image/text/URL uploads. **Chat is the core surface — implement it unless the developer explicitly opts out.** A connected app without chat (and therefore without voice, which rides the chat stream with no extra server work) feels broken on first open. If the backend has *any* conversational or agent capability, wire `/chat` (or the MCP mapping) to it; skip chat only on explicit instruction, and then leave the surface undeclared so the client hides those tabs. - **`voice`** — `{ mode: "ptt" = "ptt", stt: "device" = "device", tts }`. v1 is a push-to-talk loop with on-device STT/TTS. `mode` and `stt` accept only their v1 values. `tts` is either the string `"device"` (default) **or**, from **v1.1 (D14)**, an object `{ mode: "server", endpoint: string, format?: "wav"|"mp3" = "mp3" }`: the client POSTs reply text to `endpoint` and plays the returned audio, **falling back to device TTS on error or timeout**. The contract is generic (text in → audio out); [Voicebox] is the flagship documented recipe, never a hard dependency. On-device stays the zero-setup default. - **`dashboards`** — array of `{ id, title, source, refresh? }`. `id` matches `^[A-Za-z0-9_-]+$` and is used in deep links, refresh targets, and `GET /ui/{id}`. `source` is where the UI document lives: an MCP resource URI (`parlane://ui/{id}`), a REST path (`/ui/{id}`), or an absolute URL. **`source` is a routing instruction, not a label** — the app fetches each dashboard by exactly the transport its `source` names: `parlane://…` → MCP `readResource`; `/path` or `https://…` → HTTP GET (with the bearer header). Declare only a form you actually serve. The classic failure: an MCP-only backend declares `"source": "/ui/home"`, the app GETs it, and an SPA/catch-all route answers with HTML → "response was not valid JSON" in the app. `refresh` is an optional hint: `{ mode: "manual"|"poll"|"push" = "manual", intervalSeconds? }`. - **`push`** — `{ relay: boolean = false }`. Requires the relay (§3 / architecture §2.2). - **`shortcuts`** — array of `{ phrase, tool }`. Each becomes a Siri/App Intent that calls `tool`. - **`share`** — `{ accepts: ["url"|"text"|"image", …], tool }`. Share-sheet target and the tool that ingests shared content. `manifest-full.json` ("Ship's Computer", D13 demo persona) exercises all of the above, including the v1.1 server-TTS field and the D15 `theme` block below. ### 2.3 Theme (D15) Optional top-level `theme` object. It lets the backend **skin this connected app's own screens** — accent, palette, and light/dark preference — declared from the server, never configured in the app. All fields are optional; omit any token and the client uses its default for the effective appearance. ``` theme: { appearance: "auto" | "dark" | "light" = "auto", colors: { accent, background, surface, surfaceRaised, textPrimary, textSecondary, positive, negative // each a six-digit hex ^#[0-9a-fA-F]{6}$ } } ``` - **`appearance`** — `"auto"` (default) follows the device light/dark setting; `"dark"`/`"light"` pin this app's screens regardless of the device setting. - **`colors`** — any subset of the eight tokens. Unset tokens fall back to the client default. - **Precedence.** When both the top-level `accent` and `theme.colors.accent` are present, **`theme.colors.accent` wins.** The top-level `accent` stays for back-compat and as the single-color shorthand. - **Scope.** A theme applies **only within that app's screens** (its tabs). The app switcher and global chrome always keep the client's default look — one app cannot restyle the shell or another connected app. - **Contrast is the server's responsibility.** The client applies the tokens **as-is** and adds only a minimal *essential-contrast* fallback where a background/text pair would be dangerously illegible — a safety net, not a design tool. Choose legible, well-contrasted values (set backgrounds and their matching text tokens together). The `validate_manifest` dev tool emits **advisories** (not schema errors) for lopsided pairs, e.g. a `background` set with no `textPrimary`. - **App icon.** Apple permits runtime icon switching only among **icons bundled at build time**, so the client ships a fixed set of accent-hue app-icon variants. When the user sets a **default** app, the client auto-selects the bundled variant nearest that app's *effective* accent (`theme.colors.accent` if present, else top-level `accent`) via `setAlternateIconName` — the system shows its standard "You've changed the icon" alert. The home-screen icon follows the **default app only**; it does not churn as you open other connected apps. --- ## 3. Transports overview The same four document types (manifest, UI document, action request/response, push payload) travel over either transport: | Concern | MCP (primary, §6) | REST fallback (§5) | |---|---|---| | Manifest | MCP resource `parlane://manifest` | `GET /.well-known/parlane.json` | | Chat | MCP chat tool → streamed result | `POST /chat` → SSE stream | | Dashboard fetch | MCP resource `parlane://ui/{id}` | `GET /ui/{id}` | | Action | MCP tool call | `POST /action` | | Push | relay MCP tool / relay `POST /notify` | relay `POST /notify` | Push (§3, relay-side per architecture §2.2) is always **relay-side**, never served by the app; it is included here because its payload is part of the published contract (`spec/push.schema.json`). --- ## 4. Component catalog & the action envelope **Schema:** `spec/ui-document.schema.json` · **Examples:** `ui-home-dashboard.json`, `ui-systems-dashboard.json` A dashboard is a **UI document**: `{ version: 1, id?, title?, root: Component }`. The `root` is a single component tree drawn from the fixed 17-type native catalog. The client renders it with native components — never as downloaded code. **Full-replace only (D13):** a new document supersedes the previous one entirely; the renderer diffs by component `id`. There is **no patch format.** Every component is `{ type, id?, props?, children?, action? }`. Container types (`stack`, `card`) may carry `children`; leaf types may not (the schema enforces this). ### 4.1 The 17 component types | # | type | Required props | Optional props | Container? | Notes | |---|---|---|---|---|---| | 1 | `stack` | — | `direction: vertical\|horizontal = vertical`, `spacing≥0`, `align: start\|center\|end\|stretch` | yes | Row/column layout. | | 2 | `card` | — | `title`, `footer` | yes | Titled surface. | | 3 | `text` | `value` | `style: title\|body\|caption\|mono = body` | no | `value` may contain inline markdown. | | 4 | `metric` | `label`, `value` (string\|number) | `delta` (string\|number), `intent: good\|bad\|neutral = neutral` | no | Single figure with sentiment. | | 5 | `chart` | `kind: line\|bar\|sparkline`, `series` | `labels[]` | no | `series[] = { name?, data: number[] }`. | | 6 | `table` | `columns[]`, `rows[][]` | `maxHeight≥0` | no | Cells: string/number/boolean/null. | | 7 | `list` | `items[]` | — | no | `items[] = { title, subtitle?, icon?, action? }`. | | 8 | `image` | `url` (uri) | `aspect` (`"W:H"`, e.g. `16:9`) | no | Absolute https URL. | | 9 | `button` | `label` | `intent: primary\|destructive\|plain = primary` | no | **Requires `action`.** | | 10 | `input` | `label`, `bind` | `value`, `placeholder`, `keyboard: default\|number\|email\|url\|phone = default` | no | Bound text field. | | 11 | `toggle` | `label`, `bind` | `value: boolean = false` | no | Bound switch. | | 12 | `slider` | `label`, `bind` | `value`, `min = 0`, `max = 1`, `step>0` | no | Bound numeric slider. | | 13 | `select` | `label`, `bind`, `options[]` | `value` | no | `options[] = { label, value }`. | | 14 | `progress` | `value` (0–1) | `label` | no | Determinate bar. | | 15 | `badge` | `value` | `intent: good\|bad\|neutral\|info = neutral` | no | Status pill. | | 16 | `divider` | — | — | no | Horizontal rule; no props. | | 17 | `map` | `lat` (−90..90), `lng` (−180..180) | `zoom` (0–22), `markers[] = { lat, lng, label? }` | no | — | ### 4.2 Binding: how form components feed actions Interactive inputs (`input`, `toggle`, `slider`, `select`) declare a `bind` — a parameter name. When an `action` fires (typically from a sibling `button`), the client merges the current value of every bound component into the action's `params`, keyed by `bind`, overlaying the action's static `params`. See `ui-systems-dashboard.json` → `action-request.json` for a worked example. > **Conservative scoping choice (open question logged):** the client collects bound values > from components within the **same UI document** and applies last-write-wins on key collision. > A tighter scope (e.g. same card only) can be introduced later without breaking documents that > use unique `bind` names. Logged in BUILD-LOG for founder review. ### 4.3 The action envelope Attached as `action` on any interactive component: ```json { "tool": "set_environment", "params": { "source": "systems" }, "confirm": "Are you sure?", "refresh": "self" } ``` | Field | Type | Meaning | |---|---|---| | `tool` | string (required) | Backend tool to invoke. | | `params` | object | Static params, overlaid by bound component values at invocation time. | | `confirm` | boolean \| string | If truthy, prompt before invoking. A string is the confirmation message; `true` uses a generic prompt. | | `refresh` | string, default `"self"` | Post-ack refresh target. Reserved: `"self"` (re-fetch current dashboard), `"none"` (do nothing). Any other value is a **dashboard id** to refresh. Ignored when the response returns a full `ui` document. | > **Pro gate (D2 extension):** invoking an action envelope — i.e. the client actually calling > your tool — requires the end user to be on the app's Pro tier. Viewing a dashboard, fetching/ > refreshing it, and any purely local `bind` edit (typing, sliding, toggling, selecting — §4.2) > are always free and never gated. In practice: a Free user sees your full dashboard and can > manipulate every local control, but tapping a `button` or an actionable `list` row shows a > lock and routes to the app's paywall instead of calling your server. Nothing about your > manifest or action envelope changes to opt in or out of this — it's enforced entirely on the > client. Design your dashboards assuming Free users can read everything but only Pro users can > trigger tools. ### 4.4 Structural limits & graceful degradation - **Max depth 12, max 500 nodes per document.** These are enforced by the app renderer and the spec validator **in code, not by JSON Schema** (draft 2020-12 cannot cleanly bound recursion depth). A document exceeding them is rejected before render (see `ui-too-deep.json`). - **Unknown component `type` → labeled placeholder (soft error).** The strict schema rejects an unknown type, but the renderer must **never crash or discard the document** — it draws a labeled placeholder for the unknown node and renders the rest. This keeps older clients forward-compatible with future catalog additions (see `ui-unknown-component.json`). - **All user-visible strings come from the server.** The app adds no copy inside dashboards. --- ## 5. REST fallback contract A vibe-coder can implement this in ~50 lines; reference servers live in `examples/`. Auth is a bearer token on every request (§6a). ### 5.1 `GET /.well-known/parlane.json` Returns the manifest (§2). `200` + `application/json`. ### 5.2 `POST /chat` → SSE stream Request body: ```json { "conversation_id": "conv_123", "message": "What's the reactor load?", "attachments": [ /* §8 */ ] } ``` - `conversation_id` (string) — client-managed thread id (D13), the server may override by returning a different id in the `done` event. `message` (string) — the newest user turn. `attachments` (optional array, §8) — each element matches `spec/attachment.schema.json`. - Response: `Content-Type: text/event-stream`, one SSE event per line-group (`event: \ndata: \n\n`). **Event types (this is the contract):** | `event:` | `data` payload | Meaning | |---|---|---| | `delta` | `{ "text": "…" }` | An incremental chunk of assistant text. Concatenate in order. | | `tool` | `{ "tool": "check_reactor", "status": "start"\|"end", "label"?: "Checking reactor…" }` | Tool-call activity indicator. `label` is optional display copy. | | `done` | `{ "conversation_id": "conv_123", "message_id"?: "msg_9" }` | End of turn. If `conversation_id` differs from the request, the client adopts the server's thread id. | | `error` | `{ "code": "rate_limited", "message": "…" }` | Terminal error; the stream ends. | The stream MUST end with exactly one `done` **or** one `error`. Clients paint first `delta` within 150 ms of receipt (FR-2). SSE `id:`/`retry:` fields are permitted and ignored by v1. ### 5.3 `GET /ui/{dashboard_id}` → UI document Returns a UI document (§4). `200` + `application/json`. `dashboard_id` matches a manifest `dashboards[].id`. ### 5.4 `POST /action` → `{ ui? | ack? }` **Schema:** `spec/action.schema.json`. Request body: ```json { "action_id": "a1b2c3d4-0001", "tool": "set_environment", "params": { … }, "dashboard_id": "systems" } ``` - `action_id` (required) — client correlation id (idempotency + logging). - `tool` (required) — copied from the component's action envelope. - `params` (object) — envelope `params` merged with bound component values (§4.2). - `dashboard_id` (optional) — the dashboard the action fired from. Response (`spec/action.schema.json#/$defs/actionResponse`): ```json { "ui": { "version": 1, "root": { … } } } // full-replace document (D13) ``` or ```json { "ack": true } // no UI change; client honors envelope `refresh` ``` Exactly one of `ui` / `ack` is meaningful. If `ui` is present the client replaces the current document entirely (diff by `id`); otherwise it applies the envelope's `refresh`. A failed tool returns `{ "error": { "code"?, "message" } }`, surfaced non-fatally. **There is no patch response** (D13). See `action-request.json`, `action-response.json`. ### 5.5 `POST /notify` Relay-side only (§3 / architecture §2.2). Not implemented by the app-facing server. **Auth (relay-side, distinct from §6a's app-facing bearer token).** `POST /notify` and `POST /devices` require the same relay-issued HMAC the relay's `GET /ws` upgrade requires: ``` Authorization: Bearer . sig = base64url( HMAC-SHA256(secret, "${serverId}:${role}:${ts}") ) ``` `serverId` is public routing metadata returned by the pairing flow — it is never sufficient auth on its own, so both routes verify this signature before touching the device registry or triggering a send. `role` is fixed by which route is called (never sent, never attacker-chosen): `/notify` is called by the developer's server and is checked against `serverAuthKey`; `/devices` is called by the app and is checked against `appAuthKey`. `ts` is milliseconds since epoch; requests older than the relay's configured skew (default 60s) are rejected as stale (replay protection). **No extra step to obtain the secret.** Both HMAC secrets are already handed to their respective party during pairing — this is the same secret already used to authenticate the WS upgrade, reused here for the plain-HTTP routes: - The developer's server receives `serverAuthKey` in the `POST /pair` response (`{relayUrl, serverId, appPubKey, serverAuthKey}`). - The app receives `appAuthKey` in the `GET /pair/status` response (`{state: "PAIRED", serverId, serverPubKey, appAuthKey, relayUrl}`). A missing or invalid `Authorization` header returns `401 {"error":{"code":"unauthorized"}}`. An unknown `serverId` still returns `404` (checked first, since the secret can't be looked up without it). See `relay/src/ws/auth.ts` (`sign`/`verify`, shared by both transports) and `relay/README.md` for the reference implementation. **Request/response shapes.** - `POST /devices` `{serverId, appId, token, platform}` → `200 {"ok": true}`. Registers a push token (max 20 devices per `serverId`+`appId`; oldest evicted past the cap). - `POST /notify` `{serverId, encryptedPayload, apnsHint}` → `200 {"delivered": , "transport": "apns"|"fcm"|"dev", "deviceCount": }`. **Check the response:** `deviceCount` is how many tokens the relay held for this app; `delivered` is how many the provider accepted. `deviceCount: 0` means no device is registered (nothing was sent); `delivered` below `deviceCount` means the provider rejected some tokens (stale/uninstalled — the relay prunes those automatically, so a later send sees a smaller `deviceCount`). --- ## 6. MCP mapping MCP (streamable HTTP) is the primary transport and **lives behind the app-side `AppConnection` adapter** so MCP spec churn never leaks into the renderer (architecture §6, risk #2). The mapping is a thin projection of the same contract objects onto MCP primitives: | Contract object | MCP primitive | |---|---| | Manifest | **Resource** `parlane://manifest` returning the manifest JSON (§2). If absent, no surfaces are advertised — declare surfaces explicitly (the client does not infer them from capability discovery). | | Chat | **Tool** (e.g. `chat`) whose streamed result maps to the §5.2 event types: text deltas → `delta`, tool-use notifications → `tool`, completion → `done`, errors → `error`. | | Dashboard | **Resource** `parlane://ui/{id}` returning a UI document (§4). | | Action | **Tool call** — the tool named in the action envelope, with `params` (+ bound values) as arguments; the tool result is the action response (`ui` or `ack`, §5.4). | | Push | **Tool** on the relay, or the relay's `POST /notify` (§3 / architecture §2.2). | `parlane://` is the MCP resource prefix from `brand.mcpResourcePrefix`. Auth uses MCP's bearer/OAuth 2.1 flow (§6a). Because both transports resolve to identical document shapes, the `spec/` schemas and fixtures validate MCP payloads and REST payloads alike. --- ## 6a. Authentication > This section covers the **app-facing** bearer token (your server ↔ the app, direct > connection). The optional relay's push routes (`/notify`, `/devices`) use a *separate*, > relay-issued HMAC scheme instead — see §5.5. - **Bearer token** on every request: `Authorization: Bearer ` (REST) or the MCP auth header. This is the ONLY auth flow the app implements in v1 — **do not build an OAuth-only server**: the app has no OAuth callback endpoint yet (in-app OAuth 2.1 PKCE is planned, backlog B17; a server may offer OAuth to *other* clients, but must offer bearer device tokens to Parlane). Tokens live only in Keychain/Keystore; they are never logged and never traverse Parlane infrastructure on a direct connection. - **Production pairing pattern (recommended).** A production backend with real user auth should mint **per-device, revocable device tokens** — not share a static secret: 1. In your (already-authenticated) web UI, add a "Connect Parlane" page. 2. It calls your API to mint a scoped token bound to that user + a device label (revocable and listable, like SSH keys or app passwords; expiry/rotation per your policy). 3. The page renders the connect QR below encoding your server URL + that token. The user scans it once; the app stores the token in Keychain and sends it as the bearer on every request. 4. Ship a "connected devices" list with revoke buttons next to it. This gives per-device audit, instant revocation, and no secrets in source/config — the same properties an OAuth device flow would provide, with zero app-side surface. - **Token in the connect QR (D13).** A server may print a QR encoding both its URL and a (revocable, regenerable) token so connecting is a single scan. **QR payload format** — a deep-link URL using the brand URL scheme (`brand.urlScheme`): ``` parlane://connect?url=&token= ``` Example (values illustrative): ``` parlane://connect?url=https%3A%2F%2Fship.example.com&token=sk_live_9f8a...c21 ``` `url` is required; `token` is optional (omit for public/no-auth servers or OAuth flows). The scheme segment is the frozen `brand.urlScheme` (`parlane`); code reads it from the brand module rather than hardcoding it. Tokens are revocable and regenerable server-side, so a leaked QR is cheaply rotated. --- ## 7. "Make my app Parlane-ready" prompt snippet Copy-paste block a developer feeds their own coding agent. (The published version is generated from `brand.copy.makeReadyPrompt` and related fields; brand identifiers shown are the frozen values.) ```text Make my app Parlane-ready. Parlane is a universal native mobile client. It renders my backend natively — chat, voice, dashboards, push — from plain JSON I serve. There is no SDK. I implement a tiny contract; any of the following it can already do (MCP) it reuses. Implement chat unless I explicitly tell you not to. Chat is the core surface: voice rides on it with no extra server work (speech runs on-device), and a connected app without chat feels broken on first open. Wire /chat to whatever conversational or agent capability my backend has; if it truly has none, ask me before omitting it, and then leave the chat surface undeclared in the manifest so the client hides those tabs. Do this: 1. Serve an app manifest at /.well-known/parlane.json (or an MCP resource "parlane://manifest"). Declare only the surfaces I actually support. All fields are optional. Validate it against spec/manifest.schema.json. Ask me about the look: accent, colors, dark/light — declare a theme in the manifest to skin my app's screens. 2. If I don't already speak MCP, add the REST fallback: - POST /chat {conversation_id, message, attachments?} -> Server-Sent Events with these event types: `delta` {text}, `tool` {tool,status,label?}, `done` {conversation_id, message_id?}, `error` {code,message}. End with exactly one `done` or `error`. - GET /ui/{dashboard_id} -> a UI document (spec/ui-document.schema.json): a tree from the 17-type catalog {stack, card, text, metric, chart, table, list, image, button, input, toggle, slider, select, progress, badge, divider, map}. version:1, single root, max depth 12, max 500 nodes. Full-replace only — no patch format. - POST /action {action_id, tool, params, dashboard_id?} -> {ui} (a full replacement UI document) or {ack:true}. Interactive components carry an action envelope {tool, params?, confirm?, refresh:"self"|"none"|}; inputs bind values by a `bind` name that I merge into params. 3. Require Authorization: Bearer on every request. Optionally print a connect QR encoding parlane://connect?url=...&token=... 4. (Optional) For push, open an outbound WebSocket to the relay and send payloads matching spec/push.schema.json. Constraints: dashboards are declarative data, never code. Keep all user-visible copy in the JSON I serve. Follow the schemas in spec/ exactly and test against spec/examples/. ``` --- ## 8. Attachments Chat and share ingestion accept attachments — **v1 = images, text, URLs, and documents (PDF / Office / etc.), with a 10 MB cap per attachment (D13, founder 2026-07-22)**. Users send screenshots, images, PDFs, and office documents to the AI via the document picker in the composer. The 10 MB limit is enforced client-side and is raisable post-v1 based on usage patterns. **Schema:** `spec/attachment.schema.json` · **Examples:** `attachment-image.json`, `attachment-file-pdf.json` Attachment object (sent in `POST /chat` `attachments[]` or delivered to a `share.tool`): | Field | Type | Meaning | |---|---|---| | `kind` | `"image"` \| `"text"` \| `"url"` \| `"file"` | Attachment type (must be one the manifest's `share.accepts` / chat `attachments` allows). v1: `image` = photos/screenshots (PNG, JPEG, WebP, etc.); `text` = plain text; `url` = hyperlinks; `file` = documents (PDF, Word, Excel, etc.) for AI processing. | | `mime` | string (required) | MIME type (e.g. `image/png`, `application/pdf`, `text/plain`). Required for all kinds. Pattern: `^[a-z][a-z0-9+\.]*/.+$`. | | `name` | string? | Original filename or display name (e.g. `screenshot.png`, `invoice.pdf`). Optional; useful for context and logging. | | `data` | string? | Base64-encoded payload. For `image`/`text`/`file`, decoded size ≤ 10 MB. Exactly one of `data` or `url` must be present. | | `url` | string? | Absolute HTTPS URL pointing to the attachment. For `kind: "url"` (hyperlinks) or as a pre-uploaded blob URL instead of inline `data`. Exactly one of `data` or `url` must be present. | **Constraints:** - **Exactly one of `data` / `url` is present.** The schema enforces this via `oneOf`. - **10 MB cap.** Enforced client-side before upload; servers should reject oversize payloads defensively (check decoded `data` length or request Content-Length). - **MIME type required.** Must match the pattern `^[a-z][a-z0-9+\.]*/.+$` (e.g. `image/png`, `application/pdf`, `text/plain`). Servers validate incoming attachments against `spec/attachment.schema.json` (reference servers in `examples/` include validation examples). --- ## 9. Conformance & versioning - **Schemas are the contract.** `spec/*.schema.json` (draft 2020-12) is authoritative; this doc is the companion. The app mirrors these in zod and validates every rendered document; the two must not drift (fixtures gate both in tests). - **Test corpus.** `spec/examples/` holds valid and deliberately-broken fixtures; `npm test` in `spec/` asserts valid ones pass and broken ones fail (unknown-type being the documented soft failure). Renderer work builds against this corpus. - **Versioning.** `manifest.version` / UI document `version` gate breaking changes only. Additive fields (like v1.1 server TTS) are introduced without a bump and guarded by presence. - **Forward compatibility.** Unknown component types and unknown optional fields degrade gracefully; they never crash the client. [Voicebox]: https://github.com/jamiepine/voicebox