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_boardreturnsrows: []for every dataset you don't ask for. Always passdataset=<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:
- Purpose — what is this board for?
- Row shape — what fields per row? What's the primary identifier?
- Confirm structure — the proposed schema is read back to you.
- Create — calls
create_boardwith the agreed schema. - Seed — appends a few starter rows so the board isn't empty.
- Optional skills — saved agent recipes (e.g. "score each lead").
- Optional dashboard —
set_dashboardwith 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. |