If you find yourself prompting Claude for the same thing every Monday, that's an automation. Automations are sandboxed TypeScript that runs on a schedule, in a Deno sandbox, with a scoped MCP token. They can read and write everything you can — boards, pages, integrations — but only the subset they were granted at authoring time, only for the windows they fire on, and only up to a daily USD cost cap.
When to reach for an automation
| The user says… | You probably want… |
|---|---|
| "Every Monday I copy revenue into a sheet and email a summary." | An automation. |
| "When an email from Stripe arrives, log it to the finance board." | An automation (page-trigger). |
| "Run this query nightly and feed a board." | A dataset recipe (lighter — see boards). |
| "Help me build a CRM." | A board first; maybe an automation later. |
| "Render this data as a one-pager I can share." | A mini-site. |
If you're not sure: start with the board. Add an automation once you notice yourself doing the same thing twice.
The four superpowers
Every automation has the same four superpowers, gated by its grant list:
- Read your brain.
search,query,list_pages,get_page,list_calendar_events— the same memory layer Claude uses. - Read and write boards.
get_board,append_board_rows,update_board_row,restore_board_row,run_board_skill. - Call an LLM.
automation_llm_completeexposes Anthropic models with cost capture wired through so the runner can enforce the daily cap. - Call an integration.
fetch_from_integration(read) andact_on_integration→confirm_action(write — two-phase). For pushes to your own phone,telegram_push.
That's it. There's no ambient filesystem, no environment access, no subprocess spawn, no arbitrary network. Everything an automation does goes through MCP so it's auditable, capped, and revocable.
The mental model
trigger fires → scheduler claims a run row
(cron string in tz) (atomic UPDATE — only one runner ever claims it)
↓
scoped MCP token minted (tool_grants copied from automation)
↓
source rendered to /tmp/<run_id>/main.ts
({{secret_name}} → decrypted value substituted)
↓
deno run with --allow-net=<MCP_HOST>,<allowed_hosts>
--allow-env=BRAINS_MCP_URL,BRAINS_RUN_TOKEN
(no fs, no subprocess, no FFI)
↓
stdout/stderr streamed into the run record
↓
cost ≥ cap → SIGKILL
wall ≥ cap → SIGKILL
tokens ≥ cap → SIGKILL
↓
finalize run record; revoke scoped token
Trigger model
| Kind | Shape | Status |
|---|---|---|
cron |
{ kind: 'cron', cron: '0 8 * * *', tz: 'America/New_York' } |
v1, fully wired |
page_ingested |
{ kind: 'page_ingested', page_type?: 'email', match_query?: '…' } |
schema in place; runtime sweep is cron-only today |
board_change |
{ kind: 'board_change', board_id, dataset?, change_kinds?: ['append','update','delete'] } |
schema in place; runtime sweep is cron-only today |
webhook |
per-automation public URL | planned |
In v1, cron is the production path. The other kinds have schema slots and authoring support so the move to memory-page and webhook triggers is incremental, not a rewrite.
Authoring an automation — the right way
Ask Claude:
"Create an automation that posts a Monday morning revenue report at 9am ET."
Claude runs create_automation_flow which walks you through:
- Trigger — kind (
cron), expression (0 9 * * 1), timezone. - Reads — which MCP tools the script needs to call.
- Writes — and for every shared-collection append, a mandatory dedupe-key question. This is the one step you should never skip.
- Tool grants — the allowlist the MCP edge will enforce.
- Cost caps — daily USD, max wall seconds, max LLM tokens.
- Source review — a skeleton template with idempotency baked in.
- Save as paused — you activate explicitly from
/automations/<id>.
Why the playbook is load-bearing: automations write to shared state on a schedule. A hand-rolled one without idempotency duplicates rows on every run, every replay, every teammate-with-the-same-trigger. The skeletons read the existing collection and skip if the upstream id is already there.
If a similar automation already exists, the playbook surfaces it
(find_overlapping_automations runs a cosine similarity over the
intent field) so you can extend instead of duplicate.
What the source looks like
The source is a single main.ts. It imports a thin SDK that wraps
the MCP server over fetch:
import { brains } from "https://brains/sdks/automation-sdk-v1.ts";
// 1. Read the dataset that holds last-week's totals.
const board = await brains.get_board({ name: "Revenue weekly" });
const rows = board.data.datasets.default.rows;
// 2. Compute this week's slice.
const today = new Date();
const slug = `${today.getFullYear()}-W${weekNumber(today)}`;
if (rows.some((r) => r.week === slug)) {
console.log(`already wrote ${slug}, exiting`);
return; // idempotent — second run is a noop
}
// 3. Fetch from Stripe via http_fetch (allowlisted host).
const json = await brains.http({
url: `https://api.stripe.com/v1/charges?created[gte]=${weekStart()}`,
headers: { Authorization: `Bearer {{stripe_api_key}}` },
});
const total = json.data.reduce((s, c) => s + c.amount, 0) / 100;
// 4. Append a row.
await brains.append_board_rows({
board_id: board.id,
rows: [{ week: slug, total, generated_at: new Date().toISOString() }],
});
// 5. Push a Telegram nudge to the owner.
await brains.telegram_push({
text: `Revenue for ${slug}: $${total}`,
});
The whole thing is one file. No bundler, no build step, no transpile.
Deno imports the SDK over the network at runtime. The canonical HTTP
helper is brains.http(...); brains.http_fetch(...) is accepted as
a compatibility alias for older automation source.
Tool grants — the allowlist
automations.tool_grants is a list of { tool, params? } entries:
[
{ "tool": "get_board" },
{ "tool": "append_board_rows", "params": { "board_id": "<uuid>" } },
{ "tool": "http_fetch" },
{ "tool": "telegram_push" },
{ "tool": "automation_llm_complete", "params": { "max_tokens": 1024 } }
]
At dispatch, the MCP edge checks:
- The tool name is in the grant list.
- If
paramsis present, the call's args are a superset match — the automation can't broaden the grant at call time. - For
http_fetch, the host must also be inautomations.http_fetch_hosts. Authors populate that column throughacknowledged_http_fetch_hostsonsave_automation_draftorupdate_automation; do not put hosts intool_grants.params.
The grant list is captured into the run-scoped token at run start. If you edit the automation mid-flight, the in-progress run keeps its original posture; the new grants apply to the next run.
Admin tools (admin_*) cannot be granted to an automation.
Sandbox posture
The Deno subprocess gets:
- No filesystem access —
--allow-readand--allow-writeare not granted. - Network only to
BRAINS_MCP_URLplus the explicithttp_fetch_hostsallowlist. - Environment only:
BRAINS_MCP_URLandBRAINS_RUN_TOKEN. - No subprocess spawn, no FFI.
- Heap and open-file caps so a runaway script aborts cleanly instead of degrading the shared runner host.
External HTTP happens through MCP's http_fetch proxy, which is what
makes the egress allowlist enforceable — the host check runs at the
MCP edge, not in the script.
Secrets
Per-user secrets are stored encrypted at rest (AES-256-GCM). Reference
them in source as {{secret_name}}:
const r = await fetch("https://api.stripe.com/...", {
headers: { Authorization: `Bearer {{stripe_api_key}}` },
});
The runner substitutes {{stripe_api_key}} with the decrypted value
after the sandbox starts. Combined with the host allowlist, a
leaked secret can only egress to a host you've already approved for
http_fetch.
CRUD via automation_secret_set / automation_secret_list /
automation_secret_delete.
Write policy — draft vs auto-confirm
automations.write_policy:
always_draft(default) — every integration write goes through theact_on_integration→confirm_actiontwo-phase flow, and the confirm requires a human approval (surfaced as aclaude_inbox_actionfor the automation owner).auto_confirm_safe— the automation may callconfirm_actionitself for actions classified as safe (e.g. a Telegram nudge to yourself, an internal page creation). The "safe" set is defined globally per tool, not per automation.
The playbook defaults to always_draft and warns when offering
auto_confirm_safe. Most automations should stay always_draft —
the inbox queue is the human checkpoint that catches bugs before they
email your customers.
Cost caps
Every run is bounded by three hard caps:
| Cap | Default | Hit behavior |
|---|---|---|
cost_cap_daily_usd |
$1 | Run killed mid-flight (current LLM call completes, then SIGKILL). |
max_wall_seconds |
60s | Run killed. |
max_llm_tokens |
50k | Run killed. |
Costs are tracked via per-call usage rows so the wrapper can sum them
across every run for the day. There's also a daily egress cap on
http_fetch bytes — enforced at the MCP edge, since that's the only
place the byte count is known.
Audit log
Every run gets a row in automation_runs with:
status(queued/running/succeeded/failed/killed)cost_usd,tokens_in,tokens_outstdout/stderr(truncated)- the scoped token that was used (revoked on finalize)
- the
automation_versionsrow that was active when the run started
Run records reference the version, not just the automation, so
the trace survives edits. Roll back a regression with
update_automation { active_version_id } and the next sweep picks up
the older version within one tick (default 30s).
Versioning and rollback
Every save appends to automation_versions — source is kept
verbatim. The active version is pointed at by
automations.active_version_id.
// Rollback:
await brains.update_automation({
automation_id,
active_version_id: "<old version id>",
});
The runtime claims the new active version on the next sweep — cutover is at most one tick (default 30s).
Running once before activating
Don't activate an automation before smoke-testing it. Always:
await brains.run_automation_once({ automation_id });
This runs the active version exactly as a cron-triggered run would —
same sandbox, same grants, same caps, same write_policy — and
returns the run record. Inspect stdout, cost_usd, and the
resulting board state before flipping state to active.
Patterns
Idempotency
Every shared-collection write must be idempotent. The standard pattern is "read what's there, skip what we've already written":
const board = await brains.get_board({ name: "CRM" });
const existing = new Set(
board.data.datasets.default.rows.map((r) => r.email_id),
);
const next = (await brains.search({ q: "...", type: "email", limit: 50 }))
.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 })),
});
}
For lightweight cursors (last-seen id, dedupe sets), use
automation_kv — a tiny per-automation key/value store that survives
restarts and doesn't pollute the board.
Drafting writes to integrations
Never auto-send. Always go through draft + confirm:
const draft = await brains.act_on_integration({
install_id: gmailInstallId, // resolve from type=integration_action
action_name: "send_email",
input: {
to: ["noah@a16z.com"],
subject: "Re: next week",
body: "We're in. Friday works for us.",
thread_id: priorThreadId,
},
});
// draft = { kind: "draft", draft_id, preview, payload, expires_at }
// Owner sees `preview` in /inbox and confirms there.
If you set the automation to auto_confirm_safe AND the tool is on
the safe list, you can confirm from inside the automation — but the
playbook's default of always_draft is correct nine times out of
ten.
LLM calls
automation_llm_complete exposes Anthropic models with cost capture
wired through so the runner can enforce the cap:
const { text } = await brains.automation_llm_complete({
model: "claude-haiku-4-5-20251001",
prompt: `Summarise the following revenue notes in one sentence:\n${notes}`,
max_tokens: 200,
});
Use Haiku for cheap, repetitive work. Reserve Sonnet/Opus for the automations that pay for themselves.
Tool surface
| Tool | Purpose |
|---|---|
create_automation_flow |
Guided multi-step authoring. Use this. |
save_automation_draft |
Final save step of the playbook (creates paused). |
update_automation |
Edit source / grants / caps / state of an existing automation. |
run_automation_once |
Manual run, same sandbox as a cron-triggered one. |
delete_automation |
Flip to killed. Run history preserved. |
find_overlapping_automations |
Pre-flight check before adding a duplicate. |
automation_secret_set / automation_secret_list / automation_secret_delete |
Per-user encrypted vault. |
automation_llm_complete |
LLM endpoint exposed only to automation-scoped tokens. |
What's missing from v1
- Non-cron triggers.
board_changeandpage_ingestedschemas exist; the runtime only wakes forcrontoday. - Webhook trigger. No public per-automation webhook endpoint yet.
- Worker pool. v1 is serial — one run at a time per runner. The atomic claim is already there, so scale-out is just spinning up a second runner.
- Pre-flight cost estimate. You see actual cost post-hoc; the playbook can't yet show "this will cost ~$X/month" before you activate.
- Replay UI. Run history is read-only — no "re-run this with the same trigger payload" button yet.