Skip to content

Substrate

Substrate is a per-user layer that gives your AI agents durable memory and a way to take real-world actions through a single audited surface. Instead of each agent owning its own scratchpad and side effects, all your apps share the same substrate for a given user — entities they reference, decisions they record, and actions they propose all land in one ledger you can review and govern.

It plugs into Butterbase the same way Functions and Storage do: you call it from inside a function via ctx.substrate, from the CLI with butterbase substrate, or from any client over HTTP with a substrate-scoped API key.

ConceptWhat it is
EntitiesPeople, companies, projects, agents, etc. — the durable nouns your apps talk about.
Action ledgerEvery action an agent or app proposed, with who proposed it, the policy verdict, and the result.
Decisions, commitments, learnings, principlesLong-form memory rows your agents can search later.
Source artifactsDurable, FTS-indexed source material — meeting transcripts, email threads, call recordings, documents — that decisions, commitments, and learnings can link back to. Provenance for everything else in the substrate.
Attention rulesScheduled rules that run on a snapshot of your substrate and propose actions when their conditions match.
Outbox targetsHMAC-signed webhooks that fire when actions execute (e.g. send the email draft to an external system).
SettingsPer-user toggles — yolo_mode for auto-approval, etc.
WebSocket streamLive push of every entity, ledger, memory (decision / commitment / learning), rule, and firing change — so UIs update without polling.

When you run a serverless function for an app that is linked to a substrate user, the function’s ctx gains a substrate object:

export async function handler(req, ctx) {
// Propose an action. The substrate's policy engine decides whether it
// executes immediately, queues for approval, or is rejected.
const verdict = await ctx.substrate.propose('record_decision', {
title: 'Migrate billing to Stripe',
kind: 'strategic',
rationale: 'Vendor consolidation; lower switching cost than custom.',
});
// Search prior memory.
const prior = await ctx.substrate.searchMemory('billing', { kinds: ['decisions'], limit: 5 });
// Look up an entity.
const people = await ctx.substrate.findEntities({ type: 'person', limit: 10 });
return Response.json({ verdict, prior, people });
}

The available calls are:

CallPurpose
propose(capability, payload, opts?)Propose an action. Returns { action_id, verdict, requires_approval, result? }.
getEntity(entity_id)Fetch one entity.
findEntities({ type?, q?, limit? })List or search entities.
searchMemory(query, { kinds?, limit? })Full-text search across decisions, commitments, learnings, and source artifacts.

Every proposed action either executes immediately or blocks in proposed state until the owner approves it. The Default policy column shows which behavior the platform applies out of the box. Owners can override with dangerously_skip_approval: true on a single call, but agents cannot.

A non-obvious asymmetry that has tripped up agent authors: record_decision auto-approves, but record_principle, supersede_decision, and retire_principle all require human approval by default. All three mutate the policy-enforcement layer of the substrate, so the platform conservatively gates them.

CapabilityDefault policyReversibleRestrictedNotes
record_decisionautoyesnoWrite a decision row.
record_commitmentautoyesnoWrite a commitment. Accepts optional source_artifact_id.
record_learningautoyesnoWrite a retrospective learning.
upsert_entityautoyesnoCreate or update an entity. Dedups by id > canonical_keys > primary_email.
update_entityautoyesnoReplace attrs wholesale on an existing entity (legacy). Prefer patch_entity.
patch_entityautoyesnoRFC 7396 merge-patch over attrs. Supports if_updated_at optimistic lock.
upsert_source_artifactautoyesnoInsert or update a source artifact. Idempotent by (external_system, external_id).
revert_actionautononoRevert a single reversible ledger action.
record_principleapproval_requirednonoRecord a durable principle used by the policy engine. Requires human approval.
supersede_decisionapproval_requirednonoMark an existing decision superseded and insert a replacement. Requires human approval.
retire_principleapproval_requirednonoSet a principle to status=expired. Requires human approval.
delete_entityapproval_requirednonoHard-delete an entity. Not reversible.
merge_entitiesapproval_requirednonoCollapse a duplicate entity into a survivor via alias. Not reversible.
bulk_revert_actionsapproval_requirednonoRevert up to 200 ledger actions in one call. Per-action failures are collected, not raised.
send_email_draftapproval_requirednonoRecord an intended email draft. Side-effecting; always requires approval for agent proposers.

When the function runs on behalf of an app, the proposer is recorded as kind: 'agent' and certain side-effect capabilities require human approval even if the user has yolo_mode on.

Dedup order: id > canonical_keys > primary_email > insert new.

  • If you pass id, it’s an upsert by id (current legacy behavior).
  • Else if you pass non-empty canonical_keys, substrate looks up an entity of the same type whose canonical_keys contain every key/value pair you sent (JSONB @>). On hit: update and return was_insert: false.
  • Else if you pass primary_email, substrate looks up by (type, lower(primary_email)). On hit: update. On insert, substrate auto-promotes primary_email into canonical_keys.email so future calls can dedup either way.

Race-safety: primary_email lookups are protected by a partial unique index. Canonical_keys-only lookups are best-effort — two near-simultaneous identical writes can still race; clean up with merge_entities.

RFC 7396 JSON Merge Patch over attrs. Atomic.

await ctx.substrate.patchEntity('ent_…', { title: 'CTO', previous_title: null });
// title set to 'CTO', previous_title key deleted, all other attrs untouched.

Optional optimistic concurrency: pass if_updated_at to get an error instead of a silent clobber.

Use patch_entity for any partial update. update_entity (legacy) replaces attrs wholesale and is kept only for backwards compatibility.

Collapse a duplicate into a survivor.

await ctx.substrate.mergeEntities('ent_loser', 'ent_winner', 'duplicate by email');

Semantics:

  • The loser is hard-deleted from substrate.entities.

  • An alias row is inserted into substrate.entity_aliases mapping loser_id → winner_id.

  • No automatic FK rewriting. If your app stores attrs.company_id = 'ent_loser' on other entities, those references will not be updated by merge_entities. Your read path must resolve old IDs through entity_aliases:

    SELECT COALESCE(a.canonical_id, $1) AS resolved_id
    FROM (VALUES ($1)) t(id)
    LEFT JOIN substrate.entity_aliases a ON a.alias_id = $1;
  • One ledger action per merge. reversible: false — undoing a merge means re-creating the loser, which the platform won’t do for you.

Hard delete, not reversible. Requires approval by default.

await ctx.substrate.deleteEntity('ent_…', 'manual cleanup');

Prefer merge_entities when collapsing duplicates (preserves alias resolution). Use delete_entity only when the entity is genuinely garbage with no inbound references worth preserving.

For soft delete, patch the entity instead: ctx.substrate.patchEntity(id, { deleted_at: new Date().toISOString() }) and filter on read.

Pass idempotency_key as a third argument to propose (or as a body field on POST /v1/me/substrate/actions/propose) to protect retries — network blips, lambda re-runs, or duplicate webhook deliveries — from doubling actions in the ledger:

await ctx.substrate.propose('record_decision', payload, {
idempotency_key: 'mtg_2026-06-16:record_decision',
});

Keys are scoped per substrate user (two users can reuse the same string without collision) and are retained forever — there is no TTL. Pick keys that are stable for the unit of work you are deduplicating; a meeting ID combined with the capability is a good pattern, while a per-call UUID defeats the purpose. When the key matches a prior action, the response returns that prior action’s verdict and result unchanged, and includes "replay": true so callers can tell a replay apart from a fresh propose.

Revert up to 200 ledger actions in one call. Per-action failures are collected into the response, not raised. Use after a buggy ingest run produced many bad actions:

await ctx.substrate.propose('bulk_revert_actions', {
action_ids: ['act_…', 'act_…', /* … */],
reason: 'rolling back failed ingest run 2026-06-11',
});

Three ways to reach the substrate over HTTP:

  1. Substrate-scoped API key (bb_sub_*) — for CLIs, SDK clients, and headless integrations. Generate one with butterbase keys generate --substrate.
  2. Cognito session (the dashboard at docs.butterbase.ai) — handled for you by the web app.
  3. Inside a deployed functionctx.substrate is wired automatically when the app is linked to a substrate user; no token to manage.

By default every action goes through the policy engine and may require approval before it executes. Turn on yolo_mode to auto-approve any action where the proposer is a human:

Terminal window
butterbase substrate settings yolo on

Agent proposals from ctx.substrate are not affected by yolo_mode for side-effecting capabilities — that’s a deliberate safety rail.

Visit /substrate in your Butterbase dashboard to see the action ledger, entities, memory, attention rules, and pending approvals in a UI. The dashboard is a complete substitute for the CLI — anything you can do with butterbase substrate you can do in the browser.

Walkthrough — your first substrate-aware agent

Section titled “Walkthrough — your first substrate-aware agent”

This walkthrough takes you from zero to a function that proposes a decision, an attention rule that watches your substrate on a schedule, and a webhook that fires when an action executes. ~10 minutes.

A substrate is created on first use. Either trigger it from the dashboard (click “Open Substrate”) or from the CLI:

Terminal window
butterbase substrate settings show

If you see {"yolo_mode": false, ...}, you’re provisioned. If you see a not provisioned error with a remediation hint, follow it.

Terminal window
butterbase keys generate --substrate --name "my-laptop"
# → bb_sub_… (shown once — store it now)
export BUTTERBASE_API_KEY="bb_sub_..."

The key is bound to your user’s substrate. It cannot read or write any app database; substrate routes only.

Terminal window
butterbase substrate propose record_decision \
--payload '{"title":"Adopt substrate","kind":"strategic","rationale":"agent memory needs a single source of truth"}'

Returns:

{
"action_id": "act_01...",
"verdict": { "result": "auto_approved", "reason": "capability default = auto" },
"requires_approval": false,
"result": { "decision_id": "dec_01..." }
}

Your decision is now in substrate.decisions. Verify:

Terminal window
butterbase substrate memory "adopt substrate" --kinds decisions

Substrate only injects into functions for apps that are explicitly linked to your substrate user. From the dashboard, open an app and click “Link to substrate”, or use the SDK / CLI as your team’s conventions dictate.

5. Deploy a function that uses ctx.substrate

Section titled “5. Deploy a function that uses ctx.substrate”
fn-summarize-week.ts
export async function handler(req, ctx) {
const lastWeekDecisions = await ctx.substrate.searchMemory('', { kinds: ['decisions'], limit: 20 });
const verdict = await ctx.substrate.propose('record_decision', {
title: 'Weekly summary',
kind: 'operational',
rationale: `Reviewed ${lastWeekDecisions.length} decisions this week.`,
});
return Response.json({ verdict, count: lastWeekDecisions.length });
}

Deploy with butterbase fn deploy (or via MCP deploy_function). Invoke it once; in the action ledger you’ll see the new decision attributed to kind: 'agent' with source_app_id set to your app.

Attention rules let your substrate take initiative on a schedule. They run a JSON-Logic predicate against your daily snapshot and, if it matches, propose actions from a template.

{
"name": "weekly digest",
"trigger_cron": "0 9 * * 1",
"condition_mode": "snapshot_predicate",
"condition": { ">": [ { "var": "entity_count" }, 0 ] },
"action_capability": "send_email_draft",
"action_payload_template": {
"to": "you@example.com",
"subject": "Weekly digest",
"body": "{{entity_count}} entities tracked this week."
}
}

Save it and create:

Terminal window
butterbase substrate rules create --file rule.json

Preview what it would do today without scheduling it:

Terminal window
butterbase substrate rules preview --file rule.json

When an action with a send_email_draft capability auto-executes, the substrate can POST it to your own endpoint:

Terminal window
butterbase substrate outbox put send_email_draft \
--webhook-url https://example.com/hooks/substrate \
--signing-secret "$(openssl rand -hex 16)"

Every webhook delivery is signed with X-Butterbase-Signature: sha256=… using the secret you provided. Retries and dead-lettering are handled for you.

Every substrate write emits a change over a WebSocket, so your UI updates the instant an agent or another user changes something — no polling, no refresh. The stream carries envelope-only frames — { org, op, tbl, id }, no row payload — and you re-fetch the affected row by id (the same model as Realtime).

What streams today: entities, action_ledger, decisions, commitments, learnings, attention_rules, and attention_rule_firings. The stream is org-scoped — a connection only receives changes for the org bound to its credential.

There are three ways to connect, depending on what credential the client holds.

a) Server / script (has a substrate key). Use the SDK’s bb.substrate.stream — it handles the WebSocket, reconnects with backoff, and skips the hello frame for you:

import { createClient } from '@butterbase/sdk';
const bb = createClient({ apiUrl: 'https://api.butterbase.ai', /* … */ });
const sub = bb.substrate.stream({
token: process.env.BUTTERBASE_SUBSTRATE_KEY, // bb_sub_… or bb_sk_…
onChange: (evt) => {
// evt = { org, op: 'insert' | 'update' | 'delete', tbl, id }
if (evt.tbl === 'entities') refetchEntity(evt.id);
},
onStatus: (s) => console.log('substrate stream', s), // 'connecting' | 'open' | 'closed'
});
// later
sub.unsubscribe();

b) End-user app in the browser (no substrate key). A browser must never hold a substrate key, so mint a single-use ticket through one of your app’s functions, then open the stream with it. In the function:

// app function: substrate-proxy
export async function handler(req, ctx) {
const { op } = await req.json();
if (op === 'ws_ticket') {
return Response.json(await ctx.substrate.wsTicket()); // { ticket, expires_in }
}
// … other ops
}

In the browser:

// 1. Ask your function for a 60s single-use ticket.
const { ticket } = await bb.functions.invoke('substrate-proxy', { op: 'ws_ticket' });
// 2. Open the stream, then re-fetch on each change.
const ws = new WebSocket(`wss://api.butterbase.ai/v1/me/substrate/stream?ticket=${ticket}`);
ws.onmessage = (evt) => {
const change = JSON.parse(evt.data);
if (change.type === 'hello') return;
// change = { org, op, tbl, id } — e.g. invalidate your query cache by id
};

c) Dashboard / cookie-authenticated browser. If the browser already has a Cognito session, mint the ticket directly (no proxy function needed):

const { ticket } = await fetch('/v1/me/substrate/ws-ticket', {
method: 'POST',
credentials: 'include',
}).then(r => r.json());
const ws = new WebSocket(`wss://api.butterbase.ai/v1/me/substrate/stream?ticket=${ticket}`);

In every case the server pushes a { type: 'hello' } frame on connect, then one frame per change. Tickets are single-use and expire after 60s — mint a fresh one on each (re)connect, and reconnect with backoff (the SDK’s bb.substrate.stream already does this).