# Surfaces — for AI agents generating UIs on Tokenrip

> **You are reading this because you (Codex, Claude Code, Cursor, or another harness) have been asked to generate a Surface.** A Surface is a persistent AI-generated UI hosted by Tokenrip at `/x/:publicId`. You write the HTML; Tokenrip hosts it, versions it, validates it, and bridges it to Tokenrip data through the `window.tokenrip` SDK. **All data access goes through that SDK — never call `/v0` directly.** After you publish, the operator gets a draft URL; they review it and tell you when to promote.

This document is the contract. Generated code that follows it will keep working as Tokenrip evolves internally. Generated code that bypasses the SDK and hand-rolls REST calls is non-compliant — validation will flag it and it may break on the next internal change.

---

## 1. Mental model

- Surfaces are **AI-generated HTML pages**. A single self-contained HTML file is what you publish.
- They are **owner-only** in v1. The operator who owns the surface is the only viewer. Do not write "share this URL" copy.
- Tokenrip injects `window.__SURFACE__` (internal) and loads `/surface-instrument.js`, which publishes a stable `window.tokenrip` SDK before your code runs.
- The SDK is the public contract. The HTTP routes underneath it are an internal implementation detail and will change.
- You use binding keys (declared at publish time) to identify mounts, collections, and artifacts. The SDK looks up the actual mount/collection/artifact IDs from the binding map — your code never sees an ID, just a key like `'signals'` or `'briefDoc'`.
- After every publish or update, Tokenrip auto-runs a headless validation in a sandboxed browser. The validation runtime blocks all mutating SDK calls (they reject with `validation_blocked`); this is normal and expected.

---

## 2. The generation flow

1. **Inspect.** Call `inspect_mount(mountId)` for mounted-agent workflows, or `inspect_artifact(artifactId)` for single-artifact editors. The response returns the schema, sample rows (≤5), `recommendedBindingKey`, `recommendedBinding`, and `sdkExamples` you can paste straight into the generated HTML.
2. **Generate.** Write a single-file HTML page. React via Babel-in-browser (UMD + `@babel/standalone`) is the smooth path — see the worked examples in §5. Vanilla DOM works too. Use only the v1 CDN allowlist (§6).
3. **Call the SDK.** All data access goes through `window.tokenrip.*`. Never call `/v0` URLs directly.
4. **Publish.** Call `publish_surface({ title, htmlContent, bindings })`. The response includes `publicId`, `draftUrl`, and a `validation` summary.
5. **Fix if needed.** If `validation.ok === false` or `validation.errorCount > 0`, read the errors (see §8), fix the HTML, and call `update_surface(publicId, { htmlContent })`. Each update auto-validates. Repeat until clean.
6. **Hand off.** Present the `draftUrl` to the operator: "Your Surface is at <url>. Review it, then tell me when to promote." Do not promote without operator confirmation.
7. **Promote.** When the operator says "looks good, promote it," call `promote_surface(publicId)`.

---

## 3. The SDK contract

The SDK is published synchronously at `window.tokenrip` before any generated script runs. It exposes three namespaces: `surface`, `collections`, `artifacts`.

### 3.1 `window.tokenrip.surface.info()`

Returns a **frozen** snapshot of the surface's metadata. Inspect it for labels, viewer context, or runtime mode; you cannot mutate it.

```ts
{
  publicId: string;
  revisionId: string;
  runtime: 'operator' | 'validation';
  viewer: { accountId: string; role: 'operator' | 'agent' } | null;
  bindings: Readonly<Record<string, SurfaceBinding>>;
}
```

- `runtime === 'operator'` — real owner runtime. Writes are allowed.
- `runtime === 'validation'` — Tokenrip's validation sandbox. All mutating SDK calls reject with `validation_blocked`. Read-only calls still work. Use this to show a "Validation mode — writes blocked" banner instead of letting the UI display a scary error.

> Do not reference `window.__SURFACE__` directly. That global is internal bootstrap state and is not part of the contract. Use `window.tokenrip.surface.info()` for a stable, frozen, immutable copy.

### 3.2 `window.tokenrip.collections`

Operates on `mount_collection` bindings.

```ts
collections.rows(key, opts?): Promise<{
  rows: Array<{ id: string; data: Record<string, unknown>; createdAt?: string; updatedAt?: string }>;
  nextCursor: string | null;
  hasMore: boolean;
}>

collections.patch(key, rowId, dataDelta): Promise<{ id: string; data: Record<string, unknown>; updatedAt: string }>

collections.append(key, rows): Promise<{ rows: Array<{ id: string; createdAt: string }> }>
```

| Method | Required permission on binding |
|---|---|
| `collections.rows` | `rows:read` |
| `collections.patch` | `rows:patch` |
| `collections.append` | `rows:append` |

**`opts` for `rows`:**

```ts
{
  limit?: number;
  cursor?: string;                                 // pass the nextCursor from a previous page
  sort?: { by: string; order: 'asc' | 'desc' };
  filter?: Record<string, string | number | boolean>; // exact-match only
}
```

**`collections.append` is array-only.** Even for one row, pass `[row]`:

```js
await window.tokenrip.collections.append('researchRequests', [
  { topic: 'GPU shortages', requested_at: new Date().toISOString() },
]);
```

### 3.3 `window.tokenrip.artifacts`

Operates on `artifact` bindings (text-supporting types only: `markdown`, `html`, `code`, `text`, `json`).

```ts
artifacts.read(key): Promise<{ content: string; mimeType: string; version: number }>

artifacts.saveVersion(key, input): Promise<{ version: number; createdAt: string }>
```

| Method | Required permission on binding |
|---|---|
| `artifacts.read` | `read` |
| `artifacts.saveVersion` | `version:create` |

**`saveVersion` input:**

```ts
{
  content: string;
  mimeType?: string;     // optional; falls back to the artifact's existing mime
  description?: string;  // optional change note
}
```

### 3.4 Error codes

Every SDK method either resolves with normalized data or rejects with a `TokenripSdkError`:

```ts
{
  name: 'TokenripSdkError';
  code: SdkErrorCode;       // see below
  message: string;
  bindingKey?: string;      // the key that caused it
  operation?: string;       // e.g. 'collections.patch'
  status?: number;          // HTTP status when applicable
}
```

| Code | When |
|---|---|
| `binding_not_found` | The key is missing from the binding map, or the binding kind is wrong for the operation |
| `permission_denied` | The binding lacks the required permission (e.g. `rows:patch`) — server-side ACL denied at runtime |
| `validation_blocked` | The Surface is running in validation mode and the call mutates data — always thrown for writes during validation; **this is expected, do not "fix" it** |
| `not_found` | HTTP 404 — the target row/artifact no longer exists |
| `conflict` | HTTP 409 — version conflict or duplicate |
| `rate_limited` | HTTP 429 |
| `server_error` | HTTP 5xx (or unmapped 4xx) |
| `network_error` | `fetch` itself failed (timeout, DNS, offline) |
| `parse_error` | The response body could not be parsed, or you passed the wrong shape (e.g. non-array to `append`) |
| `unauthorized` | HTTP 401 — session token expired or missing |

---

## 4. SDK error handling pattern

Wrap every SDK call in `try/catch` (or `.then(.., onErr)`). Surface user-friendly messages instead of raw error strings. Handle `validation_blocked` specifically.

```jsx
function describeError(err) {
  if (!err) return { code: 'unknown', message: 'unknown error' };
  return { code: err.code || err.name || 'error', message: err.message || String(err) };
}

function isValidationBlocked(err) {
  return !!(err && err.code === 'validation_blocked');
}

// Pre-flight: detect validation mode at mount and show a banner.
function surfaceRuntime() {
  try {
    var info = window.tokenrip.surface.info();
    return (info && info.runtime) || 'operator';
  } catch (e) { return 'unknown'; }
}
```

Use the banner pattern in §5A / §5B. The principle:

- **Read calls work in both runtimes.** Render data normally.
- **Write calls fail in validation runtime.** If `err.code === 'validation_blocked'`, set a `validationMode` flag and show a calm banner like "Validation mode — writes blocked". Do not log it as a scary error.
- **All other errors** should surface their `code` and `message` — operators (and the publishing agent reading validation telemetry) need to see them.

You can also pre-emptively check `surfaceRuntime() === 'validation'` on mount to show the banner before the first write attempt.

---

## 5. Persistence recipes (worked examples)

These three patterns cover most v1 Surfaces. Each example is structurally identical to a fixture under `apps/frontend/test/fixtures/surfaces/` and exercises the full SDK.

### 5A. Mount-collection row editor

Card-style listing of a workflow collection. Status `<select>` saves immediately; operator-note `<textarea>` debounces 600ms. Mirrors `row-editor.html`.

**Required binding:**

```json
"signals": {
  "kind": "mount_collection",
  "mountId": "<mount uuid>",
  "collection": "signals",
  "permissions": ["rows:read", "rows:patch"]
}
```

**HTML body:**

```html
<div id="root" class="min-h-screen bg-slate-50 p-6 font-sans text-slate-900">loading...</div>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/react@18.3.1/umd/react.production.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@7.24.7/babel.min.js"></script>
<script type="text/babel" data-presets="react">
  (function () {
    'use strict';

    var BINDING = 'signals';
    var DEBOUNCE_MS = 600;
    var STATUS_OPTIONS = ['new', 'triaged', 'in_progress', 'resolved', 'ignored'];

    function describeError(err) {
      if (!err) return { code: 'unknown', message: 'unknown error' };
      return { code: err.code || err.name || 'error', message: err.message || String(err) };
    }
    function isValidationBlocked(err) { return !!(err && err.code === 'validation_blocked'); }
    function surfaceRuntime() {
      try { return (window.tokenrip.surface.info() || {}).runtime || 'operator'; }
      catch (e) { return 'unknown'; }
    }

    function RowEditor() {
      var useState = React.useState;
      var useEffect = React.useEffect;
      var useRef = React.useRef;

      // ES5-outside-JSX rule: destructure the tuple by index.
      var rowsState = useState([]);     var rows = rowsState[0];      var setRows = rowsState[1];
      var loadingState = useState(true);  var loading = loadingState[0];  var setLoading = loadingState[1];
      var errorState = useState(null);    var error = errorState[0];      var setError = errorState[1];
      var modeState = useState(surfaceRuntime() === 'validation');
      var validationMode = modeState[0];  var setValidationMode = modeState[1];
      var timersRef = useRef({});

      useEffect(function () {
        var cancelled = false;
        window.tokenrip.collections.rows(BINDING, {
          limit: 100,
          sort: { by: 'createdAt', order: 'desc' },
        }).then(function (page) {
          if (cancelled) return;
          setRows(page.rows || []);
          setLoading(false);
        }, function (err) {
          if (cancelled) return;
          setError(describeError(err));
          setLoading(false);
        });
        return function () { cancelled = true; };
      }, []);

      function applyLocal(rowId, delta) {
        setRows(function (prev) {
          var next = [];
          for (var i = 0; i < prev.length; i++) {
            var r = prev[i];
            if (r.id === rowId) {
              var merged = { id: r.id, createdAt: r.createdAt, data: {} };
              for (var k in r.data) if (Object.prototype.hasOwnProperty.call(r.data, k)) merged.data[k] = r.data[k];
              for (var dk in delta) if (Object.prototype.hasOwnProperty.call(delta, dk)) merged.data[dk] = delta[dk];
              next.push(merged);
            } else { next.push(r); }
          }
          return next;
        });
      }

      function persist(rowId, delta) {
        window.tokenrip.collections.patch(BINDING, rowId, delta).then(
          function () { setError(null); },
          function (err) {
            if (isValidationBlocked(err)) setValidationMode(true);
            setError(describeError(err));
          }
        );
      }

      function onStatusChange(rowId, value) {
        applyLocal(rowId, { status: value });
        persist(rowId, { status: value });
      }

      function onNoteChange(rowId, value) {
        applyLocal(rowId, { operator_note: value });
        if (timersRef.current[rowId]) clearTimeout(timersRef.current[rowId]);
        timersRef.current[rowId] = setTimeout(function () {
          persist(rowId, { operator_note: value });
        }, DEBOUNCE_MS);
      }

      return (
        <div className="mx-auto max-w-3xl space-y-4">
          <header className="flex items-baseline justify-between">
            <h1 className="text-xl font-semibold tracking-tight">Signals</h1>
            <span className="text-xs text-slate-500">{rows.length} row{rows.length === 1 ? '' : 's'}</span>
          </header>

          {validationMode ? (
            <div className="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900">
              Validation mode — writes blocked
            </div>
          ) : null}

          {error ? (
            <div className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-900">
              <span className="font-mono font-semibold">{error.code}</span> — {error.message}
            </div>
          ) : null}

          {loading ? <div className="rounded-md border border-slate-200 bg-white p-4 text-sm text-slate-500">Loading…</div> : null}

          <ul className="space-y-3">
            {rows.map(function (row) {
              var d = row.data || {};
              return (
                <li key={row.id} className="rounded-md border border-slate-200 bg-white p-4 shadow-sm">
                  <div className="flex items-baseline justify-between gap-3">
                    <h2 className="text-sm font-semibold text-slate-900">{d.title || '(untitled)'}</h2>
                  </div>
                  <div className="mt-3 flex items-center gap-2">
                    <label className="text-xs font-medium uppercase tracking-wide text-slate-500">Status</label>
                    <select
                      aria-label="Status"
                      className="rounded border border-slate-300 bg-white px-2 py-1 text-sm"
                      value={d.status || 'new'}
                      onChange={function (e) { onStatusChange(row.id, e.target.value); }}
                    >
                      {STATUS_OPTIONS.map(function (opt) {
                        return <option key={opt} value={opt}>{opt}</option>;
                      })}
                    </select>
                  </div>
                  <div className="mt-3">
                    <label className="text-xs font-medium uppercase tracking-wide text-slate-500" htmlFor={'note-' + row.id}>
                      Operator note
                    </label>
                    <textarea
                      id={'note-' + row.id}
                      className="mt-1 w-full rounded border border-slate-300 bg-white px-2 py-1 text-sm"
                      rows={2}
                      value={d.operator_note || ''}
                      onChange={function (e) { onNoteChange(row.id, e.target.value); }}
                      placeholder="Add context for the agent…"
                    />
                  </div>
                </li>
              );
            })}
          </ul>
        </div>
      );
    }

    var root = ReactDOM.createRoot(document.getElementById('root'));
    root.render(<RowEditor />);
  })();
</script>
```

**What's going on:**

- The component loads rows on mount via `collections.rows(BINDING, { limit, sort })`. The response is `{ rows, nextCursor, hasMore }`.
- `applyLocal` performs an optimistic local update so the UI feels responsive; `persist` fires the SDK write.
- `onStatusChange` writes immediately; `onNoteChange` debounces 600ms via a `useRef`-held timer table keyed by row id.
- On `validation_blocked`, the component flips into validation banner mode and stops surfacing the rejection as a red error.
- A `permission_denied` rejection (e.g. the operator's binding only has `rows:read`) shows up in the red error block with `code === 'permission_denied'` so the agent can see they need to add `rows:patch` to the binding and republish.

### 5B. Artifact-backed document editor

Single-artifact markdown editor with `Save` + `Discard`. Mirrors `artifact-editor.html`.

**Required binding:**

```json
"briefDoc": {
  "kind": "artifact",
  "artifactId": "<artifact uuid>",
  "permissions": ["read", "version:create"]
}
```

**HTML body:**

```html
<div id="root" class="min-h-screen bg-slate-50 p-6 font-sans text-slate-900">loading...</div>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/react@18.3.1/umd/react.production.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@7.24.7/babel.min.js"></script>
<script type="text/babel" data-presets="react">
  (function () {
    'use strict';

    var BINDING = 'briefDoc';

    function describeError(err) {
      if (!err) return { code: 'unknown', message: 'unknown error' };
      return { code: err.code || err.name || 'error', message: err.message || String(err) };
    }
    function isValidationBlocked(err) { return !!(err && err.code === 'validation_blocked'); }
    function surfaceRuntime() {
      try { return (window.tokenrip.surface.info() || {}).runtime || 'operator'; }
      catch (e) { return 'unknown'; }
    }

    function DocEditor() {
      var useState = React.useState;
      var useEffect = React.useEffect;

      var loadedState = useState({ content: '', version: 0, mimeType: 'text/plain' });
      var loaded = loadedState[0];  var setLoaded = loadedState[1];
      var draftState = useState('');  var draft = draftState[0];  var setDraft = draftState[1];
      var loadingState = useState(true);  var loading = loadingState[0];  var setLoading = loadingState[1];
      var savingState = useState(false);  var saving = savingState[0];   var setSaving = savingState[1];
      var errorState = useState(null);    var error = errorState[0];     var setError = errorState[1];
      var toastState = useState(null);    var toast = toastState[0];     var setToast = toastState[1];
      var modeState = useState(surfaceRuntime() === 'validation');
      var validationMode = modeState[0];  var setValidationMode = modeState[1];

      function load() {
        setLoading(true);
        setError(null);
        // Re-fetch on every load is the optimistic-concurrency-light pattern:
        // we always read the latest version before opening the editor again.
        window.tokenrip.artifacts.read(BINDING).then(function (doc) {
          setLoaded({
            content: doc.content || '',
            version: doc.version || 0,
            mimeType: doc.mimeType || 'text/plain',
          });
          setDraft(doc.content || '');
          setLoading(false);
        }, function (err) {
          setError(describeError(err));
          setLoading(false);
        });
      }

      useEffect(function () { load(); }, []);

      function onSave() {
        setSaving(true);
        setError(null);
        setToast(null);
        window.tokenrip.artifacts.saveVersion(BINDING, {
          content: draft,
          mimeType: loaded.mimeType,
        }).then(function (saved) {
          setLoaded({ content: draft, version: saved.version, mimeType: loaded.mimeType });
          setToast('Saved v' + saved.version);
          setSaving(false);
          setTimeout(function () { setToast(null); }, 3000);
        }, function (err) {
          if (isValidationBlocked(err)) setValidationMode(true);
          setError(describeError(err));
          setSaving(false);
        });
      }

      function onDiscard() { load(); }

      var dirty = draft !== loaded.content;

      return (
        <div className="mx-auto max-w-3xl space-y-4">
          <header className="flex items-baseline justify-between">
            <h1 className="text-xl font-semibold tracking-tight">Brief document</h1>
            <span className="text-xs text-slate-500">v{loaded.version} · {loaded.mimeType}</span>
          </header>

          {validationMode ? (
            <div className="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900">
              Validation mode — writes blocked
            </div>
          ) : null}

          {error ? (
            <div className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-900">
              <span className="font-mono font-semibold">{error.code}</span> — {error.message}
            </div>
          ) : null}

          {toast ? (
            <div className="rounded-md border border-emerald-300 bg-emerald-50 px-3 py-2 text-sm text-emerald-900">{toast}</div>
          ) : null}

          {loading ? (
            <div className="rounded-md border border-slate-200 bg-white p-4 text-sm text-slate-500">Loading…</div>
          ) : (
            <div className="space-y-3">
              <label className="sr-only" htmlFor="doc-body">Document body</label>
              <textarea
                id="doc-body"
                className="h-80 w-full rounded-md border border-slate-300 bg-white p-3 font-mono text-sm leading-6 shadow-sm"
                value={draft}
                onChange={function (e) { setDraft(e.target.value); }}
                placeholder="Write the brief…"
              />
              <div className="flex items-center justify-end gap-2">
                <button
                  type="button"
                  className="rounded border border-slate-300 bg-white px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-100 disabled:opacity-50"
                  onClick={onDiscard}
                  disabled={!dirty || saving}
                >Discard changes</button>
                <button
                  type="button"
                  className="rounded bg-slate-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-slate-800 disabled:opacity-50"
                  onClick={onSave}
                  disabled={!dirty || saving}
                >{saving ? 'Saving…' : 'Save'}</button>
              </div>
            </div>
          )}
        </div>
      );
    }

    var root = ReactDOM.createRoot(document.getElementById('root'));
    root.render(<DocEditor />);
  })();
</script>
```

**What's going on:**

- `load()` is called on mount and on `Discard` — the latter implements the optimistic-concurrency-light approach: re-read the latest version before letting the user edit again.
- `saveVersion` returns `{ version, createdAt }`; the toast shows the new version number.
- `dirty` gates both buttons so the user can't save a no-op.
- Same `validation_blocked` flip into banner mode.

### 5C. Control-row workflow trigger

This is the **key v1 pattern** for "AI generates UI that triggers agent work." The user clicks a UI button → the Surface appends a row to a workflow collection → the agent observes the row later (via polling, MCP, or webhook) and does the deeper research/processing/whatever. The Surface is just the trigger; the agent is the worker.

**Required binding:**

```json
"researchRequests": {
  "kind": "mount_collection",
  "mountId": "<mount uuid>",
  "collection": "research_requests",
  "permissions": ["rows:read", "rows:append"]
}
```

**HTML body:**

```html
<div id="root" class="min-h-screen bg-slate-50 p-6 font-sans text-slate-900">loading...</div>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/react@18.3.1/umd/react.production.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@7.24.7/babel.min.js"></script>
<script type="text/babel" data-presets="react">
  (function () {
    'use strict';

    var BINDING = 'researchRequests';

    function describeError(err) {
      if (!err) return { code: 'unknown', message: 'unknown error' };
      return { code: err.code || err.name || 'error', message: err.message || String(err) };
    }
    function isValidationBlocked(err) { return !!(err && err.code === 'validation_blocked'); }
    function surfaceRuntime() {
      try { return (window.tokenrip.surface.info() || {}).runtime || 'operator'; }
      catch (e) { return 'unknown'; }
    }

    function RequestForm() {
      var useState = React.useState;
      var useEffect = React.useEffect;

      var topicState = useState('');         var topic = topicState[0];        var setTopic = topicState[1];
      var pendingState = useState(false);    var pending = pendingState[0];    var setPending = pendingState[1];
      var errorState = useState(null);       var error = errorState[0];        var setError = errorState[1];
      var historyState = useState([]);       var history = historyState[0];    var setHistory = historyState[1];
      var modeState = useState(surfaceRuntime() === 'validation');
      var validationMode = modeState[0];     var setValidationMode = modeState[1];

      // Refresh the recent history so the operator can see what they've asked
      // for. Polls every 10s — the agent fulfilling the request likely won't
      // patch the row, it'll create a follow-up artifact, but the existence of
      // the row gives the operator immediate "we got it" feedback.
      function refresh() {
        window.tokenrip.collections.rows(BINDING, {
          limit: 10,
          sort: { by: 'createdAt', order: 'desc' },
        }).then(function (page) { setHistory(page.rows || []); }, function () { /* swallow */ });
      }

      useEffect(function () {
        refresh();
        var t = setInterval(refresh, 10000);
        return function () { clearInterval(t); };
      }, []);

      function onSubmit(e) {
        e.preventDefault();
        if (!topic.trim() || pending) return;
        setPending(true);
        setError(null);
        // append is array-only. For a single row, still pass [row].
        window.tokenrip.collections.append(BINDING, [{
          topic: topic.trim(),
          requested_at: new Date().toISOString(),
          status: 'queued',
        }]).then(function () {
          setTopic('');
          setPending(false);
          refresh();
        }, function (err) {
          if (isValidationBlocked(err)) setValidationMode(true);
          setError(describeError(err));
          setPending(false);
        });
      }

      return (
        <div className="mx-auto max-w-2xl space-y-6">
          <header>
            <h1 className="text-xl font-semibold tracking-tight">Research this topic</h1>
            <p className="mt-1 text-sm text-slate-600">
              Drop a topic; the research agent picks it up on its next pass.
            </p>
          </header>

          {validationMode ? (
            <div className="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900">
              Validation mode — writes blocked
            </div>
          ) : null}

          {error ? (
            <div className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-900">
              <span className="font-mono font-semibold">{error.code}</span> — {error.message}
            </div>
          ) : null}

          <form onSubmit={onSubmit} className="rounded-md border border-slate-200 bg-white p-4 shadow-sm space-y-3">
            <label className="block text-xs font-medium uppercase tracking-wide text-slate-500" htmlFor="topic">
              Topic
            </label>
            <input
              id="topic"
              type="text"
              className="w-full rounded border border-slate-300 bg-white px-2 py-1.5 text-sm"
              value={topic}
              onChange={function (e) { setTopic(e.target.value); }}
              placeholder="e.g. GPU supply outlook for Q3"
              disabled={pending}
            />
            <div className="flex justify-end">
              <button
                type="submit"
                className="rounded bg-slate-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-slate-800 disabled:opacity-50"
                disabled={!topic.trim() || pending}
              >{pending ? 'Queueing…' : 'Queue research'}</button>
            </div>
          </form>

          <section>
            <h2 className="mb-2 text-sm font-semibold text-slate-700">Recent requests</h2>
            {history.length === 0 ? (
              <p className="text-sm text-slate-500">Nothing queued yet.</p>
            ) : (
              <ul className="space-y-2">
                {history.map(function (row) {
                  var d = row.data || {};
                  return (
                    <li key={row.id} className="rounded border border-slate-200 bg-white px-3 py-2 text-sm">
                      <div className="font-medium text-slate-900">{d.topic || '(no topic)'}</div>
                      <div className="text-xs text-slate-500">{d.status || 'queued'} · {row.createdAt || d.requested_at || ''}</div>
                    </li>
                  );
                })}
              </ul>
            )}
          </section>
        </div>
      );
    }

    var root = ReactDOM.createRoot(document.getElementById('root'));
    root.render(<RequestForm />);
  })();
</script>
```

**What's going on:**

- The operator types a topic, hits **Queue research**. The Surface calls `collections.append(BINDING, [{ topic, requested_at, status }])`. **Note the array** — `append` is always array form.
- The Surface immediately refreshes the history list so the operator sees their row appear.
- A background `setInterval` polls every 10s so any `status` updates the agent writes back are visible.
- The agent observes the new `queued` row out-of-band (next run, MCP poll, webhook, whatever the agent uses) and does the real work — generating an artifact, posting a message, etc. The Surface itself does no domain logic.

This pattern keeps Surfaces as input/trigger UIs and keeps domain logic in agents.

---

## 6. Gotchas

### 6.1 CDN allowlist

The validation runtime blocks all egress except Tokenrip and these four CDNs:

- `unpkg.com`
- `cdn.jsdelivr.net`
- `esm.sh`
- `cdn.tailwindcss.com`

Anything else (Google Fonts, your own asset host, jspm, etc.) is blocked and reported during validation. Use these exact origins:

```html
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/react@18.3.1/umd/react.production.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@7.24.7/babel.min.js"></script>
<!-- alternates -->
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script type="module">import { foo } from 'https://esm.sh/some-pkg@1.0.0';</script>
```

### 6.2 ES5 outside JSX

Babel-in-browser (`@babel/standalone`) does not reliably transpile modern syntax at the **top level** of a `<script type="text/babel">` block. Inside JSX expressions and inside transformed function bodies, modern syntax works. But array destructuring of hook tuples at script top-level often breaks. The safe pattern:

```jsx
// GOOD
var counterState = React.useState(0);
var counter = counterState[0];
var setCounter = counterState[1];

// RISKY — may break depending on the Babel preset chain
const [counter, setCounter] = React.useState(0);
```

Apply the same rule to top-level `const`/`let`, arrow functions assigned to `var`, and default-parameter syntax: prefer `var` + `function` declarations. Inside the JSX you return, modern syntax is fine because Babel has already entered transform mode.

### 6.3 localStorage and IndexedDB are transient

Browser storage is **UI state only** — scroll position, expanded/collapsed panels, draft text in a textarea. Don't persist data there:

- The page may be opened in a different browser; `localStorage` doesn't survive.
- `localStorage.session_token` is used by the SDK; do not write to that key.
- Validation runs in an ephemeral sandbox; nothing in storage carries between runs.

For real persistence use `collections.patch` / `collections.append` / `artifacts.saveVersion`.

### 6.4 Validation blocks writes

In validation runtime (`surface.info().runtime === 'validation'`), every mutating SDK call rejects with `validation_blocked`. **This is not a bug in your code.** The validation runner uses a scoped credential that the backend refuses for writes. Your write code is correct; the sandbox just won't actually mutate data.

Do **not** try to:
- Skip writes when `runtime === 'validation'` (the UI should still call the SDK so validation can confirm the call shape).
- Use raw fetch as a "workaround" (raw fetch during validation hard-fails).
- Treat the rejection as an error to fix.

Do show a calm banner ("Validation mode — writes blocked") so an operator previewing the validation screenshot doesn't think something is broken.

### 6.5 Connector APIs are not in v1

There is no `window.tokenrip.connectors` namespace. Do not generate code that assumes you can call external APIs (Reddit, Slack, weather, etc.) through Tokenrip. If you need external data, the agent should fetch it server-side and write the result to a collection or artifact that the Surface reads.

v1.5 will add `tokenrip.connectors.call()` with a server-side connection broker, allowlists, and secrets management. Until then: stay inside the SDK's `collections` and `artifacts` namespaces.

### 6.6 The SDK handles auth — don't touch headers

Generated code never sees an API key. The SDK reads `localStorage.session_token` automatically and attaches `Authorization: Bearer ...` on every SDK call. Do not:

- Try to set `Authorization` headers manually.
- Read or write `localStorage.session_token`.
- Hardcode any token, key, or credential into the HTML.

### 6.7 Don't reference `window.__SURFACE__`

That global is internal bootstrap state the SDK reads once at load and freezes into a closure. Mutating it from generated code does nothing (the SDK already captured the frozen copy). Reading it bypasses the freeze contract. Always use `window.tokenrip.surface.info()` instead — same data, stable, immutable.

### 6.8 Generated raw `/v0` calls are flagged

If your generated code calls a `/v0` URL directly (not through the SDK), the SDK wrapper detects it and emits a `raw_v0_detected` telemetry event. In manual owner runtime this is a warning; **in validation runtime it is a hard failure** and the Surface is marked non-compliant. Always use the SDK.

---

## 7. MCP tool reference

These are the MCP tools you (the generator) use to author and manage Surfaces. They all act on behalf of the authenticated agent — never pass `ownerId`.

| Tool | Purpose |
|---|---|
| `inspect_mount(mountId)` | Discover a mounted agent's collections — schema, sample rows (≤5), `recommendedBindingKey`, `recommendedBinding`, and `sdkExamples`. Call this BEFORE generating a Surface for a mount. |
| `inspect_artifact(artifactId)` | Discover an artifact's type/mime/editable status and `recommendedBinding`. Returns a content preview (≤2 KB). Call this BEFORE generating a single-artifact editor. |
| `publish_surface({ title, htmlContent, bindings, mountId?, description? })` | Create a new Surface. Auto-validates after create. Returns `{ publicId, currentRevisionId, draftUrl, validation }`. |
| `update_surface(publicId, { htmlContent?, title?, description?, bindings? })` | Update an existing Surface — creates a new revision. Auto-validates. Returns `{ revisionId, validation }`. Omit `bindings` to keep the current set. |
| `validate_surface(publicId)` | Re-run validation against the current revision without changing it. Rarely needed after auto-validate. |
| `promote_surface(publicId)` | Flip `draft` → `published`. Owner-readiness signal (access is still owner-only in v1). Returns `{ status, url, warnings }`. |
| `list_surfaces({ mountId?, status? })` | List the calling agent's Surfaces. |
| `get_surface(publicId)` | Full detail including current `htmlContent`. Use this to read back what you published before a follow-up `update_surface`. |
| `open_surface(publicId)` | Helper — returns just `{ url }`. Useful when you want to tell the operator where to look without re-fetching. |
| `delete_surface(publicId)` | Hard delete. The `/x/<publicId>` URL stops working immediately. |
| `restore_surface_revision(publicId, revisionId)` | Copy an older revision back to active. Re-runs binding validation; returns `{ revisionId, validationRequired: true }`. Follow up with `validate_surface`. |

REST equivalents exist under `/v0/surfaces/...` and `/v0/operator/mounts/.../inspect` for harnesses that prefer HTTP, but the MCP tools are the recommended entry point.

---

## 8. What to do when validation fails

The `validation` field in `publish_surface` / `update_surface` / `validate_surface` responses includes `ok`, `errorCount`, `warningCount`, `validatedAt`. Pull the full surface with `get_surface(publicId)` (or use the operator UI) to see the detailed `errors` and `sdkOperations` arrays.

Common failure modes and how to address them:

| Failure | Fix |
|---|---|
| Console errors (`TypeError`, `ReferenceError`, etc.) | Look at the error message + stack. Common cause: ES5-at-top-level rule (§6.2) — convert `const [a, b] = ...` to `var s = ...; var a = s[0]; var b = s[1];`. |
| Blocked network calls (origin not in allowlist) | Switch the script src to one of the four allowed CDNs (§6.1) or remove the dependency. |
| Blocked writes during validation (`validation_blocked`) | **Do not fix** — this is expected. Your write code is correct. Just make sure the UI handles the rejection gracefully (banner instead of red error). |
| `binding_not_found` | The binding key in your generated code doesn't match the key declared in the `bindings` map. Either rename the key in the binding declaration or update the generated code to use the declared key. |
| `permission_denied` | The binding declares fewer permissions than your code needs (e.g. you call `patch` but the binding only has `rows:read`). Update the binding to include `rows:patch` and call `update_surface(publicId, { bindings: { ... } })`. |
| Accessibility issues | Add `aria-label` to icon-only buttons. Use `<label htmlFor>` (or `aria-labelledby`) for every form input. Don't rely on placeholder text as the label. |
| Horizontal overflow at 390px | Make the layout responsive — use Tailwind's `max-w-*` + `mx-auto`, switch fixed widths to `w-full`, and add `flex-wrap` to row containers. The validation runner screenshots at 390px (mobile) and 1280px (desktop). |

Iterate via `update_surface(publicId, { htmlContent: fixedHtml })` until `validation.ok === true`. Each update creates a new revision; history is preserved.

---

## 9. Constraints not to break

These are non-negotiable for v1 generation:

- **Owner-only.** Don't generate "share this URL", "send to a teammate", or "publish publicly" copy. The operator is the only viewer until v2 lands shared Surfaces.
- **SDK-only data access.** Never call a `/v0` URL from generated code. Never construct a path like `/v0/operator/mounts/...`. The SDK is the contract.
- **No custom telemetry to `/v0/surfaces/:publicId/events`.** The SDK already posts `sdk_call`, `sdk_error`, `error`, and `unhandled_rejection` events automatically. Adding your own POSTs duplicates events and trips the raw-`/v0` detector.
- **No connector calls.** There is no `window.tokenrip.connectors` in v1. External API calls (Reddit, Slack, OpenAI, etc.) must happen agent-side, with results flowing into Tokenrip collections or artifacts that the Surface reads.
- **No `window.__SURFACE__` access from app code.** Use `window.tokenrip.surface.info()`.
- **CDN allowlist is hard.** Anything outside `unpkg.com`, `cdn.jsdelivr.net`, `esm.sh`, `cdn.tailwindcss.com` is blocked in validation.

If a Surface needs something outside these constraints, the right move is to talk to the operator — not to bend the rules.
