Views: Agent-Generated React Components

Views are .tsx files in the src/views/ directory at the box root. They get compiled server-side and rendered in the browser. A view is always attached to a card type — it exports rendersCardTypes and becomes that type's interface on card pages, in chat embeds, and in the companion pane. There is no card-less "standalone" view.

When to Create a View

Create a view to give a card type a richer interface than the default markdown rendering — an interactive layout, a chart, a structured summary of the card's data, editable controls. A dashboard over many cards is itself a card type (e.g. a .dashboard.card whose view reads a collection).

Don't create a view for:

File Format

Each view is a .tsx file with named exports for metadata and a default export for the component:

export const name = "Ledger Overview";
export const description = "Interface for an ledger-overview card";
export const dependencies = ["_content/**/*.ledger-overview.card", "_bookkeeping/archive/**/*.record.card"];
export const modes = ["page", "chat"];
export const rendersCardTypes = ["ledger-overview"];

export default function EstateOverview({ cards, navigate, boxSlug, params, viewHistory }) {
  // params.path is the card being displayed; dependencies must cover it.
  const card = cards.find(c => c.path === params.path);
  const records = cards.filter(c => c.type === "record");
  const section = typeof viewHistory.state.section === "string"
    ? viewHistory.state.section
    : "summary";
  const openRecords = () => {
    const next = { section: "records" };
    if (viewHistory.canPush) viewHistory.pushState(next);
    else viewHistory.replaceState(next);
  };

  return (
    <div data-section={section}>
      <button onClick={openRecords}>Records</button>
      <h2>{card?.frontmatter?.title ?? "Ledger Overview"}</h2>
      <p>{records.length} records</p>
      <ul>
        {records.map(card => (
          <li key={card.path}>
            <strong>{card.frontmatter?.title || card.path}</strong>
            {card.frontmatter?.status && <span> — {String(card.frontmatter.status)}</span>}
          </li>
        ))}
      </ul>
    </div>
  );
}

Metadata Exports

ExportTypeRequiredDescription
namestringYesHuman-readable name shown in UI
descriptionstringYesWhat this view shows
dependenciesstring[]YesGlob patterns for files that affect rendering
modesstring[]YesWhere the view can appear: "page", "chat", or both
rendersCardTypesstring[]NoCard types this view renders — see below

Rendering a card type

A view that exports rendersCardTypes becomes the default renderer for those card types everywhere cards display — the card page (/card/<path>), chat embeds, and peeks. This is how a custom card type (e.g. a box-local schema) gets a custom UI without touching the app:

export const name = "Sandbox";
export const description = "Interactive sandbox card UI";
export const dependencies = ["**/*.sandbox.card"];
export const modes = ["page", "chat"];
export const rendersCardTypes = ["sandbox"];

export default function Sandbox({ cards, params }) {
  const card = cards.find((c) => c.path === params.path);
  // params.path is the card being displayed; dependencies must cover the
  // type so the card arrives in `cards`.
  ...
}

The built-in renderers (Card, Source) stay available through the renderer toggle. One view per type: if several views claim the same card type, the first by slug order wins. Without rendersCardTypes, custom types fall back to the generic built-ins.

Component Props

The default export receives a ViewProps object:

PropTypeDescription
cardsViewCard[]All cards matching the dependency globs
filesViewFile[]Metadata for non-card files matching the globs: {path, size, mtimeMs}
readFile(path, opts?) => Promise<string>Fetch a file's text; {start, end} byte range, negative start = tail
fileUrl(path) => stringOriginal box file URL — audio, downloads, and full-resolution image links
imageUrl(path, options) => stringCached, bounded image URL; options are width/height, fit, quality, format, and dpr
writeFile(path, {content, expect?}) => Promise<ViewFile>Create/overwrite (parents made); returns the new ViewFile; never commits
appendFile(path, {content, expect?}) => Promise<ViewFile>Append (creates when missing); same semantics
commitFile(path, message) => Promise<{committed, hash?}>Commit the file + its attachments, nothing else
adapterFetch(adapter, {path, ...init}) => Promise<Response>Call an external API with the box's key injected server-side
navigate(path: string) => voidNavigate within the box (e.g., navigate("chat"))
boxSlugstringThe current box slug
paramsRecord<string, string>Query parameters from the URL (e.g., params.path)
viewHistory{ state, canPush, pushState, replaceState }Explicit JSON-safe navigation state. Read state; use pushState(next) for a new Back/Forward entry or replaceState(next) to normalize the current entry. Check canPush when the UI depends on browser history. Do not call window.history directly.
reportActivity(kind, detail?) => voidWhen open in the chat companion pane, tell the agent the user touched this card. Writes auto-report "modified"; call reportActivity("explored", detail) when the user changes the view's parameters (filters, ranges, a selected tab) without changing data. The optional detail is a short free-text string surfaced to the agent as the <card-activity> element's text (e.g. the query the user typed and its top result) — it overwrites any prior detail for the same kind, so calling it on every keystroke is fine. A no-op for inline/page renders, so always safe to call.

viewHistory is opt-in persistence for meaningful view navigation, not a snapshot of React state. Values must be JSON-safe objects. Validate members before use because an old/shared URL may contain state from another version of the view. Unknown or obsolete values should fall back to the view's default; after a user action, a view may normalize them with replaceState. Do not write history during render. Renderer params remain external configuration, while React useState remains transient interaction state.

ViewCard Structure

Cards are YAML frontmatter + a markdown body. Each card in the cards array has:

{
  path: string;        // Box-relative path (e.g., "_bookkeeping/archive/Foo.record.card")
  type: string;        // Card type, from the filename Foo.<type>.card (e.g., "record", "memo")
  frontmatter?: Record<string, unknown>;  // Parsed YAML frontmatter (body and type excluded)
  body?: string;       // Markdown body
  attachments?: ViewFile[];       // Deep listing of the card's attach scope
}

Read a card's fields from frontmatter (e.g. card.frontmatter?.title, the status from card.frontmatter?.status) and its prose from body. type is the card type — filter a mixed cards array with cards.filter(c => c.type === "memo"). frontmatter values are whatever the card's schema declares (strings, numbers, arrays, nested objects), so they are typed unknown — narrow before use.

attachments is how a view discovers what lives next to a card — every file in the card's attach scope, recursively, as {path, size, mtimeMs} with box-relative paths (e.g. _content/playground/Playground.attach/sessions/history.jsonl). Content is never inlined — attachments can be huge or binary — fetch it with readFile(path) or point an <img>/<audio> at fileUrl(path).

Link-Shaped Card Data

When a card schema or a view-owned data shape points somewhere, use the same field names as {% source %}: ref for an internal box target and href for an external target. Keep the target in an object so sibling fields can say why it is there. Do not invent url/link fields or bare string arrays such as urls: ["https://…"].

For example, this card records an internal related card beside an external source. retrieved is the date the external source was checked, in date-only ISO form:

related:
  - ref: /_content/projects/Garden_Redesign.project.card
    note: Background for this recommendation
sources:
  - href: https://example.com/native-plants
    retrieved: 2026-08-30
    usage: Paraphrased for the hardiness recommendation

Internal ref values are walked by ref-aware fields, rendered as links, and rewritten by bbx mv when their targets move. External href values are not rewritten. The shared vocabulary also includes version when a source must be pinned to a measured file state. See the agent guide's {% source %} section for the full semantics.

Dependencies

The dependencies array controls two things:

  1. Which data is loaded — matching .card files arrive parsed in cards; any other matching file arrives as metadata in files ({path, size, mtimeMs} — fetch content with readFile/fileUrl)
  2. When to re-render — the view refreshes automatically when matching files change

Use glob patterns relative to the box root:

Reading a card's attachments

A card's extra data usually lives in its attach scope. The card's attachments listing (or a dependency glob into the .attach/ dir) tells you what exists; readFile fetches content on demand. Attachments can be very large — fetch only what you render. A byte-range tail is the right way to show "recent entries" from an append-only log:

export const dependencies = [
  "_content/playground/*.card",
  "_content/playground/Playground.attach/**/*.jsonl",
];

export default function PlaygroundHistory({ files, readFile }) {
  const [entries, setEntries] = useState([]);
  const log = files.find((f) => f.path.endsWith("sessions/history.jsonl"));
  useEffect(() => {
    if (!log) return;
    // Tail the last 64KB — enough for recent entries, cheap for a huge log.
    readFile(log.path, { start: -65536 }).then((text) => {
      const lines = text.split("\n").filter(Boolean);
      // A tail can start mid-line; drop the first line unless we read from byte 0.
      if (log.size > 65536) lines.shift();
      setEntries(lines.map((line) => JSON.parse(line)));
    });
  }, [log && log.path, log && log.mtimeMs]);
  return <ul>{entries.map((e, i) => <li key={i}>{e.summary}</li>)}</ul>;
}

mtimeMs in the effect deps makes the view re-fetch when the log grows. For images and audio, don't fetch. Render a bounded image with <img src={imageUrl(f.path, { width: 960, format: "auto" })} />; use fileUrl(f.path) for audio and for a link to the full-resolution image. Image variants are generated on demand and cached, so do not make a small display download the original.

imageUrl requires width or height. It uses fit: "scale-down" and quality: 85 by default; format: "auto" negotiates AVIF/WebP/JPEG. Other fits are contain, cover, crop, and pad. quality is 1–100 and dpr is at most 2. If adapting to window.devicePixelRatio, clamp it with Math.min(window.devicePixelRatio, 2). Invalid options fail clearly instead of falling back to the original.

Writing files from a view

Views can create, overwrite, and append to box files (everything except .card files — cards validate, so they go through the card API or bbx create). A ViewFile is an identity + version: its etag is the version token. The conflict-safe pattern — assert the version you read, and refresh instead of clobbering when someone else changed the file:

export default function Notes({ files, readFile, writeFile, commitFile }) {
  const [file, setFile] = useState(files.find((f) => f.path.endsWith("notes.md")));
  const [text, setText] = useState("");
  useEffect(() => { if (file) readFile(file.path).then(setText); }, [file && file.etag]);

  async function save() {
    try {
      // expect: file → the write fails (412) if the file changed since we read it
      const next = await writeFile(file.path, { content: text, expect: file });
      setFile(next); // the returned ViewFile is the new version — chain saves from it
    } catch (e) {
      if (e.name === "ViewFileConflictError") {
        setFile(e.current); // someone else wrote — e.current is the fresh version; reload
        return;
      }
      throw e;
    }
  }

  return <div>
    <textarea value={text} onChange={(e) => setText(e.target.value)} />
    <button onClick={save}>Save</button>
    <button onClick={() => commitFile(file.path, "Edited notes from view")}>Commit</button>
  </div>;
}

Calling external APIs (LLMs) from a view

Browsers can't call provider APIs directly: providers (correctly) refuse CORS so API keys never live in pages. Views go through the box's API adapters instead — adapterFetch(adapter, {path, ...init}) hits /api/adapters/<adapter>/<path>, where the server injects the key it resolves from the machine-level secret store under the adapter's own name. Adapters: replicate, mistral, anthropic, openai, openrouter.

A key is never a file you write: the boxholder grants the adapter's secret to this box (bbx secrets status <this box> shows what is granted and what is missing; bbx secrets declare names one you need). An in-tree _config/connectors/<adapter>.secret.json is not read by anything — do not create one.

const resp = await adapterFetch("replicate", {
  path: "/v1/models/meta/llama-2-7b-chat/predictions",
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ input: { prompt } }),
});
const prediction = await resp.json();
// Polling URLs from the provider are absolute; pass them straight back —
// the origin is stripped and routed through the adapter:
const poll = await adapterFetch("replicate", { path: prediction.urls.get });

Never read a *.secret.json into the browser with readFile and never put an API key in view source — the adapter exists so keys stay server-side.

Git semantics. Writes never auto-commit — interactive saves are chatty and a commit per keystroke would spam history. gitStatus on each ViewFile shows working-tree state ("dirty", "untracked", absent = committed clean) so a view can render an unsaved-changes indicator. Call commitFile(path, message) at meaningful boundaries — it commits the file's card and attach scope (and nothing else); a card path sweeps its scope, an attachment path sweeps its owning card + scope. Anything left uncommitted is safe: box housekeeping sweeps stray changes on wakeup.

Query Parameters (path and others)

Views receive query parameters via params. params.path is the card this view is rendering — set automatically to the card's own path (the view is that card type's interface). Read the card from cards with it:

export default function MyView({ cards, params }) {
  const card = cards.find(c => c.path === params.path);
  // ...render card.frontmatter / card.body...
}

Any extra query params on the link/embed — ![x](/_content/Foo.dash.card?tab=costs&range=90d) — arrive alongside path in params for filtering, sorting, selecting a tab, etc.

React

React is provided automatically. Do NOT import React — the build system handles it. If you do write import React from "react", it will still work (the compiler intercepts it), but it's unnecessary.

You can use all standard React hooks: useState, useEffect, useMemo, useCallback, useRef, etc.

Card-aware widgets

To point at another card from a view — a link, or an embedded card — import the widgets from beebox/view-widgets instead of hand-rolling an <a>. They open the card in whatever surface the view is shown in (the chat companion pane, browse, or a full page); you don't pick a navigation target.

import { CardLink, CardRef } from "beebox/view-widgets";

<CardLink cardRef="…"> — a lightly-styled inline link. Clicking opens the card in the current surface. The link text falls back to the card's title when you omit children:

<CardLink cardRef="/_content/notes/Plan.memo.card">the plan</CardLink>
<CardLink cardRef="/_content/notes/Plan.memo.card" />

<CardRef cardRef="…"> — a styled reference chip with two controls: Open (same as CardLink) and Expand (renders the card inline, in place). Use it when a card is worth showing, not just linking:

<CardRef cardRef="/_content/recipes/Pasta.recipe.card" />

The reference attribute is cardRef, not ref (React reserves ref on components). Write box-absolute refs (/_content/…). A cardRef is tracked like any card ref, so bbx validate flags a broken one and bbx mv rewrites it when the target moves.

Showing Files in Chat

To show a file to the user, reference it by its plain box path — like a normal markdown link or image:

[Meeting Notes](/_content/notes/meeting.md)
[Recipe](/_bookkeeping/archive/Pasta.recipe.card)

Clicking a link opens the file in the companion pane. The system picks the viewer by file type:

To force a specific viewer, add ?view= with the renderer name (e.g. Source for the raw card text, Card for the markdown view):

[Raw source](/_bookkeeping/archive/Pasta.recipe.card?view=Source)

Directory paths work too:

[Catalog](/_content/catalogs/My_Catalog)

Always write the box path with a leading / — links and embeds in chat resolve from the box root, never from your working directory.

Link vs Embed

There are two ways to surface a file in chat — the difference is the !:

Link [label](path) — clicking it opens the file in the companion pane, a persistent side panel beside the chat:

[Meeting Notes](/_content/notes/meeting.md)

Embed ![label](path) — renders the file inline in the chat message, scrolling with the conversation (the same syntax as an image):

![Meeting Notes](/_content/notes/meeting.md)

The companion pane:

Use an embed for a quick, one-off display within a turn. Use a link when the user needs to reference the file while continuing to talk — collaborative editing, storybuilding, reviewing a document, exploring data.

How the Agent Knows a Companion View is Open

When a companion view is open, every user message includes a zoomed-view attribute naming the open file (a box path):

<typed zoomed-view="_content/notes/meeting.md">What about the furniture?</typed>

This tells the agent what the user is looking at, so it can tailor its responses.

The per-turn <chat-app> snapshot also carries this as the read-only open-card attribute (box-relative path), plus a <card-activity kind="…"> child element per kind of activity since the agent's last reply (scrolled/navigated/explored/modified), the element text being the optional free-text detail a view attaches via reportActivity(kind, detail) (e.g. the query typed). A view contributes the explored signal by calling reportActivity("explored", detail) when the user changes its parameters; writes contribute modified automatically. For the precise change set, the agent runs bbx chat whats-changed --card <path>.

Note: The companion pane is a chat-only feature. The zoomed-view attribute is only meaningful in the chat frontend — other agent contexts (jobs, wakeup) don't surface it.

Reporting Card Activity (reportActivity)

If your view is meant to be opened beside the chat — a companion view the user pokes at while talking — report what they do, so the chat agent has context. Otherwise the agent sees only the card's config file, never the live state the user is looking at. For a static, read-only display there's nothing to report; skip this.

The signal reaches the agent as read-only <card-activity kind="…"> child elements of the per-turn snapshot — one per kind (scrolled/navigated/explored/modified), with the element text being your optional free-text detail for that kind. reportActivity is a no-op outside the companion pane, so it's always safe to call.

What's automatic vs. what you wire:

How to write the detail. Report explored from your primary inputs, not every control. The detail is a short, human-legible line of what the user is now looking at — the input plus the salient result — because that exact string is what the agent reads (the <card-activity> element text). Keep it terse (a hint, not a dump): boat-water+road -> boats (0.568), not the whole result list.

Pattern: report from a text input as the user types. Reporting on every keystroke is fine — details overwrite per kind, so b,bo,boat collapse to the final state; no debounce needed.

function NearestNeighbors({ reportActivity }) {
  const [query, setQuery] = useState("king");
  const results = useNearest(query); // your computation

  // Tell the chat agent what the user is exploring + the top hit.
  useEffect(() => {
    const q = query.trim();
    if (!q) return;
    const top = results[0];
    const detail = top ? q + " -> " + top.word + " (" + top.sim.toFixed(3) + ")" : q;
    reportActivity("explored", detail);
  }, [query, results, reportActivity]);

  return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}

For a tab or filter, call it in the handler instead: onClick={() => { setTab(t); reportActivity("explored", "tab: " + t); }}. The agent can always run bbx chat whats-changed --card <path> for the exact, git-grounded change set — <card-activity> detail is the cheap live hint, not the source of truth.

Examples

Simple Card List

export const name = "Recent Memos";
export const description = "Every processed memo, newest first";
export const dependencies = ["_content/**/*.memo.card"];
export const modes = ["page", "chat"];

export default function RecentMemos({ cards }) {
  const memos = cards.filter(c => c.type === "memo");
  return (
    <div>
      <h2>Memos</h2>
      {memos.map(card => (
        <div key={card.path} style={{ marginBottom: "1rem" }}>
          <h3>{String(card.frontmatter?.title ?? card.path)}</h3>
          <p>{card.body}</p>
        </div>
      ))}
    </div>
  );
}

Filtered Dashboard

export const name = "Inbox Dashboard";
export const description = "Overview of pending inbox items";
export const dependencies = ["_content/inbox/**/*.card"];
export const modes = ["page"];

export default function InboxDashboard({ cards }) {
  const [filter, setFilter] = useState("");

  const filtered = cards.filter(c =>
    !filter || c.type.includes(filter) || c.path.includes(filter)
  );

  const byType = {};
  for (const card of filtered) {
    byType[card.type] = (byType[card.type] || 0) + 1;
  }

  return (
    <div>
      <h2>Inbox ({filtered.length} items)</h2>
      <input
        placeholder="Filter..."
        value={filter}
        onChange={e => setFilter(e.target.value)}
        style={{ padding: "0.5rem", marginBottom: "1rem", width: "100%" }}
      />
      <div style={{ display: "flex", gap: "1rem", marginBottom: "1rem" }}>
        {Object.entries(byType).map(([type, count]) => (
          <div key={type} style={{ padding: "0.5rem 1rem", background: "#f0f0f0", borderRadius: "0.5rem" }}>
            <strong>{type}</strong>: {count}
          </div>
        ))}
      </div>
      <ul>
        {filtered.map(card => (
          <li key={card.path}>{card.path} ({card.type})</li>
        ))}
      </ul>
    </div>
  );
}

Testing a View

After writing or changing a view, render-test it from the command line instead of only checking it in the browser:

bbx view test <slug>

This compiles the view in Node, loads the real cards your dependencies globs select (the same data the running app passes), renders the component once, and prints the resulting HTML. On success it exits 0 and prints the output — so you can confirm the view shows the right thing, not just that it didn't crash. On failure it exits non-zero and prints the error with a stack mapped back to your .tsx source lines.

What it covers (and doesn't). This is a synchronous render: it runs the component body once. It catches the common bugs — syntax/JSX errors, undefined.map(), bad prop access, type mistakes. It does not run useEffect, post-mount state, or the async helpers (readFile, writeFile, adapterFetch, …) — those run only in effects/handlers in the real app. Calling an async helper directly in the render body is a bug, and the test throws to tell you so (fileUrl is synchronous and fine to call in render). Editing a view also triggers a quick compile-check automatically, the same way cards are validated on save.

Styling

Views render inside the app's existing layout. You can use:

Error Handling

If your view has a syntax error, the browser shows the compile error instead of crashing. If your view throws at runtime, an error boundary catches it and shows the error with a retry button.