@remotedraw/cli 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,2 +1,2 @@
1
- export declare const AGENT_SKILL_MARKDOWN = "---\nname: remotedraw\ndescription: Integrate RemoteDraw phone drawing into a customer's product \u2014 a phone becomes the pen for a screen the customer already owns. Covers the two-device model, which flows are valid, the ready-made components (React/Svelte/JS receiver, hosted web sender, RemoteDrawSenderKit iOS sender), per-surface recipes, and how to judge whether a proposed use case fits at all.\n---\n\n# RemoteDraw Agent Skill\n\nUse this skill when a user asks what RemoteDraw is, whether it fits their app,\nor to create, debug, or review a RemoteDraw integration.\n\nRead the whole file before proposing a design. The first two sections decide\nwhether the use case is possible; everything after decides how it is built.\n\n## The model: two devices, always\n\nRemoteDraw is not a drawing library. It is a wire between **two devices**.\n\n- **The receiver is the paper.** A screen someone is looking at \u2014 a laptop, a\n desktop, a large display, a kiosk, a tablet on a desk \u2014 showing *your*\n product. Your app renders whatever is being drawn on: the map, the photo, the\n PDF page, the form, the whiteboard. RemoteDraw renders none of that content.\n It paints ink on top of it.\n- **The sender is the pen.** A phone. It supplies a hand, pressure, tilt and a\n stroke. It does **not** supply the content: the iOS SDK opens no camera and no\n file picker (it needs no `Info.plist` entries at all). On a bounded surface\n the phone's pad *is* the surface; on a large one the phone is a viewport\n moving over it.\n- **RemoteDraw is the wire.** A hosted session carries strokes from the pen to\n the paper in real time and stores them. Nothing runs on the customer's\n infrastructure except the session-creation call.\n\nThe one question that decides whether RemoteDraw fits:\n\n> **What is being drawn on, and which screen is it already displayed on?**\n\nIf the answer is \"a screen the user is looking at, and they wish they could\ndraw on it with their hand\" \u2014 that is RemoteDraw. If the answer is \"the phone's\nown screen\", it is not: a phone drawing on its own content and uploading the\nresult is a camera-and-canvas feature you build with PencilKit or a `<canvas>`,\nand RemoteDraw would only add a round trip.\n\nThree consequences, because they are the mistakes integrators actually make:\n\n1. **The phone never supplies the picture.** \"The tenant photographs the leak\n and circles it\" is *not* a RemoteDraw flow \u2014 there is no second screen. The\n RemoteDraw version of that job: the photo is already open in your web app on\n the office desktop, and the person at that desk circles the leak with their\n phone instead of a mouse.\n2. **The receiver already exists.** RemoteDraw goes onto a screen your product\n already has. It does not get its own page unless the user asks for a demo.\n3. **Ink is coordinates, not pixels.** Strokes arrive in normalized board space\n (`0..1`, remapped through the sender's `device.aspectRatio`). Your app\n decides what board space *means* \u2014 a pixel, a page, a field, a coordinate on\n Earth. RemoteDraw never sees your content.\n\n## Which flows are valid\n\nRead the row for the **sender** (who holds the phone) and the column for the\n**receiver** (the screen showing the content).\n\n| Sender (the pen) | Receiver (the paper) | Valid? | Notes |\n| --- | --- | --- | --- |\n| iPhone \u2014 RemoteDraw app, your app via `RemoteDrawSenderKit`, or hosted `/join` in Safari | Desktop / laptop browser | **Yes \u2014 the canonical flow** | Everything below is written for it. |\n| iPhone (any of the three) | Large display, TV, projector, kiosk browser | **Yes** | Size `target.coordinateSpace` to the display. |\n| iPhone (any of the three) | Desktop app \u2014 Electron, macOS, Windows \u2014 via `@remotedraw/client` or raw HTTP | **Yes** | No React needed; poll `/v1/receiver/*` or use the realtime source. |\n| Android phone \u2014 hosted `/join` in Chrome | Any of the above | **Yes** | There is no native Android SDK. The hosted join page *is* the Android sender and it is full-featured. |\n| iPhone / Android | iPad or tablet browser, as a **second** device someone else is looking at | **Yes** | Two devices, two people. |\n| Headless script, test, or agent \u2014 raw `POST /v1/join` \u2192 `/v1/sender/draft` \u2192 `/v1/sender/commit` | Any receiver | **Yes, for verification only** | Never ship a hand-rolled sender to users. |\n| Any phone | **The same phone** \u2014 one device shows the content and draws on it | **No** | There is no second screen. Use PencilKit / `<canvas>`. RemoteDraw adds a network hop and nothing else. |\n| A phone that must first **capture** the content \u2014 photograph or scan it | *(anything)* | **No** | The iOS sender opens no camera and needs no `Info.plist` entries. The content must already be on the receiver. |\n| Desktop mouse or trackpad as the pen | *(anything)* | **No \u2014 does not exist** | There is no desktop sender. The macOS trackpad sender is unbuilt research. Do not promise it. |\n| Phone \u2192 phone, two different people, two different devices | Phone browser as receiver | *Technically yes, rarely right* | A phone browser is a browser. But if both people hold phones, ask why the drawing is not simply in one app. |\n\n**The three nevers of the model.**\n\n1. **Never same-device.** If sender and receiver would be one phone, stop and\n say so. Propose the non-RemoteDraw alternative.\n2. **Never make the phone the source of content.** The receiver supplies what\n is drawn on. (One honest exception: the hosted `/join` pad has a file-attach\n tool that can put a file on the board. It is a hosted-sender capability, not\n a way to make a same-device flow valid, and it cannot currently be disabled.)\n3. **Never promise a sender RemoteDraw does not ship.** iOS (native SDK) and\n any mobile browser (hosted `/join`) are the senders. Nothing else exists.\n\n## Never do these\n\n- **Never write a custom canvas.** Not a `UIViewRepresentable` drawing view, not\n a `<canvas>` sender pad, not a hand-rolled SVG receiver, not a raw\n `URLSession`/`fetch` draft loop. Cadence, point budgets, the packed-point\n codec, sequence healing, token refresh and presence are protocol, and the SDKs\n implement them. Go headless on the SDK's own primitives if you must.\n- **Never put the iOS surface in a small pad or a draggable sheet.** It is\n full screen (`.remoteDrawSurface` / `RemoteDrawTakeover`). A sheet is\n acceptable only if `RemoteDrawSurface` fills it edge to edge and the sheet\n cannot be dragged mid-stroke. There is no small-canvas option.\n- **Never create a session on mount or on page load.** `POST /v1/sessions` is\n billable and not idempotent, and React StrictMode fires mount effects twice.\n Create it on the server (route handler, loader, server action) or on explicit\n user intent, and guard with a ref if it must be a client effect.\n- **Never let an `rd_sk_\u2026` key reach a client.** Not browser bundles, not Swift,\n not app bundles, screenshots, logs, or generated examples. The phone never\n calls `/v1/sessions/direct-sender`; your backend does.\n- **Never poll by hand when a component or store exists.** `RemoteDrawProvider`\n / `createReceiverStore` already do one-in-flight polling plus an optional\n realtime push source.\n- **Never promise Bluetooth or Wi-Fi pairing.** The API advertises\n `bluetooth` and `localNetwork` as pairing methods and the iOS/Android apps do\n *advertise* on those radios, but **nothing scans, browses, or connects\n anywhere in the product**. They are not implemented. Do not present them as\n options; do not build UI around them. QR and direct sender are the real ones.\n- **Never promise a native Android SDK, a desktop sender, per-tenant Universal\n Links, e-signature compliance, or streaming inside `RemoteDrawSenderKit`.**\n See \"What does not exist yet\".\n- **Never delete or replace a host-app feature on your own initiative.** Build\n beside it.\n\n## The decision tree\n\n**1. What is being drawn on? \u2192 `target.kind` + `inputMapping`.**\n\n`target.kind` accepts `whiteboard`, `paper`, `canvas`, `svg`, `map`, `tldraw`,\n`field`, `image`, `pdf`, `screen`, `custom`. It selects the background the\nreceiver defaults to, the tool policy, and deposit defaults.\n`target.inputMapping` is `surface` (the phone's whole pad *is* the target \u2014 use\nfor bounded targets like a signature field) or `viewport` (the phone is a\nmovable window over a larger board \u2014 the default when omitted).\n\n| Kind | Host renders | `inputMapping` | Sender | Status |\n| --- | --- | --- | --- | --- |\n| `whiteboard` / `paper` | nothing \u2014 RemoteDraw's own ground | `viewport` (or `surface`) | hosted `/join` or SenderKit | shipped |\n| `image` (photo, screenshot) | the `<img>`, as `background` | `surface` (whole photo) or `viewport` (zoomable) | either | shipped |\n| `pdf` | your PDF renderer, one page at a time, as `background` or behind a `transparent` receiver | `viewport` | either | shipped; RemoteDraw renders no PDFs |\n| `field` (signature, initials) | the form, with a baseline as SVG `children` | **`surface`** \u2014 mandatory | either | shipped; **not** an e-signature product |\n| `screen` | a screenshot, or a live stream you publish | `viewport` | either | shipped; live view is experimental |\n| `map` | your map (Mapbox / MapLibre / Leaflet / Google) | `viewport` | hosted `/join` or SenderKit | shipped \u2014 use `RemoteDrawMapReceiver` |\n| `custom` | anything else you own | `viewport` | hosted `/join` (streaming) or SenderKit (ink only) | shipped |\n\n**2. Which stack renders the receiver? \u2192 the composition.**\n\n| Host | Use | Not |\n| --- | --- | --- |\n| React / Next / Remix | `@remotedraw/react`: `RemoteDrawProvider` + `RemoteDrawReceiver` (or `RemoteDrawMapReceiver`) + `PairingCode`/`RemoteDrawConnect`/`RemoteDrawLaunchButton` + `RemoteDrawSessionControls` | A hand-rolled SVG or polling loop |\n| Svelte / SvelteKit | `@remotedraw/svelte`: `createRemoteDrawReceiver` store + `createDirectSender` store. **No components ship** \u2014 you write all the markup, including ink rendering | Assuming React's components exist here |\n| Anything else with JS (Electron, Vue, vanilla) | `@remotedraw/client`: `createHttpReceiverClient`, `createReceiverStore`, `createRealtimeReceiverSource` | \u2014 |\n| No JS at all | Raw HTTP `POST /v1/receiver/*` with the receiver token | \u2014 |\n\n**3. Who holds the phone? \u2192 the sender path.**\n\n| Situation | Path |\n| --- | --- |\n| Anyone who can scan; no phone app in the product | **Hosted `/join`.** Render `joinUrl` as a QR with `PairingCode`. Zero sender code. The RemoteDraw iOS app opens the same HTTPS link through Universal Links; every other phone gets the web pad. |\n| The product has a first-party iOS app the user already installed *and* it is the RemoteDraw app | Same QR. Universal Links open it. |\n| The product has **its own** iOS app and the user is signed in | **Direct sender.** Backend calls `POST /v1/sessions/direct-sender` with `launchUrlTemplate: \"yourapp://draw?senderToken={senderToken}\"`; the browser shows `RemoteDrawLaunchButton`; the app receives the URL in `onOpenURL` and presents `.remoteDrawSurface(isPresented:senderToken:)`. Keep the QR as the fallback \u2014 the button reveals one automatically. |\n| A QR that opens the **customer's own** app | **Not possible.** The Universal-Link association file has one hard-coded appID and the QR host is a RemoteDraw deployment setting. Use the direct sender instead. |\n| Headless / tests | `POST /v1/join` \u2192 `/v1/sender/draft` \u2192 `/v1/sender/commit` |\n\n## Component catalogue\n\nEverything below is a real export, read from source. Anything not listed does\nnot exist. Grouped by decision: the board \u2192 what is behind it \u2192 pairing \u2192\nsession state \u2192 AI \u2192 clients \u2192 iOS.\n\n### `@remotedraw/react`\n\n**`RemoteDrawProvider`** \u2014 receiver session state, polling, and actions.\nRequires a `receiver` client to do anything.\nProps: `children`, `session`/`sessionId`/`joinUrl`/`joinUrls`/`joinTokenUse`/`pairing`/`receiverToken`,\n`receiver` (a `ReceiverClient` \u2014 **without it the store is inert and silent**),\n`createSession`, `createSessionRequest` (default `{ target: { kind: \"custom\" } }`),\n`autoCreate` (default: true when `createSession` and no `session`),\n`pollIntervalMs` (`1000`), `draftPollIntervalMs` (`250`), `source` (`null`),\n`fallbackToPolling` (`true`), `initialDrawings`/`initialDrafts`/`initialSenders` (`[]`),\n`onError` (`(error: Error) => void` \u2014 **wire it; it is the only signal for a dead credential**),\n`refreshJoinToken` (`({ sessionId }) => Promise<result | null>` \u2014 the QR re-arm.\nThe join token lives 10 minutes; the session usually outlives it. Point this at\na backend route that calls `POST /v1/sessions/join-token` and return the\nresponse as-is; when the token dies on a live, unpaired session the provider\nswaps in the fresh code and every pairing component follows. Without it a stale\nQR shows \"Link expired\" until the host recreates the whole session \u2014 wire it in\nany integration where a board can sit unpaired for more than 10 minutes).\nContext: `session, sessionId, joinUrl, joinUrls, joinTokenUse, pairing, receiverToken, credentials, drawings, drafts, senders, loading, creating, error, transport, create, refetch, ingest, undo, clear`.\n*Limits:* `error` is a plain `Error`; narrow with `instanceof RemoteDrawHttpError`\nfor `.status`/`.code`/`.shouldReJoin`.\n\n**`RemoteDrawReceiver`** \u2014 the receiver foundation. Paints committed ink, live\ndrafts, sender pointers and connected phones over a configurable background, and\nprescribes nothing around it.\nProps: `drawings`/`drafts`/`senders` (fall back to the provider), `background`\n(default: the session target's surface inside a provider, else `\"whiteboard\"`;\naccepts a surface name, `\"transparent\"`, any CSS background string, or any\nReactNode), `pointers` (`true`), `phones` (`true`), `pointerColor` (`#1f7a8c`),\n`strokeColor` (`#151512`), `draftColor` (`#1f7a8c`), `strokeWidth` (`6`),\n`coordinateAspectRatio` (default: the session's `coordinateSpace`, else `1`;\nalso sets CSS `aspect-ratio`), `preserveAspectRatio` (`\"none\"`),\n**`projectPoint`** (`(point) => {x,y} | null` in **CSS pixels from the\nreceiver's top-left** \u2014 the seam for a camera; return `null` to drop a point),\n**`space`** (the same seam in the receiver's own viewBox coordinates; `projectPoint`\nwins when both are given), `animate` (`true`), `children` (SVG overlay in\n`0..surfaceWidth \u00D7 0..1000`), `className`/`style`/`svgProps`/`aria-label`.\n*Defaults that surprise:* it renders immediately with no empty state; `phones`\nand `pointers` are on.\n*Limits:* without `projectPoint`/`space` it stretches board space across its own\nelement \u2014 correct for a fixed board, silently wrong over a live map. Use\n`RemoteDrawMapReceiver` there.\n*Never:* stack it on a pannable map without a projection.\n\n**`RemoteDrawMapReceiver`** \u2014 `RemoteDrawReceiver` wired to a map the host owns.\nRenders a transparent ground (your map is the ground) plus the\nboard\u2192geography\u2192pixel projection. Takes every `RemoteDrawReceiver` prop except\n`projectPoint`/`space`, plus:\n`bounds` (the board's `target.coordinateSpace.bounds`; defaults to the session's\nown inside a provider \u2014 usually pass nothing), `mapBounds` (the map's current\nvisible bounds; **exact only for a north-up, unpitched camera**),\n`projectLngLat` (`(lng, lat) => {x,y} | null` \u2014 normally\n`map.project([lng, lat])`; exact under rotation and pitch, and wins over\n`mapBounds`).\n*Limits:* with neither `projectLngLat` nor `mapBounds` it renders unprojected\nand warns once in development. Leave `preserveAspectRatio` at `\"none\"`.\n*Never:* recompute the projection identity on every render \u2014 memoize it and bump\nit on the map's `move` event.\n\n**`RemoteDrawPhoneProjection`** \u2014 one connected phone drawn in place, as an SVG\n`<g>` for a host with its own `<svg>`. `layout` (required, from\n`phoneProjectionLayouts(senders)`), `space` (default 1000\u00D71000), `color`,\n`model` (`\"auto\"`), `className`. `RemoteDrawReceiver` already renders these.\n\n**`PairingCode`** \u2014 the pairing component: the scannable code with live status,\nhover-to-copy, and subtle branding.\nProps: `joinUrl` (default: the provider's, falling back to `joinUrls.web`),\n`size` (`200`, or `\"fill\"`), `direction` (`\"paper\"`, one of 14 \u2014 TEMPORARY),\n`treatment` (`\"fluid\"`, one of 11 \u2014 TEMPORARY), `accentColor` (`#1f7a8c`),\n`inset`, `tile` (`true`), `logo` (the RemoteDraw mark; `false` for none, a\nstring for a URL), `logoPlacement` (`\"plate\"`), `title`/`description`,\n`showStatus` (`\"auto\"`), `joinMode` (the session's `joinTokenUse`),\n`showJoinMode`/`showLink` (`false`), `copyOnHover` (`true`), `onCopy`,\n`onConnected`/`onConnectedDismiss`, `labels`, `alt`, `placement` (`\"inline\"` +\nfour corners), `position`/`offset`/`zIndex` (`\"absolute\"`/`12`/`20`),\n`className`/`style`/`codeClassName`/`codeStyle`.\n*There is no `card`, `variant`, `showBrand`, `brand`, or `status` prop.* The\ncard is always drawn (`tile`, on by default) and extends to hold `title` /\n`description`; status is **inferred** (connected \u2192 error \u2192 expired \u2192 ready \u2192\nidle) and cannot be passed.\n*Limits:* always encodes the HTTPS `joinUrl`. The \"expired\" status is real and\nterminal unless the provider has `refreshJoinToken` wired \u2014 the component shows\nthe death of the 10-minute join token but cannot mint a replacement itself. The\nanimated optical field that `variant=\"aurora\"` once selected is now the separate\nEXPERIMENTAL `AuroraPairingField`, decodable only by the RemoteDraw app's own\nscanner.\n\n**`PairingDevices`** \u2014 the list UI for pairing methods that resolve to a device\n(`bluetooth | localNetwork | accountPresence | direct`). `method` (required),\n`devices` (`[]`), `onSelectDevice`, `joinUrl`, `showCodeFallback` (`true`),\n`status`, `size` (`168`), `emptyLabel`, `actions`, `accentColor`,\n`autoHideOnConnected` (`true`), `connectedHideDelayMs` (`1150`), `direction`,\n`logo`, `onCopy`, `className`/`style`.\n*Limits:* **purely presentational \u2014 it discovers nothing.** You supply `devices`\nand `onSelectDevice` from your own backend. There is no Bluetooth, no Bonjour,\nand no customer-reachable account-presence route.\n*Never:* present it as \"nearby device pairing\" to a customer. It is chrome.\n\n**`RemoteDrawConnect`** \u2014 a compact trigger button that opens the code or the\ndevice list in a popover, for pairing exactly at the field, margin, or toolbar\nthat needs it. Everything from `PairingCodeProps` except placement/position/\noffset/zIndex/className/style/size/tile, plus `size` (`168`), `open`,\n`defaultOpen` (`false`), `onOpenChange`, `placement` (`\"bottom\"`), `method`,\n`devices`, `onSelectDevice`, `trigger`, `triggerLabel` (`\"Pair phone\"`),\n`showTriggerLabel` (`false`), `triggerClassName`/`triggerStyle`/`triggerDisabled`,\n`openOnHover` (`true`), `panel*`/`popover*` class and style.\n*Use it instead of hand-rolling a \"connect phone\" button.*\n\n**`RemoteDrawLaunchButton`** \u2014 \"open on my phone\" for a user whose **own** app is\nthe sender. Mints a scoped sender through your backend, opens the deep link, and\nreveals a QR when the app never comes back.\nProps: `connect` (required \u2014 your own backend endpoint; may return the raw\n`connectSender` response, a create-session response carrying `senderConnection`,\nor just `{ launchUrl }`; resolving `null` means \"no direct sender for this user\"),\n`handoffTimeoutMs` (`DIRECT_SENDER_HANDOFF_TIMEOUT_MS` = `12_000`),\n`showQrFallback` (`true`), `fallback`, `pairingProps`, `labels`, `accentColor`\n(`#1f7a8c`), `disabled` (`false`), `autoLaunch` (`true`), `openUrl` (default\nassigns `window.location.href`), `onError`, `onStatusChange` (transitions only),\n`children` (node or `(state) => node`), `className`/`style`/`buttonClassName`/\n`buttonStyle`/`aria-label`.\n*Why it is a component and not an `onClick`:* a custom scheme nothing has\nregistered fails **silently** \u2014 no error, no navigation, no event. The timeout\nwith no sender on the board is the only detector.\n\n**`useDirectSender(options)`** \u2014 the hook the button is a thin default over.\nOptions: `connect` (required), `autoLaunch`, `openUrl`, `onError`,\n`onStatusChange`. Returns `{ status, launchUrl, senderToken, senderId,\nconnection, error, connect(), launch(), reset() }`.\n`status`: `idle | connecting | ready | drawing | submitted | expired | error`.\n`error` is a `RemoteDrawLaunchError` (an `Error`) with\n`kind: \"connect-failed\" | \"no-launch-url\" | \"launch-blocked\"` and the original\nrejection on `cause`.\n*Limits:* phases after `ready` are read from `RemoteDrawProvider`; outside one\nit can never advance past `ready`.\n\n**`RemoteDrawSessionControls`** \u2014 the one session-state surface: a collected\nstatus bar (or card, via `title`) telling the session's story \u2014 waiting for a\nphone, connected, drawing, submitted, with the sender's device name \u2014 plus\nundo/clear. `actions` (`[\"undo\",\"clear\"]`), `confirmClear` (`true`), `title`,\n`submission`, `labels`, `undoLabel`/`clearLabel`/`confirmClearLabel`,\n`metadataKeys`/`metadataLabels`/`formatMetadataValue` (keys render humanized,\nnever raw), `className`/`style`.\n*Limits:* every failure renders as one string, \"Connection problem\".\n\n**`AiImage` / `AiText`** \u2014 render a finished `AiAction`. `AiImage`: `action`,\n`direction` (`\"plate\"`, 6 options), `actions` (`\"hover\"`, 4),\n`standardActions` (`[\"download\",\"copy\"]`, plus `\"open\"`), `customActions`,\n`theme` (`\"auto\"`), `aspectRatio`, `radius` (`14`), `fit` (`\"cover\"`),\n`fileName`, `labels`, `onRetry`, `placeholder`, `imageAlt`, `className`/`style`.\n`AiText`: `action`, `direction` (`\"note\"`, 5), `actions` (`\"bar\"`),\n`standardActions`, `customActions`, `theme`, `radius`, `maxWidth` (`\"60ch\"`),\n`labels`, `onRetry`, `placeholder`, `className`/`style`.\n*Limits:* neither ever shows the model, tier, latency, credit cost, or the\nprovider's error string. A schema run renders nothing \u2014 `result.generatedData`\nis for your code.\n\n**`useVisualContextPublisher`** (experimental) \u2014 the **publish** half of live\nview: sends the receiver's pixels to the phone drawing on it. Has its own\n`onError`.\n\n**`RemoteDrawStreamView`** \u2014 the **consume** half, for a sender pad you host\nyourself: the receiver's stream as a ground, your pad as its `children`.\nProps: `senderToken`, `signaling` (a `VisualContextSignalingClient` \u2014 build it\nwith `createRealtimeVisualContextSignalingClient` for push, or\n`createHttpVisualContextSignalingClient` for the polled `/v1/.../visual-context/*`\nroutes), `enabled` (gate it on `viewReceiverContext` + `visualContext.enabled` +\na reported phone projection), `iceServers` (from the session's\n`visualContext.iceServers` \u2014 without them a phone on cellular connects to\nnothing), `pollIntervalMs`, `onStatus`, `onError`, plus `fit` (`\"contain\"`),\n`fadeMs`, `posterStyle`, `className`/`style`/`aria-label`, `children`.\n`useRemoteDrawStream(options)` is the same thing headless, returning\n`{ mediaStream, status, streamStatus, stream, error, markStreamLive }` with\n`status` one of `idle | connecting | streaming | unsupported | failed | closed`.\n*Limits:* `children` are deliberately **not** gated on the stream \u2014 a pad that\nonly appears once pixels arrive never appears on the networks where WebRTC\ncannot connect, and drawing must keep working there.\n\n**`VisualContextVideoLayer`** \u2014 the raw `<video>` for a `MediaStream` you\nproduce yourself. `mediaStream`, `status`, `fit` (`\"contain\"`), `fadeMs`\n(`VISUAL_CONTEXT_FADE_MS` = `220`), `posterStyle`, `onLiveChange`,\n`className`/`style`/`aria-label`. Hand over on `onLiveChange(true)`, not on\nhaving a stream. `RemoteDrawStreamView` wires this for you.\n\n**Hooks:** `useRemoteDraw` (throws outside the provider),\n`useRemoteDrawSession`, `useReceiverData`, `usePairingUrl`,\n`useRemoteDrawPointers`, `useReceiverStrokes`.\n\n**Low-level / rarely right:** `InkCanvas` (WebGL2 ink substrate \u2014 the receiver\ndrives it), `RemoteDrawMark`, `AuroraPairingField` (EXPERIMENTAL),\n`FreehandFilmGroup`/`freehandStrokePaths`, the element-selection helpers.\n\n**Deliberately not exported:** a web sender component. Hosted `/join` is the web\nsender. Custom in-page pads are built headless on `createHttpSenderClient`.\n`@remotedraw/react/next` is a separate, unfinished v2 entry point \u2014 do not mix\nit into a normal integration.\n\n### `@remotedraw/svelte`\n\n`createRemoteDrawReceiver(options)` \u2192 `{ subscribe, create, refetch, ingest,\nundo, clear, setSession, configure, start, stop }`.\n`createDirectSender({ connect, receiver, autoLaunch, openUrl, onError })` \u2192\n`{ subscribe, connect, launch, reset, stop }`, the same state machine React's\n`useDirectSender` binds. Plus `export * from \"@remotedraw/client\"`.\n**No components ship.** A Svelte integrator writes the pairing UI, the ink\nrendering and the session UI themselves.\n\n### `@remotedraw/client` (framework-free)\n\n- `createHttpRemoteDrawApiClient(baseUrl, { apiKey })` \u2014 **server-only.**\n `createSession`, `getSession`, `listSessions`, `issueJoinToken`,\n `connectSender`, `endSession`, `createAiAction`, `getAiAction`,\n `cancelAiAction`, `waitForAiAction`.\n- `createSessionWithHttpApi(baseUrl, options)` \u2014 server-only convenience.\n- `createHttpReceiverClient(baseUrl)` \u2014 the seven `/v1/receiver/*` calls.\n Credentials go in the body, not a header.\n- `createHttpSenderClient(baseUrl, { packPoints })` \u2014 14 sender calls.\n- `createReceiverStore(options)` \u2014 the headless receiver state machine\n `RemoteDrawProvider` and the Svelte store both bind.\n- `createRealtimeReceiverSource({ driver })` + `createConvexRealtimeDriver({ client })`\n \u2014 push transport over the hosted realtime endpoint. `convex` is never imported\n by the package; you hand it a two-method driver.\n- `RemoteDrawHttpError` \u2014 `status`, `code`, `upgradeUrl`, `body`, and the\n getters `isAuthenticationFailure`, `isPermissionFailure`, `isSessionOver`,\n `shouldReJoin`.\n- `joinTokenFromInput`, `joinUrlForOrigin`, `nativeJoinUrlFromSession`.\n- `createPacedDraftQueue` / `createLatestOnlyQueue` / `draftPointsForTransport`\n / `retryIdempotentRequest` \u2014 the 32 ms latest-only draft gate a custom sender\n must use instead of POSTing every pointer event.\n- `createDirectSenderController`, `resolveDirectSenderStatus`,\n `directSenderConnectionFromResult` \u2014 the shared direct-sender rules.\n- `createRealtimeVisualContextSignalingClient` /\n `createHttpVisualContextSignalingClient` \u2014 where live-view signals travel\n (realtime push, or the polled public `/v1/.../visual-context/*` routes).\n\n### `@remotedraw/geometry`\n\nStroke/shape helpers (`buildNormalizedStroke`, `recognizeStroke`,\n`simplifyNormalizedPoints`, hit-testing, transforms, `drawingsToSvg`), **and the\nmap board transform**, re-exported by `@remotedraw/react`:\n`boardPointFromLngLat`, `lngLatFromBoardPoint`, `longitudeFromBoardX`,\n`latitudeFromBoardY`, `mercatorYFromLatitude`, `latitudeFromMercatorY`,\n`boardViewportFromMapBounds`, `mapBoundsFromBoardViewport`,\n`mapBoardPointToScreen`, `mapBoardPointFromScreen`, `screenPointFromBoardPoint`,\n`surfacePointFromBoardPoint`, `padMapBounds`, `MAX_MERCATOR_LATITUDE`.\nUse these rather than reimplementing the projection \u2014 `x` is linear in\nlongitude, `y` is linear in **Web Mercator**, and a version that is linear in\nlatitude puts ink kilometres away.\n\n### `RemoteDrawSenderKit` (SwiftPM, iOS 17+)\n\n`https://github.com/AxioSOzo/remotedraw-swift.git`, product\n`RemoteDrawSenderKit`. Zero external dependencies. No `Info.plist` entries.\n\n- `RemoteDraw.shared` \u2014 **zero-config**: it installs production defaults the\n first time anything reads it. `RemoteDraw.configure(_:)` in `App.init` is\n *optional* and only overrides `apiBaseURL`, `device`, `tokenProvider`,\n `urlSession`, `onClientAdvisory`. `try RemoteDraw.requireConfigured()` throws\n `RemoteDrawError.notConfigured` if you want the strict behaviour back.\n (This used to `preconditionFailure` from inside the modifier's `.task` \u2014 a\n crash on the user's tap. It no longer does.)\n- `.remoteDrawSurface(isPresented:senderToken:appearance:strings:exit:onOutcome:)`\n \u2014 the whole integration, a full-screen cover with the board, an exit and an\n outcome. A second overload adds `background:` \u2014 a `@ViewBuilder` handed a\n `RemoteDrawGroundContext` (`session`, `mapBounds`, `phoneProjection`, `size`,\n `reportViewport`) for your own cartography or ground. A ground that **moves**\n must call `reportViewport(_:)`; a static one calls nothing.\n- `RemoteDrawTakeover` \u2014 the same board plus scene-phase wiring and exit, for\n your own cover / navigation push / `UIHostingController`.\n- `RemoteDrawSurface` \u2014 the board as a plain `View`, for a host that fills its\n presentation with it edge to edge.\n- `RemoteDrawSenderSession` \u2014 the headless core (`begin`/`append`/`end`,\n `undo`, `clear`, `submit(metadata:)`, `edit`, `updateProjection`, `leave`,\n published `phase`, `strokes`, `live`, `lastError`), with\n `RemoteDrawInkCanvas` + `RemoteDrawStrokeCapture` when you own the screen.\n- `RemoteDrawBoardCanvas`, `RemoteDrawMapBoardGround`, `RemoteDrawMapGeometry`,\n `RemoteDrawAppearance`, `RemoteDrawStrings`, `RemoteDrawExit`,\n `RemoteDrawError` (13 cases with `shouldReJoin` / `isRetriable`).\n- **Map boards are built in.** A `kind: \"map\"` session with\n `coordinateSpace.bounds` draws the geography through MapKit, using the same\n transform as `@remotedraw/geometry`. The built-in map is deliberately not\n pannable; supply your own through `background:` if it should be.\n- **Streaming boards are not.** A session with `senderIntegrationMode:\n \"streaming\"` or a receiver publishing `visualContext.enabled` cannot be drawn\n by this SDK \u2014 no WebRTC, no video, no `WKWebView`. It reports\n `.unsupportedSurface(_:)` carrying `hostedSenderURL`; present that in a\n `WKWebView` and streaming works today.\n\n`RemoteDrawOutcome` is a **closed** enum:\n\n| Case | Meaning | Do |\n| --- | --- | --- |\n| `.submitted(RemoteDrawReceipt)` | Drawing submitted. | Record it. Call `session.submit(metadata:)` yourself for the server's own ids \u2014 a submit from the SDK's controls reports a placeholder. |\n| `.left` | The person left; ink is on the board. | Nothing. |\n| `.expired` | The board finished or timed out. **Terminal.** | Create a new session. |\n| `.credentialLost(RemoteDrawError?)` | The token died; the board did not. **Recoverable.** | Mint a fresh `rd_send_` and present again. |\n| `.unsupportedSurface(RemoteDrawUnsupportedSurface)` | The board wants a renderer this SDK lacks. The cover **stays up** showing the reason. | Open `hostedSenderURL` in a `WKWebView`. |\n| `.failed(RemoteDrawError)` | Anything else. | Read `error.shouldReJoin` / `error.isRetriable`. |\n\n**There is no `.revoked`.** A revoked, unknown and malformed token all answer\n`invalid_sender_token` on purpose; only a genuine expiry is distinguishable, and\nthat travels in the error on `.credentialLost`.\n\nNote: `RemoteDrawKit` is a *different*, internal macOS-only package that some\nolder docs still name. Customers use `RemoteDrawSenderKit`.\n\n## Recipes, one per surface\n\nAll React snippets assume the session came from your backend and are wrapped in:\n\n```tsx\nconst receiver = createHttpReceiverClient(\"https://api.remotedraw.com\");\n\n<RemoteDrawProvider\n session={session.session}\n receiverToken={session.receiverToken}\n joinUrl={session.joinUrl}\n joinUrls={session.joinUrls}\n receiver={receiver}\n onError={(error) => reportToYourLogger(error)}\n>\n {/* the recipe */}\n</RemoteDrawProvider>\n```\n\n### Whiteboard / freeform sketch\n\n```ts\n// Backend\ntarget: { kind: \"whiteboard\", label: \"Session notes\" }\n```\n\n```tsx\n<PairingCode title=\"Scan to draw\" />\n<RemoteDrawReceiver />\n<RemoteDrawSessionControls title=\"Sender workflow\" />\n```\n\n### Photo / screenshot annotation (`image`)\n\nThe photo is **already on the receiver**. The phone never takes it.\n\n```ts\ntarget: {\n kind: \"image\",\n inputMapping: \"surface\", // the pad is the whole photo\n coordinateSpace: { width: 1600, height: 900 },\n metadata: { label: \"Inspection photo\", imageId: \"img_123\" },\n}\n```\n\n```tsx\n<div style={{ position: \"relative\" }}>\n <RemoteDrawReceiver\n coordinateAspectRatio={16 / 9}\n background={<img src={photoUrl} alt=\"\" style={{ width: \"100%\", height: \"100%\", objectFit: \"contain\" }} />}\n />\n <PairingCode placement=\"top-right\" size={112} />\n</div>\n```\n\n*Limit:* RemoteDraw stores no images. Your app owns the photo and the link\nbetween it and the drawings.\n\n### PDF page\n\n```ts\ntarget: { kind: \"pdf\", inputMapping: \"viewport\",\n metadata: { documentId: \"doc_9\", page: 3 } }\n```\n\n```tsx\n<div style={{ position: \"relative\" }}>\n <YourPdfPage page={3} />\n <RemoteDrawReceiver\n background=\"transparent\"\n style={{ position: \"absolute\", inset: 0, pointerEvents: \"none\" }}\n />\n</div>\n```\n\n*Limit:* RemoteDraw renders no PDFs and knows nothing about pages. One session\nper page, or carry the page number in `target.metadata` and re-create.\n\n### Signature / bounded field\n\n```ts\ntarget: {\n kind: \"field\",\n inputMapping: \"surface\", // mandatory \u2014 the pad IS the field\n coordinateSpace: { width: 1600, height: 500 },\n metadata: { label: \"Customer signature\", fieldId: \"sig_1\" },\n}\n```\n\n```tsx\n<RemoteDrawConnect method=\"qr\" triggerLabel=\"Sign with your phone\" showTriggerLabel />\n<RemoteDrawReceiver coordinateAspectRatio={16 / 5} strokeWidth={7}\n style={{ border: \"1px solid #ddd8cf\", borderRadius: 12 }}>\n <line x1=\"140\" y1=\"760\" x2=\"3060\" y2=\"760\" stroke=\"#d8d8d8\" strokeWidth=\"4\" />\n</RemoteDrawReceiver>\n```\n\n*Limit:* this is markup transport, **not** e-signature compliance. No identity\nproofing, no intent-to-sign ceremony, no tamper-evident audit package, no\ncertificates. Say so if the user asks for a legal signature.\n\n### Map\n\nThe board's `coordinateSpace.bounds` is a **hard geographic fence, fixed for the\nlife of the session** \u2014 no route changes it. Size it larger than the camera you\nopen on. The phone pans *inside* it.\n\n```ts\nimport { padMapBounds } from \"@remotedraw/geometry\";\n\ntarget: {\n kind: \"map\",\n inputMapping: \"viewport\",\n coordinateSpace: {\n width: 1600, height: 1310, // the fence's Mercator aspect\n ...padMapBounds(currentCameraBounds, 1), // 3x the camera\n },\n}\n```\n\n```tsx\nconst [camera, setCamera] = useState(0);\nuseEffect(() => {\n const onMove = () => setCamera((n) => n + 1);\n map.on(\"move\", onMove);\n return () => map.off(\"move\", onMove);\n}, [map]);\nconst projectLngLat = useCallback(\n (lng: number, lat: number) => map.project([lng, lat]), // CSS px in the container\n [map, camera],\n);\n\n<div style={{ position: \"relative\" }}>\n <div ref={mapContainer} style={{ position: \"absolute\", inset: 0 }} />\n <RemoteDrawMapReceiver\n projectLngLat={projectLngLat}\n style={{ position: \"absolute\", inset: 0, pointerEvents: \"none\" }}\n />\n</div>\n```\n\n*Limits:* `mapBounds={map.getBounds()}` is the no-callback alternative but is\nexact only for a north-up, unpitched camera. Omitting `bounds` at session\ncreation does not mean \"the customer's map\" \u2014 it means RemoteDraw's own default\nregion. `senderIntegrationMode` stays `\"native\"` on a map board: streaming\nrequires a `phoneProjection` that a native map sender never sends.\n\n### Screen / live view\n\n```ts\ntarget: { kind: \"screen\", inputMapping: \"viewport\" }\ncapabilities: [..., \"viewReceiverContext\"]\nvisualContext: { enabled: true }\n```\n\nThe receiver publishes with `useVisualContextPublisher`. Three consumers, in\norder of how little you write:\n\n1. **The hosted `/join` pad** \u2014 consumes the stream automatically. Zero code.\n2. **Your own web pad** \u2014 `RemoteDrawStreamView` with your pad as its\n `children`, plus a signaling client.\n3. **`RemoteDrawSenderKit`** \u2014 *cannot* consume it. It returns\n `.unsupportedSurface(_:)` carrying a `hostedSenderURL`; present that in a\n `WKWebView` and streaming works today.\n\nLive view is experimental: build so that a session whose stream never starts is\nstill a working session \u2014 the phone keeps drawing, it simply does not see the\nreceiver's pixels.\n\n### iOS sender \u2014 the complete Swift\n\n```swift\n// Package.swift / Xcode \u2192 Add Package\n.package(url: \"https://github.com/AxioSOzo/remotedraw-swift.git\", from: \"0.1.0\")\n.product(name: \"RemoteDrawSenderKit\", package: \"remotedraw-swift\")\n\nimport RemoteDrawSenderKit\n\n// Optional. RemoteDraw.shared installs production defaults on first use, so\n// this line exists only to OVERRIDE something.\n@main struct MyApp: App {\n init() { RemoteDraw.configure(.init(tokenProvider: mintSenderToken)) }\n var body: some Scene { WindowGroup { RootView() } }\n}\n\n// Wherever drawing starts. `token` is the rd_send_ string your backend minted\n// with POST /v1/sessions/direct-sender. A QR's rd_join_ works too, but spending\n// one revokes every other sender on that session.\nButton(\"Draw\") { drawing = true }\n .remoteDrawSurface(isPresented: $drawing, senderToken: token) { outcome in\n switch outcome {\n case .submitted(let receipt): record(receipt)\n case .left: dismissBanner()\n case .expired: refreshSession() // terminal\n case .credentialLost: refreshToken() // recoverable\n case .unsupportedSurface(let it): openHostedPad(it.hostedSenderURL)\n case .failed(let error): report(error) // do not swallow\n }\n }\n```\n\nReceiving the launch URL (the other half of the direct sender \u2014 nothing else\ndocuments it):\n\n```swift\n// Info.plist: CFBundleURLTypes \u2192 your scheme, e.g. \"yourapp\"\n.onOpenURL { url in\n guard url.scheme == \"yourapp\",\n let token = URLComponents(url: url, resolvingAgainstBaseURL: false)?\n .queryItems?.first(where: { $0.name == \"senderToken\" })?.value\n else { return }\n senderToken = token\n drawing = true\n}\n```\n\nRules:\n\n- The surface is **full screen**. Not a small pad, not a draggable sheet.\n- The user can always leave; `RemoteDrawExit` only decides whether leaving with\n unsubmitted ink asks first. The host always gets an outcome.\n- Do not write a `UIViewRepresentable` canvas, a draft loop, or an HTTP client.\n Go headless on `RemoteDrawSenderSession` + `RemoteDrawInkCanvas` +\n `RemoteDrawStrokeCapture` if you own the screen \u2014 never raw `URLSession`.\n- Customers do not ship a separate RemoteDraw app; their app *is* the sender.\n\n## How to suggest use cases\n\n**What the product is good at**, as a sentence to pattern-match against:\n\n> A person is at a screen. The thing they need to mark is already on that\n> screen. A mouse is the wrong instrument for the mark \u2014 because it is\n> handwriting, a circle around a defect, a diagram, a signature, or a gesture\n> over a map \u2014 and their phone is in their pocket.\n\n**Ask these four before proposing anything:**\n\n1. Which screen in your product already shows the thing to be marked, and what\n device is that screen on?\n2. Is the person in front of it holding a phone at the same time?\n3. What does the mark mean afterwards \u2014 saved to which record, shown where?\n4. Anyone who scans, or a signed-in user of your own app? (QR vs direct sender.)\n\n**Good vs bad, for a property-management SaaS:**\n\n- \u2705 Property manager reviews an inspection photo on the office desktop and\n circles the damage with their phone. *Two devices; content already on the\n receiver.*\n- \u2705 Tenant signs the handover report on the manager's laptop screen using their\n own phone as the pen. *This is the flow that replaces a stylus.*\n- \u2705 Planner marks a route on the dispatch map on the wall display.\n- \u2705 Support agent circles the broken control on a customer's shared screen.\n- \u274C Tenant photographs a leak on their phone and circles it. *One device,\n phone-supplied content. **Not RemoteDraw.*** Say so and propose PencilKit.\n- \u274C An in-app sketch pad in the mobile app. *One device.*\n- \u274C Field engineer marks up a PDF on their iPad in the van. *One device \u2014\n unless a second screen is genuinely present.*\n- \u274C \"Pair over Bluetooth when the phone is nearby.\" *Not implemented.*\n\n**The disqualifier:** if you cannot name two devices and say which one already\ndisplays the content, you do not have a use case yet \u2014 ask.\n\n## The flow \u2014 in this order\n\n1. **Explain before touching anything.** If the user is asking what RemoteDraw\n can do, answer from this file and the docs. Do not install, scaffold, or\n create sessions to answer a question.\n2. **Ask for approval before installing.** Name exactly what you want to add\n (`@remotedraw/cli`, `@remotedraw/react`, a Swift package, a dashboard\n project + key) and why, then wait. This includes `remotedraw init` without\n `--offline`, which provisions a billable project and key. Install with the\n project's own package manager \u2014 the scan below reports it, and a lockfile\n the project did not ask for is a mess a human has to clean up:\n `npm install -g @remotedraw/cli`, `pnpm add -g @remotedraw/cli`,\n `bun add -g @remotedraw/cli`, or \u2014 Yarn Berry has no global install \u2014\n `yarn dlx @remotedraw/cli`. Same for the SDKs: `npm install`, `pnpm add`,\n `yarn add`, or `bun add`.\n3. **Scan the codebase.** `remotedraw scan --format json` (or, before the CLI\n is installed, `npx @remotedraw/cli@latest scan --format json` \u2014 `pnpm dlx`,\n `yarn dlx`, or `bunx @remotedraw/cli` for those managers) reports the\n web/server/iOS projects, the project's package manager and its install/add\n commands, where an `rd_sk_` key may live, any RemoteDraw wiring already\n present, the integration options that fit, and the product questions to ask.\n Read it; verify its `evidence` where it matters.\n4. **Ask the product questions** (the four above, plus the scan's own). Do not\n invent answers; a one-page \"drawing lab\" is only right when the user says a\n demo is what they want.\n5. **Propose one plan, then build all of it.** Receiver, session creation,\n sender, and the exit/submit path \u2014 an integration is not done when ink\n appears once on a test page. Build beside existing features.\n6. **Verify by using it.** Run `remotedraw doctor --format json`, open a real\n session, draw from a phone (or `create-input --execute` + the hosted join\n URL), and confirm ink lands on the receiver. On iOS, run it on a device or\n simulator and look at the screen; a compiling canvas is not a working one.\n\nSession creation (`POST /v1/sessions`) needs the `rd_sk_` key and therefore runs\nonly where the scan found server-side code: a Convex action, a Next route\nhandler, an Express/Hono route, a serverless function. If the scan found none,\nask where the backend is. Never scaffold `createRemoteDrawSession.ts` into a\nVite/Next client tree \u2014 `--target web` in `remotedraw init` still writes it\nunder `src/`; move it, or scaffold into a scratch directory and copy only what\nbelongs.\n\n## CLI\n\n```sh\nremotedraw options --format json # the option catalog\nremotedraw scan --format json # read the codebase first (step 3)\nremotedraw init --non-interactive --offline --dry-run --format json \\\n --path apps/web --target web --sender remotedraw-ios --sdk react --preset sketch --package-manager npm\nremotedraw doctor --format json\nremotedraw create-input --preset sketch --json\nremotedraw create-input --execute # a real session + join URL, needs a key\nremotedraw agent --print-skill # this file, no install needed\n```\n\n`--sender own-ios` requires `--sdk swift`; other invalid combinations fail with\n`INVALID_COMBINATION`. By default `init`/`new` also create a dashboard project\nand a project-scoped development key in `.env.local` \u2014 that is the step that\nneeds approval (step 2); `--offline` writes files only. `--preset mapMarkup`\nemits an explicit `coordinateSpace.bounds`; replace the example region with the\ncustomer's. Never pass `--force` unless the user approved overwriting. Do not\ndrive the interactive wizard or scrape human-formatted output; every command has\n`--format json`.\n\n## Security and tenancy\n\n- `rd_sk_\u2026` keys: backend secrets only. Never in browser bundles, Swift, app\n bundles, screenshots, logs, or generated examples.\n- The account-level `rd_cli_\u2026` credential stays in the user config directory;\n never copy it into a project. `REMOTEDRAW_CLI_TOKEN` is for CI secrets only.\n- Public clients receive only `joinUrl`, `joinToken`, `receiverToken`, or\n `senderToken`, each scoped to one session. Production QR codes use the HTTPS\n `joinUrl`, not the custom scheme.\n- One key serves every customer of the product, so a session id is not a\n capability. Create sessions with `externalId: \"<product>:<tenant>\"`, and check\n it (`POST /v1/sessions/get`) before attaching a sender or ending a session on\n a tenant's behalf.\n\n## Gotchas the SDKs hide and hand-written code hits\n\n- `POST /v1/sessions` is billable and not idempotent. React StrictMode runs\n mount effects twice in development: guard with a ref, or create the session in\n a server action / loader. Store the `receiverToken` if the receiver outlives a\n page load \u2014 it is the only credential that reads a session's ink.\n- Timestamps are integer milliseconds. `occurredAt` and point `t` values are\n accepted with a fraction (floored) but a hand-written client should send\n integers.\n- Committed points come back in board space, remapped through\n `device.aspectRatio`; send the aspect ratio of the pad the finger touches.\n- `RemoteDrawReceiver` defaults `phones` and `pointers` to on, and renders\n immediately with no empty state. Pass `phones={false}` for a plain surface;\n drive your own empty state from `useReceiverData().senders`.\n- `PairingCode` hides the join URL text unless `showLink`.\n- `joinTokenExpiresAt` is earlier than the session's `expiresAt`: the QR dies\n first, the board stays live.\n- Wire `RemoteDrawProvider`'s `onError` (and `useVisualContextPublisher`'s).\n Without it a dead credential is silent and the board simply stops updating.\n- The hosted `/join` pad respects the joined capability list; a custom sender\n must too. It also has a **file-attach tool** available on any session granting\n `draw` or `point`, which cannot currently be turned off.\n\n## API contract\n\n- Backend: `POST /v1/sessions` (create), `/v1/sessions/get`, `/v1/sessions/end`,\n `/v1/sessions/direct-sender` (mint `rd_send_` for your own app),\n `/v1/sessions/join-token` (a fresh QR).\n- Receiver: `POST /v1/receiver/session`, `/drawings`, `/drafts`, `/senders`\n with the receiver token, or the realtime source in the SDKs.\n- Sender: `POST /v1/join` (spends a join token; revokes other senders), then\n `/v1/sender/draft` (latest-only preview, throttle to ~32 ms),\n `/v1/sender/commit` (one durable stroke per pointer-up with a stable\n `clientStrokeId`), `/v1/sender/submit`.\n- Capabilities: `draw`, `point`, `undo`, `clear`, `moveViewport`,\n `viewExisting`, `viewReceiverContext`. Omitting `capabilities` grants the\n first six. Reissued join tokens may narrow but never widen.\n\n## AI actions\n\nReach for AI when the product needs something _from_ the finished drawing: a\ngenerated image, a description, or structured data to branch on. Backend only\n(`aiActions:*` scopes on an `rd_sk_...` key). Never wire it to a commit, submit,\nor presence event \u2014 AI runs only on an explicit `POST /v1/ai-actions` call the\nuser asked for. Run it after the user is done; the route accepts `active` and\n`ended` sessions.\n\nMinimal request per outcome (`POST /v1/ai-actions`, plus optional\n`quality: \"fast\" | \"balanced\" | \"max\"`, default `balanced`):\n\n```jsonc\n{ \"sessionId\": \"...\", \"request\": \"image\", \"prompt\": \"...\" } // image back in the response\n{ \"sessionId\": \"...\", \"request\": \"image\", \"prompt\": \"...\", \"deliver\": [\"result\", \"board\"] } // and onto the board\n{ \"sessionId\": \"...\", \"request\": \"text\", \"prompt\": \"Describe this drawing.\" } // text back\n{ \"sessionId\": \"...\", \"request\": \"text\", \"prompt\": \"...\", \"text\": { \"schema\": { /* JSON Schema */ } } } // typed JSON\n```\n\nThe response is asynchronous: `create` returns `status: \"queued\"`. Poll\n`POST /v1/ai-actions/get` until `status` is `succeeded`, `failed`, or\n`canceled`, or use `createAiAction` + `waitForAiAction` on\n`createHttpRemoteDrawApiClient` from `@remotedraw/client` (re-exported by\n`@remotedraw/react`) \u2014 backend only, it holds the key. There is no completion\nwebhook. Render results with `AiImage` / `AiText`. See\nhttps://docs.remotedraw.com/docs/api#ai.\n\n## What does not exist yet\n\nVerified against source, 2026-08-30. Read this *before* designing, so you never\npromise any of it.\n\n- **No Bluetooth or Wi-Fi pairing.** The radios advertise; nothing scans or\n browses, on any platform. `PairingDevices` is presentational chrome.\n- **No QR into a customer's own app.** The Universal-Link association file has\n one hard-coded appID and the QR host is a RemoteDraw deployment setting. Use\n the direct sender.\n- **No native Android SDK.** Hosted `/join` in a mobile browser is the Android\n sender, and it is full-featured.\n- **No desktop or trackpad sender.** Unbuilt research.\n- **No streaming consumer in `RemoteDrawSenderKit`.** It reports\n `.unsupportedSurface(_:)` with a `hostedSenderURL` to open in a `WKWebView`.\n (A customer's own *web* pad can consume a stream \u2014 `RemoteDrawStreamView`.\n It is the native SDK that cannot.)\n- **No Svelte components.** A receiver store and a direct-sender store only.\n- **No web sender component.** Deliberate: hosted `/join` is the web sender.\n- **No API-key route that reads a session's ink.** Lose the `receiverToken` and\n the session is unreadable while still billable.\n- **No `clientSessionId` idempotency on `POST /v1/sessions`.**\n- **No completion webhook for AI actions** \u2014 poll `POST /v1/ai-actions/get`.\n- **No e-signature compliance.** No identity proofing, intent-to-sign ceremony,\n tamper-evident audit package, or certificate handling.\n- **No content rendering of any kind.** No PDF renderer, no image storage, no\n document pipeline, no auth, no billing UI. The host renders; RemoteDraw inks.\n\n## Verification\n\nAfter changes, verify against the customer's project \u2014 never assume RemoteDraw's\nown repo scripts exist here.\n\n```sh\nremotedraw doctor # config, SDK deps, REMOTEDRAW_* env\nremotedraw create-input --execute # open a real session, print the join URL\n```\n\nThen run whatever type check and test command the project already defines (for\nexample `npm run typecheck` and `npm test`). Do not invent script names, and do\nnot run `bun run test:api`, `bun run typecheck`, or `bun run ios:kit:test` \u2014\nthose are RemoteDraw's internal monorepo scripts and will not exist in a\ncustomer project.\n\n## Reference\n\n- API: `https://api.remotedraw.com` \u00B7 Docs: `https://docs.remotedraw.com/docs`\n (agent summary: `https://docs.remotedraw.com/llms.txt`) \u00B7 Keys:\n `https://dashboard.remotedraw.com/api/keys`\n- Packages: `@remotedraw/cli`, `@remotedraw/react`, `@remotedraw/svelte`,\n `@remotedraw/client`, `@remotedraw/protocol`, `@remotedraw/geometry`, and the\n SwiftPM package `https://github.com/AxioSOzo/remotedraw-swift.git` (product\n `RemoteDrawSenderKit`).";
1
+ export declare const AGENT_SKILL_MARKDOWN = "---\nname: remotedraw\ndescription: Integrate RemoteDraw phone drawing into a customer's product \u2014 a phone becomes the pen for a screen the customer already owns. Covers the two-device model, which flows are valid, the ready-made components (React/Svelte/JS receiver, hosted web sender, RemoteDrawSenderKit iOS sender), per-surface recipes, and how to judge whether a proposed use case fits at all.\n---\n\n# RemoteDraw Agent Skill\n\nUse this skill when a user asks what RemoteDraw is, whether it fits their app,\nor to create, debug, or review a RemoteDraw integration.\n\nRead the whole file before proposing a design. The first two sections decide\nwhether the use case is possible; everything after decides how it is built.\n\n## The model: two devices, always\n\nRemoteDraw is not a drawing library. It is a wire between **two devices**.\n\n- **The receiver is the paper.** A screen someone is looking at \u2014 a laptop, a\n desktop, a large display, a kiosk, a tablet on a desk \u2014 showing *your*\n product. Your app renders whatever is being drawn on: the map, the photo, the\n PDF page, the form, the whiteboard. RemoteDraw renders none of that content.\n It paints ink on top of it.\n- **The sender is the pen.** A phone. It supplies a hand, pressure, tilt and a\n stroke. It does **not** supply the content: the iOS SDK opens no camera and no\n file picker (it needs no `Info.plist` entries at all). On a bounded surface\n the phone's pad *is* the surface; on a large one the phone is a viewport\n moving over it.\n- **RemoteDraw is the wire.** A hosted session carries strokes from the pen to\n the paper in real time and stores them. Nothing runs on the customer's\n infrastructure except the session-creation call.\n\nThe one question that decides whether RemoteDraw fits:\n\n> **What is being drawn on, and which screen is it already displayed on?**\n\nIf the answer is \"a screen the user is looking at, and they wish they could\ndraw on it with their hand\" \u2014 that is RemoteDraw. If the answer is \"the phone's\nown screen\", it is not: a phone drawing on its own content and uploading the\nresult is a camera-and-canvas feature you build with PencilKit or a `<canvas>`,\nand RemoteDraw would only add a round trip.\n\nThree consequences, because they are the mistakes integrators actually make:\n\n1. **The phone never supplies the picture.** \"The tenant photographs the leak\n and circles it\" is *not* a RemoteDraw flow \u2014 there is no second screen. The\n RemoteDraw version of that job: the photo is already open in your web app on\n the office desktop, and the person at that desk circles the leak with their\n phone instead of a mouse.\n2. **The receiver already exists.** RemoteDraw goes onto a screen your product\n already has. It does not get its own page unless the user asks for a demo.\n3. **Ink is coordinates, not pixels.** Strokes arrive in normalized board space\n (`0..1`, remapped through the sender's `device.aspectRatio`). Your app\n decides what board space *means* \u2014 a pixel, a page, a field, a coordinate on\n Earth. RemoteDraw never sees your content.\n\n**The receiver shows the phone, not only its ink.** Once a sender carries a\n`phoneProjection` \u2014 every hosted `/join` sender does, map boards included; a\nnative iOS sender does on every board except `map` \u2014 `RemoteDrawReceiver` draws\nthe phone's frame where it sits on the board, the pointer under the finger, a\npresence badge with the device name, and the live-view state, with no receiver\ncode. `RemoteDrawSessionControls` tells the phase story (waiting / connected /\ndrawing / submitted, plus \"\u00B7 Away\" or \"\u00B7 Live view\" when it is news) from the\nprovider's `presence`, which is the wire's `senders[].presence`\n(`present | stale | disconnected`) re-derived on the client's clock. Every\nreceiver-side component takes `theme=\"light\" | \"dark\" | \"auto\"`; `\"auto\"` is the\ndefault and follows the host page (`data-theme`, a `dark` class) before the OS.\n\n## Which flows are valid\n\nRead the row for the **sender** (who holds the phone) and the column for the\n**receiver** (the screen showing the content).\n\n| Sender (the pen) | Receiver (the paper) | Valid? | Notes |\n| --- | --- | --- | --- |\n| iPhone \u2014 RemoteDraw app, your app via `RemoteDrawSenderKit`, or hosted `/join` in Safari | Desktop / laptop browser | **Yes \u2014 the canonical flow** | Everything below is written for it. |\n| iPhone (any of the three) | Large display, TV, projector, kiosk browser | **Yes** | Size `target.coordinateSpace` to the display. |\n| iPhone (any of the three) | Desktop app \u2014 Electron, macOS, Windows \u2014 via `@remotedraw/client` or raw HTTP | **Yes** | No React needed; poll `/v1/receiver/*` or use the realtime source. |\n| Android phone \u2014 hosted `/join` in Chrome | Any of the above | **Yes** | There is no native Android SDK. The hosted join page *is* the Android sender and it is full-featured. |\n| iPhone / Android | iPad or tablet browser, as a **second** device someone else is looking at | **Yes** | Two devices, two people. |\n| Headless script, test, or agent \u2014 raw `POST /v1/join` \u2192 `/v1/sender/draft` \u2192 `/v1/sender/commit` | Any receiver | **Yes, for verification only** | Never ship a hand-rolled sender to users. |\n| Any phone | **The same phone** \u2014 one device shows the content and draws on it | **No** | There is no second screen. Use PencilKit / `<canvas>`. RemoteDraw adds a network hop and nothing else. |\n| A phone that must first **capture** the content \u2014 photograph or scan it | *(anything)* | **No** | The iOS sender opens no camera and needs no `Info.plist` entries. The content must already be on the receiver. |\n| Desktop mouse or trackpad as the pen | *(anything)* | **No \u2014 does not exist** | There is no desktop sender. The macOS trackpad sender is unbuilt research. Do not promise it. |\n| Phone \u2192 phone, two different people, two different devices | Phone browser as receiver | *Technically yes, rarely right* | A phone browser is a browser. But if both people hold phones, ask why the drawing is not simply in one app. |\n\n**The three nevers of the model.**\n\n1. **Never same-device.** If sender and receiver would be one phone, stop and\n say so. Propose the non-RemoteDraw alternative.\n2. **Never make the phone the source of content.** The receiver supplies what\n is drawn on. (One honest exception: the hosted `/join` pad has a file-attach\n tool that can put a file on the board. It is a hosted-sender capability, not\n a way to make a same-device flow valid, and it cannot currently be disabled.)\n3. **Never promise a sender RemoteDraw does not ship.** iOS (native SDK) and\n any mobile browser (hosted `/join`) are the senders. Nothing else exists.\n\n## Never do these\n\n- **Never write a custom canvas.** Not a `UIViewRepresentable` drawing view, not\n a `<canvas>` sender pad, not a hand-rolled SVG receiver, not a raw\n `URLSession`/`fetch` draft loop. Cadence, point budgets, the packed-point\n codec, sequence healing, token refresh and presence are protocol, and the SDKs\n implement them. Go headless on the SDK's own primitives if you must.\n- **Never put the iOS surface in a small pad or a draggable sheet.** It is\n full screen (`.remoteDrawSurface` / `RemoteDrawTakeover`). A sheet is\n acceptable only if `RemoteDrawSurface` fills it edge to edge and the sheet\n cannot be dragged mid-stroke. There is no small-canvas option.\n- **Never create a session on mount or on page load.** `POST /v1/sessions` is\n billable and not idempotent, and React StrictMode fires mount effects twice.\n Create it on the server (route handler, loader, server action) or on explicit\n user intent, and guard with a ref if it must be a client effect.\n- **Never let an `rd_sk_\u2026` key reach a client.** Not browser bundles, not Swift,\n not app bundles, screenshots, logs, or generated examples. The phone never\n calls `/v1/sessions/direct-sender`; your backend does.\n- **Never poll by hand when a component or store exists.** `RemoteDrawProvider`\n / `createReceiverStore` already do one-in-flight polling plus an optional\n realtime push source.\n- **Never promise Bluetooth or Wi-Fi pairing.** The API advertises\n `bluetooth` and `localNetwork` as pairing methods and the iOS/Android apps do\n *advertise* on those radios, but **nothing scans, browses, or connects\n anywhere in the product**. They are not implemented. Do not present them as\n options; do not build UI around them. QR and direct sender are the real ones.\n- **Never promise a native Android SDK, a desktop sender, per-tenant Universal\n Links, e-signature compliance, or streaming inside `RemoteDrawSenderKit`.**\n See \"What does not exist yet\".\n- **Never delete or replace a host-app feature on your own initiative.** Build\n beside it.\n\n## The decision tree\n\n**1. What is being drawn on? \u2192 `target.kind` + `inputMapping`.**\n\n`target.kind` accepts `whiteboard`, `paper`, `canvas`, `svg`, `map`, `tldraw`,\n`field`, `image`, `pdf`, `screen`, `custom`. It selects the background the\nreceiver defaults to, the tool policy, and deposit defaults.\n`target.inputMapping` is `surface` (the phone's whole pad *is* the target \u2014 use\nfor bounded targets like a signature field) or `viewport` (the phone is a\nmovable window over a larger board \u2014 the default when omitted).\n\n| Kind | Host renders | `inputMapping` | Sender | Status |\n| --- | --- | --- | --- | --- |\n| `whiteboard` / `paper` | nothing \u2014 RemoteDraw's own ground | `viewport` (or `surface`) | hosted `/join` or SenderKit | shipped |\n| `image` (photo, screenshot) | the `<img>`, as `background` | `surface` (whole photo) or `viewport` (zoomable) | either | shipped |\n| `pdf` | your PDF renderer, one page at a time, as `background` or behind a `transparent` receiver | `viewport` | either | shipped; RemoteDraw renders no PDFs |\n| `field` (signature, initials) | the form, with a baseline as SVG `children` | **`surface`** \u2014 mandatory | either | shipped; **not** an e-signature product |\n| `screen` | a screenshot, or a live stream you publish | `viewport` | either | shipped; live view is experimental |\n| `map` | your map (Mapbox / MapLibre / Leaflet / Google) | `viewport` | hosted `/join` or SenderKit | shipped \u2014 use `RemoteDrawMapReceiver` |\n| `custom` | anything else you own | `viewport` | hosted `/join` (streaming) or SenderKit (ink only) | shipped |\n\n**2. Which stack renders the receiver? \u2192 the composition.**\n\n| Host | Use | Not |\n| --- | --- | --- |\n| React / Next / Remix | `@remotedraw/react`: `RemoteDrawProvider` + `RemoteDrawReceiver` (or `RemoteDrawMapReceiver`) + `PairingCode`/`RemoteDrawConnect`/`RemoteDrawLaunchButton` + `RemoteDrawSessionControls` | A hand-rolled SVG or polling loop |\n| Svelte / SvelteKit | `@remotedraw/svelte`: `createRemoteDrawReceiver` store + `createDirectSender` store. **No components ship** \u2014 you write all the markup, including ink rendering | Assuming React's components exist here |\n| Anything else with JS (Electron, Vue, vanilla) | `@remotedraw/client`: `createHttpReceiverClient`, `createReceiverStore`, `createRealtimeReceiverSource` | \u2014 |\n| No JS at all | Raw HTTP `POST /v1/receiver/*` with the receiver token | \u2014 |\n\n**3. Who holds the phone? \u2192 the sender path.**\n\n| Situation | Path |\n| --- | --- |\n| Anyone who can scan; no phone app in the product | **Hosted `/join`.** Render `joinUrl` as a QR with `PairingCode`. Zero sender code. The RemoteDraw iOS app opens the same HTTPS link through Universal Links; every other phone gets the web pad. |\n| The product has a first-party iOS app the user already installed *and* it is the RemoteDraw app | Same QR. Universal Links open it. |\n| The product has **its own** iOS app and the user is signed in | **Direct sender.** Backend calls `POST /v1/sessions/direct-sender` with `launchUrlTemplate: \"yourapp://draw?senderToken={senderToken}\"`; the browser shows `RemoteDrawLaunchButton`; the app receives the URL in `onOpenURL` and presents `.remoteDrawSurface(isPresented:senderToken:)`. Keep the QR as the fallback \u2014 the button reveals one automatically. |\n| A QR that opens the **customer's own** app | **Not possible.** The Universal-Link association file has one hard-coded appID and the QR host is a RemoteDraw deployment setting. Use the direct sender instead. |\n| Headless / tests | `POST /v1/join` \u2192 `/v1/sender/draft` \u2192 `/v1/sender/commit` |\n\n## Component catalogue\n\nEverything below is a real export, read from source. Anything not listed does\nnot exist. Grouped by decision: the board \u2192 what is behind it \u2192 pairing \u2192\nsession state \u2192 AI \u2192 clients \u2192 iOS.\n\n### `@remotedraw/react`\n\n**`RemoteDrawProvider`** \u2014 receiver session state, polling, and actions.\nRequires a `receiver` client to do anything.\nProps: `children`, `session`/`sessionId`/`joinUrl`/`joinUrls`/`joinTokenUse`/`pairing`/`receiverToken`,\n`receiver` (a `ReceiverClient` \u2014 **without it the store is inert and silent**),\n`createSession`, `createSessionRequest` (default `{ target: { kind: \"custom\" } }`),\n`autoCreate` (default: true when `createSession` and no `session`),\n`pollIntervalMs` (`1000`), `draftPollIntervalMs` (`250`), `source` (`null`),\n`fallbackToPolling` (`true`), `initialDrawings`/`initialDrafts`/`initialSenders` (`[]`),\n`onError` (`(error: Error) => void` \u2014 **wire it; it is the only signal for a dead credential**),\n`refreshJoinToken` (`({ sessionId }) => Promise<result | null>` \u2014 the QR re-arm.\nThe join token lives 10 minutes; the session usually outlives it. Point this at\na backend route that calls `POST /v1/sessions/join-token` and return the\nresponse as-is; when the token dies on a live, unpaired session the provider\nswaps in the fresh code and every pairing component follows. Without it a stale\nQR shows \"Link expired\" until the host recreates the whole session \u2014 wire it in\nany integration where a board can sit unpaired for more than 10 minutes).\nContext: `session, sessionId, joinUrl, joinUrls, joinTokenUse, pairing, receiverToken, credentials, drawings, drafts, senders, presence, loading, creating, error, transport, create, refetch, ingest, undo, clear`.\n`presence` is one entry per sender \u2014 `{ id, senderId, name, state, drawing,\nliveView, lastSeenAt, hasProjection, sender }` \u2014 where `state` is the wire's\n`sender.presence` (`present | stale | disconnected`), `drawing` is a fresh\ndraft from that sender, and `liveView` is the wire's `sender.visualContext`\n(`{ status, reason?, error? }`). Re-derived on a 5 s clock while senders exist.\nAlso available as `useRemoteDrawPresence(senders?, drafts?)` (outside a\nprovider, pass records) and as the pure `resolveBoardPresence(senders, drafts)`.\n*Limits:* `error` is a plain `Error`; narrow with `instanceof RemoteDrawHttpError`\nfor `.status`/`.code`/`.shouldReJoin`.\n\n**`RemoteDrawReceiver`** \u2014 the receiver foundation. Paints committed ink, live\ndrafts, sender pointers, connected phones and their presence badges over a\nconfigurable background, and prescribes nothing around it.\nProps: `drawings`/`drafts`/`senders` (fall back to the provider), `background`\n(default: the session target's surface inside a provider, else `\"whiteboard\"`;\naccepts a surface name, `\"transparent\"`, any CSS background string, or any\nReactNode), `pointers` (`true`), `phones` (`true`),\n`presenceBadge` (`\"frame\"` \u2014 a compact badge riding each drawn phone with the\ndevice name, a state dot, and one word when it is news: Drawing / Away / Live\nview / Connecting view\u2026 / No live view yet; `\"corner\"` stacks one per present\nsender top-right, the only mode that also lists senders with no projection;\n`\"none\"`), `presence` (override the derived list), `theme` (`\"auto\"` \u2014 the\nbadge follows the host page; the phone band follows the **board's** surface, so\na white whiteboard in a dark app keeps the light band), `presenceLabels`,\n`pointerColor` (`#1f7a8c`),\n`strokeColor` (`#151512`), `draftColor` (`#1f7a8c`), `strokeWidth` (`6`),\n`coordinateAspectRatio` (default: the session's `coordinateSpace`, else `1`;\nalso sets CSS `aspect-ratio`), `preserveAspectRatio` (`\"none\"`),\n**`projectPoint`** (`(point) => {x,y} | null` in **CSS pixels from the\nreceiver's top-left** \u2014 the seam for a camera; return `null` to drop a point),\n**`space`** (the same seam in the receiver's own viewBox coordinates; `projectPoint`\nwins when both are given), `animate` (`true`), `children` (SVG overlay in\n`0..surfaceWidth \u00D7 0..1000`), `className`/`style`/`svgProps`/`aria-label`.\n*Defaults that surprise:* it renders immediately with no empty state; `phones`,\n`pointers` and the frame badge are on. A phone is drawn only for a sender whose\nrecord carries a `phoneProjection`; a `stale` sender's phone is dimmed and\ndashed, a `disconnected` one is not drawn, a drawing one has a lit aperture and\na breathing halo, and a `visualContext.status: \"connected\"` one shows the green\nin-use dot beside the camera. With `projectPoint`, pointers and phones are\ndrawn in the element's own pixel space, so they never squash when the viewBox\nand the element differ in shape.\n*Limits:* without `projectPoint`/`space` it stretches board space across its own\nelement \u2014 correct for a fixed board, silently wrong over a live map. Use\n`RemoteDrawMapReceiver` there.\n*Never:* stack it on a pannable map without a projection.\n\n**`RemoteDrawMapReceiver`** \u2014 `RemoteDrawReceiver` wired to a map the host owns.\nRenders a transparent ground (your map is the ground) plus the\nboard\u2192geography\u2192pixel projection. Takes every `RemoteDrawReceiver` prop except\n`projectPoint`/`space`, plus:\n`bounds` (the board's `target.coordinateSpace.bounds`; defaults to the session's\nown inside a provider \u2014 usually pass nothing), `mapBounds` (the map's current\nvisible bounds; **exact only for a north-up, unpitched camera**),\n`projectLngLat` (`(lng, lat) => {x,y} | null` \u2014 normally\n`map.project([lng, lat])`; exact under rotation and pitch, and wins over\n`mapBounds`).\n*Limits:* with neither `projectLngLat` nor `mapBounds` it renders unprojected\nand warns once in development. Leave `preserveAspectRatio` at `\"none\"`. The\nreceiver reprojects only when `projectLngLat` changes identity, so the callback\nmust depend on the camera: a lint rule (Biome, `react-hooks/exhaustive-deps`)\nwill flag the `camera`/`cameraFrame` state in its dependency list as unnecessary\nbecause the function body never reads it. **It is the anchoring mechanism.**\nRemoving it freezes the projection at the opening camera and the ink slides\nduring pans. Keep the dependency and suppress the rule with a comment.\n*Never:* recompute the projection identity on every render \u2014 memoize it and bump\nit on the map's `move` event.\n\n**`RemoteDrawPhoneProjection`** \u2014 one connected phone drawn in place, as an SVG\n`<g>` for a host with its own `<svg>`: a thin tinted band around the exact\nboard region the screen holds (translucent, aperture cut out, never opaque\nblack), the Dynamic Island / notch / home button of the detected model, and a\nleader line when phones stack. `layout` (required, from\n`phoneProjectionLayouts(senders)`), `space` (default 1000\u00D71000), `color`,\n`model` (`\"auto\"`), `state` (`\"present\"`; `\"stale\"`/`\"disconnected\"` dim and\ndash), `drawing` (`false`; lights the aperture), `liveView`\n(`sender.visualContext.status`; `\"connected\"` = green dot, `requested`/`offering`\n= amber), `theme` (`\"light\"`, the surface it lies on), `className`.\n`RemoteDrawReceiver` already renders these.\n\n**`RemoteDrawPresenceBadge`** \u2014 the compact badge on its own, for a host's own\nroster or toolbar. `presence` (required, one entry from `useRemoteDrawPresence`),\n`color`, `theme` (`\"auto\"`), `labels` (`phone, drawing, stale, liveViewLive,\nliveViewRequested, liveViewPaused, liveViewFailed, liveViewUnavailable,\nliveViewNoProjection, liveViewOff`), `className`/`style`.\n\n**`PairingCode`** \u2014 the pairing component: the scannable code with live status,\nhover-to-copy, and subtle branding.\nProps: `joinUrl` (default: the provider's, falling back to `joinUrls.web`),\n`size` (`200`, or `\"fill\"`), `direction` (`\"paper\"`, one of 14 \u2014 TEMPORARY),\n`treatment` (`\"fluid\"`, one of 11 \u2014 TEMPORARY), `accentColor` (`#1f7a8c`),\n`inset`, `tile` (`true`), `logo` (the RemoteDraw mark; `false` for none, a\nstring for a URL), `logoPlacement` (`\"plate\"`), `title`/`description`,\n`showStatus` (`\"auto\"`), `joinMode` (the session's `joinTokenUse`),\n`showJoinMode`/`showLink` (`false`), `copyOnHover` (`true`), `onCopy`,\n`onConnected`/`onConnectedDismiss`, `labels`, `alt`, `theme` (`\"auto\"` \u2014 see\nTheming below; the code itself stays dark-on-white in every theme, the card and\ncaption follow; only `paper`/`outline`/`glass` have a designed dark card),\n`placement` (`\"inline\"` + four corners), `position`/`offset`/`zIndex`\n(`\"absolute\"`/`12`/`20`), `className`/`style`/`codeClassName`/`codeStyle`.\n*There is no `card`, `variant`, `showBrand`, `brand`, or `status` prop.* The\ncard is always drawn (`tile`, on by default) and extends to hold `title` /\n`description`; status is **inferred** (connected \u2192 error \u2192 expired \u2192 ready \u2192\nidle) and cannot be passed.\n*Limits:* always encodes the HTTPS `joinUrl`. The \"expired\" status is real and\nterminal unless the provider has `refreshJoinToken` wired \u2014 the component shows\nthe death of the 10-minute join token but cannot mint a replacement itself. The\nanimated optical field that `variant=\"aurora\"` once selected is now the separate\nEXPERIMENTAL `AuroraPairingField`, decodable only by the RemoteDraw app's own\nscanner.\n\n**`PairingDevices`** \u2014 the list UI for pairing methods that resolve to a device\n(`bluetooth | localNetwork | accountPresence | direct`). `method` (required),\n`devices` (`[]`), `onSelectDevice`, `joinUrl`, `showCodeFallback` (`true`),\n`status`, `size` (`168`), `emptyLabel`, `actions`, `accentColor`, `theme`\n(`\"auto\"`), `autoHideOnConnected` (`true`), `connectedHideDelayMs` (`1150`),\n`direction`, `logo`, `onCopy`, `className`/`style`.\n*Limits:* **purely presentational \u2014 it discovers nothing.** You supply `devices`\nand `onSelectDevice` from your own backend. There is no Bluetooth, no Bonjour,\nand no customer-reachable account-presence route.\n*Never:* present it as \"nearby device pairing\" to a customer. It is chrome.\n\n**`RemoteDrawConnect`** \u2014 a compact trigger button that opens the code or the\ndevice list in a popover, for pairing exactly at the field, margin, or toolbar\nthat needs it. Everything from `PairingCodeProps` except placement/position/\noffset/zIndex/className/style/size/tile, plus `size` (`168`), `open`,\n`defaultOpen` (`false`), `onOpenChange`, `placement` (`\"bottom\"`), `method`,\n`devices`, `onSelectDevice`, `trigger`, `triggerLabel` (`\"Pair phone\"`),\n`showTriggerLabel` (`false`), `triggerClassName`/`triggerStyle`/`triggerDisabled`,\n`openOnHover` (`true`), `theme` (`\"auto\"`, passed on to the popover),\n`panel*`/`popover*` class and style.\n*Use it instead of hand-rolling a \"connect phone\" button.*\n\n**`RemoteDrawLaunchButton`** \u2014 \"open on my phone\" for a user whose **own** app is\nthe sender. Mints a scoped sender through your backend, opens the deep link, and\nreveals a QR when the app never comes back.\nProps: `connect` (required \u2014 your own backend endpoint; may return the raw\n`connectSender` response, a create-session response carrying `senderConnection`,\nor just `{ launchUrl }`; resolving `null` means \"no direct sender for this user\"),\n`handoffTimeoutMs` (`DIRECT_SENDER_HANDOFF_TIMEOUT_MS` = `12_000`),\n`showQrFallback` (`true`), `fallback`, `pairingProps`, `labels`, `accentColor`\n(`#1f7a8c`), `theme` (`\"auto\"`), `disabled` (`false`), `autoLaunch` (`true`), `openUrl` (default\nassigns `window.location.href`), `onError`, `onStatusChange` (transitions only),\n`children` (node or `(state) => node`), `className`/`style`/`buttonClassName`/\n`buttonStyle`/`aria-label`.\n*Why it is a component and not an `onClick`:* a custom scheme nothing has\nregistered fails **silently** \u2014 no error, no navigation, no event. The timeout\nwith no sender on the board is the only detector.\n\n**`useDirectSender(options)`** \u2014 the hook the button is a thin default over.\nOptions: `connect` (required), `autoLaunch`, `openUrl`, `onError`,\n`onStatusChange`. Returns `{ status, launchUrl, senderToken, senderId,\nconnection, error, connect(), launch(), reset() }`.\n`status`: `idle | connecting | ready | drawing | submitted | expired | error`.\n`error` is a `RemoteDrawLaunchError` (an `Error`) with\n`kind: \"connect-failed\" | \"no-launch-url\" | \"launch-blocked\"` and the original\nrejection on `cause`.\n*Limits:* phases after `ready` are read from `RemoteDrawProvider`; outside one\nit can never advance past `ready`.\n\n**`RemoteDrawSessionControls`** \u2014 the one session-state surface: a collected\nstatus bar (or card, via `title`) telling the session's story \u2014 waiting for a\nphone, connected, drawing, submitted, with the sender's device name and one\ndetail when it is news (\"\u00B7 Away\", \"\u00B7 Live view\", \"\u00B7 Connecting view\u2026\", \"\u00B7 No\nlive view yet\", \"\u00B7 Live view failed\", \"\u00B7 +1 more\") \u2014 plus undo/clear, and end\nwhen the host wires it. `actions` (`[\"undo\",\"clear\"]`, plus `\"end\"` when\n`onEndSession` is given), `confirmClear` (`true`), `onEndSession` (arms like\nclear; ending needs the API key so it is the host's call), `title`,\n`submission`, `labels` (keyed by phase **or** detail: `stale, liveViewLive,\nliveViewRequested, liveViewPaused, liveViewFailed, liveViewUnavailable,\nliveViewNoProjection, liveViewOff, others`),\n`undoLabel`/`clearLabel`/`confirmClearLabel`/`endLabel`/`confirmEndLabel`,\n`metadataKeys`/`metadataLabels`/`formatMetadataValue` (keys render humanized,\nnever raw), `theme` (`\"auto\"`), `accentColor`, `placement` (`\"inline\"` + four\ncorners; a docked bar caps its width at `calc(100% - 2*offset)` so a\nbottom-right pill cannot run off the host), `position`/`offset`/`zIndex`\n(`\"absolute\"`/`12`/`20`), `className`/`style`.\nPresence comes from the provider's `presence` (wire `sender.presence`), never\nfrom a `lastSeenAt` window of its own: `present` \u2192 connected, `stale` \u2192 still\nconnected with \"Away\", `disconnected` \u2192 waiting. The bar wraps rather than\noverflows: text wraps, actions stay on one line and drop below when narrow.\n*Limits:* every failure renders as one string, \"Connection problem\".\n\n**Theming (every receiver-side component).** `theme?: \"light\" | \"dark\" | \"auto\"`\non `RemoteDrawReceiver`, `RemoteDrawSessionControls`, `RemoteDrawLaunchButton`,\n`PairingCode`, `PairingDevices`, `RemoteDrawConnect`, `RemoteDrawPresenceBadge`.\n`\"auto\"` (the default) reads the **host page** before the OS: the nearest\nancestor `data-theme` / `data-color-scheme` / `data-mode` / `data-rd-theme`\nattribute, a `dark` or `light` class (Tailwind/shadcn), or an inherited CSS\n`color-scheme`, then `prefers-color-scheme` \u2014 and re-resolves when the host\nflips. Every colour is a CSS custom property a host can set on any ancestor\nwith no prop: `--rd-ink, --rd-ink-muted, --rd-ink-faint, --rd-surface,\n--rd-surface-raised, --rd-surface-sunken, --rd-line, --rd-line-strong,\n--rd-accent, --rd-accent-soft, --rd-ok, --rd-warn, --rd-danger, --rd-danger-soft,\n--rd-glass, --rd-shadow, --rd-font`. Dark is a designed warm near-black\n(`#1a1a1c` surface, `#ece8df` ink, accent `#4db3c4`), not an inversion. The\nsender-side `/join` chrome is deliberately theme-independent and untouched.\nHelpers: `hostThemeFor(element)`, `resolveTheme(theme, element)`, `RD_TOKENS_CSS`.\n\n**`AiImage` / `AiText`** \u2014 render a finished `AiAction`. `AiImage`: `action`,\n`direction` (`\"plate\"`, 6 options), `actions` (`\"hover\"`, 4),\n`standardActions` (`[\"download\",\"copy\"]`, plus `\"open\"`), `customActions`,\n`theme` (`\"auto\"`), `aspectRatio`, `radius` (`14`), `fit` (`\"cover\"`),\n`fileName`, `labels`, `onRetry`, `placeholder`, `imageAlt`, `className`/`style`.\n`AiText`: `action`, `direction` (`\"note\"`, 5), `actions` (`\"bar\"`),\n`standardActions`, `customActions`, `theme`, `radius`, `maxWidth` (`\"60ch\"`),\n`labels`, `onRetry`, `placeholder`, `className`/`style`.\n*Limits:* neither ever shows the model, tier, latency, credit cost, or the\nprovider's error string. A schema run renders nothing \u2014 `result.generatedData`\nis for your code.\n\n**`useVisualContextPublisher`** (experimental) \u2014 the **publish** half of live\nview: sends the receiver's pixels to the phone drawing on it. Has its own\n`onError`.\n\n**`RemoteDrawStreamView`** \u2014 the **consume** half, for a sender pad you host\nyourself: the receiver's stream as a ground, your pad as its `children`.\nProps: `senderToken`, `signaling` (a `VisualContextSignalingClient` \u2014 build it\nwith `createRealtimeVisualContextSignalingClient` for push, or\n`createHttpVisualContextSignalingClient` for the polled `/v1/.../visual-context/*`\nroutes), `enabled` (gate it on `viewReceiverContext` + `visualContext.enabled` +\na reported phone projection), `iceServers` (from the session's\n`visualContext.iceServers` \u2014 without them a phone on cellular connects to\nnothing), `pollIntervalMs`, `onStatus`, `onError`, plus `fit` (`\"contain\"`),\n`fadeMs`, `posterStyle`, `className`/`style`/`aria-label`, `children`.\n`useRemoteDrawStream(options)` is the same thing headless, returning\n`{ mediaStream, status, streamStatus, stream, error, markStreamLive }` with\n`status` one of `idle | connecting | streaming | unsupported | failed | closed`.\n*Limits:* `children` are deliberately **not** gated on the stream \u2014 a pad that\nonly appears once pixels arrive never appears on the networks where WebRTC\ncannot connect, and drawing must keep working there.\n\n**`VisualContextVideoLayer`** \u2014 the raw `<video>` for a `MediaStream` you\nproduce yourself. `mediaStream`, `status`, `fit` (`\"contain\"`), `fadeMs`\n(`VISUAL_CONTEXT_FADE_MS` = `220`), `posterStyle`, `onLiveChange`,\n`className`/`style`/`aria-label`. Hand over on `onLiveChange(true)`, not on\nhaving a stream. `RemoteDrawStreamView` wires this for you.\n\n**Hooks:** `useRemoteDraw` (throws outside the provider),\n`useRemoteDrawSession`, `useReceiverData`, `usePairingUrl`,\n`useRemoteDrawPointers`, `useReceiverStrokes`.\n\n**Low-level / rarely right:** `InkCanvas` (WebGL2 ink substrate \u2014 the receiver\ndrives it), `RemoteDrawMark`, `AuroraPairingField` (EXPERIMENTAL),\n`FreehandFilmGroup`/`freehandStrokePaths`, the element-selection helpers.\n\n**Deliberately not exported:** a web sender component. Hosted `/join` is the web\nsender. Custom in-page pads are built headless on `createHttpSenderClient`.\n`@remotedraw/react/next` is a separate, unfinished v2 entry point \u2014 do not mix\nit into a normal integration.\n\n### `@remotedraw/svelte`\n\n`createRemoteDrawReceiver(options)` \u2192 `{ subscribe, create, refetch, ingest,\nundo, clear, setSession, configure, start, stop }`.\n`createDirectSender({ connect, receiver, autoLaunch, openUrl, onError })` \u2192\n`{ subscribe, connect, launch, reset, stop }`, the same state machine React's\n`useDirectSender` binds. Plus `export * from \"@remotedraw/client\"`.\n**No components ship.** A Svelte integrator writes the pairing UI, the ink\nrendering and the session UI themselves.\n\n### `@remotedraw/client` (framework-free)\n\n- `createHttpRemoteDrawApiClient(baseUrl, { apiKey })` \u2014 **server-only.**\n `createSession`, `getSession`, `listSessions`, `issueJoinToken`,\n `connectSender`, `endSession`, `createAiAction`, `getAiAction`,\n `cancelAiAction`, `waitForAiAction`.\n- `createSessionWithHttpApi(baseUrl, options)` \u2014 server-only convenience.\n- `createHttpReceiverClient(baseUrl)` \u2014 the seven `/v1/receiver/*` calls.\n Credentials go in the body, not a header.\n- `createHttpSenderClient(baseUrl, { packPoints })` \u2014 14 sender calls.\n- `createReceiverStore(options)` \u2014 the headless receiver state machine\n `RemoteDrawProvider` and the Svelte store both bind.\n- `createRealtimeReceiverSource({ driver })` + `createConvexRealtimeDriver({ client })`\n \u2014 push transport over the hosted realtime endpoint. `convex` is never imported\n by the package; you hand it a two-method driver.\n- `RemoteDrawHttpError` \u2014 `status`, `code`, `upgradeUrl`, `body`, and the\n getters `isAuthenticationFailure`, `isPermissionFailure`, `isSessionOver`,\n `shouldReJoin`.\n- `joinTokenFromInput`, `joinUrlForOrigin`, `nativeJoinUrlFromSession`.\n- `createPacedDraftQueue` / `createLatestOnlyQueue` / `draftPointsForTransport`\n / `retryIdempotentRequest` \u2014 the 32 ms latest-only draft gate a custom sender\n must use instead of POSTing every pointer event.\n- `createDirectSenderController`, `resolveDirectSenderStatus`,\n `directSenderConnectionFromResult` \u2014 the shared direct-sender rules.\n- `createRealtimeVisualContextSignalingClient` /\n `createHttpVisualContextSignalingClient` \u2014 where live-view signals travel\n (realtime push, or the polled public `/v1/.../visual-context/*` routes).\n\n### `@remotedraw/geometry`\n\nStroke/shape helpers (`buildNormalizedStroke`, `recognizeStroke`,\n`simplifyNormalizedPoints`, hit-testing, transforms, `drawingsToSvg`), **and the\nmap board transform**, re-exported by `@remotedraw/react`:\n`boardPointFromLngLat`, `lngLatFromBoardPoint`, `longitudeFromBoardX`,\n`latitudeFromBoardY`, `mercatorYFromLatitude`, `latitudeFromMercatorY`,\n`boardViewportFromMapBounds`, `mapBoundsFromBoardViewport`,\n`mapBoardPointToScreen`, `mapBoardPointFromScreen`, `screenPointFromBoardPoint`,\n`surfacePointFromBoardPoint`, `padMapBounds`, `MAX_MERCATOR_LATITUDE`.\nUse these rather than reimplementing the projection \u2014 `x` is linear in\nlongitude, `y` is linear in **Web Mercator**, and a version that is linear in\nlatitude puts ink kilometres away.\n\n### `RemoteDrawSenderKit` (SwiftPM, iOS 17+)\n\n`https://github.com/AxioSOzo/remotedraw-swift.git`, product\n`RemoteDrawSenderKit`. Zero external dependencies. No `Info.plist` entries.\n\n- `RemoteDraw.shared` \u2014 **zero-config**: it installs production defaults the\n first time anything reads it. `RemoteDraw.configure(_:)` in `App.init` is\n *optional* and only overrides `apiBaseURL`, `device`, `tokenProvider`,\n `urlSession`, `onClientAdvisory`. `try RemoteDraw.requireConfigured()` throws\n `RemoteDrawError.notConfigured` if you want the strict behaviour back.\n (This used to `preconditionFailure` from inside the modifier's `.task` \u2014 a\n crash on the user's tap. It no longer does.)\n- `.remoteDrawSurface(isPresented:senderToken:appearance:strings:exit:onOutcome:)`\n \u2014 the whole integration, a full-screen cover with the board, an exit and an\n outcome. A second overload adds `background:` \u2014 a `@ViewBuilder` handed a\n `RemoteDrawGroundContext` (`session`, `mapBounds`, `phoneProjection`, `size`,\n `reportViewport`) for your own cartography or ground. A ground that **moves**\n must call `reportViewport(_:)`; a static one calls nothing.\n- `RemoteDrawTakeover` \u2014 the same board plus scene-phase wiring and exit, for\n your own cover / navigation push / `UIHostingController`.\n- `RemoteDrawSurface` \u2014 the board as a plain `View`, for a host that fills its\n presentation with it edge to edge.\n- `RemoteDrawSenderSession` \u2014 the headless core (`begin`/`append`/`end`,\n `undo`, `clear`, `submit(metadata:)`, `edit`, `updateProjection`, `leave`,\n published `phase`, `strokes`, `live`, `lastError`), with\n `RemoteDrawInkCanvas` + `RemoteDrawStrokeCapture` when you own the screen.\n- `RemoteDrawBoardCanvas`, `RemoteDrawMapBoardGround`, `RemoteDrawMapGeometry`,\n `RemoteDrawAppearance`, `RemoteDrawStrings`, `RemoteDrawExit`,\n `RemoteDrawError` (13 cases with `shouldReJoin` / `isRetriable`).\n- **Map boards are built in.** A `kind: \"map\"` session with\n `coordinateSpace.bounds` draws the geography through MapKit, using the same\n transform as `@remotedraw/geometry`. The built-in map is deliberately not\n pannable; supply your own through `background:` if it should be.\n- **Streaming boards are not.** A session with `senderIntegrationMode:\n \"streaming\"` or a receiver publishing `visualContext.enabled` cannot be drawn\n by this SDK \u2014 no WebRTC, no video, no `WKWebView`. It reports\n `.unsupportedSurface(_:)` carrying `hostedSenderURL`; present that in a\n `WKWebView` and streaming works today.\n\n`RemoteDrawOutcome` is a **closed** enum:\n\n| Case | Meaning | Do |\n| --- | --- | --- |\n| `.submitted(RemoteDrawReceipt)` | Drawing submitted. | Record it. Call `session.submit(metadata:)` yourself for the server's own ids \u2014 a submit from the SDK's controls reports a placeholder. |\n| `.left` | The person left; ink is on the board. | Nothing. |\n| `.expired` | The board finished or timed out. **Terminal.** | Create a new session. |\n| `.credentialLost(RemoteDrawError?)` | The token died; the board did not. **Recoverable.** | Mint a fresh `rd_send_` and present again. |\n| `.unsupportedSurface(RemoteDrawUnsupportedSurface)` | The board wants a renderer this SDK lacks. The cover **stays up** showing the reason. | Open `hostedSenderURL` in a `WKWebView`. |\n| `.failed(RemoteDrawError)` | Anything else. | Read `error.shouldReJoin` / `error.isRetriable`. |\n\n**There is no `.revoked`.** A revoked, unknown and malformed token all answer\n`invalid_sender_token` on purpose; only a genuine expiry is distinguishable, and\nthat travels in the error on `.credentialLost`.\n\nNote: `RemoteDrawKit` is a *different*, internal macOS-only package that some\nolder docs still name. Customers use `RemoteDrawSenderKit`.\n\n## Recipes, one per surface\n\nAll React snippets assume the session came from your backend and are wrapped in:\n\n```tsx\nconst receiver = createHttpReceiverClient(\"https://api.remotedraw.com\");\n\n<RemoteDrawProvider\n session={session.session}\n receiverToken={session.receiverToken}\n joinUrl={session.joinUrl}\n joinUrls={session.joinUrls}\n receiver={receiver}\n onError={(error) => reportToYourLogger(error)}\n>\n {/* the recipe */}\n</RemoteDrawProvider>\n```\n\n### Streaming or native \u2014 choose the sender mode first\n\n`senderIntegrationMode` decides what the phone shows under the ink.\n\n- **Streaming for surfaces that show the customer's own content** \u2014 `map`,\n `image`, `pdf`, `screen`, `custom`. The phone sees exactly what the receiver\n shows: the receiver's pixels are streamed to it over WebRTC at ~8 fps with a\n 1280 px long edge by default (`visualContext.maxFps` / `maxLongEdge`). The\n phone is the hosted `/join` page, or your own `RemoteDrawStreamView` pad.\n- **Native for boards RemoteDraw grounds itself** \u2014 `whiteboard`, `paper`,\n `field`. There is nothing to stream; the phone draws on the same ground.\n- **Native is the higher-performance option on a map.** The phone renders its\n own basemap \u2014 OpenFreeMap Positron on the hosted `/join` sender, Apple Maps\n through MapKit in `RemoteDrawSenderKit` \u2014 at full frame rate, with no\n receiver tab required. Choose it when the customer's cartography is not\n itself what is being marked; choose streaming when it is (proprietary layers,\n live data drawn on the map).\n\nStreaming has **two halves**. Both must hold, or the phone draws on the\nfallback ground:\n\n1. **The session flag.** `senderIntegrationMode: \"streaming\"` implies\n `visualContext: { enabled: true }`, and either of the two appends the\n `viewReceiverContext` capability to the session's `capabilities` whether or\n not you listed capabilities. An explicit `visualContext: { enabled: false }`\n under `\"streaming\"` is respected. A join token minted with an explicit\n capability subset can still withhold the grant from one phone.\n2. **The receiver publishing its pixels.** `useVisualContextPublisher` on the\n receiver page with `projections` from the provider's senders and `geometry`\n updated on **every** camera move (pan, zoom, resize). A WebGL canvas\n (Mapbox, MapLibre, deck.gl) must be created with `preserveDrawingBuffer:\n true` or the publisher reads nothing. The receiver tab must stay open *and\n publishing* for as long as the phone draws; closing it ends the stream.\n\nHow the two devices relate: the phone pans and zooms inside the fence, and the\nreceiver crops its frame to the phone's projection. A frame never shows a\ndifferent place \u2014 at most blank backdrop past the receiver's own camera. Ink\nkeeps working in every state.\n\nIf the receiver never publishes, the phone shows \"Waiting for the board's live\nview \u2014 drawing on the basemap meanwhile\" (on non-map surfaces: \"\u2026on the board\")\nafter ~8 s, keeps drawing on the fallback ground, and logs one\n`[remotedraw] Live view is not showing on this sender: \u2026` warning that names the\nowner of the problem. The receiver's `senders[].visualContext.status` reads\n`requested` \u2014 the phone asked, the receiver never offered. A stream that dies\nlater reads `failed` (with `error`) or `closed`, and the phone shows \"Live view\nunavailable \u2014 drawing on the basemap\". `unavailable` with a `reason` \u2014 one of\n`disabled`, `capability_not_granted`, `no_projection`, `projection_closed` \u2014\nmeans the session or the token stops it, not the receiver. The receiver's phone\nbadge and `RemoteDrawSessionControls` show the same states (\"Connecting view\u2026\",\n\"Live view\", \"Live view failed\", \"No live view yet\").\n\nTwo limits to state plainly: `RemoteDrawSenderKit` has no streaming consumer \u2014\non a streaming session it reports `.unsupportedSurface(_:)` with a\n`hostedSenderURL` to present in a `WKWebView` \u2014 and the one refused combination\nis *explicit* `senderIntegrationMode: \"native\"` together with\n`visualContext.enabled: true` on a `map` board, because a native iOS map sender\nsends no projection to crop to.\n\n### Whiteboard / freeform sketch\n\n```ts\n// Backend\ntarget: { kind: \"whiteboard\", label: \"Session notes\" }\n```\n\n```tsx\n<PairingCode title=\"Scan to draw\" />\n<RemoteDrawReceiver />\n<RemoteDrawSessionControls title=\"Sender workflow\" />\n```\n\n### Photo / screenshot annotation (`image`)\n\nThe photo is **already on the receiver**. The phone never takes it.\n\n```ts\ntarget: {\n kind: \"image\",\n inputMapping: \"surface\", // the pad is the whole photo\n coordinateSpace: { width: 1600, height: 900 },\n metadata: { label: \"Inspection photo\", imageId: \"img_123\" },\n}\n```\n\n```tsx\n<div style={{ position: \"relative\" }}>\n <RemoteDrawReceiver\n coordinateAspectRatio={16 / 9}\n background={<img src={photoUrl} alt=\"\" style={{ width: \"100%\", height: \"100%\", objectFit: \"contain\" }} />}\n />\n <PairingCode placement=\"top-right\" size={112} />\n</div>\n```\n\n*Limit:* RemoteDraw stores no images. Your app owns the photo and the link\nbetween it and the drawings.\n\n### PDF page\n\n```ts\ntarget: { kind: \"pdf\", inputMapping: \"viewport\",\n metadata: { documentId: \"doc_9\", page: 3 } }\n```\n\n```tsx\n<div style={{ position: \"relative\" }}>\n <YourPdfPage page={3} />\n <RemoteDrawReceiver\n background=\"transparent\"\n style={{ position: \"absolute\", inset: 0, pointerEvents: \"none\" }}\n />\n</div>\n```\n\n*Limit:* RemoteDraw renders no PDFs and knows nothing about pages. One session\nper page, or carry the page number in `target.metadata` and re-create.\n\n### Signature / bounded field\n\n```ts\ntarget: {\n kind: \"field\",\n inputMapping: \"surface\", // mandatory \u2014 the pad IS the field\n coordinateSpace: { width: 1600, height: 500 },\n metadata: { label: \"Customer signature\", fieldId: \"sig_1\" },\n}\n```\n\n```tsx\n<RemoteDrawConnect method=\"qr\" triggerLabel=\"Sign with your phone\" showTriggerLabel />\n<RemoteDrawReceiver coordinateAspectRatio={16 / 5} strokeWidth={7}\n style={{ border: \"1px solid #ddd8cf\", borderRadius: 12 }}>\n <line x1=\"140\" y1=\"760\" x2=\"3060\" y2=\"760\" stroke=\"#d8d8d8\" strokeWidth=\"4\" />\n</RemoteDrawReceiver>\n```\n\n*Limit:* this is markup transport, **not** e-signature compliance. No identity\nproofing, no intent-to-sign ceremony, no tamper-evident audit package, no\ncertificates. Say so if the user asks for a legal signature.\n\n### Map\n\nThe board's `coordinateSpace.bounds` is a **hard geographic fence, fixed for the\nlife of the session** \u2014 no route changes it. Size it larger than the camera you\nopen on. The phone pans *inside* it.\n\n```ts\nimport { padMapBounds } from \"@remotedraw/geometry\";\n\ntarget: {\n kind: \"map\",\n inputMapping: \"viewport\",\n coordinateSpace: {\n width: 1600, height: 1310, // the fence's Mercator aspect\n ...padMapBounds(currentCameraBounds, 1), // 3x the camera\n },\n}\n```\n\n```tsx\nconst [camera, setCamera] = useState(0);\nuseEffect(() => {\n const onMove = () => setCamera((n) => n + 1);\n map.on(\"move\", onMove);\n return () => map.off(\"move\", onMove);\n}, [map]);\nconst projectLngLat = useCallback(\n (lng: number, lat: number) => map.project([lng, lat]), // CSS px in the container\n [map, camera],\n);\n\n<div style={{ position: \"relative\" }}>\n <div ref={mapContainer} style={{ position: \"absolute\", inset: 0 }} />\n <RemoteDrawMapReceiver\n projectLngLat={projectLngLat}\n style={{ position: \"absolute\", inset: 0, pointerEvents: \"none\" }}\n />\n</div>\n```\n\n*Limits:* `mapBounds={map.getBounds()}` is the no-callback alternative but is\nexact only for a north-up, unpitched camera. Omitting `bounds` at session\ncreation does not mean \"the customer's map\" \u2014 it means RemoteDraw's own default\nregion. Both sender modes work on a map board. The default is `\"native\"`: the\nphone renders its own basemap and sends board-space points. `\"streaming\"` is\naccepted with `kind: \"map\"` and is what the hosted `/join` sender expects when\nthe customer's cartography is the thing being marked. In either mode the hosted\nsender opens on the fence's **centre third** at the phone's aspect and publishes\nits measured camera as `phoneProjection` (seeded at join, re-published on every\npan/zoom); that projection is what the receiver's phone frame and the stream\ncrop follow. The one refusal is *explicit* `senderIntegrationMode: \"native\"`\ncombined with `visualContext.enabled: true` \u2014 a native iOS map sender sends no\nprojection. See \"Streaming or native\" above.\n\n### Screen / live view\n\n```ts\ntarget: { kind: \"screen\", inputMapping: \"viewport\" }\nsenderIntegrationMode: \"streaming\" // implies visualContext.enabled and the viewReceiverContext grant\n```\n\nThe receiver publishes with `useVisualContextPublisher`. Three consumers, in\norder of how little you write:\n\n1. **The hosted `/join` pad** \u2014 consumes the stream automatically. Zero code.\n2. **Your own web pad** \u2014 `RemoteDrawStreamView` with your pad as its\n `children`, plus a signaling client.\n3. **`RemoteDrawSenderKit`** \u2014 *cannot* consume it. It returns\n `.unsupportedSurface(_:)` carrying a `hostedSenderURL`; present that in a\n `WKWebView` and streaming works today.\n\nLive view is experimental: build so that a session whose stream never starts is\nstill a working session \u2014 the phone keeps drawing, it simply does not see the\nreceiver's pixels.\n\n### iOS sender \u2014 the complete Swift\n\n```swift\n// Package.swift / Xcode \u2192 Add Package\n.package(url: \"https://github.com/AxioSOzo/remotedraw-swift.git\", from: \"0.1.0\")\n.product(name: \"RemoteDrawSenderKit\", package: \"remotedraw-swift\")\n\nimport RemoteDrawSenderKit\n\n// Optional. RemoteDraw.shared installs production defaults on first use, so\n// this line exists only to OVERRIDE something.\n@main struct MyApp: App {\n init() { RemoteDraw.configure(.init(tokenProvider: mintSenderToken)) }\n var body: some Scene { WindowGroup { RootView() } }\n}\n\n// Wherever drawing starts. `token` is the rd_send_ string your backend minted\n// with POST /v1/sessions/direct-sender. A QR's rd_join_ works too, but spending\n// one revokes every other sender on that session.\nButton(\"Draw\") { drawing = true }\n .remoteDrawSurface(isPresented: $drawing, senderToken: token) { outcome in\n switch outcome {\n case .submitted(let receipt): record(receipt)\n case .left: dismissBanner()\n case .expired: refreshSession() // terminal\n case .credentialLost: refreshToken() // recoverable\n case .unsupportedSurface(let it): openHostedPad(it.hostedSenderURL)\n case .failed(let error): report(error) // do not swallow\n }\n }\n```\n\nReceiving the launch URL (the other half of the direct sender \u2014 nothing else\ndocuments it):\n\n```swift\n// Info.plist: CFBundleURLTypes \u2192 your scheme, e.g. \"yourapp\"\n.onOpenURL { url in\n guard url.scheme == \"yourapp\",\n let token = URLComponents(url: url, resolvingAgainstBaseURL: false)?\n .queryItems?.first(where: { $0.name == \"senderToken\" })?.value\n else { return }\n senderToken = token\n drawing = true\n}\n```\n\nRules:\n\n- The surface is **full screen**. Not a small pad, not a draggable sheet.\n- The user can always leave; `RemoteDrawExit` only decides whether leaving with\n unsubmitted ink asks first. The host always gets an outcome.\n- Do not write a `UIViewRepresentable` canvas, a draft loop, or an HTTP client.\n Go headless on `RemoteDrawSenderSession` + `RemoteDrawInkCanvas` +\n `RemoteDrawStrokeCapture` if you own the screen \u2014 never raw `URLSession`.\n- Customers do not ship a separate RemoteDraw app; their app *is* the sender.\n\n## How to suggest use cases\n\n**What the product is good at**, as a sentence to pattern-match against:\n\n> A person is at a screen. The thing they need to mark is already on that\n> screen. A mouse is the wrong instrument for the mark \u2014 because it is\n> handwriting, a circle around a defect, a diagram, a signature, or a gesture\n> over a map \u2014 and their phone is in their pocket.\n\n**Ask these four before proposing anything:**\n\n1. Which screen in your product already shows the thing to be marked, and what\n device is that screen on?\n2. Is the person in front of it holding a phone at the same time?\n3. What does the mark mean afterwards \u2014 saved to which record, shown where?\n4. Anyone who scans, or a signed-in user of your own app? (QR vs direct sender.)\n\n**Good vs bad, for a property-management SaaS:**\n\n- \u2705 Property manager reviews an inspection photo on the office desktop and\n circles the damage with their phone. *Two devices; content already on the\n receiver.*\n- \u2705 Tenant signs the handover report on the manager's laptop screen using their\n own phone as the pen. *This is the flow that replaces a stylus.*\n- \u2705 Planner marks a route on the dispatch map on the wall display.\n- \u2705 Support agent circles the broken control on a customer's shared screen.\n- \u274C Tenant photographs a leak on their phone and circles it. *One device,\n phone-supplied content. **Not RemoteDraw.*** Say so and propose PencilKit.\n- \u274C An in-app sketch pad in the mobile app. *One device.*\n- \u274C Field engineer marks up a PDF on their iPad in the van. *One device \u2014\n unless a second screen is genuinely present.*\n- \u274C \"Pair over Bluetooth when the phone is nearby.\" *Not implemented.*\n\n**The disqualifier:** if you cannot name two devices and say which one already\ndisplays the content, you do not have a use case yet \u2014 ask.\n\n## The flow \u2014 in this order\n\n1. **Explain before touching anything.** If the user is asking what RemoteDraw\n can do, answer from this file and the docs. Do not install, scaffold, or\n create sessions to answer a question.\n2. **Ask for approval before installing** \u2014 the first checkpoint in the list\n below. Name exactly what you want to add (`@remotedraw/cli`,\n `@remotedraw/react`, a Swift package, a dashboard project + key) and why,\n then wait. This includes `remotedraw init` without `--offline`, which\n provisions a billable project and key. Install with the\n project's own package manager \u2014 the scan below reports it, and a lockfile\n the project did not ask for is a mess a human has to clean up:\n `npm install -g @remotedraw/cli`, `pnpm add -g @remotedraw/cli`,\n `bun add -g @remotedraw/cli`, or \u2014 Yarn Berry has no global install \u2014\n `yarn dlx @remotedraw/cli`. Same for the SDKs: `npm install`, `pnpm add`,\n `yarn add`, or `bun add`.\n3. **Scan the codebase.** `remotedraw scan --format json` (or, before the CLI\n is installed, `npx @remotedraw/cli@latest scan --format json` \u2014 `pnpm dlx`,\n `yarn dlx`, or `bunx @remotedraw/cli` for those managers) reports the\n web/server/iOS projects, the project's package manager and its install/add\n commands, where an `rd_sk_` key may live, any RemoteDraw wiring already\n present, the integration options that fit, and the product questions to ask.\n Read it; verify its `evidence` where it matters.\n4. **Ask the product questions** (the four above, plus the scan's own). Do not\n invent answers; a one-page \"drawing lab\" is only right when the user says a\n demo is what they want.\n5. **Propose one plan, then build all of it.** Receiver, session creation,\n sender, and the exit/submit path \u2014 an integration is not done when ink\n appears once on a test page. Build beside existing features.\n6. **Verify by using it.** Run `remotedraw doctor --format json`, open a real\n session, draw from a phone (or `create-input --execute` + the hosted join\n URL), and confirm ink lands on the receiver. On iOS, run it on a device or\n simulator and look at the screen; a compiling canvas is not a working one.\n\nSession creation (`POST /v1/sessions`) needs the `rd_sk_` key and therefore runs\nonly where the scan found server-side code: a Convex action, a Next route\nhandler, an Express/Hono route, a serverless function. If the scan found none,\nask where the backend is. Never scaffold `createRemoteDrawSession.ts` into a\nVite/Next client tree \u2014 `--target web` in `remotedraw init` still writes it\nunder `src/`; move it, or scaffold into a scratch directory and copy only what\nbelongs.\n\n### Checkpoints \u2014 stop and ask, every time\n\nAt each moment below, stop and ask with one explicit, polished request \u2014\n**\"I need this from you now: <what>. May I proceed?\"** \u2014 then wait. Do not\nbury it in progress text, and do not proceed on silence.\n\n| Moment | What you say you need |\n| --- | --- |\n| Before installing anything \u2014 `@remotedraw/cli`, `@remotedraw/react` / `svelte` / `client`, the `RemoteDrawSenderKit` Swift package | The exact package names, the command in the project's own package manager, and why. |\n| Before provisioning \u2014 `remotedraw init` without `--offline`, `remotedraw project create`, or a key from the dashboard | That it creates a dashboard project and an `rd_sk_` key on the user's account. |\n| When the `rd_sk_` key must be placed in the customer's server environment | The exact variable and location \u2014 for example `REMOTEDRAW_API_KEY` in `.env.local`, or the same name in the Convex / Vercel / hosting env \u2014 and that the user pastes it there. Never print the key back, write it into a committed file, or echo it into a log. |\n| Before creating billable sessions in a test \u2014 `remotedraw create-input --execute`, `POST /v1/sessions` from a script | How many, on which project, and that each is billable. |\n| When the phone is needed | Which URL or QR to scan and from which page; which mode to expect (native: the phone's own basemap or ground; streaming: the basemap first, then the receiver's own pixels fading in within ~5 s); what a correct result looks like (a stroke at a landmark on the phone lands at the same landmark on the receiver, the phone frame appears within ~1 s and follows a pan); and what to report back (what the phone showed, any pill text, any `[remotedraw]` console line, what the receiver's session bar said). |\n| Before deploying | What goes where, and that the key is in that environment's secrets, not in the bundle. |\n| When sign-in to the customer's own app is required to reach the receiver | Which account and which page; you cannot sign in for them. |\n\nNo approval needed: `npx @remotedraw/cli@latest agent --print-skill`,\n`remotedraw scan --format json`, `remotedraw options --format json`,\n`remotedraw init --offline --dry-run`, and reading the docs. None of these\ninstall, provision, or bill.\n\n## CLI\n\n```sh\nremotedraw options --format json # the option catalog\nremotedraw scan --format json # read the codebase first (step 3)\nremotedraw init --non-interactive --offline --dry-run --format json \\\n --path apps/web --target web --sender remotedraw-ios --sdk react --preset sketch --package-manager npm\nremotedraw doctor --format json\nremotedraw create-input --preset sketch --json\nremotedraw create-input --execute # a real session + join URL, needs a key\nremotedraw agent --print-skill # this file, no install needed\n```\n\n`--sender own-ios` requires `--sdk swift`; other invalid combinations fail with\n`INVALID_COMBINATION`. By default `init`/`new` also create a dashboard project\nand a project-scoped development key in `.env.local` \u2014 that is the step that\nneeds approval (step 2); `--offline` writes files only. `--preset mapMarkup`\nemits an explicit `coordinateSpace.bounds`; replace the example region with the\ncustomer's. Never pass `--force` unless the user approved overwriting. Do not\ndrive the interactive wizard or scrape human-formatted output; every command has\n`--format json`.\n\n## Security and tenancy\n\n- `rd_sk_\u2026` keys: backend secrets only. Never in browser bundles, Swift, app\n bundles, screenshots, logs, or generated examples.\n- The account-level `rd_cli_\u2026` credential stays in the user config directory;\n never copy it into a project. `REMOTEDRAW_CLI_TOKEN` is for CI secrets only.\n- Public clients receive only `joinUrl`, `joinToken`, `receiverToken`, or\n `senderToken`, each scoped to one session. Production QR codes use the HTTPS\n `joinUrl`, not the custom scheme.\n- One key serves every customer of the product, so a session id is not a\n capability. Create sessions with `externalId: \"<product>:<tenant>\"`, and check\n it (`POST /v1/sessions/get`) before attaching a sender or ending a session on\n a tenant's behalf.\n\n## Gotchas the SDKs hide and hand-written code hits\n\n- `POST /v1/sessions` is billable and not idempotent. React StrictMode runs\n mount effects twice in development: guard with a ref, or create the session in\n a server action / loader. Store the `receiverToken` if the receiver outlives a\n page load \u2014 it is the only credential that reads a session's ink.\n- Timestamps are integer milliseconds. `occurredAt` and point `t` values are\n accepted with a fraction (floored) but a hand-written client should send\n integers.\n- Committed points come back in board space, remapped through\n `device.aspectRatio`; send the aspect ratio of the pad the finger touches.\n- `RemoteDrawReceiver` defaults `phones` and `pointers` to on, and renders\n immediately with no empty state. Pass `phones={false}` for a plain surface;\n drive your own empty state from `useReceiverData().senders`.\n- `PairingCode` hides the join URL text unless `showLink`.\n- `joinTokenExpiresAt` is earlier than the session's `expiresAt`: the QR dies\n first, the board stays live.\n- Wire `RemoteDrawProvider`'s `onError` (and `useVisualContextPublisher`'s).\n Without it a dead credential is silent and the board simply stops updating.\n- The hosted `/join` pad respects the joined capability list; a custom sender\n must too. It also has a **file-attach tool** available on any session granting\n `draw` or `point`, which cannot currently be turned off.\n\n## API contract\n\n- Backend: `POST /v1/sessions` (create), `/v1/sessions/get`, `/v1/sessions/end`,\n `/v1/sessions/direct-sender` (mint `rd_send_` for your own app),\n `/v1/sessions/join-token` (a fresh QR).\n- Receiver: `POST /v1/receiver/session`, `/drawings`, `/drafts`, `/senders`\n with the receiver token, or the realtime source in the SDKs.\n- Sender: `POST /v1/join` (spends a join token; revokes other senders), then\n `/v1/sender/draft` (latest-only preview, throttle to ~32 ms),\n `/v1/sender/commit` (one durable stroke per pointer-up with a stable\n `clientStrokeId`), `/v1/sender/submit`.\n- Capabilities: `draw`, `point`, `undo`, `clear`, `moveViewport`,\n `viewExisting`, `viewReceiverContext`. Omitting `capabilities` grants the\n first six. Reissued join tokens may narrow but never widen.\n\n## AI actions\n\nReach for AI when the product needs something _from_ the finished drawing: a\ngenerated image, a description, or structured data to branch on. Backend only\n(`aiActions:*` scopes on an `rd_sk_...` key). Never wire it to a commit, submit,\nor presence event \u2014 AI runs only on an explicit `POST /v1/ai-actions` call the\nuser asked for. Run it after the user is done; the route accepts `active` and\n`ended` sessions.\n\nMinimal request per outcome (`POST /v1/ai-actions`, plus optional\n`quality: \"fast\" | \"balanced\" | \"max\"`, default `balanced`):\n\n```jsonc\n{ \"sessionId\": \"...\", \"request\": \"image\", \"prompt\": \"...\" } // image back in the response\n{ \"sessionId\": \"...\", \"request\": \"image\", \"prompt\": \"...\", \"deliver\": [\"result\", \"board\"] } // and onto the board\n{ \"sessionId\": \"...\", \"request\": \"text\", \"prompt\": \"Describe this drawing.\" } // text back\n{ \"sessionId\": \"...\", \"request\": \"text\", \"prompt\": \"...\", \"text\": { \"schema\": { /* JSON Schema */ } } } // typed JSON\n```\n\nThe response is asynchronous: `create` returns `status: \"queued\"`. Poll\n`POST /v1/ai-actions/get` until `status` is `succeeded`, `failed`, or\n`canceled`, or use `createAiAction` + `waitForAiAction` on\n`createHttpRemoteDrawApiClient` from `@remotedraw/client` (re-exported by\n`@remotedraw/react`) \u2014 backend only, it holds the key. There is no completion\nwebhook. Render results with `AiImage` / `AiText`. See\nhttps://docs.remotedraw.com/docs/api#ai.\n\n## What does not exist yet\n\nVerified against source, 2026-08-30. Read this *before* designing, so you never\npromise any of it.\n\n- **No Bluetooth or Wi-Fi pairing.** The radios advertise; nothing scans or\n browses, on any platform. `PairingDevices` is presentational chrome.\n- **No QR into a customer's own app.** The Universal-Link association file has\n one hard-coded appID and the QR host is a RemoteDraw deployment setting. Use\n the direct sender.\n- **No native Android SDK.** Hosted `/join` in a mobile browser is the Android\n sender, and it is full-featured.\n- **No desktop or trackpad sender.** Unbuilt research.\n- **No streaming consumer in `RemoteDrawSenderKit`.** It reports\n `.unsupportedSurface(_:)` with a `hostedSenderURL` to open in a `WKWebView`.\n (A customer's own *web* pad can consume a stream \u2014 `RemoteDrawStreamView`.\n It is the native SDK that cannot.)\n- **No Svelte components.** A receiver store and a direct-sender store only.\n- **No web sender component.** Deliberate: hosted `/join` is the web sender.\n- **No API-key route that reads a session's ink.** Lose the `receiverToken` and\n the session is unreadable while still billable.\n- **No `clientSessionId` idempotency on `POST /v1/sessions`.**\n- **No completion webhook for AI actions** \u2014 poll `POST /v1/ai-actions/get`.\n- **No e-signature compliance.** No identity proofing, intent-to-sign ceremony,\n tamper-evident audit package, or certificate handling.\n- **No content rendering of any kind.** No PDF renderer, no image storage, no\n document pipeline, no auth, no billing UI. The host renders; RemoteDraw inks.\n\n## Verification\n\nAfter changes, verify against the customer's project \u2014 never assume RemoteDraw's\nown repo scripts exist here.\n\n```sh\nremotedraw doctor # config, SDK deps, REMOTEDRAW_* env\nremotedraw create-input --execute # open a real session, print the join URL\n```\n\nThen run whatever type check and test command the project already defines (for\nexample `npm run typecheck` and `npm test`). Do not invent script names, and do\nnot run `bun run test:api`, `bun run typecheck`, or `bun run ios:kit:test` \u2014\nthose are RemoteDraw's internal monorepo scripts and will not exist in a\ncustomer project.\n\n## Reference\n\n- API: `https://api.remotedraw.com` \u00B7 Docs: `https://docs.remotedraw.com/docs`\n (agent summary: `https://docs.remotedraw.com/llms.txt`) \u00B7 Keys:\n `https://dashboard.remotedraw.com/api/keys`\n- Packages: `@remotedraw/cli`, `@remotedraw/react`, `@remotedraw/svelte`,\n `@remotedraw/client`, `@remotedraw/protocol`, `@remotedraw/geometry`, and the\n SwiftPM package `https://github.com/AxioSOzo/remotedraw-swift.git` (product\n `RemoteDrawSenderKit`).";
2
2
  //# sourceMappingURL=agent-skill.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"agent-skill.d.ts","sourceRoot":"","sources":["../../src/generated/agent-skill.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,oBAAoB,o4mDAAsplD,CAAC"}
1
+ {"version":3,"file":"agent-skill.d.ts","sourceRoot":"","sources":["../../src/generated/agent-skill.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,oBAAoB,g3gEAAk5+D,CAAC"}
@@ -1,4 +1,4 @@
1
1
  // GENERATED FILE — do not edit.
2
2
  // Source: docs/agents/remotedraw/SKILL.md
3
3
  // Regenerate: bun run --cwd packages/cli generate:skill
4
- export const AGENT_SKILL_MARKDOWN = "---\nname: remotedraw\ndescription: Integrate RemoteDraw phone drawing into a customer's product — a phone becomes the pen for a screen the customer already owns. Covers the two-device model, which flows are valid, the ready-made components (React/Svelte/JS receiver, hosted web sender, RemoteDrawSenderKit iOS sender), per-surface recipes, and how to judge whether a proposed use case fits at all.\n---\n\n# RemoteDraw Agent Skill\n\nUse this skill when a user asks what RemoteDraw is, whether it fits their app,\nor to create, debug, or review a RemoteDraw integration.\n\nRead the whole file before proposing a design. The first two sections decide\nwhether the use case is possible; everything after decides how it is built.\n\n## The model: two devices, always\n\nRemoteDraw is not a drawing library. It is a wire between **two devices**.\n\n- **The receiver is the paper.** A screen someone is looking at — a laptop, a\n desktop, a large display, a kiosk, a tablet on a desk — showing *your*\n product. Your app renders whatever is being drawn on: the map, the photo, the\n PDF page, the form, the whiteboard. RemoteDraw renders none of that content.\n It paints ink on top of it.\n- **The sender is the pen.** A phone. It supplies a hand, pressure, tilt and a\n stroke. It does **not** supply the content: the iOS SDK opens no camera and no\n file picker (it needs no `Info.plist` entries at all). On a bounded surface\n the phone's pad *is* the surface; on a large one the phone is a viewport\n moving over it.\n- **RemoteDraw is the wire.** A hosted session carries strokes from the pen to\n the paper in real time and stores them. Nothing runs on the customer's\n infrastructure except the session-creation call.\n\nThe one question that decides whether RemoteDraw fits:\n\n> **What is being drawn on, and which screen is it already displayed on?**\n\nIf the answer is \"a screen the user is looking at, and they wish they could\ndraw on it with their hand\" — that is RemoteDraw. If the answer is \"the phone's\nown screen\", it is not: a phone drawing on its own content and uploading the\nresult is a camera-and-canvas feature you build with PencilKit or a `<canvas>`,\nand RemoteDraw would only add a round trip.\n\nThree consequences, because they are the mistakes integrators actually make:\n\n1. **The phone never supplies the picture.** \"The tenant photographs the leak\n and circles it\" is *not* a RemoteDraw flow — there is no second screen. The\n RemoteDraw version of that job: the photo is already open in your web app on\n the office desktop, and the person at that desk circles the leak with their\n phone instead of a mouse.\n2. **The receiver already exists.** RemoteDraw goes onto a screen your product\n already has. It does not get its own page unless the user asks for a demo.\n3. **Ink is coordinates, not pixels.** Strokes arrive in normalized board space\n (`0..1`, remapped through the sender's `device.aspectRatio`). Your app\n decides what board space *means* — a pixel, a page, a field, a coordinate on\n Earth. RemoteDraw never sees your content.\n\n## Which flows are valid\n\nRead the row for the **sender** (who holds the phone) and the column for the\n**receiver** (the screen showing the content).\n\n| Sender (the pen) | Receiver (the paper) | Valid? | Notes |\n| --- | --- | --- | --- |\n| iPhone — RemoteDraw app, your app via `RemoteDrawSenderKit`, or hosted `/join` in Safari | Desktop / laptop browser | **Yes — the canonical flow** | Everything below is written for it. |\n| iPhone (any of the three) | Large display, TV, projector, kiosk browser | **Yes** | Size `target.coordinateSpace` to the display. |\n| iPhone (any of the three) | Desktop app — Electron, macOS, Windows — via `@remotedraw/client` or raw HTTP | **Yes** | No React needed; poll `/v1/receiver/*` or use the realtime source. |\n| Android phone — hosted `/join` in Chrome | Any of the above | **Yes** | There is no native Android SDK. The hosted join page *is* the Android sender and it is full-featured. |\n| iPhone / Android | iPad or tablet browser, as a **second** device someone else is looking at | **Yes** | Two devices, two people. |\n| Headless script, test, or agent — raw `POST /v1/join` → `/v1/sender/draft` → `/v1/sender/commit` | Any receiver | **Yes, for verification only** | Never ship a hand-rolled sender to users. |\n| Any phone | **The same phone** — one device shows the content and draws on it | **No** | There is no second screen. Use PencilKit / `<canvas>`. RemoteDraw adds a network hop and nothing else. |\n| A phone that must first **capture** the content — photograph or scan it | *(anything)* | **No** | The iOS sender opens no camera and needs no `Info.plist` entries. The content must already be on the receiver. |\n| Desktop mouse or trackpad as the pen | *(anything)* | **No — does not exist** | There is no desktop sender. The macOS trackpad sender is unbuilt research. Do not promise it. |\n| Phone → phone, two different people, two different devices | Phone browser as receiver | *Technically yes, rarely right* | A phone browser is a browser. But if both people hold phones, ask why the drawing is not simply in one app. |\n\n**The three nevers of the model.**\n\n1. **Never same-device.** If sender and receiver would be one phone, stop and\n say so. Propose the non-RemoteDraw alternative.\n2. **Never make the phone the source of content.** The receiver supplies what\n is drawn on. (One honest exception: the hosted `/join` pad has a file-attach\n tool that can put a file on the board. It is a hosted-sender capability, not\n a way to make a same-device flow valid, and it cannot currently be disabled.)\n3. **Never promise a sender RemoteDraw does not ship.** iOS (native SDK) and\n any mobile browser (hosted `/join`) are the senders. Nothing else exists.\n\n## Never do these\n\n- **Never write a custom canvas.** Not a `UIViewRepresentable` drawing view, not\n a `<canvas>` sender pad, not a hand-rolled SVG receiver, not a raw\n `URLSession`/`fetch` draft loop. Cadence, point budgets, the packed-point\n codec, sequence healing, token refresh and presence are protocol, and the SDKs\n implement them. Go headless on the SDK's own primitives if you must.\n- **Never put the iOS surface in a small pad or a draggable sheet.** It is\n full screen (`.remoteDrawSurface` / `RemoteDrawTakeover`). A sheet is\n acceptable only if `RemoteDrawSurface` fills it edge to edge and the sheet\n cannot be dragged mid-stroke. There is no small-canvas option.\n- **Never create a session on mount or on page load.** `POST /v1/sessions` is\n billable and not idempotent, and React StrictMode fires mount effects twice.\n Create it on the server (route handler, loader, server action) or on explicit\n user intent, and guard with a ref if it must be a client effect.\n- **Never let an `rd_sk_…` key reach a client.** Not browser bundles, not Swift,\n not app bundles, screenshots, logs, or generated examples. The phone never\n calls `/v1/sessions/direct-sender`; your backend does.\n- **Never poll by hand when a component or store exists.** `RemoteDrawProvider`\n / `createReceiverStore` already do one-in-flight polling plus an optional\n realtime push source.\n- **Never promise Bluetooth or Wi-Fi pairing.** The API advertises\n `bluetooth` and `localNetwork` as pairing methods and the iOS/Android apps do\n *advertise* on those radios, but **nothing scans, browses, or connects\n anywhere in the product**. They are not implemented. Do not present them as\n options; do not build UI around them. QR and direct sender are the real ones.\n- **Never promise a native Android SDK, a desktop sender, per-tenant Universal\n Links, e-signature compliance, or streaming inside `RemoteDrawSenderKit`.**\n See \"What does not exist yet\".\n- **Never delete or replace a host-app feature on your own initiative.** Build\n beside it.\n\n## The decision tree\n\n**1. What is being drawn on? → `target.kind` + `inputMapping`.**\n\n`target.kind` accepts `whiteboard`, `paper`, `canvas`, `svg`, `map`, `tldraw`,\n`field`, `image`, `pdf`, `screen`, `custom`. It selects the background the\nreceiver defaults to, the tool policy, and deposit defaults.\n`target.inputMapping` is `surface` (the phone's whole pad *is* the target — use\nfor bounded targets like a signature field) or `viewport` (the phone is a\nmovable window over a larger board — the default when omitted).\n\n| Kind | Host renders | `inputMapping` | Sender | Status |\n| --- | --- | --- | --- | --- |\n| `whiteboard` / `paper` | nothing — RemoteDraw's own ground | `viewport` (or `surface`) | hosted `/join` or SenderKit | shipped |\n| `image` (photo, screenshot) | the `<img>`, as `background` | `surface` (whole photo) or `viewport` (zoomable) | either | shipped |\n| `pdf` | your PDF renderer, one page at a time, as `background` or behind a `transparent` receiver | `viewport` | either | shipped; RemoteDraw renders no PDFs |\n| `field` (signature, initials) | the form, with a baseline as SVG `children` | **`surface`** — mandatory | either | shipped; **not** an e-signature product |\n| `screen` | a screenshot, or a live stream you publish | `viewport` | either | shipped; live view is experimental |\n| `map` | your map (Mapbox / MapLibre / Leaflet / Google) | `viewport` | hosted `/join` or SenderKit | shipped — use `RemoteDrawMapReceiver` |\n| `custom` | anything else you own | `viewport` | hosted `/join` (streaming) or SenderKit (ink only) | shipped |\n\n**2. Which stack renders the receiver? → the composition.**\n\n| Host | Use | Not |\n| --- | --- | --- |\n| React / Next / Remix | `@remotedraw/react`: `RemoteDrawProvider` + `RemoteDrawReceiver` (or `RemoteDrawMapReceiver`) + `PairingCode`/`RemoteDrawConnect`/`RemoteDrawLaunchButton` + `RemoteDrawSessionControls` | A hand-rolled SVG or polling loop |\n| Svelte / SvelteKit | `@remotedraw/svelte`: `createRemoteDrawReceiver` store + `createDirectSender` store. **No components ship** — you write all the markup, including ink rendering | Assuming React's components exist here |\n| Anything else with JS (Electron, Vue, vanilla) | `@remotedraw/client`: `createHttpReceiverClient`, `createReceiverStore`, `createRealtimeReceiverSource` | — |\n| No JS at all | Raw HTTP `POST /v1/receiver/*` with the receiver token | — |\n\n**3. Who holds the phone? → the sender path.**\n\n| Situation | Path |\n| --- | --- |\n| Anyone who can scan; no phone app in the product | **Hosted `/join`.** Render `joinUrl` as a QR with `PairingCode`. Zero sender code. The RemoteDraw iOS app opens the same HTTPS link through Universal Links; every other phone gets the web pad. |\n| The product has a first-party iOS app the user already installed *and* it is the RemoteDraw app | Same QR. Universal Links open it. |\n| The product has **its own** iOS app and the user is signed in | **Direct sender.** Backend calls `POST /v1/sessions/direct-sender` with `launchUrlTemplate: \"yourapp://draw?senderToken={senderToken}\"`; the browser shows `RemoteDrawLaunchButton`; the app receives the URL in `onOpenURL` and presents `.remoteDrawSurface(isPresented:senderToken:)`. Keep the QR as the fallback — the button reveals one automatically. |\n| A QR that opens the **customer's own** app | **Not possible.** The Universal-Link association file has one hard-coded appID and the QR host is a RemoteDraw deployment setting. Use the direct sender instead. |\n| Headless / tests | `POST /v1/join` → `/v1/sender/draft` → `/v1/sender/commit` |\n\n## Component catalogue\n\nEverything below is a real export, read from source. Anything not listed does\nnot exist. Grouped by decision: the board → what is behind it → pairing →\nsession state → AI → clients → iOS.\n\n### `@remotedraw/react`\n\n**`RemoteDrawProvider`** — receiver session state, polling, and actions.\nRequires a `receiver` client to do anything.\nProps: `children`, `session`/`sessionId`/`joinUrl`/`joinUrls`/`joinTokenUse`/`pairing`/`receiverToken`,\n`receiver` (a `ReceiverClient` — **without it the store is inert and silent**),\n`createSession`, `createSessionRequest` (default `{ target: { kind: \"custom\" } }`),\n`autoCreate` (default: true when `createSession` and no `session`),\n`pollIntervalMs` (`1000`), `draftPollIntervalMs` (`250`), `source` (`null`),\n`fallbackToPolling` (`true`), `initialDrawings`/`initialDrafts`/`initialSenders` (`[]`),\n`onError` (`(error: Error) => void` — **wire it; it is the only signal for a dead credential**),\n`refreshJoinToken` (`({ sessionId }) => Promise<result | null>` — the QR re-arm.\nThe join token lives 10 minutes; the session usually outlives it. Point this at\na backend route that calls `POST /v1/sessions/join-token` and return the\nresponse as-is; when the token dies on a live, unpaired session the provider\nswaps in the fresh code and every pairing component follows. Without it a stale\nQR shows \"Link expired\" until the host recreates the whole session — wire it in\nany integration where a board can sit unpaired for more than 10 minutes).\nContext: `session, sessionId, joinUrl, joinUrls, joinTokenUse, pairing, receiverToken, credentials, drawings, drafts, senders, loading, creating, error, transport, create, refetch, ingest, undo, clear`.\n*Limits:* `error` is a plain `Error`; narrow with `instanceof RemoteDrawHttpError`\nfor `.status`/`.code`/`.shouldReJoin`.\n\n**`RemoteDrawReceiver`** — the receiver foundation. Paints committed ink, live\ndrafts, sender pointers and connected phones over a configurable background, and\nprescribes nothing around it.\nProps: `drawings`/`drafts`/`senders` (fall back to the provider), `background`\n(default: the session target's surface inside a provider, else `\"whiteboard\"`;\naccepts a surface name, `\"transparent\"`, any CSS background string, or any\nReactNode), `pointers` (`true`), `phones` (`true`), `pointerColor` (`#1f7a8c`),\n`strokeColor` (`#151512`), `draftColor` (`#1f7a8c`), `strokeWidth` (`6`),\n`coordinateAspectRatio` (default: the session's `coordinateSpace`, else `1`;\nalso sets CSS `aspect-ratio`), `preserveAspectRatio` (`\"none\"`),\n**`projectPoint`** (`(point) => {x,y} | null` in **CSS pixels from the\nreceiver's top-left** — the seam for a camera; return `null` to drop a point),\n**`space`** (the same seam in the receiver's own viewBox coordinates; `projectPoint`\nwins when both are given), `animate` (`true`), `children` (SVG overlay in\n`0..surfaceWidth × 0..1000`), `className`/`style`/`svgProps`/`aria-label`.\n*Defaults that surprise:* it renders immediately with no empty state; `phones`\nand `pointers` are on.\n*Limits:* without `projectPoint`/`space` it stretches board space across its own\nelement — correct for a fixed board, silently wrong over a live map. Use\n`RemoteDrawMapReceiver` there.\n*Never:* stack it on a pannable map without a projection.\n\n**`RemoteDrawMapReceiver`** — `RemoteDrawReceiver` wired to a map the host owns.\nRenders a transparent ground (your map is the ground) plus the\nboard→geography→pixel projection. Takes every `RemoteDrawReceiver` prop except\n`projectPoint`/`space`, plus:\n`bounds` (the board's `target.coordinateSpace.bounds`; defaults to the session's\nown inside a provider — usually pass nothing), `mapBounds` (the map's current\nvisible bounds; **exact only for a north-up, unpitched camera**),\n`projectLngLat` (`(lng, lat) => {x,y} | null` — normally\n`map.project([lng, lat])`; exact under rotation and pitch, and wins over\n`mapBounds`).\n*Limits:* with neither `projectLngLat` nor `mapBounds` it renders unprojected\nand warns once in development. Leave `preserveAspectRatio` at `\"none\"`.\n*Never:* recompute the projection identity on every render — memoize it and bump\nit on the map's `move` event.\n\n**`RemoteDrawPhoneProjection`** — one connected phone drawn in place, as an SVG\n`<g>` for a host with its own `<svg>`. `layout` (required, from\n`phoneProjectionLayouts(senders)`), `space` (default 1000×1000), `color`,\n`model` (`\"auto\"`), `className`. `RemoteDrawReceiver` already renders these.\n\n**`PairingCode`** — the pairing component: the scannable code with live status,\nhover-to-copy, and subtle branding.\nProps: `joinUrl` (default: the provider's, falling back to `joinUrls.web`),\n`size` (`200`, or `\"fill\"`), `direction` (`\"paper\"`, one of 14 — TEMPORARY),\n`treatment` (`\"fluid\"`, one of 11 — TEMPORARY), `accentColor` (`#1f7a8c`),\n`inset`, `tile` (`true`), `logo` (the RemoteDraw mark; `false` for none, a\nstring for a URL), `logoPlacement` (`\"plate\"`), `title`/`description`,\n`showStatus` (`\"auto\"`), `joinMode` (the session's `joinTokenUse`),\n`showJoinMode`/`showLink` (`false`), `copyOnHover` (`true`), `onCopy`,\n`onConnected`/`onConnectedDismiss`, `labels`, `alt`, `placement` (`\"inline\"` +\nfour corners), `position`/`offset`/`zIndex` (`\"absolute\"`/`12`/`20`),\n`className`/`style`/`codeClassName`/`codeStyle`.\n*There is no `card`, `variant`, `showBrand`, `brand`, or `status` prop.* The\ncard is always drawn (`tile`, on by default) and extends to hold `title` /\n`description`; status is **inferred** (connected → error → expired → ready →\nidle) and cannot be passed.\n*Limits:* always encodes the HTTPS `joinUrl`. The \"expired\" status is real and\nterminal unless the provider has `refreshJoinToken` wired — the component shows\nthe death of the 10-minute join token but cannot mint a replacement itself. The\nanimated optical field that `variant=\"aurora\"` once selected is now the separate\nEXPERIMENTAL `AuroraPairingField`, decodable only by the RemoteDraw app's own\nscanner.\n\n**`PairingDevices`** — the list UI for pairing methods that resolve to a device\n(`bluetooth | localNetwork | accountPresence | direct`). `method` (required),\n`devices` (`[]`), `onSelectDevice`, `joinUrl`, `showCodeFallback` (`true`),\n`status`, `size` (`168`), `emptyLabel`, `actions`, `accentColor`,\n`autoHideOnConnected` (`true`), `connectedHideDelayMs` (`1150`), `direction`,\n`logo`, `onCopy`, `className`/`style`.\n*Limits:* **purely presentational — it discovers nothing.** You supply `devices`\nand `onSelectDevice` from your own backend. There is no Bluetooth, no Bonjour,\nand no customer-reachable account-presence route.\n*Never:* present it as \"nearby device pairing\" to a customer. It is chrome.\n\n**`RemoteDrawConnect`** — a compact trigger button that opens the code or the\ndevice list in a popover, for pairing exactly at the field, margin, or toolbar\nthat needs it. Everything from `PairingCodeProps` except placement/position/\noffset/zIndex/className/style/size/tile, plus `size` (`168`), `open`,\n`defaultOpen` (`false`), `onOpenChange`, `placement` (`\"bottom\"`), `method`,\n`devices`, `onSelectDevice`, `trigger`, `triggerLabel` (`\"Pair phone\"`),\n`showTriggerLabel` (`false`), `triggerClassName`/`triggerStyle`/`triggerDisabled`,\n`openOnHover` (`true`), `panel*`/`popover*` class and style.\n*Use it instead of hand-rolling a \"connect phone\" button.*\n\n**`RemoteDrawLaunchButton`** — \"open on my phone\" for a user whose **own** app is\nthe sender. Mints a scoped sender through your backend, opens the deep link, and\nreveals a QR when the app never comes back.\nProps: `connect` (required — your own backend endpoint; may return the raw\n`connectSender` response, a create-session response carrying `senderConnection`,\nor just `{ launchUrl }`; resolving `null` means \"no direct sender for this user\"),\n`handoffTimeoutMs` (`DIRECT_SENDER_HANDOFF_TIMEOUT_MS` = `12_000`),\n`showQrFallback` (`true`), `fallback`, `pairingProps`, `labels`, `accentColor`\n(`#1f7a8c`), `disabled` (`false`), `autoLaunch` (`true`), `openUrl` (default\nassigns `window.location.href`), `onError`, `onStatusChange` (transitions only),\n`children` (node or `(state) => node`), `className`/`style`/`buttonClassName`/\n`buttonStyle`/`aria-label`.\n*Why it is a component and not an `onClick`:* a custom scheme nothing has\nregistered fails **silently** — no error, no navigation, no event. The timeout\nwith no sender on the board is the only detector.\n\n**`useDirectSender(options)`** — the hook the button is a thin default over.\nOptions: `connect` (required), `autoLaunch`, `openUrl`, `onError`,\n`onStatusChange`. Returns `{ status, launchUrl, senderToken, senderId,\nconnection, error, connect(), launch(), reset() }`.\n`status`: `idle | connecting | ready | drawing | submitted | expired | error`.\n`error` is a `RemoteDrawLaunchError` (an `Error`) with\n`kind: \"connect-failed\" | \"no-launch-url\" | \"launch-blocked\"` and the original\nrejection on `cause`.\n*Limits:* phases after `ready` are read from `RemoteDrawProvider`; outside one\nit can never advance past `ready`.\n\n**`RemoteDrawSessionControls`** — the one session-state surface: a collected\nstatus bar (or card, via `title`) telling the session's story — waiting for a\nphone, connected, drawing, submitted, with the sender's device name — plus\nundo/clear. `actions` (`[\"undo\",\"clear\"]`), `confirmClear` (`true`), `title`,\n`submission`, `labels`, `undoLabel`/`clearLabel`/`confirmClearLabel`,\n`metadataKeys`/`metadataLabels`/`formatMetadataValue` (keys render humanized,\nnever raw), `className`/`style`.\n*Limits:* every failure renders as one string, \"Connection problem\".\n\n**`AiImage` / `AiText`** — render a finished `AiAction`. `AiImage`: `action`,\n`direction` (`\"plate\"`, 6 options), `actions` (`\"hover\"`, 4),\n`standardActions` (`[\"download\",\"copy\"]`, plus `\"open\"`), `customActions`,\n`theme` (`\"auto\"`), `aspectRatio`, `radius` (`14`), `fit` (`\"cover\"`),\n`fileName`, `labels`, `onRetry`, `placeholder`, `imageAlt`, `className`/`style`.\n`AiText`: `action`, `direction` (`\"note\"`, 5), `actions` (`\"bar\"`),\n`standardActions`, `customActions`, `theme`, `radius`, `maxWidth` (`\"60ch\"`),\n`labels`, `onRetry`, `placeholder`, `className`/`style`.\n*Limits:* neither ever shows the model, tier, latency, credit cost, or the\nprovider's error string. A schema run renders nothing — `result.generatedData`\nis for your code.\n\n**`useVisualContextPublisher`** (experimental) — the **publish** half of live\nview: sends the receiver's pixels to the phone drawing on it. Has its own\n`onError`.\n\n**`RemoteDrawStreamView`** — the **consume** half, for a sender pad you host\nyourself: the receiver's stream as a ground, your pad as its `children`.\nProps: `senderToken`, `signaling` (a `VisualContextSignalingClient` — build it\nwith `createRealtimeVisualContextSignalingClient` for push, or\n`createHttpVisualContextSignalingClient` for the polled `/v1/.../visual-context/*`\nroutes), `enabled` (gate it on `viewReceiverContext` + `visualContext.enabled` +\na reported phone projection), `iceServers` (from the session's\n`visualContext.iceServers` — without them a phone on cellular connects to\nnothing), `pollIntervalMs`, `onStatus`, `onError`, plus `fit` (`\"contain\"`),\n`fadeMs`, `posterStyle`, `className`/`style`/`aria-label`, `children`.\n`useRemoteDrawStream(options)` is the same thing headless, returning\n`{ mediaStream, status, streamStatus, stream, error, markStreamLive }` with\n`status` one of `idle | connecting | streaming | unsupported | failed | closed`.\n*Limits:* `children` are deliberately **not** gated on the stream — a pad that\nonly appears once pixels arrive never appears on the networks where WebRTC\ncannot connect, and drawing must keep working there.\n\n**`VisualContextVideoLayer`** — the raw `<video>` for a `MediaStream` you\nproduce yourself. `mediaStream`, `status`, `fit` (`\"contain\"`), `fadeMs`\n(`VISUAL_CONTEXT_FADE_MS` = `220`), `posterStyle`, `onLiveChange`,\n`className`/`style`/`aria-label`. Hand over on `onLiveChange(true)`, not on\nhaving a stream. `RemoteDrawStreamView` wires this for you.\n\n**Hooks:** `useRemoteDraw` (throws outside the provider),\n`useRemoteDrawSession`, `useReceiverData`, `usePairingUrl`,\n`useRemoteDrawPointers`, `useReceiverStrokes`.\n\n**Low-level / rarely right:** `InkCanvas` (WebGL2 ink substrate — the receiver\ndrives it), `RemoteDrawMark`, `AuroraPairingField` (EXPERIMENTAL),\n`FreehandFilmGroup`/`freehandStrokePaths`, the element-selection helpers.\n\n**Deliberately not exported:** a web sender component. Hosted `/join` is the web\nsender. Custom in-page pads are built headless on `createHttpSenderClient`.\n`@remotedraw/react/next` is a separate, unfinished v2 entry point — do not mix\nit into a normal integration.\n\n### `@remotedraw/svelte`\n\n`createRemoteDrawReceiver(options)` → `{ subscribe, create, refetch, ingest,\nundo, clear, setSession, configure, start, stop }`.\n`createDirectSender({ connect, receiver, autoLaunch, openUrl, onError })` →\n`{ subscribe, connect, launch, reset, stop }`, the same state machine React's\n`useDirectSender` binds. Plus `export * from \"@remotedraw/client\"`.\n**No components ship.** A Svelte integrator writes the pairing UI, the ink\nrendering and the session UI themselves.\n\n### `@remotedraw/client` (framework-free)\n\n- `createHttpRemoteDrawApiClient(baseUrl, { apiKey })` — **server-only.**\n `createSession`, `getSession`, `listSessions`, `issueJoinToken`,\n `connectSender`, `endSession`, `createAiAction`, `getAiAction`,\n `cancelAiAction`, `waitForAiAction`.\n- `createSessionWithHttpApi(baseUrl, options)` — server-only convenience.\n- `createHttpReceiverClient(baseUrl)` — the seven `/v1/receiver/*` calls.\n Credentials go in the body, not a header.\n- `createHttpSenderClient(baseUrl, { packPoints })` — 14 sender calls.\n- `createReceiverStore(options)` — the headless receiver state machine\n `RemoteDrawProvider` and the Svelte store both bind.\n- `createRealtimeReceiverSource({ driver })` + `createConvexRealtimeDriver({ client })`\n — push transport over the hosted realtime endpoint. `convex` is never imported\n by the package; you hand it a two-method driver.\n- `RemoteDrawHttpError` — `status`, `code`, `upgradeUrl`, `body`, and the\n getters `isAuthenticationFailure`, `isPermissionFailure`, `isSessionOver`,\n `shouldReJoin`.\n- `joinTokenFromInput`, `joinUrlForOrigin`, `nativeJoinUrlFromSession`.\n- `createPacedDraftQueue` / `createLatestOnlyQueue` / `draftPointsForTransport`\n / `retryIdempotentRequest` — the 32 ms latest-only draft gate a custom sender\n must use instead of POSTing every pointer event.\n- `createDirectSenderController`, `resolveDirectSenderStatus`,\n `directSenderConnectionFromResult` — the shared direct-sender rules.\n- `createRealtimeVisualContextSignalingClient` /\n `createHttpVisualContextSignalingClient` — where live-view signals travel\n (realtime push, or the polled public `/v1/.../visual-context/*` routes).\n\n### `@remotedraw/geometry`\n\nStroke/shape helpers (`buildNormalizedStroke`, `recognizeStroke`,\n`simplifyNormalizedPoints`, hit-testing, transforms, `drawingsToSvg`), **and the\nmap board transform**, re-exported by `@remotedraw/react`:\n`boardPointFromLngLat`, `lngLatFromBoardPoint`, `longitudeFromBoardX`,\n`latitudeFromBoardY`, `mercatorYFromLatitude`, `latitudeFromMercatorY`,\n`boardViewportFromMapBounds`, `mapBoundsFromBoardViewport`,\n`mapBoardPointToScreen`, `mapBoardPointFromScreen`, `screenPointFromBoardPoint`,\n`surfacePointFromBoardPoint`, `padMapBounds`, `MAX_MERCATOR_LATITUDE`.\nUse these rather than reimplementing the projection — `x` is linear in\nlongitude, `y` is linear in **Web Mercator**, and a version that is linear in\nlatitude puts ink kilometres away.\n\n### `RemoteDrawSenderKit` (SwiftPM, iOS 17+)\n\n`https://github.com/AxioSOzo/remotedraw-swift.git`, product\n`RemoteDrawSenderKit`. Zero external dependencies. No `Info.plist` entries.\n\n- `RemoteDraw.shared` — **zero-config**: it installs production defaults the\n first time anything reads it. `RemoteDraw.configure(_:)` in `App.init` is\n *optional* and only overrides `apiBaseURL`, `device`, `tokenProvider`,\n `urlSession`, `onClientAdvisory`. `try RemoteDraw.requireConfigured()` throws\n `RemoteDrawError.notConfigured` if you want the strict behaviour back.\n (This used to `preconditionFailure` from inside the modifier's `.task` — a\n crash on the user's tap. It no longer does.)\n- `.remoteDrawSurface(isPresented:senderToken:appearance:strings:exit:onOutcome:)`\n — the whole integration, a full-screen cover with the board, an exit and an\n outcome. A second overload adds `background:` — a `@ViewBuilder` handed a\n `RemoteDrawGroundContext` (`session`, `mapBounds`, `phoneProjection`, `size`,\n `reportViewport`) for your own cartography or ground. A ground that **moves**\n must call `reportViewport(_:)`; a static one calls nothing.\n- `RemoteDrawTakeover` — the same board plus scene-phase wiring and exit, for\n your own cover / navigation push / `UIHostingController`.\n- `RemoteDrawSurface` — the board as a plain `View`, for a host that fills its\n presentation with it edge to edge.\n- `RemoteDrawSenderSession` — the headless core (`begin`/`append`/`end`,\n `undo`, `clear`, `submit(metadata:)`, `edit`, `updateProjection`, `leave`,\n published `phase`, `strokes`, `live`, `lastError`), with\n `RemoteDrawInkCanvas` + `RemoteDrawStrokeCapture` when you own the screen.\n- `RemoteDrawBoardCanvas`, `RemoteDrawMapBoardGround`, `RemoteDrawMapGeometry`,\n `RemoteDrawAppearance`, `RemoteDrawStrings`, `RemoteDrawExit`,\n `RemoteDrawError` (13 cases with `shouldReJoin` / `isRetriable`).\n- **Map boards are built in.** A `kind: \"map\"` session with\n `coordinateSpace.bounds` draws the geography through MapKit, using the same\n transform as `@remotedraw/geometry`. The built-in map is deliberately not\n pannable; supply your own through `background:` if it should be.\n- **Streaming boards are not.** A session with `senderIntegrationMode:\n \"streaming\"` or a receiver publishing `visualContext.enabled` cannot be drawn\n by this SDK — no WebRTC, no video, no `WKWebView`. It reports\n `.unsupportedSurface(_:)` carrying `hostedSenderURL`; present that in a\n `WKWebView` and streaming works today.\n\n`RemoteDrawOutcome` is a **closed** enum:\n\n| Case | Meaning | Do |\n| --- | --- | --- |\n| `.submitted(RemoteDrawReceipt)` | Drawing submitted. | Record it. Call `session.submit(metadata:)` yourself for the server's own ids — a submit from the SDK's controls reports a placeholder. |\n| `.left` | The person left; ink is on the board. | Nothing. |\n| `.expired` | The board finished or timed out. **Terminal.** | Create a new session. |\n| `.credentialLost(RemoteDrawError?)` | The token died; the board did not. **Recoverable.** | Mint a fresh `rd_send_` and present again. |\n| `.unsupportedSurface(RemoteDrawUnsupportedSurface)` | The board wants a renderer this SDK lacks. The cover **stays up** showing the reason. | Open `hostedSenderURL` in a `WKWebView`. |\n| `.failed(RemoteDrawError)` | Anything else. | Read `error.shouldReJoin` / `error.isRetriable`. |\n\n**There is no `.revoked`.** A revoked, unknown and malformed token all answer\n`invalid_sender_token` on purpose; only a genuine expiry is distinguishable, and\nthat travels in the error on `.credentialLost`.\n\nNote: `RemoteDrawKit` is a *different*, internal macOS-only package that some\nolder docs still name. Customers use `RemoteDrawSenderKit`.\n\n## Recipes, one per surface\n\nAll React snippets assume the session came from your backend and are wrapped in:\n\n```tsx\nconst receiver = createHttpReceiverClient(\"https://api.remotedraw.com\");\n\n<RemoteDrawProvider\n session={session.session}\n receiverToken={session.receiverToken}\n joinUrl={session.joinUrl}\n joinUrls={session.joinUrls}\n receiver={receiver}\n onError={(error) => reportToYourLogger(error)}\n>\n {/* the recipe */}\n</RemoteDrawProvider>\n```\n\n### Whiteboard / freeform sketch\n\n```ts\n// Backend\ntarget: { kind: \"whiteboard\", label: \"Session notes\" }\n```\n\n```tsx\n<PairingCode title=\"Scan to draw\" />\n<RemoteDrawReceiver />\n<RemoteDrawSessionControls title=\"Sender workflow\" />\n```\n\n### Photo / screenshot annotation (`image`)\n\nThe photo is **already on the receiver**. The phone never takes it.\n\n```ts\ntarget: {\n kind: \"image\",\n inputMapping: \"surface\", // the pad is the whole photo\n coordinateSpace: { width: 1600, height: 900 },\n metadata: { label: \"Inspection photo\", imageId: \"img_123\" },\n}\n```\n\n```tsx\n<div style={{ position: \"relative\" }}>\n <RemoteDrawReceiver\n coordinateAspectRatio={16 / 9}\n background={<img src={photoUrl} alt=\"\" style={{ width: \"100%\", height: \"100%\", objectFit: \"contain\" }} />}\n />\n <PairingCode placement=\"top-right\" size={112} />\n</div>\n```\n\n*Limit:* RemoteDraw stores no images. Your app owns the photo and the link\nbetween it and the drawings.\n\n### PDF page\n\n```ts\ntarget: { kind: \"pdf\", inputMapping: \"viewport\",\n metadata: { documentId: \"doc_9\", page: 3 } }\n```\n\n```tsx\n<div style={{ position: \"relative\" }}>\n <YourPdfPage page={3} />\n <RemoteDrawReceiver\n background=\"transparent\"\n style={{ position: \"absolute\", inset: 0, pointerEvents: \"none\" }}\n />\n</div>\n```\n\n*Limit:* RemoteDraw renders no PDFs and knows nothing about pages. One session\nper page, or carry the page number in `target.metadata` and re-create.\n\n### Signature / bounded field\n\n```ts\ntarget: {\n kind: \"field\",\n inputMapping: \"surface\", // mandatory — the pad IS the field\n coordinateSpace: { width: 1600, height: 500 },\n metadata: { label: \"Customer signature\", fieldId: \"sig_1\" },\n}\n```\n\n```tsx\n<RemoteDrawConnect method=\"qr\" triggerLabel=\"Sign with your phone\" showTriggerLabel />\n<RemoteDrawReceiver coordinateAspectRatio={16 / 5} strokeWidth={7}\n style={{ border: \"1px solid #ddd8cf\", borderRadius: 12 }}>\n <line x1=\"140\" y1=\"760\" x2=\"3060\" y2=\"760\" stroke=\"#d8d8d8\" strokeWidth=\"4\" />\n</RemoteDrawReceiver>\n```\n\n*Limit:* this is markup transport, **not** e-signature compliance. No identity\nproofing, no intent-to-sign ceremony, no tamper-evident audit package, no\ncertificates. Say so if the user asks for a legal signature.\n\n### Map\n\nThe board's `coordinateSpace.bounds` is a **hard geographic fence, fixed for the\nlife of the session** — no route changes it. Size it larger than the camera you\nopen on. The phone pans *inside* it.\n\n```ts\nimport { padMapBounds } from \"@remotedraw/geometry\";\n\ntarget: {\n kind: \"map\",\n inputMapping: \"viewport\",\n coordinateSpace: {\n width: 1600, height: 1310, // the fence's Mercator aspect\n ...padMapBounds(currentCameraBounds, 1), // 3x the camera\n },\n}\n```\n\n```tsx\nconst [camera, setCamera] = useState(0);\nuseEffect(() => {\n const onMove = () => setCamera((n) => n + 1);\n map.on(\"move\", onMove);\n return () => map.off(\"move\", onMove);\n}, [map]);\nconst projectLngLat = useCallback(\n (lng: number, lat: number) => map.project([lng, lat]), // CSS px in the container\n [map, camera],\n);\n\n<div style={{ position: \"relative\" }}>\n <div ref={mapContainer} style={{ position: \"absolute\", inset: 0 }} />\n <RemoteDrawMapReceiver\n projectLngLat={projectLngLat}\n style={{ position: \"absolute\", inset: 0, pointerEvents: \"none\" }}\n />\n</div>\n```\n\n*Limits:* `mapBounds={map.getBounds()}` is the no-callback alternative but is\nexact only for a north-up, unpitched camera. Omitting `bounds` at session\ncreation does not mean \"the customer's map\" — it means RemoteDraw's own default\nregion. `senderIntegrationMode` stays `\"native\"` on a map board: streaming\nrequires a `phoneProjection` that a native map sender never sends.\n\n### Screen / live view\n\n```ts\ntarget: { kind: \"screen\", inputMapping: \"viewport\" }\ncapabilities: [..., \"viewReceiverContext\"]\nvisualContext: { enabled: true }\n```\n\nThe receiver publishes with `useVisualContextPublisher`. Three consumers, in\norder of how little you write:\n\n1. **The hosted `/join` pad** — consumes the stream automatically. Zero code.\n2. **Your own web pad** — `RemoteDrawStreamView` with your pad as its\n `children`, plus a signaling client.\n3. **`RemoteDrawSenderKit`** — *cannot* consume it. It returns\n `.unsupportedSurface(_:)` carrying a `hostedSenderURL`; present that in a\n `WKWebView` and streaming works today.\n\nLive view is experimental: build so that a session whose stream never starts is\nstill a working session — the phone keeps drawing, it simply does not see the\nreceiver's pixels.\n\n### iOS sender — the complete Swift\n\n```swift\n// Package.swift / Xcode → Add Package\n.package(url: \"https://github.com/AxioSOzo/remotedraw-swift.git\", from: \"0.1.0\")\n.product(name: \"RemoteDrawSenderKit\", package: \"remotedraw-swift\")\n\nimport RemoteDrawSenderKit\n\n// Optional. RemoteDraw.shared installs production defaults on first use, so\n// this line exists only to OVERRIDE something.\n@main struct MyApp: App {\n init() { RemoteDraw.configure(.init(tokenProvider: mintSenderToken)) }\n var body: some Scene { WindowGroup { RootView() } }\n}\n\n// Wherever drawing starts. `token` is the rd_send_ string your backend minted\n// with POST /v1/sessions/direct-sender. A QR's rd_join_ works too, but spending\n// one revokes every other sender on that session.\nButton(\"Draw\") { drawing = true }\n .remoteDrawSurface(isPresented: $drawing, senderToken: token) { outcome in\n switch outcome {\n case .submitted(let receipt): record(receipt)\n case .left: dismissBanner()\n case .expired: refreshSession() // terminal\n case .credentialLost: refreshToken() // recoverable\n case .unsupportedSurface(let it): openHostedPad(it.hostedSenderURL)\n case .failed(let error): report(error) // do not swallow\n }\n }\n```\n\nReceiving the launch URL (the other half of the direct sender — nothing else\ndocuments it):\n\n```swift\n// Info.plist: CFBundleURLTypes → your scheme, e.g. \"yourapp\"\n.onOpenURL { url in\n guard url.scheme == \"yourapp\",\n let token = URLComponents(url: url, resolvingAgainstBaseURL: false)?\n .queryItems?.first(where: { $0.name == \"senderToken\" })?.value\n else { return }\n senderToken = token\n drawing = true\n}\n```\n\nRules:\n\n- The surface is **full screen**. Not a small pad, not a draggable sheet.\n- The user can always leave; `RemoteDrawExit` only decides whether leaving with\n unsubmitted ink asks first. The host always gets an outcome.\n- Do not write a `UIViewRepresentable` canvas, a draft loop, or an HTTP client.\n Go headless on `RemoteDrawSenderSession` + `RemoteDrawInkCanvas` +\n `RemoteDrawStrokeCapture` if you own the screen — never raw `URLSession`.\n- Customers do not ship a separate RemoteDraw app; their app *is* the sender.\n\n## How to suggest use cases\n\n**What the product is good at**, as a sentence to pattern-match against:\n\n> A person is at a screen. The thing they need to mark is already on that\n> screen. A mouse is the wrong instrument for the mark — because it is\n> handwriting, a circle around a defect, a diagram, a signature, or a gesture\n> over a map — and their phone is in their pocket.\n\n**Ask these four before proposing anything:**\n\n1. Which screen in your product already shows the thing to be marked, and what\n device is that screen on?\n2. Is the person in front of it holding a phone at the same time?\n3. What does the mark mean afterwards — saved to which record, shown where?\n4. Anyone who scans, or a signed-in user of your own app? (QR vs direct sender.)\n\n**Good vs bad, for a property-management SaaS:**\n\n- ✅ Property manager reviews an inspection photo on the office desktop and\n circles the damage with their phone. *Two devices; content already on the\n receiver.*\n- ✅ Tenant signs the handover report on the manager's laptop screen using their\n own phone as the pen. *This is the flow that replaces a stylus.*\n- ✅ Planner marks a route on the dispatch map on the wall display.\n- ✅ Support agent circles the broken control on a customer's shared screen.\n- ❌ Tenant photographs a leak on their phone and circles it. *One device,\n phone-supplied content. **Not RemoteDraw.*** Say so and propose PencilKit.\n- ❌ An in-app sketch pad in the mobile app. *One device.*\n- ❌ Field engineer marks up a PDF on their iPad in the van. *One device —\n unless a second screen is genuinely present.*\n- ❌ \"Pair over Bluetooth when the phone is nearby.\" *Not implemented.*\n\n**The disqualifier:** if you cannot name two devices and say which one already\ndisplays the content, you do not have a use case yet — ask.\n\n## The flow — in this order\n\n1. **Explain before touching anything.** If the user is asking what RemoteDraw\n can do, answer from this file and the docs. Do not install, scaffold, or\n create sessions to answer a question.\n2. **Ask for approval before installing.** Name exactly what you want to add\n (`@remotedraw/cli`, `@remotedraw/react`, a Swift package, a dashboard\n project + key) and why, then wait. This includes `remotedraw init` without\n `--offline`, which provisions a billable project and key. Install with the\n project's own package manager — the scan below reports it, and a lockfile\n the project did not ask for is a mess a human has to clean up:\n `npm install -g @remotedraw/cli`, `pnpm add -g @remotedraw/cli`,\n `bun add -g @remotedraw/cli`, or — Yarn Berry has no global install —\n `yarn dlx @remotedraw/cli`. Same for the SDKs: `npm install`, `pnpm add`,\n `yarn add`, or `bun add`.\n3. **Scan the codebase.** `remotedraw scan --format json` (or, before the CLI\n is installed, `npx @remotedraw/cli@latest scan --format json` — `pnpm dlx`,\n `yarn dlx`, or `bunx @remotedraw/cli` for those managers) reports the\n web/server/iOS projects, the project's package manager and its install/add\n commands, where an `rd_sk_` key may live, any RemoteDraw wiring already\n present, the integration options that fit, and the product questions to ask.\n Read it; verify its `evidence` where it matters.\n4. **Ask the product questions** (the four above, plus the scan's own). Do not\n invent answers; a one-page \"drawing lab\" is only right when the user says a\n demo is what they want.\n5. **Propose one plan, then build all of it.** Receiver, session creation,\n sender, and the exit/submit path — an integration is not done when ink\n appears once on a test page. Build beside existing features.\n6. **Verify by using it.** Run `remotedraw doctor --format json`, open a real\n session, draw from a phone (or `create-input --execute` + the hosted join\n URL), and confirm ink lands on the receiver. On iOS, run it on a device or\n simulator and look at the screen; a compiling canvas is not a working one.\n\nSession creation (`POST /v1/sessions`) needs the `rd_sk_` key and therefore runs\nonly where the scan found server-side code: a Convex action, a Next route\nhandler, an Express/Hono route, a serverless function. If the scan found none,\nask where the backend is. Never scaffold `createRemoteDrawSession.ts` into a\nVite/Next client tree — `--target web` in `remotedraw init` still writes it\nunder `src/`; move it, or scaffold into a scratch directory and copy only what\nbelongs.\n\n## CLI\n\n```sh\nremotedraw options --format json # the option catalog\nremotedraw scan --format json # read the codebase first (step 3)\nremotedraw init --non-interactive --offline --dry-run --format json \\\n --path apps/web --target web --sender remotedraw-ios --sdk react --preset sketch --package-manager npm\nremotedraw doctor --format json\nremotedraw create-input --preset sketch --json\nremotedraw create-input --execute # a real session + join URL, needs a key\nremotedraw agent --print-skill # this file, no install needed\n```\n\n`--sender own-ios` requires `--sdk swift`; other invalid combinations fail with\n`INVALID_COMBINATION`. By default `init`/`new` also create a dashboard project\nand a project-scoped development key in `.env.local` — that is the step that\nneeds approval (step 2); `--offline` writes files only. `--preset mapMarkup`\nemits an explicit `coordinateSpace.bounds`; replace the example region with the\ncustomer's. Never pass `--force` unless the user approved overwriting. Do not\ndrive the interactive wizard or scrape human-formatted output; every command has\n`--format json`.\n\n## Security and tenancy\n\n- `rd_sk_…` keys: backend secrets only. Never in browser bundles, Swift, app\n bundles, screenshots, logs, or generated examples.\n- The account-level `rd_cli_…` credential stays in the user config directory;\n never copy it into a project. `REMOTEDRAW_CLI_TOKEN` is for CI secrets only.\n- Public clients receive only `joinUrl`, `joinToken`, `receiverToken`, or\n `senderToken`, each scoped to one session. Production QR codes use the HTTPS\n `joinUrl`, not the custom scheme.\n- One key serves every customer of the product, so a session id is not a\n capability. Create sessions with `externalId: \"<product>:<tenant>\"`, and check\n it (`POST /v1/sessions/get`) before attaching a sender or ending a session on\n a tenant's behalf.\n\n## Gotchas the SDKs hide and hand-written code hits\n\n- `POST /v1/sessions` is billable and not idempotent. React StrictMode runs\n mount effects twice in development: guard with a ref, or create the session in\n a server action / loader. Store the `receiverToken` if the receiver outlives a\n page load — it is the only credential that reads a session's ink.\n- Timestamps are integer milliseconds. `occurredAt` and point `t` values are\n accepted with a fraction (floored) but a hand-written client should send\n integers.\n- Committed points come back in board space, remapped through\n `device.aspectRatio`; send the aspect ratio of the pad the finger touches.\n- `RemoteDrawReceiver` defaults `phones` and `pointers` to on, and renders\n immediately with no empty state. Pass `phones={false}` for a plain surface;\n drive your own empty state from `useReceiverData().senders`.\n- `PairingCode` hides the join URL text unless `showLink`.\n- `joinTokenExpiresAt` is earlier than the session's `expiresAt`: the QR dies\n first, the board stays live.\n- Wire `RemoteDrawProvider`'s `onError` (and `useVisualContextPublisher`'s).\n Without it a dead credential is silent and the board simply stops updating.\n- The hosted `/join` pad respects the joined capability list; a custom sender\n must too. It also has a **file-attach tool** available on any session granting\n `draw` or `point`, which cannot currently be turned off.\n\n## API contract\n\n- Backend: `POST /v1/sessions` (create), `/v1/sessions/get`, `/v1/sessions/end`,\n `/v1/sessions/direct-sender` (mint `rd_send_` for your own app),\n `/v1/sessions/join-token` (a fresh QR).\n- Receiver: `POST /v1/receiver/session`, `/drawings`, `/drafts`, `/senders`\n with the receiver token, or the realtime source in the SDKs.\n- Sender: `POST /v1/join` (spends a join token; revokes other senders), then\n `/v1/sender/draft` (latest-only preview, throttle to ~32 ms),\n `/v1/sender/commit` (one durable stroke per pointer-up with a stable\n `clientStrokeId`), `/v1/sender/submit`.\n- Capabilities: `draw`, `point`, `undo`, `clear`, `moveViewport`,\n `viewExisting`, `viewReceiverContext`. Omitting `capabilities` grants the\n first six. Reissued join tokens may narrow but never widen.\n\n## AI actions\n\nReach for AI when the product needs something _from_ the finished drawing: a\ngenerated image, a description, or structured data to branch on. Backend only\n(`aiActions:*` scopes on an `rd_sk_...` key). Never wire it to a commit, submit,\nor presence event — AI runs only on an explicit `POST /v1/ai-actions` call the\nuser asked for. Run it after the user is done; the route accepts `active` and\n`ended` sessions.\n\nMinimal request per outcome (`POST /v1/ai-actions`, plus optional\n`quality: \"fast\" | \"balanced\" | \"max\"`, default `balanced`):\n\n```jsonc\n{ \"sessionId\": \"...\", \"request\": \"image\", \"prompt\": \"...\" } // image back in the response\n{ \"sessionId\": \"...\", \"request\": \"image\", \"prompt\": \"...\", \"deliver\": [\"result\", \"board\"] } // and onto the board\n{ \"sessionId\": \"...\", \"request\": \"text\", \"prompt\": \"Describe this drawing.\" } // text back\n{ \"sessionId\": \"...\", \"request\": \"text\", \"prompt\": \"...\", \"text\": { \"schema\": { /* JSON Schema */ } } } // typed JSON\n```\n\nThe response is asynchronous: `create` returns `status: \"queued\"`. Poll\n`POST /v1/ai-actions/get` until `status` is `succeeded`, `failed`, or\n`canceled`, or use `createAiAction` + `waitForAiAction` on\n`createHttpRemoteDrawApiClient` from `@remotedraw/client` (re-exported by\n`@remotedraw/react`) — backend only, it holds the key. There is no completion\nwebhook. Render results with `AiImage` / `AiText`. See\nhttps://docs.remotedraw.com/docs/api#ai.\n\n## What does not exist yet\n\nVerified against source, 2026-08-30. Read this *before* designing, so you never\npromise any of it.\n\n- **No Bluetooth or Wi-Fi pairing.** The radios advertise; nothing scans or\n browses, on any platform. `PairingDevices` is presentational chrome.\n- **No QR into a customer's own app.** The Universal-Link association file has\n one hard-coded appID and the QR host is a RemoteDraw deployment setting. Use\n the direct sender.\n- **No native Android SDK.** Hosted `/join` in a mobile browser is the Android\n sender, and it is full-featured.\n- **No desktop or trackpad sender.** Unbuilt research.\n- **No streaming consumer in `RemoteDrawSenderKit`.** It reports\n `.unsupportedSurface(_:)` with a `hostedSenderURL` to open in a `WKWebView`.\n (A customer's own *web* pad can consume a stream — `RemoteDrawStreamView`.\n It is the native SDK that cannot.)\n- **No Svelte components.** A receiver store and a direct-sender store only.\n- **No web sender component.** Deliberate: hosted `/join` is the web sender.\n- **No API-key route that reads a session's ink.** Lose the `receiverToken` and\n the session is unreadable while still billable.\n- **No `clientSessionId` idempotency on `POST /v1/sessions`.**\n- **No completion webhook for AI actions** — poll `POST /v1/ai-actions/get`.\n- **No e-signature compliance.** No identity proofing, intent-to-sign ceremony,\n tamper-evident audit package, or certificate handling.\n- **No content rendering of any kind.** No PDF renderer, no image storage, no\n document pipeline, no auth, no billing UI. The host renders; RemoteDraw inks.\n\n## Verification\n\nAfter changes, verify against the customer's project — never assume RemoteDraw's\nown repo scripts exist here.\n\n```sh\nremotedraw doctor # config, SDK deps, REMOTEDRAW_* env\nremotedraw create-input --execute # open a real session, print the join URL\n```\n\nThen run whatever type check and test command the project already defines (for\nexample `npm run typecheck` and `npm test`). Do not invent script names, and do\nnot run `bun run test:api`, `bun run typecheck`, or `bun run ios:kit:test` —\nthose are RemoteDraw's internal monorepo scripts and will not exist in a\ncustomer project.\n\n## Reference\n\n- API: `https://api.remotedraw.com` · Docs: `https://docs.remotedraw.com/docs`\n (agent summary: `https://docs.remotedraw.com/llms.txt`) · Keys:\n `https://dashboard.remotedraw.com/api/keys`\n- Packages: `@remotedraw/cli`, `@remotedraw/react`, `@remotedraw/svelte`,\n `@remotedraw/client`, `@remotedraw/protocol`, `@remotedraw/geometry`, and the\n SwiftPM package `https://github.com/AxioSOzo/remotedraw-swift.git` (product\n `RemoteDrawSenderKit`).";
4
+ export const AGENT_SKILL_MARKDOWN = "---\nname: remotedraw\ndescription: Integrate RemoteDraw phone drawing into a customer's product — a phone becomes the pen for a screen the customer already owns. Covers the two-device model, which flows are valid, the ready-made components (React/Svelte/JS receiver, hosted web sender, RemoteDrawSenderKit iOS sender), per-surface recipes, and how to judge whether a proposed use case fits at all.\n---\n\n# RemoteDraw Agent Skill\n\nUse this skill when a user asks what RemoteDraw is, whether it fits their app,\nor to create, debug, or review a RemoteDraw integration.\n\nRead the whole file before proposing a design. The first two sections decide\nwhether the use case is possible; everything after decides how it is built.\n\n## The model: two devices, always\n\nRemoteDraw is not a drawing library. It is a wire between **two devices**.\n\n- **The receiver is the paper.** A screen someone is looking at — a laptop, a\n desktop, a large display, a kiosk, a tablet on a desk — showing *your*\n product. Your app renders whatever is being drawn on: the map, the photo, the\n PDF page, the form, the whiteboard. RemoteDraw renders none of that content.\n It paints ink on top of it.\n- **The sender is the pen.** A phone. It supplies a hand, pressure, tilt and a\n stroke. It does **not** supply the content: the iOS SDK opens no camera and no\n file picker (it needs no `Info.plist` entries at all). On a bounded surface\n the phone's pad *is* the surface; on a large one the phone is a viewport\n moving over it.\n- **RemoteDraw is the wire.** A hosted session carries strokes from the pen to\n the paper in real time and stores them. Nothing runs on the customer's\n infrastructure except the session-creation call.\n\nThe one question that decides whether RemoteDraw fits:\n\n> **What is being drawn on, and which screen is it already displayed on?**\n\nIf the answer is \"a screen the user is looking at, and they wish they could\ndraw on it with their hand\" — that is RemoteDraw. If the answer is \"the phone's\nown screen\", it is not: a phone drawing on its own content and uploading the\nresult is a camera-and-canvas feature you build with PencilKit or a `<canvas>`,\nand RemoteDraw would only add a round trip.\n\nThree consequences, because they are the mistakes integrators actually make:\n\n1. **The phone never supplies the picture.** \"The tenant photographs the leak\n and circles it\" is *not* a RemoteDraw flow — there is no second screen. The\n RemoteDraw version of that job: the photo is already open in your web app on\n the office desktop, and the person at that desk circles the leak with their\n phone instead of a mouse.\n2. **The receiver already exists.** RemoteDraw goes onto a screen your product\n already has. It does not get its own page unless the user asks for a demo.\n3. **Ink is coordinates, not pixels.** Strokes arrive in normalized board space\n (`0..1`, remapped through the sender's `device.aspectRatio`). Your app\n decides what board space *means* — a pixel, a page, a field, a coordinate on\n Earth. RemoteDraw never sees your content.\n\n**The receiver shows the phone, not only its ink.** Once a sender carries a\n`phoneProjection` — every hosted `/join` sender does, map boards included; a\nnative iOS sender does on every board except `map` — `RemoteDrawReceiver` draws\nthe phone's frame where it sits on the board, the pointer under the finger, a\npresence badge with the device name, and the live-view state, with no receiver\ncode. `RemoteDrawSessionControls` tells the phase story (waiting / connected /\ndrawing / submitted, plus \"· Away\" or \"· Live view\" when it is news) from the\nprovider's `presence`, which is the wire's `senders[].presence`\n(`present | stale | disconnected`) re-derived on the client's clock. Every\nreceiver-side component takes `theme=\"light\" | \"dark\" | \"auto\"`; `\"auto\"` is the\ndefault and follows the host page (`data-theme`, a `dark` class) before the OS.\n\n## Which flows are valid\n\nRead the row for the **sender** (who holds the phone) and the column for the\n**receiver** (the screen showing the content).\n\n| Sender (the pen) | Receiver (the paper) | Valid? | Notes |\n| --- | --- | --- | --- |\n| iPhone — RemoteDraw app, your app via `RemoteDrawSenderKit`, or hosted `/join` in Safari | Desktop / laptop browser | **Yes — the canonical flow** | Everything below is written for it. |\n| iPhone (any of the three) | Large display, TV, projector, kiosk browser | **Yes** | Size `target.coordinateSpace` to the display. |\n| iPhone (any of the three) | Desktop app — Electron, macOS, Windows — via `@remotedraw/client` or raw HTTP | **Yes** | No React needed; poll `/v1/receiver/*` or use the realtime source. |\n| Android phone — hosted `/join` in Chrome | Any of the above | **Yes** | There is no native Android SDK. The hosted join page *is* the Android sender and it is full-featured. |\n| iPhone / Android | iPad or tablet browser, as a **second** device someone else is looking at | **Yes** | Two devices, two people. |\n| Headless script, test, or agent — raw `POST /v1/join` → `/v1/sender/draft` → `/v1/sender/commit` | Any receiver | **Yes, for verification only** | Never ship a hand-rolled sender to users. |\n| Any phone | **The same phone** — one device shows the content and draws on it | **No** | There is no second screen. Use PencilKit / `<canvas>`. RemoteDraw adds a network hop and nothing else. |\n| A phone that must first **capture** the content — photograph or scan it | *(anything)* | **No** | The iOS sender opens no camera and needs no `Info.plist` entries. The content must already be on the receiver. |\n| Desktop mouse or trackpad as the pen | *(anything)* | **No — does not exist** | There is no desktop sender. The macOS trackpad sender is unbuilt research. Do not promise it. |\n| Phone → phone, two different people, two different devices | Phone browser as receiver | *Technically yes, rarely right* | A phone browser is a browser. But if both people hold phones, ask why the drawing is not simply in one app. |\n\n**The three nevers of the model.**\n\n1. **Never same-device.** If sender and receiver would be one phone, stop and\n say so. Propose the non-RemoteDraw alternative.\n2. **Never make the phone the source of content.** The receiver supplies what\n is drawn on. (One honest exception: the hosted `/join` pad has a file-attach\n tool that can put a file on the board. It is a hosted-sender capability, not\n a way to make a same-device flow valid, and it cannot currently be disabled.)\n3. **Never promise a sender RemoteDraw does not ship.** iOS (native SDK) and\n any mobile browser (hosted `/join`) are the senders. Nothing else exists.\n\n## Never do these\n\n- **Never write a custom canvas.** Not a `UIViewRepresentable` drawing view, not\n a `<canvas>` sender pad, not a hand-rolled SVG receiver, not a raw\n `URLSession`/`fetch` draft loop. Cadence, point budgets, the packed-point\n codec, sequence healing, token refresh and presence are protocol, and the SDKs\n implement them. Go headless on the SDK's own primitives if you must.\n- **Never put the iOS surface in a small pad or a draggable sheet.** It is\n full screen (`.remoteDrawSurface` / `RemoteDrawTakeover`). A sheet is\n acceptable only if `RemoteDrawSurface` fills it edge to edge and the sheet\n cannot be dragged mid-stroke. There is no small-canvas option.\n- **Never create a session on mount or on page load.** `POST /v1/sessions` is\n billable and not idempotent, and React StrictMode fires mount effects twice.\n Create it on the server (route handler, loader, server action) or on explicit\n user intent, and guard with a ref if it must be a client effect.\n- **Never let an `rd_sk_…` key reach a client.** Not browser bundles, not Swift,\n not app bundles, screenshots, logs, or generated examples. The phone never\n calls `/v1/sessions/direct-sender`; your backend does.\n- **Never poll by hand when a component or store exists.** `RemoteDrawProvider`\n / `createReceiverStore` already do one-in-flight polling plus an optional\n realtime push source.\n- **Never promise Bluetooth or Wi-Fi pairing.** The API advertises\n `bluetooth` and `localNetwork` as pairing methods and the iOS/Android apps do\n *advertise* on those radios, but **nothing scans, browses, or connects\n anywhere in the product**. They are not implemented. Do not present them as\n options; do not build UI around them. QR and direct sender are the real ones.\n- **Never promise a native Android SDK, a desktop sender, per-tenant Universal\n Links, e-signature compliance, or streaming inside `RemoteDrawSenderKit`.**\n See \"What does not exist yet\".\n- **Never delete or replace a host-app feature on your own initiative.** Build\n beside it.\n\n## The decision tree\n\n**1. What is being drawn on? → `target.kind` + `inputMapping`.**\n\n`target.kind` accepts `whiteboard`, `paper`, `canvas`, `svg`, `map`, `tldraw`,\n`field`, `image`, `pdf`, `screen`, `custom`. It selects the background the\nreceiver defaults to, the tool policy, and deposit defaults.\n`target.inputMapping` is `surface` (the phone's whole pad *is* the target — use\nfor bounded targets like a signature field) or `viewport` (the phone is a\nmovable window over a larger board — the default when omitted).\n\n| Kind | Host renders | `inputMapping` | Sender | Status |\n| --- | --- | --- | --- | --- |\n| `whiteboard` / `paper` | nothing — RemoteDraw's own ground | `viewport` (or `surface`) | hosted `/join` or SenderKit | shipped |\n| `image` (photo, screenshot) | the `<img>`, as `background` | `surface` (whole photo) or `viewport` (zoomable) | either | shipped |\n| `pdf` | your PDF renderer, one page at a time, as `background` or behind a `transparent` receiver | `viewport` | either | shipped; RemoteDraw renders no PDFs |\n| `field` (signature, initials) | the form, with a baseline as SVG `children` | **`surface`** — mandatory | either | shipped; **not** an e-signature product |\n| `screen` | a screenshot, or a live stream you publish | `viewport` | either | shipped; live view is experimental |\n| `map` | your map (Mapbox / MapLibre / Leaflet / Google) | `viewport` | hosted `/join` or SenderKit | shipped — use `RemoteDrawMapReceiver` |\n| `custom` | anything else you own | `viewport` | hosted `/join` (streaming) or SenderKit (ink only) | shipped |\n\n**2. Which stack renders the receiver? → the composition.**\n\n| Host | Use | Not |\n| --- | --- | --- |\n| React / Next / Remix | `@remotedraw/react`: `RemoteDrawProvider` + `RemoteDrawReceiver` (or `RemoteDrawMapReceiver`) + `PairingCode`/`RemoteDrawConnect`/`RemoteDrawLaunchButton` + `RemoteDrawSessionControls` | A hand-rolled SVG or polling loop |\n| Svelte / SvelteKit | `@remotedraw/svelte`: `createRemoteDrawReceiver` store + `createDirectSender` store. **No components ship** — you write all the markup, including ink rendering | Assuming React's components exist here |\n| Anything else with JS (Electron, Vue, vanilla) | `@remotedraw/client`: `createHttpReceiverClient`, `createReceiverStore`, `createRealtimeReceiverSource` | — |\n| No JS at all | Raw HTTP `POST /v1/receiver/*` with the receiver token | — |\n\n**3. Who holds the phone? → the sender path.**\n\n| Situation | Path |\n| --- | --- |\n| Anyone who can scan; no phone app in the product | **Hosted `/join`.** Render `joinUrl` as a QR with `PairingCode`. Zero sender code. The RemoteDraw iOS app opens the same HTTPS link through Universal Links; every other phone gets the web pad. |\n| The product has a first-party iOS app the user already installed *and* it is the RemoteDraw app | Same QR. Universal Links open it. |\n| The product has **its own** iOS app and the user is signed in | **Direct sender.** Backend calls `POST /v1/sessions/direct-sender` with `launchUrlTemplate: \"yourapp://draw?senderToken={senderToken}\"`; the browser shows `RemoteDrawLaunchButton`; the app receives the URL in `onOpenURL` and presents `.remoteDrawSurface(isPresented:senderToken:)`. Keep the QR as the fallback — the button reveals one automatically. |\n| A QR that opens the **customer's own** app | **Not possible.** The Universal-Link association file has one hard-coded appID and the QR host is a RemoteDraw deployment setting. Use the direct sender instead. |\n| Headless / tests | `POST /v1/join` → `/v1/sender/draft` → `/v1/sender/commit` |\n\n## Component catalogue\n\nEverything below is a real export, read from source. Anything not listed does\nnot exist. Grouped by decision: the board → what is behind it → pairing →\nsession state → AI → clients → iOS.\n\n### `@remotedraw/react`\n\n**`RemoteDrawProvider`** — receiver session state, polling, and actions.\nRequires a `receiver` client to do anything.\nProps: `children`, `session`/`sessionId`/`joinUrl`/`joinUrls`/`joinTokenUse`/`pairing`/`receiverToken`,\n`receiver` (a `ReceiverClient` — **without it the store is inert and silent**),\n`createSession`, `createSessionRequest` (default `{ target: { kind: \"custom\" } }`),\n`autoCreate` (default: true when `createSession` and no `session`),\n`pollIntervalMs` (`1000`), `draftPollIntervalMs` (`250`), `source` (`null`),\n`fallbackToPolling` (`true`), `initialDrawings`/`initialDrafts`/`initialSenders` (`[]`),\n`onError` (`(error: Error) => void` — **wire it; it is the only signal for a dead credential**),\n`refreshJoinToken` (`({ sessionId }) => Promise<result | null>` — the QR re-arm.\nThe join token lives 10 minutes; the session usually outlives it. Point this at\na backend route that calls `POST /v1/sessions/join-token` and return the\nresponse as-is; when the token dies on a live, unpaired session the provider\nswaps in the fresh code and every pairing component follows. Without it a stale\nQR shows \"Link expired\" until the host recreates the whole session — wire it in\nany integration where a board can sit unpaired for more than 10 minutes).\nContext: `session, sessionId, joinUrl, joinUrls, joinTokenUse, pairing, receiverToken, credentials, drawings, drafts, senders, presence, loading, creating, error, transport, create, refetch, ingest, undo, clear`.\n`presence` is one entry per sender — `{ id, senderId, name, state, drawing,\nliveView, lastSeenAt, hasProjection, sender }` — where `state` is the wire's\n`sender.presence` (`present | stale | disconnected`), `drawing` is a fresh\ndraft from that sender, and `liveView` is the wire's `sender.visualContext`\n(`{ status, reason?, error? }`). Re-derived on a 5 s clock while senders exist.\nAlso available as `useRemoteDrawPresence(senders?, drafts?)` (outside a\nprovider, pass records) and as the pure `resolveBoardPresence(senders, drafts)`.\n*Limits:* `error` is a plain `Error`; narrow with `instanceof RemoteDrawHttpError`\nfor `.status`/`.code`/`.shouldReJoin`.\n\n**`RemoteDrawReceiver`** — the receiver foundation. Paints committed ink, live\ndrafts, sender pointers, connected phones and their presence badges over a\nconfigurable background, and prescribes nothing around it.\nProps: `drawings`/`drafts`/`senders` (fall back to the provider), `background`\n(default: the session target's surface inside a provider, else `\"whiteboard\"`;\naccepts a surface name, `\"transparent\"`, any CSS background string, or any\nReactNode), `pointers` (`true`), `phones` (`true`),\n`presenceBadge` (`\"frame\"` — a compact badge riding each drawn phone with the\ndevice name, a state dot, and one word when it is news: Drawing / Away / Live\nview / Connecting view… / No live view yet; `\"corner\"` stacks one per present\nsender top-right, the only mode that also lists senders with no projection;\n`\"none\"`), `presence` (override the derived list), `theme` (`\"auto\"` — the\nbadge follows the host page; the phone band follows the **board's** surface, so\na white whiteboard in a dark app keeps the light band), `presenceLabels`,\n`pointerColor` (`#1f7a8c`),\n`strokeColor` (`#151512`), `draftColor` (`#1f7a8c`), `strokeWidth` (`6`),\n`coordinateAspectRatio` (default: the session's `coordinateSpace`, else `1`;\nalso sets CSS `aspect-ratio`), `preserveAspectRatio` (`\"none\"`),\n**`projectPoint`** (`(point) => {x,y} | null` in **CSS pixels from the\nreceiver's top-left** — the seam for a camera; return `null` to drop a point),\n**`space`** (the same seam in the receiver's own viewBox coordinates; `projectPoint`\nwins when both are given), `animate` (`true`), `children` (SVG overlay in\n`0..surfaceWidth × 0..1000`), `className`/`style`/`svgProps`/`aria-label`.\n*Defaults that surprise:* it renders immediately with no empty state; `phones`,\n`pointers` and the frame badge are on. A phone is drawn only for a sender whose\nrecord carries a `phoneProjection`; a `stale` sender's phone is dimmed and\ndashed, a `disconnected` one is not drawn, a drawing one has a lit aperture and\na breathing halo, and a `visualContext.status: \"connected\"` one shows the green\nin-use dot beside the camera. With `projectPoint`, pointers and phones are\ndrawn in the element's own pixel space, so they never squash when the viewBox\nand the element differ in shape.\n*Limits:* without `projectPoint`/`space` it stretches board space across its own\nelement — correct for a fixed board, silently wrong over a live map. Use\n`RemoteDrawMapReceiver` there.\n*Never:* stack it on a pannable map without a projection.\n\n**`RemoteDrawMapReceiver`** — `RemoteDrawReceiver` wired to a map the host owns.\nRenders a transparent ground (your map is the ground) plus the\nboard→geography→pixel projection. Takes every `RemoteDrawReceiver` prop except\n`projectPoint`/`space`, plus:\n`bounds` (the board's `target.coordinateSpace.bounds`; defaults to the session's\nown inside a provider — usually pass nothing), `mapBounds` (the map's current\nvisible bounds; **exact only for a north-up, unpitched camera**),\n`projectLngLat` (`(lng, lat) => {x,y} | null` — normally\n`map.project([lng, lat])`; exact under rotation and pitch, and wins over\n`mapBounds`).\n*Limits:* with neither `projectLngLat` nor `mapBounds` it renders unprojected\nand warns once in development. Leave `preserveAspectRatio` at `\"none\"`. The\nreceiver reprojects only when `projectLngLat` changes identity, so the callback\nmust depend on the camera: a lint rule (Biome, `react-hooks/exhaustive-deps`)\nwill flag the `camera`/`cameraFrame` state in its dependency list as unnecessary\nbecause the function body never reads it. **It is the anchoring mechanism.**\nRemoving it freezes the projection at the opening camera and the ink slides\nduring pans. Keep the dependency and suppress the rule with a comment.\n*Never:* recompute the projection identity on every render — memoize it and bump\nit on the map's `move` event.\n\n**`RemoteDrawPhoneProjection`** — one connected phone drawn in place, as an SVG\n`<g>` for a host with its own `<svg>`: a thin tinted band around the exact\nboard region the screen holds (translucent, aperture cut out, never opaque\nblack), the Dynamic Island / notch / home button of the detected model, and a\nleader line when phones stack. `layout` (required, from\n`phoneProjectionLayouts(senders)`), `space` (default 1000×1000), `color`,\n`model` (`\"auto\"`), `state` (`\"present\"`; `\"stale\"`/`\"disconnected\"` dim and\ndash), `drawing` (`false`; lights the aperture), `liveView`\n(`sender.visualContext.status`; `\"connected\"` = green dot, `requested`/`offering`\n= amber), `theme` (`\"light\"`, the surface it lies on), `className`.\n`RemoteDrawReceiver` already renders these.\n\n**`RemoteDrawPresenceBadge`** — the compact badge on its own, for a host's own\nroster or toolbar. `presence` (required, one entry from `useRemoteDrawPresence`),\n`color`, `theme` (`\"auto\"`), `labels` (`phone, drawing, stale, liveViewLive,\nliveViewRequested, liveViewPaused, liveViewFailed, liveViewUnavailable,\nliveViewNoProjection, liveViewOff`), `className`/`style`.\n\n**`PairingCode`** — the pairing component: the scannable code with live status,\nhover-to-copy, and subtle branding.\nProps: `joinUrl` (default: the provider's, falling back to `joinUrls.web`),\n`size` (`200`, or `\"fill\"`), `direction` (`\"paper\"`, one of 14 — TEMPORARY),\n`treatment` (`\"fluid\"`, one of 11 — TEMPORARY), `accentColor` (`#1f7a8c`),\n`inset`, `tile` (`true`), `logo` (the RemoteDraw mark; `false` for none, a\nstring for a URL), `logoPlacement` (`\"plate\"`), `title`/`description`,\n`showStatus` (`\"auto\"`), `joinMode` (the session's `joinTokenUse`),\n`showJoinMode`/`showLink` (`false`), `copyOnHover` (`true`), `onCopy`,\n`onConnected`/`onConnectedDismiss`, `labels`, `alt`, `theme` (`\"auto\"` — see\nTheming below; the code itself stays dark-on-white in every theme, the card and\ncaption follow; only `paper`/`outline`/`glass` have a designed dark card),\n`placement` (`\"inline\"` + four corners), `position`/`offset`/`zIndex`\n(`\"absolute\"`/`12`/`20`), `className`/`style`/`codeClassName`/`codeStyle`.\n*There is no `card`, `variant`, `showBrand`, `brand`, or `status` prop.* The\ncard is always drawn (`tile`, on by default) and extends to hold `title` /\n`description`; status is **inferred** (connected → error → expired → ready →\nidle) and cannot be passed.\n*Limits:* always encodes the HTTPS `joinUrl`. The \"expired\" status is real and\nterminal unless the provider has `refreshJoinToken` wired — the component shows\nthe death of the 10-minute join token but cannot mint a replacement itself. The\nanimated optical field that `variant=\"aurora\"` once selected is now the separate\nEXPERIMENTAL `AuroraPairingField`, decodable only by the RemoteDraw app's own\nscanner.\n\n**`PairingDevices`** — the list UI for pairing methods that resolve to a device\n(`bluetooth | localNetwork | accountPresence | direct`). `method` (required),\n`devices` (`[]`), `onSelectDevice`, `joinUrl`, `showCodeFallback` (`true`),\n`status`, `size` (`168`), `emptyLabel`, `actions`, `accentColor`, `theme`\n(`\"auto\"`), `autoHideOnConnected` (`true`), `connectedHideDelayMs` (`1150`),\n`direction`, `logo`, `onCopy`, `className`/`style`.\n*Limits:* **purely presentational — it discovers nothing.** You supply `devices`\nand `onSelectDevice` from your own backend. There is no Bluetooth, no Bonjour,\nand no customer-reachable account-presence route.\n*Never:* present it as \"nearby device pairing\" to a customer. It is chrome.\n\n**`RemoteDrawConnect`** — a compact trigger button that opens the code or the\ndevice list in a popover, for pairing exactly at the field, margin, or toolbar\nthat needs it. Everything from `PairingCodeProps` except placement/position/\noffset/zIndex/className/style/size/tile, plus `size` (`168`), `open`,\n`defaultOpen` (`false`), `onOpenChange`, `placement` (`\"bottom\"`), `method`,\n`devices`, `onSelectDevice`, `trigger`, `triggerLabel` (`\"Pair phone\"`),\n`showTriggerLabel` (`false`), `triggerClassName`/`triggerStyle`/`triggerDisabled`,\n`openOnHover` (`true`), `theme` (`\"auto\"`, passed on to the popover),\n`panel*`/`popover*` class and style.\n*Use it instead of hand-rolling a \"connect phone\" button.*\n\n**`RemoteDrawLaunchButton`** — \"open on my phone\" for a user whose **own** app is\nthe sender. Mints a scoped sender through your backend, opens the deep link, and\nreveals a QR when the app never comes back.\nProps: `connect` (required — your own backend endpoint; may return the raw\n`connectSender` response, a create-session response carrying `senderConnection`,\nor just `{ launchUrl }`; resolving `null` means \"no direct sender for this user\"),\n`handoffTimeoutMs` (`DIRECT_SENDER_HANDOFF_TIMEOUT_MS` = `12_000`),\n`showQrFallback` (`true`), `fallback`, `pairingProps`, `labels`, `accentColor`\n(`#1f7a8c`), `theme` (`\"auto\"`), `disabled` (`false`), `autoLaunch` (`true`), `openUrl` (default\nassigns `window.location.href`), `onError`, `onStatusChange` (transitions only),\n`children` (node or `(state) => node`), `className`/`style`/`buttonClassName`/\n`buttonStyle`/`aria-label`.\n*Why it is a component and not an `onClick`:* a custom scheme nothing has\nregistered fails **silently** — no error, no navigation, no event. The timeout\nwith no sender on the board is the only detector.\n\n**`useDirectSender(options)`** — the hook the button is a thin default over.\nOptions: `connect` (required), `autoLaunch`, `openUrl`, `onError`,\n`onStatusChange`. Returns `{ status, launchUrl, senderToken, senderId,\nconnection, error, connect(), launch(), reset() }`.\n`status`: `idle | connecting | ready | drawing | submitted | expired | error`.\n`error` is a `RemoteDrawLaunchError` (an `Error`) with\n`kind: \"connect-failed\" | \"no-launch-url\" | \"launch-blocked\"` and the original\nrejection on `cause`.\n*Limits:* phases after `ready` are read from `RemoteDrawProvider`; outside one\nit can never advance past `ready`.\n\n**`RemoteDrawSessionControls`** — the one session-state surface: a collected\nstatus bar (or card, via `title`) telling the session's story — waiting for a\nphone, connected, drawing, submitted, with the sender's device name and one\ndetail when it is news (\"· Away\", \"· Live view\", \"· Connecting view…\", \"· No\nlive view yet\", \"· Live view failed\", \"· +1 more\") — plus undo/clear, and end\nwhen the host wires it. `actions` (`[\"undo\",\"clear\"]`, plus `\"end\"` when\n`onEndSession` is given), `confirmClear` (`true`), `onEndSession` (arms like\nclear; ending needs the API key so it is the host's call), `title`,\n`submission`, `labels` (keyed by phase **or** detail: `stale, liveViewLive,\nliveViewRequested, liveViewPaused, liveViewFailed, liveViewUnavailable,\nliveViewNoProjection, liveViewOff, others`),\n`undoLabel`/`clearLabel`/`confirmClearLabel`/`endLabel`/`confirmEndLabel`,\n`metadataKeys`/`metadataLabels`/`formatMetadataValue` (keys render humanized,\nnever raw), `theme` (`\"auto\"`), `accentColor`, `placement` (`\"inline\"` + four\ncorners; a docked bar caps its width at `calc(100% - 2*offset)` so a\nbottom-right pill cannot run off the host), `position`/`offset`/`zIndex`\n(`\"absolute\"`/`12`/`20`), `className`/`style`.\nPresence comes from the provider's `presence` (wire `sender.presence`), never\nfrom a `lastSeenAt` window of its own: `present` → connected, `stale` → still\nconnected with \"Away\", `disconnected` → waiting. The bar wraps rather than\noverflows: text wraps, actions stay on one line and drop below when narrow.\n*Limits:* every failure renders as one string, \"Connection problem\".\n\n**Theming (every receiver-side component).** `theme?: \"light\" | \"dark\" | \"auto\"`\non `RemoteDrawReceiver`, `RemoteDrawSessionControls`, `RemoteDrawLaunchButton`,\n`PairingCode`, `PairingDevices`, `RemoteDrawConnect`, `RemoteDrawPresenceBadge`.\n`\"auto\"` (the default) reads the **host page** before the OS: the nearest\nancestor `data-theme` / `data-color-scheme` / `data-mode` / `data-rd-theme`\nattribute, a `dark` or `light` class (Tailwind/shadcn), or an inherited CSS\n`color-scheme`, then `prefers-color-scheme` — and re-resolves when the host\nflips. Every colour is a CSS custom property a host can set on any ancestor\nwith no prop: `--rd-ink, --rd-ink-muted, --rd-ink-faint, --rd-surface,\n--rd-surface-raised, --rd-surface-sunken, --rd-line, --rd-line-strong,\n--rd-accent, --rd-accent-soft, --rd-ok, --rd-warn, --rd-danger, --rd-danger-soft,\n--rd-glass, --rd-shadow, --rd-font`. Dark is a designed warm near-black\n(`#1a1a1c` surface, `#ece8df` ink, accent `#4db3c4`), not an inversion. The\nsender-side `/join` chrome is deliberately theme-independent and untouched.\nHelpers: `hostThemeFor(element)`, `resolveTheme(theme, element)`, `RD_TOKENS_CSS`.\n\n**`AiImage` / `AiText`** — render a finished `AiAction`. `AiImage`: `action`,\n`direction` (`\"plate\"`, 6 options), `actions` (`\"hover\"`, 4),\n`standardActions` (`[\"download\",\"copy\"]`, plus `\"open\"`), `customActions`,\n`theme` (`\"auto\"`), `aspectRatio`, `radius` (`14`), `fit` (`\"cover\"`),\n`fileName`, `labels`, `onRetry`, `placeholder`, `imageAlt`, `className`/`style`.\n`AiText`: `action`, `direction` (`\"note\"`, 5), `actions` (`\"bar\"`),\n`standardActions`, `customActions`, `theme`, `radius`, `maxWidth` (`\"60ch\"`),\n`labels`, `onRetry`, `placeholder`, `className`/`style`.\n*Limits:* neither ever shows the model, tier, latency, credit cost, or the\nprovider's error string. A schema run renders nothing — `result.generatedData`\nis for your code.\n\n**`useVisualContextPublisher`** (experimental) — the **publish** half of live\nview: sends the receiver's pixels to the phone drawing on it. Has its own\n`onError`.\n\n**`RemoteDrawStreamView`** — the **consume** half, for a sender pad you host\nyourself: the receiver's stream as a ground, your pad as its `children`.\nProps: `senderToken`, `signaling` (a `VisualContextSignalingClient` — build it\nwith `createRealtimeVisualContextSignalingClient` for push, or\n`createHttpVisualContextSignalingClient` for the polled `/v1/.../visual-context/*`\nroutes), `enabled` (gate it on `viewReceiverContext` + `visualContext.enabled` +\na reported phone projection), `iceServers` (from the session's\n`visualContext.iceServers` — without them a phone on cellular connects to\nnothing), `pollIntervalMs`, `onStatus`, `onError`, plus `fit` (`\"contain\"`),\n`fadeMs`, `posterStyle`, `className`/`style`/`aria-label`, `children`.\n`useRemoteDrawStream(options)` is the same thing headless, returning\n`{ mediaStream, status, streamStatus, stream, error, markStreamLive }` with\n`status` one of `idle | connecting | streaming | unsupported | failed | closed`.\n*Limits:* `children` are deliberately **not** gated on the stream — a pad that\nonly appears once pixels arrive never appears on the networks where WebRTC\ncannot connect, and drawing must keep working there.\n\n**`VisualContextVideoLayer`** — the raw `<video>` for a `MediaStream` you\nproduce yourself. `mediaStream`, `status`, `fit` (`\"contain\"`), `fadeMs`\n(`VISUAL_CONTEXT_FADE_MS` = `220`), `posterStyle`, `onLiveChange`,\n`className`/`style`/`aria-label`. Hand over on `onLiveChange(true)`, not on\nhaving a stream. `RemoteDrawStreamView` wires this for you.\n\n**Hooks:** `useRemoteDraw` (throws outside the provider),\n`useRemoteDrawSession`, `useReceiverData`, `usePairingUrl`,\n`useRemoteDrawPointers`, `useReceiverStrokes`.\n\n**Low-level / rarely right:** `InkCanvas` (WebGL2 ink substrate — the receiver\ndrives it), `RemoteDrawMark`, `AuroraPairingField` (EXPERIMENTAL),\n`FreehandFilmGroup`/`freehandStrokePaths`, the element-selection helpers.\n\n**Deliberately not exported:** a web sender component. Hosted `/join` is the web\nsender. Custom in-page pads are built headless on `createHttpSenderClient`.\n`@remotedraw/react/next` is a separate, unfinished v2 entry point — do not mix\nit into a normal integration.\n\n### `@remotedraw/svelte`\n\n`createRemoteDrawReceiver(options)` → `{ subscribe, create, refetch, ingest,\nundo, clear, setSession, configure, start, stop }`.\n`createDirectSender({ connect, receiver, autoLaunch, openUrl, onError })` →\n`{ subscribe, connect, launch, reset, stop }`, the same state machine React's\n`useDirectSender` binds. Plus `export * from \"@remotedraw/client\"`.\n**No components ship.** A Svelte integrator writes the pairing UI, the ink\nrendering and the session UI themselves.\n\n### `@remotedraw/client` (framework-free)\n\n- `createHttpRemoteDrawApiClient(baseUrl, { apiKey })` — **server-only.**\n `createSession`, `getSession`, `listSessions`, `issueJoinToken`,\n `connectSender`, `endSession`, `createAiAction`, `getAiAction`,\n `cancelAiAction`, `waitForAiAction`.\n- `createSessionWithHttpApi(baseUrl, options)` — server-only convenience.\n- `createHttpReceiverClient(baseUrl)` — the seven `/v1/receiver/*` calls.\n Credentials go in the body, not a header.\n- `createHttpSenderClient(baseUrl, { packPoints })` — 14 sender calls.\n- `createReceiverStore(options)` — the headless receiver state machine\n `RemoteDrawProvider` and the Svelte store both bind.\n- `createRealtimeReceiverSource({ driver })` + `createConvexRealtimeDriver({ client })`\n — push transport over the hosted realtime endpoint. `convex` is never imported\n by the package; you hand it a two-method driver.\n- `RemoteDrawHttpError` — `status`, `code`, `upgradeUrl`, `body`, and the\n getters `isAuthenticationFailure`, `isPermissionFailure`, `isSessionOver`,\n `shouldReJoin`.\n- `joinTokenFromInput`, `joinUrlForOrigin`, `nativeJoinUrlFromSession`.\n- `createPacedDraftQueue` / `createLatestOnlyQueue` / `draftPointsForTransport`\n / `retryIdempotentRequest` — the 32 ms latest-only draft gate a custom sender\n must use instead of POSTing every pointer event.\n- `createDirectSenderController`, `resolveDirectSenderStatus`,\n `directSenderConnectionFromResult` — the shared direct-sender rules.\n- `createRealtimeVisualContextSignalingClient` /\n `createHttpVisualContextSignalingClient` — where live-view signals travel\n (realtime push, or the polled public `/v1/.../visual-context/*` routes).\n\n### `@remotedraw/geometry`\n\nStroke/shape helpers (`buildNormalizedStroke`, `recognizeStroke`,\n`simplifyNormalizedPoints`, hit-testing, transforms, `drawingsToSvg`), **and the\nmap board transform**, re-exported by `@remotedraw/react`:\n`boardPointFromLngLat`, `lngLatFromBoardPoint`, `longitudeFromBoardX`,\n`latitudeFromBoardY`, `mercatorYFromLatitude`, `latitudeFromMercatorY`,\n`boardViewportFromMapBounds`, `mapBoundsFromBoardViewport`,\n`mapBoardPointToScreen`, `mapBoardPointFromScreen`, `screenPointFromBoardPoint`,\n`surfacePointFromBoardPoint`, `padMapBounds`, `MAX_MERCATOR_LATITUDE`.\nUse these rather than reimplementing the projection — `x` is linear in\nlongitude, `y` is linear in **Web Mercator**, and a version that is linear in\nlatitude puts ink kilometres away.\n\n### `RemoteDrawSenderKit` (SwiftPM, iOS 17+)\n\n`https://github.com/AxioSOzo/remotedraw-swift.git`, product\n`RemoteDrawSenderKit`. Zero external dependencies. No `Info.plist` entries.\n\n- `RemoteDraw.shared` — **zero-config**: it installs production defaults the\n first time anything reads it. `RemoteDraw.configure(_:)` in `App.init` is\n *optional* and only overrides `apiBaseURL`, `device`, `tokenProvider`,\n `urlSession`, `onClientAdvisory`. `try RemoteDraw.requireConfigured()` throws\n `RemoteDrawError.notConfigured` if you want the strict behaviour back.\n (This used to `preconditionFailure` from inside the modifier's `.task` — a\n crash on the user's tap. It no longer does.)\n- `.remoteDrawSurface(isPresented:senderToken:appearance:strings:exit:onOutcome:)`\n — the whole integration, a full-screen cover with the board, an exit and an\n outcome. A second overload adds `background:` — a `@ViewBuilder` handed a\n `RemoteDrawGroundContext` (`session`, `mapBounds`, `phoneProjection`, `size`,\n `reportViewport`) for your own cartography or ground. A ground that **moves**\n must call `reportViewport(_:)`; a static one calls nothing.\n- `RemoteDrawTakeover` — the same board plus scene-phase wiring and exit, for\n your own cover / navigation push / `UIHostingController`.\n- `RemoteDrawSurface` — the board as a plain `View`, for a host that fills its\n presentation with it edge to edge.\n- `RemoteDrawSenderSession` — the headless core (`begin`/`append`/`end`,\n `undo`, `clear`, `submit(metadata:)`, `edit`, `updateProjection`, `leave`,\n published `phase`, `strokes`, `live`, `lastError`), with\n `RemoteDrawInkCanvas` + `RemoteDrawStrokeCapture` when you own the screen.\n- `RemoteDrawBoardCanvas`, `RemoteDrawMapBoardGround`, `RemoteDrawMapGeometry`,\n `RemoteDrawAppearance`, `RemoteDrawStrings`, `RemoteDrawExit`,\n `RemoteDrawError` (13 cases with `shouldReJoin` / `isRetriable`).\n- **Map boards are built in.** A `kind: \"map\"` session with\n `coordinateSpace.bounds` draws the geography through MapKit, using the same\n transform as `@remotedraw/geometry`. The built-in map is deliberately not\n pannable; supply your own through `background:` if it should be.\n- **Streaming boards are not.** A session with `senderIntegrationMode:\n \"streaming\"` or a receiver publishing `visualContext.enabled` cannot be drawn\n by this SDK — no WebRTC, no video, no `WKWebView`. It reports\n `.unsupportedSurface(_:)` carrying `hostedSenderURL`; present that in a\n `WKWebView` and streaming works today.\n\n`RemoteDrawOutcome` is a **closed** enum:\n\n| Case | Meaning | Do |\n| --- | --- | --- |\n| `.submitted(RemoteDrawReceipt)` | Drawing submitted. | Record it. Call `session.submit(metadata:)` yourself for the server's own ids — a submit from the SDK's controls reports a placeholder. |\n| `.left` | The person left; ink is on the board. | Nothing. |\n| `.expired` | The board finished or timed out. **Terminal.** | Create a new session. |\n| `.credentialLost(RemoteDrawError?)` | The token died; the board did not. **Recoverable.** | Mint a fresh `rd_send_` and present again. |\n| `.unsupportedSurface(RemoteDrawUnsupportedSurface)` | The board wants a renderer this SDK lacks. The cover **stays up** showing the reason. | Open `hostedSenderURL` in a `WKWebView`. |\n| `.failed(RemoteDrawError)` | Anything else. | Read `error.shouldReJoin` / `error.isRetriable`. |\n\n**There is no `.revoked`.** A revoked, unknown and malformed token all answer\n`invalid_sender_token` on purpose; only a genuine expiry is distinguishable, and\nthat travels in the error on `.credentialLost`.\n\nNote: `RemoteDrawKit` is a *different*, internal macOS-only package that some\nolder docs still name. Customers use `RemoteDrawSenderKit`.\n\n## Recipes, one per surface\n\nAll React snippets assume the session came from your backend and are wrapped in:\n\n```tsx\nconst receiver = createHttpReceiverClient(\"https://api.remotedraw.com\");\n\n<RemoteDrawProvider\n session={session.session}\n receiverToken={session.receiverToken}\n joinUrl={session.joinUrl}\n joinUrls={session.joinUrls}\n receiver={receiver}\n onError={(error) => reportToYourLogger(error)}\n>\n {/* the recipe */}\n</RemoteDrawProvider>\n```\n\n### Streaming or native — choose the sender mode first\n\n`senderIntegrationMode` decides what the phone shows under the ink.\n\n- **Streaming for surfaces that show the customer's own content** — `map`,\n `image`, `pdf`, `screen`, `custom`. The phone sees exactly what the receiver\n shows: the receiver's pixels are streamed to it over WebRTC at ~8 fps with a\n 1280 px long edge by default (`visualContext.maxFps` / `maxLongEdge`). The\n phone is the hosted `/join` page, or your own `RemoteDrawStreamView` pad.\n- **Native for boards RemoteDraw grounds itself** — `whiteboard`, `paper`,\n `field`. There is nothing to stream; the phone draws on the same ground.\n- **Native is the higher-performance option on a map.** The phone renders its\n own basemap — OpenFreeMap Positron on the hosted `/join` sender, Apple Maps\n through MapKit in `RemoteDrawSenderKit` — at full frame rate, with no\n receiver tab required. Choose it when the customer's cartography is not\n itself what is being marked; choose streaming when it is (proprietary layers,\n live data drawn on the map).\n\nStreaming has **two halves**. Both must hold, or the phone draws on the\nfallback ground:\n\n1. **The session flag.** `senderIntegrationMode: \"streaming\"` implies\n `visualContext: { enabled: true }`, and either of the two appends the\n `viewReceiverContext` capability to the session's `capabilities` whether or\n not you listed capabilities. An explicit `visualContext: { enabled: false }`\n under `\"streaming\"` is respected. A join token minted with an explicit\n capability subset can still withhold the grant from one phone.\n2. **The receiver publishing its pixels.** `useVisualContextPublisher` on the\n receiver page with `projections` from the provider's senders and `geometry`\n updated on **every** camera move (pan, zoom, resize). A WebGL canvas\n (Mapbox, MapLibre, deck.gl) must be created with `preserveDrawingBuffer:\n true` or the publisher reads nothing. The receiver tab must stay open *and\n publishing* for as long as the phone draws; closing it ends the stream.\n\nHow the two devices relate: the phone pans and zooms inside the fence, and the\nreceiver crops its frame to the phone's projection. A frame never shows a\ndifferent place — at most blank backdrop past the receiver's own camera. Ink\nkeeps working in every state.\n\nIf the receiver never publishes, the phone shows \"Waiting for the board's live\nview — drawing on the basemap meanwhile\" (on non-map surfaces: \"…on the board\")\nafter ~8 s, keeps drawing on the fallback ground, and logs one\n`[remotedraw] Live view is not showing on this sender: …` warning that names the\nowner of the problem. The receiver's `senders[].visualContext.status` reads\n`requested` — the phone asked, the receiver never offered. A stream that dies\nlater reads `failed` (with `error`) or `closed`, and the phone shows \"Live view\nunavailable — drawing on the basemap\". `unavailable` with a `reason` — one of\n`disabled`, `capability_not_granted`, `no_projection`, `projection_closed` —\nmeans the session or the token stops it, not the receiver. The receiver's phone\nbadge and `RemoteDrawSessionControls` show the same states (\"Connecting view…\",\n\"Live view\", \"Live view failed\", \"No live view yet\").\n\nTwo limits to state plainly: `RemoteDrawSenderKit` has no streaming consumer —\non a streaming session it reports `.unsupportedSurface(_:)` with a\n`hostedSenderURL` to present in a `WKWebView` — and the one refused combination\nis *explicit* `senderIntegrationMode: \"native\"` together with\n`visualContext.enabled: true` on a `map` board, because a native iOS map sender\nsends no projection to crop to.\n\n### Whiteboard / freeform sketch\n\n```ts\n// Backend\ntarget: { kind: \"whiteboard\", label: \"Session notes\" }\n```\n\n```tsx\n<PairingCode title=\"Scan to draw\" />\n<RemoteDrawReceiver />\n<RemoteDrawSessionControls title=\"Sender workflow\" />\n```\n\n### Photo / screenshot annotation (`image`)\n\nThe photo is **already on the receiver**. The phone never takes it.\n\n```ts\ntarget: {\n kind: \"image\",\n inputMapping: \"surface\", // the pad is the whole photo\n coordinateSpace: { width: 1600, height: 900 },\n metadata: { label: \"Inspection photo\", imageId: \"img_123\" },\n}\n```\n\n```tsx\n<div style={{ position: \"relative\" }}>\n <RemoteDrawReceiver\n coordinateAspectRatio={16 / 9}\n background={<img src={photoUrl} alt=\"\" style={{ width: \"100%\", height: \"100%\", objectFit: \"contain\" }} />}\n />\n <PairingCode placement=\"top-right\" size={112} />\n</div>\n```\n\n*Limit:* RemoteDraw stores no images. Your app owns the photo and the link\nbetween it and the drawings.\n\n### PDF page\n\n```ts\ntarget: { kind: \"pdf\", inputMapping: \"viewport\",\n metadata: { documentId: \"doc_9\", page: 3 } }\n```\n\n```tsx\n<div style={{ position: \"relative\" }}>\n <YourPdfPage page={3} />\n <RemoteDrawReceiver\n background=\"transparent\"\n style={{ position: \"absolute\", inset: 0, pointerEvents: \"none\" }}\n />\n</div>\n```\n\n*Limit:* RemoteDraw renders no PDFs and knows nothing about pages. One session\nper page, or carry the page number in `target.metadata` and re-create.\n\n### Signature / bounded field\n\n```ts\ntarget: {\n kind: \"field\",\n inputMapping: \"surface\", // mandatory — the pad IS the field\n coordinateSpace: { width: 1600, height: 500 },\n metadata: { label: \"Customer signature\", fieldId: \"sig_1\" },\n}\n```\n\n```tsx\n<RemoteDrawConnect method=\"qr\" triggerLabel=\"Sign with your phone\" showTriggerLabel />\n<RemoteDrawReceiver coordinateAspectRatio={16 / 5} strokeWidth={7}\n style={{ border: \"1px solid #ddd8cf\", borderRadius: 12 }}>\n <line x1=\"140\" y1=\"760\" x2=\"3060\" y2=\"760\" stroke=\"#d8d8d8\" strokeWidth=\"4\" />\n</RemoteDrawReceiver>\n```\n\n*Limit:* this is markup transport, **not** e-signature compliance. No identity\nproofing, no intent-to-sign ceremony, no tamper-evident audit package, no\ncertificates. Say so if the user asks for a legal signature.\n\n### Map\n\nThe board's `coordinateSpace.bounds` is a **hard geographic fence, fixed for the\nlife of the session** — no route changes it. Size it larger than the camera you\nopen on. The phone pans *inside* it.\n\n```ts\nimport { padMapBounds } from \"@remotedraw/geometry\";\n\ntarget: {\n kind: \"map\",\n inputMapping: \"viewport\",\n coordinateSpace: {\n width: 1600, height: 1310, // the fence's Mercator aspect\n ...padMapBounds(currentCameraBounds, 1), // 3x the camera\n },\n}\n```\n\n```tsx\nconst [camera, setCamera] = useState(0);\nuseEffect(() => {\n const onMove = () => setCamera((n) => n + 1);\n map.on(\"move\", onMove);\n return () => map.off(\"move\", onMove);\n}, [map]);\nconst projectLngLat = useCallback(\n (lng: number, lat: number) => map.project([lng, lat]), // CSS px in the container\n [map, camera],\n);\n\n<div style={{ position: \"relative\" }}>\n <div ref={mapContainer} style={{ position: \"absolute\", inset: 0 }} />\n <RemoteDrawMapReceiver\n projectLngLat={projectLngLat}\n style={{ position: \"absolute\", inset: 0, pointerEvents: \"none\" }}\n />\n</div>\n```\n\n*Limits:* `mapBounds={map.getBounds()}` is the no-callback alternative but is\nexact only for a north-up, unpitched camera. Omitting `bounds` at session\ncreation does not mean \"the customer's map\" — it means RemoteDraw's own default\nregion. Both sender modes work on a map board. The default is `\"native\"`: the\nphone renders its own basemap and sends board-space points. `\"streaming\"` is\naccepted with `kind: \"map\"` and is what the hosted `/join` sender expects when\nthe customer's cartography is the thing being marked. In either mode the hosted\nsender opens on the fence's **centre third** at the phone's aspect and publishes\nits measured camera as `phoneProjection` (seeded at join, re-published on every\npan/zoom); that projection is what the receiver's phone frame and the stream\ncrop follow. The one refusal is *explicit* `senderIntegrationMode: \"native\"`\ncombined with `visualContext.enabled: true` — a native iOS map sender sends no\nprojection. See \"Streaming or native\" above.\n\n### Screen / live view\n\n```ts\ntarget: { kind: \"screen\", inputMapping: \"viewport\" }\nsenderIntegrationMode: \"streaming\" // implies visualContext.enabled and the viewReceiverContext grant\n```\n\nThe receiver publishes with `useVisualContextPublisher`. Three consumers, in\norder of how little you write:\n\n1. **The hosted `/join` pad** — consumes the stream automatically. Zero code.\n2. **Your own web pad** — `RemoteDrawStreamView` with your pad as its\n `children`, plus a signaling client.\n3. **`RemoteDrawSenderKit`** — *cannot* consume it. It returns\n `.unsupportedSurface(_:)` carrying a `hostedSenderURL`; present that in a\n `WKWebView` and streaming works today.\n\nLive view is experimental: build so that a session whose stream never starts is\nstill a working session — the phone keeps drawing, it simply does not see the\nreceiver's pixels.\n\n### iOS sender — the complete Swift\n\n```swift\n// Package.swift / Xcode → Add Package\n.package(url: \"https://github.com/AxioSOzo/remotedraw-swift.git\", from: \"0.1.0\")\n.product(name: \"RemoteDrawSenderKit\", package: \"remotedraw-swift\")\n\nimport RemoteDrawSenderKit\n\n// Optional. RemoteDraw.shared installs production defaults on first use, so\n// this line exists only to OVERRIDE something.\n@main struct MyApp: App {\n init() { RemoteDraw.configure(.init(tokenProvider: mintSenderToken)) }\n var body: some Scene { WindowGroup { RootView() } }\n}\n\n// Wherever drawing starts. `token` is the rd_send_ string your backend minted\n// with POST /v1/sessions/direct-sender. A QR's rd_join_ works too, but spending\n// one revokes every other sender on that session.\nButton(\"Draw\") { drawing = true }\n .remoteDrawSurface(isPresented: $drawing, senderToken: token) { outcome in\n switch outcome {\n case .submitted(let receipt): record(receipt)\n case .left: dismissBanner()\n case .expired: refreshSession() // terminal\n case .credentialLost: refreshToken() // recoverable\n case .unsupportedSurface(let it): openHostedPad(it.hostedSenderURL)\n case .failed(let error): report(error) // do not swallow\n }\n }\n```\n\nReceiving the launch URL (the other half of the direct sender — nothing else\ndocuments it):\n\n```swift\n// Info.plist: CFBundleURLTypes → your scheme, e.g. \"yourapp\"\n.onOpenURL { url in\n guard url.scheme == \"yourapp\",\n let token = URLComponents(url: url, resolvingAgainstBaseURL: false)?\n .queryItems?.first(where: { $0.name == \"senderToken\" })?.value\n else { return }\n senderToken = token\n drawing = true\n}\n```\n\nRules:\n\n- The surface is **full screen**. Not a small pad, not a draggable sheet.\n- The user can always leave; `RemoteDrawExit` only decides whether leaving with\n unsubmitted ink asks first. The host always gets an outcome.\n- Do not write a `UIViewRepresentable` canvas, a draft loop, or an HTTP client.\n Go headless on `RemoteDrawSenderSession` + `RemoteDrawInkCanvas` +\n `RemoteDrawStrokeCapture` if you own the screen — never raw `URLSession`.\n- Customers do not ship a separate RemoteDraw app; their app *is* the sender.\n\n## How to suggest use cases\n\n**What the product is good at**, as a sentence to pattern-match against:\n\n> A person is at a screen. The thing they need to mark is already on that\n> screen. A mouse is the wrong instrument for the mark — because it is\n> handwriting, a circle around a defect, a diagram, a signature, or a gesture\n> over a map — and their phone is in their pocket.\n\n**Ask these four before proposing anything:**\n\n1. Which screen in your product already shows the thing to be marked, and what\n device is that screen on?\n2. Is the person in front of it holding a phone at the same time?\n3. What does the mark mean afterwards — saved to which record, shown where?\n4. Anyone who scans, or a signed-in user of your own app? (QR vs direct sender.)\n\n**Good vs bad, for a property-management SaaS:**\n\n- ✅ Property manager reviews an inspection photo on the office desktop and\n circles the damage with their phone. *Two devices; content already on the\n receiver.*\n- ✅ Tenant signs the handover report on the manager's laptop screen using their\n own phone as the pen. *This is the flow that replaces a stylus.*\n- ✅ Planner marks a route on the dispatch map on the wall display.\n- ✅ Support agent circles the broken control on a customer's shared screen.\n- ❌ Tenant photographs a leak on their phone and circles it. *One device,\n phone-supplied content. **Not RemoteDraw.*** Say so and propose PencilKit.\n- ❌ An in-app sketch pad in the mobile app. *One device.*\n- ❌ Field engineer marks up a PDF on their iPad in the van. *One device —\n unless a second screen is genuinely present.*\n- ❌ \"Pair over Bluetooth when the phone is nearby.\" *Not implemented.*\n\n**The disqualifier:** if you cannot name two devices and say which one already\ndisplays the content, you do not have a use case yet — ask.\n\n## The flow — in this order\n\n1. **Explain before touching anything.** If the user is asking what RemoteDraw\n can do, answer from this file and the docs. Do not install, scaffold, or\n create sessions to answer a question.\n2. **Ask for approval before installing** — the first checkpoint in the list\n below. Name exactly what you want to add (`@remotedraw/cli`,\n `@remotedraw/react`, a Swift package, a dashboard project + key) and why,\n then wait. This includes `remotedraw init` without `--offline`, which\n provisions a billable project and key. Install with the\n project's own package manager — the scan below reports it, and a lockfile\n the project did not ask for is a mess a human has to clean up:\n `npm install -g @remotedraw/cli`, `pnpm add -g @remotedraw/cli`,\n `bun add -g @remotedraw/cli`, or — Yarn Berry has no global install —\n `yarn dlx @remotedraw/cli`. Same for the SDKs: `npm install`, `pnpm add`,\n `yarn add`, or `bun add`.\n3. **Scan the codebase.** `remotedraw scan --format json` (or, before the CLI\n is installed, `npx @remotedraw/cli@latest scan --format json` — `pnpm dlx`,\n `yarn dlx`, or `bunx @remotedraw/cli` for those managers) reports the\n web/server/iOS projects, the project's package manager and its install/add\n commands, where an `rd_sk_` key may live, any RemoteDraw wiring already\n present, the integration options that fit, and the product questions to ask.\n Read it; verify its `evidence` where it matters.\n4. **Ask the product questions** (the four above, plus the scan's own). Do not\n invent answers; a one-page \"drawing lab\" is only right when the user says a\n demo is what they want.\n5. **Propose one plan, then build all of it.** Receiver, session creation,\n sender, and the exit/submit path — an integration is not done when ink\n appears once on a test page. Build beside existing features.\n6. **Verify by using it.** Run `remotedraw doctor --format json`, open a real\n session, draw from a phone (or `create-input --execute` + the hosted join\n URL), and confirm ink lands on the receiver. On iOS, run it on a device or\n simulator and look at the screen; a compiling canvas is not a working one.\n\nSession creation (`POST /v1/sessions`) needs the `rd_sk_` key and therefore runs\nonly where the scan found server-side code: a Convex action, a Next route\nhandler, an Express/Hono route, a serverless function. If the scan found none,\nask where the backend is. Never scaffold `createRemoteDrawSession.ts` into a\nVite/Next client tree — `--target web` in `remotedraw init` still writes it\nunder `src/`; move it, or scaffold into a scratch directory and copy only what\nbelongs.\n\n### Checkpoints — stop and ask, every time\n\nAt each moment below, stop and ask with one explicit, polished request —\n**\"I need this from you now: <what>. May I proceed?\"** — then wait. Do not\nbury it in progress text, and do not proceed on silence.\n\n| Moment | What you say you need |\n| --- | --- |\n| Before installing anything — `@remotedraw/cli`, `@remotedraw/react` / `svelte` / `client`, the `RemoteDrawSenderKit` Swift package | The exact package names, the command in the project's own package manager, and why. |\n| Before provisioning — `remotedraw init` without `--offline`, `remotedraw project create`, or a key from the dashboard | That it creates a dashboard project and an `rd_sk_` key on the user's account. |\n| When the `rd_sk_` key must be placed in the customer's server environment | The exact variable and location — for example `REMOTEDRAW_API_KEY` in `.env.local`, or the same name in the Convex / Vercel / hosting env — and that the user pastes it there. Never print the key back, write it into a committed file, or echo it into a log. |\n| Before creating billable sessions in a test — `remotedraw create-input --execute`, `POST /v1/sessions` from a script | How many, on which project, and that each is billable. |\n| When the phone is needed | Which URL or QR to scan and from which page; which mode to expect (native: the phone's own basemap or ground; streaming: the basemap first, then the receiver's own pixels fading in within ~5 s); what a correct result looks like (a stroke at a landmark on the phone lands at the same landmark on the receiver, the phone frame appears within ~1 s and follows a pan); and what to report back (what the phone showed, any pill text, any `[remotedraw]` console line, what the receiver's session bar said). |\n| Before deploying | What goes where, and that the key is in that environment's secrets, not in the bundle. |\n| When sign-in to the customer's own app is required to reach the receiver | Which account and which page; you cannot sign in for them. |\n\nNo approval needed: `npx @remotedraw/cli@latest agent --print-skill`,\n`remotedraw scan --format json`, `remotedraw options --format json`,\n`remotedraw init --offline --dry-run`, and reading the docs. None of these\ninstall, provision, or bill.\n\n## CLI\n\n```sh\nremotedraw options --format json # the option catalog\nremotedraw scan --format json # read the codebase first (step 3)\nremotedraw init --non-interactive --offline --dry-run --format json \\\n --path apps/web --target web --sender remotedraw-ios --sdk react --preset sketch --package-manager npm\nremotedraw doctor --format json\nremotedraw create-input --preset sketch --json\nremotedraw create-input --execute # a real session + join URL, needs a key\nremotedraw agent --print-skill # this file, no install needed\n```\n\n`--sender own-ios` requires `--sdk swift`; other invalid combinations fail with\n`INVALID_COMBINATION`. By default `init`/`new` also create a dashboard project\nand a project-scoped development key in `.env.local` — that is the step that\nneeds approval (step 2); `--offline` writes files only. `--preset mapMarkup`\nemits an explicit `coordinateSpace.bounds`; replace the example region with the\ncustomer's. Never pass `--force` unless the user approved overwriting. Do not\ndrive the interactive wizard or scrape human-formatted output; every command has\n`--format json`.\n\n## Security and tenancy\n\n- `rd_sk_…` keys: backend secrets only. Never in browser bundles, Swift, app\n bundles, screenshots, logs, or generated examples.\n- The account-level `rd_cli_…` credential stays in the user config directory;\n never copy it into a project. `REMOTEDRAW_CLI_TOKEN` is for CI secrets only.\n- Public clients receive only `joinUrl`, `joinToken`, `receiverToken`, or\n `senderToken`, each scoped to one session. Production QR codes use the HTTPS\n `joinUrl`, not the custom scheme.\n- One key serves every customer of the product, so a session id is not a\n capability. Create sessions with `externalId: \"<product>:<tenant>\"`, and check\n it (`POST /v1/sessions/get`) before attaching a sender or ending a session on\n a tenant's behalf.\n\n## Gotchas the SDKs hide and hand-written code hits\n\n- `POST /v1/sessions` is billable and not idempotent. React StrictMode runs\n mount effects twice in development: guard with a ref, or create the session in\n a server action / loader. Store the `receiverToken` if the receiver outlives a\n page load — it is the only credential that reads a session's ink.\n- Timestamps are integer milliseconds. `occurredAt` and point `t` values are\n accepted with a fraction (floored) but a hand-written client should send\n integers.\n- Committed points come back in board space, remapped through\n `device.aspectRatio`; send the aspect ratio of the pad the finger touches.\n- `RemoteDrawReceiver` defaults `phones` and `pointers` to on, and renders\n immediately with no empty state. Pass `phones={false}` for a plain surface;\n drive your own empty state from `useReceiverData().senders`.\n- `PairingCode` hides the join URL text unless `showLink`.\n- `joinTokenExpiresAt` is earlier than the session's `expiresAt`: the QR dies\n first, the board stays live.\n- Wire `RemoteDrawProvider`'s `onError` (and `useVisualContextPublisher`'s).\n Without it a dead credential is silent and the board simply stops updating.\n- The hosted `/join` pad respects the joined capability list; a custom sender\n must too. It also has a **file-attach tool** available on any session granting\n `draw` or `point`, which cannot currently be turned off.\n\n## API contract\n\n- Backend: `POST /v1/sessions` (create), `/v1/sessions/get`, `/v1/sessions/end`,\n `/v1/sessions/direct-sender` (mint `rd_send_` for your own app),\n `/v1/sessions/join-token` (a fresh QR).\n- Receiver: `POST /v1/receiver/session`, `/drawings`, `/drafts`, `/senders`\n with the receiver token, or the realtime source in the SDKs.\n- Sender: `POST /v1/join` (spends a join token; revokes other senders), then\n `/v1/sender/draft` (latest-only preview, throttle to ~32 ms),\n `/v1/sender/commit` (one durable stroke per pointer-up with a stable\n `clientStrokeId`), `/v1/sender/submit`.\n- Capabilities: `draw`, `point`, `undo`, `clear`, `moveViewport`,\n `viewExisting`, `viewReceiverContext`. Omitting `capabilities` grants the\n first six. Reissued join tokens may narrow but never widen.\n\n## AI actions\n\nReach for AI when the product needs something _from_ the finished drawing: a\ngenerated image, a description, or structured data to branch on. Backend only\n(`aiActions:*` scopes on an `rd_sk_...` key). Never wire it to a commit, submit,\nor presence event — AI runs only on an explicit `POST /v1/ai-actions` call the\nuser asked for. Run it after the user is done; the route accepts `active` and\n`ended` sessions.\n\nMinimal request per outcome (`POST /v1/ai-actions`, plus optional\n`quality: \"fast\" | \"balanced\" | \"max\"`, default `balanced`):\n\n```jsonc\n{ \"sessionId\": \"...\", \"request\": \"image\", \"prompt\": \"...\" } // image back in the response\n{ \"sessionId\": \"...\", \"request\": \"image\", \"prompt\": \"...\", \"deliver\": [\"result\", \"board\"] } // and onto the board\n{ \"sessionId\": \"...\", \"request\": \"text\", \"prompt\": \"Describe this drawing.\" } // text back\n{ \"sessionId\": \"...\", \"request\": \"text\", \"prompt\": \"...\", \"text\": { \"schema\": { /* JSON Schema */ } } } // typed JSON\n```\n\nThe response is asynchronous: `create` returns `status: \"queued\"`. Poll\n`POST /v1/ai-actions/get` until `status` is `succeeded`, `failed`, or\n`canceled`, or use `createAiAction` + `waitForAiAction` on\n`createHttpRemoteDrawApiClient` from `@remotedraw/client` (re-exported by\n`@remotedraw/react`) — backend only, it holds the key. There is no completion\nwebhook. Render results with `AiImage` / `AiText`. See\nhttps://docs.remotedraw.com/docs/api#ai.\n\n## What does not exist yet\n\nVerified against source, 2026-08-30. Read this *before* designing, so you never\npromise any of it.\n\n- **No Bluetooth or Wi-Fi pairing.** The radios advertise; nothing scans or\n browses, on any platform. `PairingDevices` is presentational chrome.\n- **No QR into a customer's own app.** The Universal-Link association file has\n one hard-coded appID and the QR host is a RemoteDraw deployment setting. Use\n the direct sender.\n- **No native Android SDK.** Hosted `/join` in a mobile browser is the Android\n sender, and it is full-featured.\n- **No desktop or trackpad sender.** Unbuilt research.\n- **No streaming consumer in `RemoteDrawSenderKit`.** It reports\n `.unsupportedSurface(_:)` with a `hostedSenderURL` to open in a `WKWebView`.\n (A customer's own *web* pad can consume a stream — `RemoteDrawStreamView`.\n It is the native SDK that cannot.)\n- **No Svelte components.** A receiver store and a direct-sender store only.\n- **No web sender component.** Deliberate: hosted `/join` is the web sender.\n- **No API-key route that reads a session's ink.** Lose the `receiverToken` and\n the session is unreadable while still billable.\n- **No `clientSessionId` idempotency on `POST /v1/sessions`.**\n- **No completion webhook for AI actions** — poll `POST /v1/ai-actions/get`.\n- **No e-signature compliance.** No identity proofing, intent-to-sign ceremony,\n tamper-evident audit package, or certificate handling.\n- **No content rendering of any kind.** No PDF renderer, no image storage, no\n document pipeline, no auth, no billing UI. The host renders; RemoteDraw inks.\n\n## Verification\n\nAfter changes, verify against the customer's project — never assume RemoteDraw's\nown repo scripts exist here.\n\n```sh\nremotedraw doctor # config, SDK deps, REMOTEDRAW_* env\nremotedraw create-input --execute # open a real session, print the join URL\n```\n\nThen run whatever type check and test command the project already defines (for\nexample `npm run typecheck` and `npm test`). Do not invent script names, and do\nnot run `bun run test:api`, `bun run typecheck`, or `bun run ios:kit:test` —\nthose are RemoteDraw's internal monorepo scripts and will not exist in a\ncustomer project.\n\n## Reference\n\n- API: `https://api.remotedraw.com` · Docs: `https://docs.remotedraw.com/docs`\n (agent summary: `https://docs.remotedraw.com/llms.txt`) · Keys:\n `https://dashboard.remotedraw.com/api/keys`\n- Packages: `@remotedraw/cli`, `@remotedraw/react`, `@remotedraw/svelte`,\n `@remotedraw/client`, `@remotedraw/protocol`, `@remotedraw/geometry`, and the\n SwiftPM package `https://github.com/AxioSOzo/remotedraw-swift.git` (product\n `RemoteDrawSenderKit`).";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remotedraw/cli",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Command-line tools for creating and inspecting RemoteDraw integrations.",
5
5
  "type": "module",
6
6
  "bin": {