Every external system brains talks to. Some are read sources — they poll outside services and write pages into your brain. Some are write targets — they accept a natural-language request, return a draft, and only send after you confirm. Most are both.
Recipes can declare actions the AI agent can take on your behalf (send an email, create a calendar event, open a GitHub issue, post a Monday update). Each action is described in the recipe, materialized as a discovery page in your brain, found by the LLM via semantic search, drafted to your inbox, and only sent after you confirm — same two-phase flow as the builtin writes.
This page covers how the builtin integrations work, the two-phase write pattern that every write target follows, and how you add a new integration of your own as a codex recipe.
What's connected
Ask Claude:
"What integrations do I have?"
Claude calls list_integrations which returns one row per known
integration with:
name— the source key (e.g.gmail,monday)label— display nameconnected— whether this user has it linkedsupports.{fetch, act, adapter}— which surfaces accept this sourcedescription— one-line summary
The source enums on fetch_from_integration / act_on_integration
are filtered per-user — if a source isn't in the schema's enum, you
haven't connected it. Connect from /integrations/<source>.
The builtin set
| Integration | Read | Write | Notes |
|---|---|---|---|
| Gmail | ✓ | ✓ | OAuth + offline access. Threads → email pages, attachments parsed inline. |
| Google Calendar | ✓ | ✓ | OAuth. Events → calendar_event pages with parsed start/end/location/attendees. |
| Google Drive | ✓ | ✓ | OAuth. Metadata-only ingest today; body extraction on a follow-up cron. |
| GitHub | ✓ (board-link adapter) | — | OAuth. Connect a board to a repo, pull issues/PRs/releases on a schedule. |
| Monday.com | ✓ (board-link adapter) | — | OAuth. Materialize Monday items into a brains board. |
| Telegram | ✓ | ✓ | Per-user Bot tokens. Text your brain from your phone. |
| Discord | — | — | Sign-in only today; channel ingest is planned. |
| CoinMarketCap Top 10 | ✓ | — | First "from-scratch" declarative recipe — hourly snapshot of top 10 coins. |
Plus internal infra: Anthropic (every service uses Claude for LLM
calls), OpenAI (embeddings — text-embedding-3-small 1536-d for
hybrid search), Groq (cheap fallback for short Telegram turns).
Reading from an integration — fetch_from_integration
When you ask "any emails from Noah about Ethera?" and brains returns
nothing, the cron ingestor may not have caught up. fetch_from_integration
pulls fresh data from Gmail/Calendar/Drive/etc., writes new pages into
brains, and returns. After it returns, re-run your search / query /
list_pages and the data will be there.
await brains.fetch_from_integration({
source: "gmail",
input: { query: "from:noah ethera after:2026/04/01", limit: 100 },
});
// → { ingested_count: N, page_slugs: [...] }
// (then re-call search / query / list_pages)
For gmail and drive you can pass either an explicit
input: { query } (recommended — matches the codex action shape) or
a bare request string (the routing shim maps it to {query: request}).
For calendar you must pass input: { start, end } — NL-to-window
translation is not supported on the codex path.
Prefer fetch_from_integration over the raw mcp__claude_ai_* Google
MCPs because it persists into your brain — the same data is queryable
from the web app and every future Claude session.
Writing to an integration — act_on_integration (two-phase)
Every write — sending an email, creating a calendar event, creating a Drive doc — is two-phase: draft, then confirm. The tools never reach the external service on their own.
// 0. Resolve the install_id from the user's gmail-inbox install
// (via brains.query type=integration_action — the slug encodes it).
// 1. Draft.
const draft = await brains.act_on_integration({
install_id: gmailInstallId,
action_name: "send_email",
input: {
to: ["noah@a16z.com"],
subject: "Re: next week",
body: "We're in. Let me know what day works.",
thread_id: priorThreadId, // optional, for proper threading
},
});
// draft = { kind: "draft", draft_id, action, preview, payload, expires_at }
// 2a. Confirm — sends to Gmail.
await brains.confirm_action({ draft_id: draft.draft_id });
// → { status: "confirmed", result: { message_id: "<id>" } }
// 2b. Discard — no external side effect.
await brains.discard_action({ draft_id: draft.draft_id });
Always show the preview to the user before confirming. Drafts
expire after one hour. The draft lives in your brain so it's auditable
and resumable from /inbox.
Optional edits at confirm time
confirm_action accepts an edits object that patches whitelisted
fields right before sending:
| Action | Editable fields |
|---|---|
email |
to, cc, bcc, subject, body |
event |
summary, description, location, start, end, attendees, send_updates |
file |
name, content |
await brains.confirm_action({
draft_id: draft.draft_id,
edits: { subject: "Re: Q3 — confirmed", cc: ["legal@..."] },
});
Return shapes from act_on_integration
{ kind: "draft", draft_id, action, preview, payload, expires_at }— happy path. Showpreview, wait for confirmation.{ kind: "clarification", question }— ambiguous request (e.g. two "Omer"s in your mailbox). Ask the user back, then re-call with a tighter request.{ kind: "noop", reason }— no write was appropriate. Tell the user why.
Batching drafts in one turn
If you're producing N drafts in one turn (e.g. "send a follow-up to
everyone I met this week"), mint one UUID per turn and pass it as
batch_id on every draft. /inbox groups them together so the user
approves them as a set.
How board-link integrations work
GitHub and Monday are different — they don't draft and confirm. They're board-link adapters: a board points at the external resource, and a recipe materializes data on a schedule.
const link = await brains.create_board_link({
board_id,
kind: "github",
ref: { owner: "ssvlabs", repo: "brains" },
display_name: "brains repo PRs",
});
await brains.create_dataset_recipe({
link_id: link.id,
dataset_name: "prs",
adapter_op: "github.list_open_prs",
refresh_cron: "0 * * * *",
dedupe_key_field: "id",
dedupe_target_field: "pr_id",
});
Adapters live in apps/web/src/lib/adapters/ and are pluggable. Each
adapter exports a set of op handlers (e.g. github.list_open_prs,
monday.list_items) callable from the recipe runtime.
Building your own integration
There are two ways to add a new integration: declarative recipes (YAML-ish JSON, no code) or sandboxed recipes (TypeScript inside a Deno sandbox). Always try declarative first.
Declarative recipes — the CoinMarketCap pattern
A declarative integration is a single object that says: trigger on this cron, GET this URL with these headers, map each response item into a brains page. The runtime handles auth, retries, rate-limiting, idempotency, and metering. No code.
The minimum shape:
add({
slug: "coinmarketcap-top10",
name: "CoinMarketCap Top 10",
description: "Hourly snapshot of the top 10 cryptocurrencies.",
publisher: "<your-user-id>",
auth: { type: "api_key", header: "X-CMC_PRO_API_KEY", secret_name: "CMC_API_KEY" },
trigger: { kind: "cron", cron: "0 * * * *" },
endpoints: [{
op: "snapshot",
method: "GET",
url: "https://pro-api.coinmarketcap.com/v1/cryptocurrency/listings/latest?limit=10",
headers: { "X-CMC_PRO_API_KEY": "{{publisher.CMC_API_KEY}}" },
}],
mapping: {
iterate: "$.data[*]", // JSONPath over response
page_type: "crypto_coin_snapshot",
source_ref_prefix: "cmc:",
source_ref: "{{symbol}}@{{last_updated}}", // idempotency key
title: "{{name}} ({{symbol}}) — ${{quote.USD.price}}",
frontmatter: {
symbol: "{{symbol}}",
price_usd: "{{quote.USD.price}}",
market_cap_usd: "{{quote.USD.market_cap}}",
last_updated: "{{last_updated}}",
},
},
});
The whole integration is one object. The source_ref field is the
idempotency key — same source_ref → no duplicate page write. Pick
something that's stable per snapshot (an upstream ID + timestamp works
well).
A few quirks to know up front:
- The template engine does not support Python-style format specs
(
{value:.2f},{value:,.0f}). Strings come through asString(value)— full precision, no thousands separator. Format in the consumer (dashboard, mini-site), not in the recipe. - There's no
{{run_iso_hour}}built-in. Derive idempotency from the response's own data (e.g. an upstreamlast_updatedtimestamp), not from run metadata. auth.type=api_keyis metadata only — the runner doesn't auto-inject the header. You must reference the secret explicitly inendpoints[].headersvia{{publisher.SECRET_NAME}}. Theauthblock exists so the installer UI knows the recipe needs that secret in the publisher vault.- Frontmatter values are strings — numeric fields become stringified numbers. Downstream consumers parse as needed.
Sandboxed recipes — when declarative isn't enough
If your integration needs OAuth, pagination, conditional fetches, cursor management, or any non-trivial logic, drop into a sandboxed recipe — same Deno sandbox as a user automation, just authored as a recipe so others can install it.
The shape mirrors a normal automation: a main.ts, a tool_grants
list, http_fetch hosts, and the same {{secret_name}} substitution
for credentials. The difference is that a recipe is installable —
other users can add it to their brain with one click.
The five existing Google/Monday integrations are all sandboxed recipes (they predate the declarative path). CoinMarketCap is the first declarative one built from scratch.
Lifecycle
Every integration recipe has a publisher (you) and N installers (other users). When you publish a new version:
- Existing installers see "Update available" in the codex.
- Installs can be standalone or bundled (installed via a parent bundle). Bundled installs upgrade with the bundle; standalone installs upgrade individually.
- Per-install state (cursors, dedupe sets, OAuth tokens) is preserved across upgrades.
Anthropic, OpenAI, Groq
These aren't integrations you connect — they're infrastructure brains uses internally:
- Anthropic — every service that runs an LLM call (the web Agent
SDK,
act_on_integration,automation_llm_complete, board skills, board form intake, chat summarization, Telegram). Cost capture writes anllm_usagerow per call with acallertag for attribution. - OpenAI —
text-embedding-3-small(1536-d) for thequeryhybrid search and routing scope embeddings. The same model is used everywhere so similarity scores are comparable. - Groq — optional cheap fallback for short Telegram turns. If
GROQ_API_KEYisn't set, every Telegram turn goes to Anthropic.
You don't connect these per-user. They're served by the platform's API keys.
API reference
Every integration primitive is an MCP tool. The schemas below are the
source of truth — what the web UI and act_on_integration /
fetch_from_integration flows call internally is exactly what you
call from your own LLM session.
Discovery
list_integrations
Discover what's available for the current user and what's actually
connected. Use before suggesting fetch_from_integration /
act_on_integration so you don't propose a source the user hasn't
linked.
| Parameter | Type | Required | Notes |
|---|---|---|---|
include_unconnected |
boolean | no | Default true. Pass false to only see connected integrations. |
Returns — { integrations: [{ name, label, oauth_provider, supports: { fetch, act, adapter }, connected, description }, …] }.
list_starter_recipes
Browse the codex — prebuilt boards, automations, workflows, and integrations ready to instantiate into a brain.
| Parameter | Type | Required | Notes |
|---|---|---|---|
kind |
enum: board | automation | workflow | bundle |
no | |
category |
string | no | |
tag |
string | no | Single tag filter. |
search |
string | no | FTS over name + description. |
limit |
int 1–100 | no | Default 50. |
Returns — { recipes: [{ id, slug, name, kind, category, tags, version, description_excerpt, preview_image_url, author, created_at, updated_at }, …] }.
get_starter_recipe
Fetch one recipe in full, including the template payload that
apply_starter_recipe will instantiate.
| Parameter | Type | Required | Notes |
|---|---|---|---|
slug |
string | one of | Kebab-case slug. |
id |
uuid | one of | Recipe UUID. |
Returns — { recipe: {…full recipe object…} }.
Reading from an integration
fetch_from_integration
Pull fresh data from a connected integration on demand. Results are
persisted as pages in the target brain so subsequent
search / query / list_pages find them. For calendar, the fetch
is exhaustive over the requested window across all calendars; for
gmail / drive, up to 250 items per call.
| Parameter | Type | Required | Notes |
|---|---|---|---|
source |
enum: gmail | calendar | drive |
yes | |
request |
string (1–1000) | yes | Free-form NL query (e.g. "events from 2026-06-01 through 2026-06-07"). |
brain_id |
uuid | no | Defaults to user's default brain. |
Returns — { source, request, brain_id, ingested_count, page_slugs, errors?, translation_log, verify_mode?, note? }.
Two-phase writes
act_on_integration
Draft a write from a natural-language request. Never sends — returns
a draft_id plus a human-readable preview. Hand the preview to the
user; only confirm_action actually hits the external service.
| Parameter | Type | Required | Notes |
|---|---|---|---|
source |
enum: gmail | calendar | drive |
yes | |
request |
string (1–1000) | yes | NL description of the action. |
attachments |
array (≤5) | no | [{ filename, content_base64, mime_type? }, …] — 10 MB each max. |
attach_from_page |
string (≤500) | no | Gmail only: extract attachments from an existing email page slug. |
notify |
array of inbox | telegram |
no | Where to push the approval prompt. Default ["inbox"]. |
batch_id |
uuid | no | Group multiple drafts produced in one turn so /inbox clusters them. |
Returns — one of:
{ kind: "draft", draft_id, preview, payload, expires_at, … }(happy path; show preview).{ kind: "clarification", question }(ambiguous; ask the user back, then re-call).{ kind: "noop", reason }(no write was appropriate).
confirm_action
Execute a draft. One-shot per draft. Optional edits patch
whitelisted fields immediately before sending:
| Action | Whitelisted edit fields |
|---|---|
email |
to, cc, bcc, subject, body |
event |
summary, description, location, start, end, attendees, send_updates |
file |
name, content |
| Parameter | Type | Required | Notes |
|---|---|---|---|
draft_id |
uuid | yes | |
edits |
object | no | See whitelist above. |
Returns — { status: "confirmed", result: { message_id? \| event_id? \| file_id? \| modified_count? } } or { status: "failed", error }.
discard_action
Drop a draft without sending. No external side effects.
| Parameter | Type | Required | Notes |
|---|---|---|---|
draft_id |
uuid | yes |
Returns — { ok: true, draft_id, status: "discarded" }.
confirm_actions (batch)
| Parameter | Type | Required | Notes |
|---|---|---|---|
draft_ids |
uuid[] (1–50) | yes | Executed in order. Failures don't stop later drafts. |
Returns — { results: [{ draft_id, status: "confirmed" \| "failed", source?, action?, result?, error? }, …] }.
discard_actions (batch)
| Parameter | Type | Required | Notes |
|---|---|---|---|
draft_ids |
uuid[] (1–100) | yes |
Returns — { results: [{ draft_id, status: "discarded" \| "failed", error? }, …] }.
list_pending_actions
List outstanding drafts (status=pending, not expired). Does not
execute anything.
| Parameter | Type | Required | Notes |
|---|---|---|---|
limit |
int 1–100 | no | Default 50. |
source |
enum | no | Filter by source. |
Returns — { count, drafts: [{ draft_id, source, action, preview, created_at, expires_at, notify_targets }, …] }.
ack_inbox_action
Record a user decision on an inbox prompt from the SessionStart hook
(one-shot per (action, user)). Most callers don't need this — the
web UI files it automatically when the user clicks Approve/Decline.
| Parameter | Type | Required | Notes |
|---|---|---|---|
id |
uuid | yes | Action UUID from the HTML marker. |
decision |
enum: approved | declined | auto |
yes |
Returns — { ok: true, id, decision, recorded }.
Board-link adapters
create_board_link
Bind a board to an external resource via an adapter (http_json,
ics, github, monday, …). ref is adapter-specific. For
private resources, pass integration_provider so brains uses the
caller's OAuth credentials.
| Parameter | Type | Required | Notes |
|---|---|---|---|
board_id |
uuid | yes | |
kind |
string (1–100) | yes | Adapter kind. |
ref |
object | yes | Adapter-specific (URL, { owner, name }, …). |
display_name |
string (1–200) | yes | |
integration_provider |
string | no | OAuth provider name. |
integration_user_id |
uuid | no | Whose credentials to use. Defaults to caller. |
Returns — the created link row.
create_dataset_recipe
Schedule (or define for manual refresh) a materialization from a
link into a board dataset. The recipe replaces
data.datasets.<dataset_name>.rows on each refresh — mark such
datasets read-only by convention.
| Parameter | Type | Required | Notes |
|---|---|---|---|
link_id |
uuid | yes | |
dataset_name |
string (1–100) | yes | |
adapter_op |
string (1–100) | yes | e.g. github.list_open_prs. |
params |
object | no | Adapter args. |
refresh_cron |
string | no | Omit / null = manual refresh only. |
Returns — the created recipe row.
Installing and upgrading recipes
apply_starter_recipe
Instantiate a starter recipe into a brain. Dispatches to the right
creator (create_board, save_automation_draft,
create_workflow). Automations are always created paused.
| Parameter | Type | Required | Notes |
|---|---|---|---|
slug |
string | one of | Recipe slug. |
id |
uuid | one of | Recipe UUID. |
brain_id |
uuid | no | Default brain if omitted. |
overrides |
object | no | { board_name?, board_description?, title?, charter?, roster? }. |
Missing dependency recipes are ALWAYS cascade-installed before the target — there is no opt-out flag.
Returns — { recipe, created, dependencies_used?, recovered?, cascade_installed? }.
check_recipe_dependencies
Pre-flight a recipe install — does the caller have every prerequisite already?
| Parameter | Type | Required | Notes |
|---|---|---|---|
slug |
string | one of | |
id |
uuid | one of | |
brain_id |
uuid | no |
Returns — { recipe, declared, satisfied, missing: [{ slug, min_version, reason?, available: { has_recipe, latest_version } }, …], chain }.
check_recipe_upgrade
Check whether an instantiated entity has a newer recipe version available and whether upgrading would clobber user changes. Row content does not count as a user change.
| Parameter | Type | Required | Notes |
|---|---|---|---|
entity_id |
uuid | yes | |
entity_kind |
enum: board | automation | workflow |
yes |
Returns — { installed, latest, has_upgrade, has_user_changes, change_summary }.
upgrade_recipe
Rebuild an instantiated board/automation/workflow from the latest recipe version. Preserves row content, automation state, workflow roster. Replaces datasets/skills/dashboard (boards), source / cron / grants / caps (automations), charter / KPIs / deadlines (workflows).
| Parameter | Type | Required | Notes |
|---|---|---|---|
entity_id |
uuid | yes | |
entity_kind |
enum: board | automation | workflow |
yes | |
force |
boolean | no | Default false. Discard user changes and rebuild. |
Returns — { entity_id, entity_kind, upgraded_from_version, upgraded_to_version, … }.
uninstall_recipe
Uninstall a recipe-spawned entity (or every entity stamped with a
slug, for bundles) with a 7-day recovery window. Sets uninstalled_at
rather than hard-deleting.
| Parameter | Type | Required | Notes |
|---|---|---|---|
entity_id |
uuid | one of | Single entity to uninstall. |
slug |
string | one of | Uninstall all instances of this recipe. |
brain_id |
uuid | with slug |
Required when using slug. |
cascade |
boolean | no | Default true for bundles. |
Returns — { uninstalled: [{ kind, id, name, recoverable_until }, …], cascade_count }.
list_uninstalled
List entities currently inside their 7-day recovery window.
| Parameter | Type | Required | Notes |
|---|---|---|---|
brain_id |
uuid | no | Optional brain scope. |
limit |
int 1–500 | no | Default 100. |
Returns — [{ kind, id, name, source_recipe_slug, uninstalled_at, expires_at }, …].
recover_recipe
Recover an entity within its 7-day window. Flips uninstalled_at
back to NULL; rows / source / provenance all intact.
| Parameter | Type | Required | Notes |
|---|---|---|---|
entity_id |
uuid | yes |
Returns — { kind, id, name, source_recipe_slug, recoverable_until, … }.