// DOCS

Automations

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:

  1. Read your brain. search, query, list_pages, get_page, list_calendar_events — the same memory layer Claude uses.
  2. Read and write boards. get_board, append_board_rows, update_board_row, restore_board_row, run_board_skill.
  3. Call an LLM. automation_llm_complete exposes Anthropic models with cost capture wired through so the runner can enforce the daily cap.
  4. Call an integration. fetch_from_integration (read) and act_on_integrationconfirm_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:

  1. Trigger — kind (cron), expression (0 9 * * 1), timezone.
  2. Reads — which MCP tools the script needs to call.
  3. Writes — and for every shared-collection append, a mandatory dedupe-key question. This is the one step you should never skip.
  4. Tool grants — the allowlist the MCP edge will enforce.
  5. Cost caps — daily USD, max wall seconds, max LLM tokens.
  6. Source review — a skeleton template with idempotency baked in.
  7. 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:

  1. The tool name is in the grant list.
  2. If params is present, the call's args are a superset match — the automation can't broaden the grant at call time.
  3. For http_fetch, the host must also be in automations.http_fetch_hosts. Authors populate that column through acknowledged_http_fetch_hosts on save_automation_draft or update_automation; do not put hosts in tool_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-read and --allow-write are not granted.
  • Network only to BRAINS_MCP_URL plus the explicit http_fetch_hosts allowlist.
  • Environment only: BRAINS_MCP_URL and BRAINS_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 the act_on_integrationconfirm_action two-phase flow, and the confirm requires a human approval (surfaced as a claude_inbox_action for the automation owner).
  • auto_confirm_safe — the automation may call confirm_action itself 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_out
  • stdout / stderr (truncated)
  • the scoped token that was used (revoked on finalize)
  • the automation_versions row 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_change and page_ingested schemas exist; the runtime only wakes for cron today.
  • 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.