// 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 cron 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.
"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.
"I want to ship X by Q3 with a team." A workflow (which owns its own automations).

If you're not sure: start with the board. Add an automation once you notice yourself doing the same thing twice.

The mental model

trigger fires        →  scheduler claims a run row
   (cron string in tz)    (one runner, atomic UPDATE)
                       ↓
   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 automation_runs
   llm_usage parsed from stdout markers for cost attribution
                       ↓
   cost ≥ cap   → SIGKILL
   wall ≥ cap   → SIGKILL
   tokens ≥ cap → SIGKILL
                       ↓
   finalize run record; revoke scoped token

Triggers in v1 are cron-only. board_change and page_ingested schemas exist in the trigger table but only cron is wired end-to-end.

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 that 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 will surface 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_fetch({
  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. Draft a Telegram nudge to the owner — never auto-send.
await brains.telegram_push({
  text: `Revenue for ${slug}: $${total}`,
});
// (a draft is created; the owner confirms from /inbox)

The whole thing is one file. No bundler, no build step, no transpile. Deno imports the SDK over the network at runtime.

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", "params": { "hosts": ["api.stripe.com"] } },
  { "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 automation_http_fetch_hosts (its own table for easy audit).

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.

Secrets

Per-user secrets live in automation_secrets, AES-256-GCM at rest. 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 name=stripe_api_key value=sk_live_…
  • automation_secret_list
  • automation_secret_delete name=stripe_api_key

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 llm_usage rows parsed from stdout markers — the automation writes JSON tags like [[llm_usage]]{...}[[/llm_usage]] when it calls automation_llm_complete, and the wrapper sums them across all runs for the day.

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). Run records reference the version, not just the automation, so traces survive edits.

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 to llm_usage:

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.

API reference

Every automation primitive is an MCP tool you can call from your own LLM session, from a custom MCP client, or from inside an automation itself (subject to grants). The schemas below are the source of truth — the playbook and the web UI both call exactly these tools with these parameters.

Authoring

create_automation_flow

Start a guided flow to scaffold a brand-new automation. Returns a multi-step playbook the caller follows one question at a time. Use this for any "create / set up / build a new automation" request.

Parameter Type Required Notes
purpose_hint string no Pre-fills the purpose so the playbook skips its cold-open question.

Returns{ purpose_hint, playbook } where playbook is the ordered step instructions the agent follows.

save_automation_draft

Create a new automation in paused state — the final step of create_automation_flow. Do not use to edit existing automations (update_automation is the right tool — calling save_automation_draft for an edit creates a duplicate). The automation won't fire until the user activates it from /automations/<id>.

Parameter Type Required Notes
name string (1–200) yes Unique per owner.
source string (1–200k) yes Full Deno script.
triggers array (≥1) yes [{ kind: "cron", cron: "0 9 * * 1-5", tz: "America/New_York" }, …]
tool_grants array (≥1) yes [{ tool, params? }, …]params is a superset match.
description string (≤500) no Human-readable summary.
intent string (≤2000) no Used by find_overlapping_automations.
cost_cap_daily_usd number 0–100 no Default 1.
max_wall_seconds int 5–300 no Default 60.
max_llm_tokens int 100–2000000 no Default 200000.
model string no Default claude-sonnet-4-6.
writes_to_boards uuid[] no Board IDs this automation writes to.
dedupe_key_field string no Upstream stable ID field (e.g. postId).
dedupe_target_field string no Local array field on rows (e.g. updates).
acknowledged_overlap boolean no Required if find_overlapping_automations returned a conflict.
coexistence_note string (10–1000) no Required with acknowledged_overlap=true.
acknowledged_http_fetch_hosts string[] no Required if tool_grants includes http_fetch. Bare hostnames.

Returns{ automation_id, version_id, version, state: "paused", admin_url }.

update_automation

Edit an existing automation. Appends a new automation_versions row when source changes; patches config columns for everything else. At least one editable field must be provided.

Parameter Type Required Notes
automation_id uuid yes
source string (1–200k) no Full replacement; bumps version.
tool_grants array (≥1) no Full replacement.
triggers array (≥1) no Full replacement.
cost_cap_daily_usd number 0–100 no
max_wall_seconds int 5–300 no
max_llm_tokens int 100–2000000 no
acknowledged_http_fetch_hosts string[] no Required if resulting grants include http_fetch.
state enum: active | paused no Use delete_automation for kill.

Returns{ automation_id, version_id?, version?, columns_changed, state, admin_url }.

Running

run_automation_once

Fire a single manual run and wait for it to terminate. Works regardless of state — paused automations still execute manual runs. This is how you smoke-test before flipping to active.

Parameter Type Required Notes
automation_id uuid yes
dry_run boolean no Default true — executes the run but suppresses outbound writes.
verify_mode boolean no Default false. Blocks telegram_push, act_on_integration, non-GET http_fetch while keeping board writes.
wait_seconds int 10–360 no Default computed from max_wall_seconds.

Returns{ run_id, status, terminal, timed_out, timeout_reason, next_step, exit_code, duration_ms, cost_usd, stdout, stderr, error, admin_url }. stdout/stderr truncated to 8 KB.

delete_automation

Flip state to killed. Run history is preserved; the automation stops firing and stops appearing in overlap checks.

Parameter Type Required Notes
automation_id uuid yes

Returns{ automation_id, state: "killed" }.

find_overlapping_automations

Read-only check used by the playbook (step 4.5) and worth running before any custom-built automation that writes to a shared board. Returns existing automations that write the same way, with enough metadata to decide between "extend", "coexist", or "skip".

Parameter Type Required Notes
writes_to_boards uuid[] (≥1) yes Boards the new automation will write.
dedupe_target_field string no Narrows to same array-field writers.
trigger_kind string no Narrows to same trigger kind (cron, page_ingested, board_change).

Returns{ count, strict_count, matches: [{ automation_id, owner_email, is_self, name, description, state, last_run_at, writes_to_boards, dedupe_target_field, dedupe_key_field, trigger_kinds, trigger_types, strict_overlap }, …] }.

Secrets

Per-user encrypted vault. Names match /^[a-z0-9_]+$/. Secrets are referenced from automation source as {{name}}.

automation_secret_set

Parameter Type Required Notes
name string (1–64, lowercase) yes
value string (1–8192) yes
description string (≤500) no
owner_user_id uuid no Admin-only override; defaults to caller.

Returns{ id, owner_user_id, name }.

automation_secret_list

Parameter Type Required Notes
owner_user_id uuid no Admin-only override.

Returns{ owner_user_id, secrets: [{ id, name, description, created_at, updated_at }, …] }. Never returns the decrypted value.

automation_secret_delete

Parameter Type Required Notes
name string (lowercase) yes
owner_user_id uuid no Admin-only override.

Returns{ deleted: integer }.

LLM inside an automation

automation_llm_complete

The LLM completion endpoint exposed only to automation-scoped tokens. Records llm_usage against the run for cost attribution and cap enforcement. Use it instead of going direct to Anthropic.

Parameter Type Required Notes
messages array (≥1) yes [{ role: "user" | "assistant", content: string }, …]
system string no System prompt.
model string no Defaults to the automation's model.
max_tokens int 1–16000 no Capped by automation max_llm_tokens.
temperature number 0–1 no

Returns{ text, model, usage: { input_tokens, output_tokens }, stop_reason }.

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.