// DOCS

Boards

A board is the user-facing "spreadsheet, but actually a brain." Each board lives inside a brain, holds typed rows in named datasets, and is the substrate for almost everything else you build: automations write to it, workflows own one, dashboards render it, intake forms append to it.

What a board actually is

brain
 └── board
      ├── data         { datasets: { "<name>": { rows: [...], schema: {} } } }
      ├── schema       { version: 2, datasets: [ { name, description, fields[] } ] }
      ├── skills       named agent recipes scoped to this board
      ├── forms        chat intake forms that append rows
      ├── links        pointers to external HTTP / GitHub / Monday / ICS data
      │     └── recipes  link + cron → materialize into a dataset
      ├── files        binary attachments (S3-backed)
      └── dashboard    one HTML iframe per board, owner-only edits

Boards are stored as a single jsonb document per board, with rows grouped under named datasets. The default dataset is named default; tools that don't take a dataset argument target it.

Why jsonb instead of one table per board

Schemas evolve constantly — a user adds a column mid-conversation with Claude. A normalized "table per board" model would mean DDL on every edit. Storing the whole board as one jsonb document trades query ergonomics for schema flexibility, and since boards are read whole almost every time, the trade pays off.

Each row carries a stable row_id generated at append time. All update/delete operations address rows by row_id, not by position, so concurrent appends are safe.

Why datasets are a sub-structure

Many boards (a research tracker, a CRM, a workflow board) need parallel rows of different shapes: findings next to experiments next to meetings. Datasets give you that without forcing separate boards. Most tools accept an optional dataset arg; if omitted they target default.

get_board returns rows: [] for every dataset you don't ask for. Always pass dataset=<name> explicitly when you need rows from a non-default dataset.

Creating a board — the right way

Ask Claude:

"Create a board to track inbound investor meetings."

Claude runs create_board_flow which walks you through:

  1. Purpose — what is this board for?
  2. Row shape — what fields per row? What's the primary identifier?
  3. Confirm structure — the proposed schema is read back to you.
  4. Create — calls create_board with the agreed schema.
  5. Seed — appends a few starter rows so the board isn't empty.
  6. Optional skills — saved agent recipes (e.g. "score each lead").
  7. Optional dashboardset_dashboard with a starter HTML template.

The playbook exists so the board you end up with has skills, a dashboard, and a schema that fits what you wanted to track. Don't call create_board directly unless you're reproducing an existing board verbatim — hand-rolled boards typically forget the dashboard and skills.

Reading and writing from code

All boards are accessible via MCP tools. Inside an automation:

import { brains } from "https://brains/sdks/automation-sdk-v1.ts";

const board = await brains.get_board({ name: "Inbound investors" });
const existing = new Set(
  board.data.datasets.default.rows.map((r) => r.email_id),
);

const recent = await brains.search({ q: "investor", type: "email", limit: 50 });
const next = recent.filter((p) => !existing.has(p.id));

if (next.length) {
  await brains.append_board_rows({
    board_id: board.id,
    rows: next.map((p) => ({
      email_id: p.id,
      subject: p.title,
      from: p.frontmatter?.from,
      received_at: p.frontmatter?.internalDate,
    })),
  });
}

The same tools — get_board, append_board_rows, update_board_row, delete_board_row — work from the web Agent SDK, from create_board_flow, from custom MCP clients, and from any other LLM you connect over MCP. There is no "agent API" separate from the human API.

Skills — saved agent recipes

A board skill is a named, parameterized prompt that operates on the board's rows. Skills give you a one-click button for the repetitive thing you keep prompting Claude to do.

await brains.create_board_skill({
  board_id,
  name: "score_lead",
  prompt: `
    You are scoring a single inbound investor row.
    Row: {{row}}
    Score 1–5 on: fit, stage, momentum.
    Return JSON: { fit: 1-5, stage: 1-5, momentum: 1-5, notes: "" }
  `,
  inputs_schema: { type: "object", properties: { row_id: { type: "string" } } },
  allowed_tools: ["get_board", "update_board_row"],
});

// Later, run it:
await brains.run_board_skill({
  board_id,
  name: "score_lead",
  inputs: { row_id: "ROW-123" },
});

allowed_tools is the key: it restricts what the skill agent can call, even though the user running the skill could call anything. Skills always run with the minimum surface needed.

For expensive multi-turn skills, prefer authoring an automation that calls the skill on a cron — the inline runner is tuned for fast single turns.

Forms — chat intake

A board form is a chat-based intake that appends a row when complete. Useful for collecting feedback, scoping calls, structured data from users who shouldn't see the board.

await brains.create_board_form({
  board_id,
  dataset: "responses",
  fields: [
    { key: "name", question: "What's your name?", required: true },
    { key: "company", question: "Where do you work?", required: true },
    { key: "ask", question: "What can we help with?", required: true },
  ],
  context:
    "You're collecting product feedback for the Q2 launch — be friendly and probing.",
  intro: "Thanks for sharing your perspective on the launch.",
  allow_anonymous: true,
});

The respondent visits /f/<form_id>, the agent chats with them incrementally, and the moment every required field is filled the agent appends a row to boards.data.datasets.<dataset>.rows. Full chat transcripts are preserved on each response so the board owner can audit how the data was collected.

Links and recipes — external data sources

A board link is a pointer to an external resource. A recipe glues a link to a dataset on a schedule.

// 1. A link tells brains where the data lives.
const link = await brains.create_board_link({
  board_id,
  kind: "github",                          // or http_json, ics, monday, …
  ref: { owner: "ssvlabs", repo: "brains" },
  display_name: "brains repo",
});

// 2. A recipe says "every hour, hit this op, write rows into this dataset."
await brains.create_dataset_recipe({
  link_id: link.id,
  dataset_name: "prs",
  adapter_op: "github.list_open_prs",
  params: { state: "open" },
  refresh_cron: "0 * * * *",
  dedupe_key_field: "id",                  // upstream id field
  dedupe_target_field: "pr_id",            // local row field
});

dedupe_key_field + dedupe_target_field make refreshes idempotent: the recipe will skip rows whose pr_id already exists on the board. This is non-optional in practice — without it, every hour adds duplicate rows.

Adapters live in apps/web/src/lib/adapters/ and are pluggable. Builtin adapters: http_json, ics, github, monday. Adding a new one is a single file that exports op handlers.

Dashboards — a sandboxed iframe per board

Each board has one dashboard (board_dashboards, keyed by board_id), visible to every brain member, editable only by the board's creator. The dashboard is an HTML iframe that runs sandboxed at /d/<board_id> with no network access.

Writes from the dashboard happen via postMessage: the iframe sends a message to the parent window, which calls update_board_row via MCP on behalf of the signed-in user. The board's permissions still apply — the bridge only executes operations the current user is authorized to perform.

Dashboard HTML is trusted content. The creator can author arbitrary JS that reads the board's rows and writes them back. Don't paste dashboard templates from untrusted sources.

To set a dashboard:

await brains.set_dashboard({
  board_id,
  html: `<!doctype html>
<html><body style="font-family: system-ui; padding: 24px;">
  <h1>{{board.title}}</h1>
  <div id="rows"></div>
  <script>
    // window.brains is the iframe SDK — read rows, update via postMessage.
    window.brains.onBoardLoad((board) => {
      document.querySelector('#rows').innerHTML =
        board.data.datasets.default.rows.map(r => '<div>' + r.title + '</div>').join('');
    });
  </script>
</body></html>`,
});

When you edit a dashboard for a Codex-installed board, edit the recipe that installed it, not the dashboard directly — your changes get overwritten on the next recipe upgrade.

Relationships and the write-side normalizer

CRM-shaped boards link datasets via array fields:

// a row in datasets.meetings
{
  "row_id": "MT-001",
  "subject": "Intro chat with LSEG",
  "companyIds": ["CO-LSEG"],          // points at datasets.companies row
  "leadIds":    ["LD-NOAH"]           // points at datasets.leads row
}

Dashboards render those arrays as linked chips. Rows that store the reference in any other shape don't render — they look invisible.

append_board_rows and update_board_row run a write-side normalizer before persisting. For every key matching /^(.+)Ids$/ (and the legacy <stem>_id / free-text <stem> triggers), it promotes legacy shapes into the array form:

Incoming shape on the row Outcome
companyIds: ["CO-LSEG"] trusted as-is
company_id: "CO-LSEG" (no array) wrapped → companyIds: ["CO-LSEG"]
company: "LSEG" (no id, no array) looked up in sibling companies dataset by row_id or name. One match → filled. Zero/many → the whole write is rejected.

The normalizer does not auto-create the referenced row. That's the caller's job (the upstream automation knows whether the missing entity is real or a typo). The boundary normalizer just refuses to silently lose the relationship.

Files

board_files holds binary attachments per board. The web UI handles drag-and-drop upload; storage is local disk in dev, S3 in stage, encrypted at rest. Use upload_board_file, list_board_files, get_board_file_url, delete_board_file.

Soft delete

boards.deleted_at gives a 30-day recovery window. recover_board restores a soft-deleted board. A hard-delete cleanup job runs after the window expires.

Sharing

share_board / unshare_board invite users to the brain containing the board (in the single-owner brain model, a user who can see the brain can see all its boards). The sharing surface exists for cases where you want to onboard a collaborator with a single tool call from inside a playbook or automation.

Tool surface

Tool Purpose
create_board_flow Guided multi-step authoring. Use this.
create_board Raw create — only for reproducing existing boards.
get_board Read board + selected datasets. Always pass dataset.
list_boards List all boards in a brain.
append_board_rows Append rows (write-side normalizer runs).
update_board_row Patch one row by row_id.
delete_board_row Soft-delete one row.
create_board_skill / run_board_skill / list_board_skills Skills.
create_board_form / update_board_form / list_board_forms Forms.
create_board_link / list_board_links Links.
create_dataset_recipe / refresh_dataset_recipe Recipes.
upload_board_file / list_board_files / get_board_file_url Files.
set_dashboard / get_dashboard Dashboards.
share_board / unshare_board Sharing.