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:
- 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 that 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 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:
- 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 inautomation_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_listautomation_secret_delete name=stripe_api_key
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 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_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.