Skip to content

Substrate API

The substrate API is a per-user surface: every route operates on the substrate that belongs to the calling user, identified by the Bearer token. There is no {app_id} path segment.

All routes accept either:

  • A substrate-scoped API key: Authorization: Bearer bb_sub_… (generate with butterbase keys generate --substrate).
  • A platform JWT (dashboard / Cognito session).

Non-substrate-scoped API keys (bb_sk_…) are not accepted by these routes — they return 403.

Errors follow the standard envelope:

{ "error": { "code": "AUTH_INVALID_TOKEN", "message": "", "remediation": "" } }
MethodPathPurpose
GET/v1/me/substrate/settingsGet yolo mode and other per-user toggles
PUT/v1/me/substrate/settings/yoloToggle yolo mode
PUT /v1/me/substrate/settings/yolo
{ "yolo_mode": true }
MethodPathPurpose
POST/v1/me/substrate/actions/proposePropose a new action
GET/v1/me/substrate/actionsList actions in the ledger
GET/v1/me/substrate/actions/{action_id}Fetch one action
POST/v1/me/substrate/actions/{action_id}/approveApprove a pending action
POST/v1/me/substrate/actions/{action_id}/rejectReject a pending action
POST /v1/me/substrate/actions/propose
{
"capability": "record_decision",
"payload": { "title": "", "kind": "operational", "rationale": "" },
"idempotency_key": "optional-stable-string"
}

Response:

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

Verdict values: auto_approved, auto_approved_yolo, requires_approval, rejected.

idempotency_key is an optional string field in the request body. When set, the substrate deduplicates retries so that a network blip or a lambda re-run does not produce a duplicate ledger row.

  • Scope: per substrate user. Two different users can use the same key without collision.
  • Window: forever. Keys are never aged out. Pick keys that are stable for the unit-of-work you are deduplicating (e.g. meeting_id + ":" + capability is a good choice; a per-call UUID is not).
  • Collision behavior: if a prior action with the same (substrate_user_id, idempotency_key) exists, the propose returns that prior action’s verdict and result without executing the capability again.
  • Response shape on replay: the response is identical to the original — same action_id, same verdict (with conflicts always empty on replay), same result. The replay reflects the action’s current status: if the original action was later approved, requires_approval on the replay will be false. A top-level "replay": true field is added so callers can distinguish a replay from a fresh propose.

Example — same key sent twice:

// First call — fresh propose
POST /v1/me/substrate/actions/propose
{
"capability": "record_decision",
"payload": { "title": "Migrate billing to Stripe", "kind": "strategic" },
"idempotency_key": "mtg_2026-06-16:record_decision"
}
// → { "action_id": "act_01…", "verdict": { … }, "requires_approval": false, "result": { … } }
// Second call — same key, same user
POST /v1/me/substrate/actions/propose
{ …same body… }
// → { "action_id": "act_01…", "verdict": { … }, "requires_approval": false, "result": { … }, "replay": true }

For the capability payload schemas, see Capability payloads.

GET /v1/me/substrate/actions?status=executed&capability=send_email_draft&limit=25&before=2026-05-28T00:00:00Z
Query paramTypeDefault
statusproposed | executed | rejectedall
capabilitystringall
limitint (1–500)100
beforeISO timestampnow
source_app_idstringall
source_rule_idstringall
POST /v1/me/substrate/actions/{action_id}/approve
POST /v1/me/substrate/actions/{action_id}/reject
{ "reason": "policy mismatch" }

Both return the updated action row. Approving or rejecting an action that is not in proposed status returns 409 wrong_status.

MethodPathPurpose
GET/v1/me/substrate/entitiesList entities
GET/v1/me/substrate/entities/{entity_id}Fetch one entity
PATCH/v1/me/substrate/entities/{entity_id}Update an entity (routed through propose)
GET /v1/me/substrate/entities?type=person&q=alice&limit=20&count=true
Query paramTypeDefault
typeperson | company | fund | workspace | team | project | event | agent | selfall
qstring (display-name search)none
limitint (1–200)50
counttrue to include total in responsefalse

Source artifacts are durable, full-text-indexed source material — meeting transcripts, email threads, call recordings, documents — that the substrate extracts decisions, commitments, and learnings from. A commitment can carry a source_artifact_id pointing back to the artifact it was extracted from.

MethodPathPurpose
GET/v1/me/substrate/source-artifactsList / filter / FTS over artifacts
GET/v1/me/substrate/source-artifacts/{artifact_id}Fetch one artifact (incl. full content)
GET /v1/me/substrate/source-artifacts?kind=meeting_transcript&q=billing&limit=20&count=true
Query paramTypeDefault
kindstringall
qstring (FTS over title + summary + content)none
limitint (1–200)50
counttrue to include total in responsefalse

Response:

{
"artifacts": [
{
"id": "art_01…",
"kind": "meeting_transcript",
"external_system": "fireflies",
"external_id": "abc123",
"title": "Weekly product sync — 2026-06-09",
"summary": "Reviewed phase 6 scope; Alice owns billing migration.",
"url": "https://fireflies.ai/…",
"storage_object_id": null,
"links": { "entity_ids": ["ent_01…"], "project_tags": ["phase-6"] },
"attrs": {},
"source_app_id": "app_…",
"created_at": "2026-06-09T…",
"updated_at": "2026-06-09T…"
}
]
}

GET /v1/me/substrate/source-artifacts/{artifact_id} returns the same shape plus the full content field.

Artifacts are written via the upsert_source_artifact capability through POST /v1/me/substrate/actions/propose. Upsert is idempotent: if id is omitted, the substrate resolves the row by (external_system, external_id); otherwise it generates an art_<ulid>. Artifacts proposed by an installed app are auto-attributed to that app via source_app_id.

Full-text search across decisions, commitments, learnings, and source_artifacts.

GET /v1/me/substrate/memory?q=billing&kinds=decisions,commitments&limit=20

kinds accepts any subset of decisions, commitments, learnings, source_artifacts (comma-separated). Omitting kinds searches all of them.

Query paramTypeDefault
qstring (FTS query; omit or pass * to list all)none
kindscomma-separated subset of decisions, commitments, learnings, source_artifactsall
limitint (1–200)20
matchand | or | phraseand

When q is omitted or empty (or *), the endpoint returns the most recent items across the selected kinds ordered by updated_at DESC, capped by limit. No full-text ranking is performed — rank will be null in those rows. Use this as a “list all” path.

match controls how multi-word queries are evaluated: and requires all words (default), or matches any word, phrase requires words to appear adjacently. Omitting match is identical to match=and.

Response:

{
"results": [
{
"id": "dec_01…",
"kind": "decision",
"title": "Adopt substrate",
"body_text": "agent memory needs a single source of truth",
"rank": 0.18,
"updated_at": "2026-05-31T…",
"source_artifact_id": null,
"supersedes": null,
"status": "active"
},
{
"id": "art_01…",
"kind": "source_artifact",
"title": "Weekly product sync — 2026-06-09",
"body_text": "Reviewed phase 6 scope; Alice owns billing migration.",
"rank": 0.12,
"updated_at": "2026-06-09T…",
"source_artifact_id": null,
"supersedes": null,
"status": null
}
]
}

Three fields are present on every result row:

FieldTypeNotes
source_artifact_idstring | nullBack-pointer to the source artifact this memory was extracted from. null for source artifacts themselves and for items ingested without one.
supersedesstring | nullOnly set on kind: 'decision' rows where this decision replaces an earlier one (holds the replaced decision ID).
statusstring | null'active' / 'superseded' / 'reversed' / 'expired' for decisions; 'proposed' / 'confirmed' / 'fulfilled' / 'expired' / 'broken' / etc. for commitments; null for learnings and source_artifacts.

Chronological browse across decisions, commitments, learnings, and source_artifacts with structural filters. Unlike the FTS search endpoint, results are always ordered by updated_at DESC and there is no relevance ranking.

GET /v1/me/substrate/memory/list?source_artifact_id=art_…&kinds=decisions,commitments,learnings&limit=25
Query paramTypeDefault
kindscomma-separated subset of decisions, commitments, learnings, source_artifactsall
source_artifact_idstringnone
supersededtrue | falseinclude all
beforeISO timestamp (keyset cursor from next_before)none
limitint (1–100)25

source_artifact_id restricts results to rows linked to that source artifact. Source-artifact rows themselves are excluded from the response when this filter is set, because they carry no source_artifact_id back-pointer of their own.

superseded when false, excludes decisions with status='superseded' and commitments with status='expired'. When true or omitted, all statuses are returned.

before accepts the next_before value from a previous response for keyset pagination. Pass it verbatim — it is an ISO timestamp.

Response:

{
"results": [
{
"id": "dec_…",
"kind": "decision",
"title": "",
"body_text": "",
"updated_at": "",
"source_artifact_id": "art_…",
"supersedes": "dec_…",
"status": "active"
}
],
"next_before": "2026-06-09T14:32:00.000Z"
}

next_before is null when there are no more pages.

status is set on decisions and commitments; null for learnings and source_artifacts. supersedes is only set on decisions that replace an earlier decision.

Example — list every decision, commitment, and learning extracted from a meeting

Section titled “Example — list every decision, commitment, and learning extracted from a meeting”
GET /v1/me/substrate/memory/list
?source_artifact_id=art_01JXYZ…
&kinds=decisions,commitments,learnings
&superseded=false
&limit=50

Response:

{
"results": [
{
"id": "dec_01JXYZ…",
"kind": "decision",
"title": "Ship phase 6 by end of June",
"body_text": "Agreed in the weekly sync. Alice owns the billing migration.",
"updated_at": "2026-06-09T14:30:00.000Z",
"source_artifact_id": "art_01JXYZ…",
"supersedes": null,
"status": "active"
},
{
"id": "com_01JXYZ…",
"kind": "commitment",
"title": "Alice to deliver billing migration PR by 2026-06-13",
"body_text": null,
"updated_at": "2026-06-09T14:30:01.000Z",
"source_artifact_id": "art_01JXYZ…",
"supersedes": null,
"status": "confirmed"
}
],
"next_before": null
}

A few capabilities accept fields that aren’t obvious from their name:

CapabilityOptional fieldsNotes
record_commitmentsource_artifact_id, attrssource_artifact_id links the commitment back to the artifact it was extracted from (FK to source_artifacts(id) ON DELETE SET NULL). attrs is a free-form JSON bag for caller metadata.
upsert_source_artifactid, external_system, external_id, summary, content, storage_object_id, url, links, attrsRequired: kind, title. Idempotent by (external_system, external_id) when id is omitted. default_policy='auto', reversible=true, yolo_eligible=true. Returns { artifact_id, was_insert, before }.

For the default-policy and flags for every capability, see the Capabilities at a glance table in the concept doc.

The schemas below list exact field names and types as defined in the capability Zod schemas. Fields marked optional may be omitted.

record_decision
required: title (string), kind ('operational'|'strategic'|'mission'|'vision'|'principle'|'policy_decision')
optional: rationale (string), salience ('ambient'|'normal'|'archival', default 'normal'), source_artifact_id (string)
default_policy: auto
record_commitment
required: description (string), status ('proposed'|'tentative'|'confirmed'|'fulfilled'|'expired'|'broken')
optional: from_entity (string), to_entity (string), amount (number), unit (string), due_at (string, ISO 8601), source_artifact_id (string), attrs (object)
default_policy: auto
record_learning
required: title (string), description (string)
optional: resolution_taken (string), related_decision_id (string), source_artifact_id (string)
default_policy: auto
record_principle
required: title (string), applies_to (object, e.g. {capability: "send_email_draft", target_role: "investor"}), constraint_spec (object, e.g. {type: "max_per_week", params: {n: 2}})
optional: rationale (string), salience ('ambient'|'normal'|'archival', default 'ambient')
default_policy: approval_required
supersede_decision
required: old_decision_id (string), new_title (string), kind ('operational'|'strategic'|'mission'|'vision'|'principle'|'policy_decision')
optional: new_rationale (string)
default_policy: approval_required
retire_principle
required: principle_id (string), reason (string)
default_policy: approval_required
upsert_entity
required: type ('person'|'company'|'fund'|'workspace'|'team'|'project'|'event'|'agent'|'self'), display_name (string)
optional: id (string), primary_email (string, email), canonical_keys (object), linked_app_user_id (string, uuid), linked_app_id (string), attrs (object)
default_policy: auto
note: dedups by id > canonical_keys > primary_email
update_entity
required: id (string)
optional: display_name (string), primary_email (string, email), attrs (object)
default_policy: auto
note: replaces attrs wholesale; prefer patch_entity for partial updates
patch_entity
required: id (string)
optional: display_name (string), primary_email (string, email), attrs_patch (object, RFC 7396 merge-patch), if_updated_at (string, datetime, optimistic lock)
default_policy: auto
merge_entities
required: loser_id (string), winner_id (string), reason (string)
default_policy: approval_required
delete_entity
required: id (string), reason (string)
default_policy: approval_required
upsert_source_artifact
required: kind (string), title (string)
optional: id (string), external_system (string), external_id (string), summary (string), content (string), storage_object_id (string), url (string), links ({entity_ids?: string[], project_tags?: string[]}), attrs (object)
default_policy: auto
note: idempotent by (external_system, external_id) when id is omitted
revert_action
required: action_id (string)
default_policy: auto
bulk_revert_actions
required: action_ids (string[], 1–200 items), reason (string)
default_policy: approval_required
send_email_draft
required: to (string), subject (string), body (string)
optional: target_role (string)
default_policy: approval_required

Snapshots are the basis for attention-rule snapshot_predicate conditions.

GET /v1/me/substrate/snapshots?days=7

Response:

{
"snapshots": [
{ "snapshot_date": "2026-05-31", "entity_count": 12, "decision_count": 8, "…": "" }
]
}
MethodPathPurpose
GET/v1/me/substrate/attention-rulesList rules
GET/v1/me/substrate/attention-rules/{rule_id}Fetch one rule
POST/v1/me/substrate/attention-rulesCreate a rule
PUT/v1/me/substrate/attention-rules/{rule_id}Update a rule
DELETE/v1/me/substrate/attention-rules/{rule_id}Delete a rule
POST/v1/me/substrate/attention-rules/{rule_id}/enableEnable
POST/v1/me/substrate/attention-rules/{rule_id}/disableDisable
POST/v1/me/substrate/attention-rules/previewDry-run a rule body against today’s snapshot
GET/v1/me/substrate/attention-rules/{rule_id}/firingsList firings
{
"name": "weekly digest",
"description": "Monday morning summary",
"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."
},
"enabled": true,
"max_fires_per_day": 1
}
FieldRequiredNotes
nameyesDisplay name.
trigger_cronyesStandard 5-field cron expression (UTC).
condition_modeyessnapshot_predicate evaluates a JSON-Logic predicate against today’s snapshot. row_query runs the condition as a row-query (advanced).
conditionyesJSON-Logic object. Available variables depend on condition_mode.
action_capabilityyesThe capability to propose when the rule fires.
action_payload_templateyesObject with {{var}} placeholders interpolated from the matched binding.
enablednoDefaults to true.
max_fires_per_daynoCaps daily proposals.
POST /v1/me/substrate/attention-rules/preview
{ <same shape as rule body, name optional> }

Response:

{
"bindings_count": 3,
"sample_proposals": [
{ "binding": { "entity_count": 12 }, "rendered_payload": { }, "would_require_approval": false }
],
"skip_reason": null
}

skip_reason is set (and bindings_count is 0) when the snapshot is missing or the condition can’t be evaluated.

When an action with capability X executes, the substrate POSTs the rendered payload to the registered outbox target for X (if any). Deliveries are HMAC-signed and retried with backoff.

MethodPathPurpose
GET/v1/me/substrate/outbox-targetsList all targets
PUT/v1/me/substrate/outbox-targets/{capability}Register or replace a target
DELETE/v1/me/substrate/outbox-targets/{capability}Remove the target
PUT /v1/me/substrate/outbox-targets/send_email_draft
{
"webhook_url": "https://example.com/hooks/substrate",
"signing_secret": "min-8-chars",
"source_app_id": "app_optional_scope"
}

source_app_id is optional; when set, the target only fires for actions proposed by that app.

POST https://example.com/hooks/substrate
Content-Type: application/json
X-Butterbase-Signature: sha256=<hex>
X-Butterbase-Delivery: <uuid>
{
"action_id": "act_01…",
"capability": "send_email_draft",
"payload": { … the rendered action payload … },
"executed_at": "2026-05-31T…"
}

Verify the signature with HMAC-SHA-256 over the raw body using signing_secret.

Live push of every change to the caller’s substrate.

GET /v1/me/substrate/stream

Browsers can’t send custom headers on a WebSocket upgrade, so the stream accepts a one-shot ticket.

  1. Mint a ticket (Cognito or bb_sub_ Bearer):

    POST /v1/me/substrate/ws-ticket
    { "ticket": "wst_…", "expires_in": 60 }
  2. Open the WS with ?ticket=:

    wss://api.butterbase.ai/v1/me/substrate/stream?ticket=wst_…

Tickets are single-use and expire after 60 seconds. Reused or expired tickets close the WS with code 1008 unauthenticated.

Programmatic / server clients can put the substrate-scoped key in the Authorization header on the upgrade:

GET /v1/me/substrate/stream
Upgrade: websocket
Authorization: Bearer bb_sub_…

Or as a fallback query string:

wss://api.butterbase.ai/v1/me/substrate/stream?token=bb_sub_…

An app linked to an org can mint a ticket for its own users without ever putting a substrate key in the browser. Inside a deployed function, call:

const { ticket, expires_in } = await ctx.substrate.wsTicket();

It returns a 60s single-use ticket scoped to the app’s linked org (server-to-server, over the internal bridge). Return it to the browser, which opens the stream with ?ticket=. This is the recommended pattern for end-user-facing apps — see the browser example.

// First frame on open:
{ "type": "hello", "ts": 1780198304 }
// Subsequent frames, one per change — envelope only, no row payload:
{ "tbl": "entities", "op": "insert", "id": "ent_…", "org": "" }
{ "tbl": "action_ledger", "op": "insert", "id": "act_…", "org": "" }
{ "tbl": "decisions", "op": "insert", "id": "dec_…", "org": "" }
{ "tbl": "commitments", "op": "update", "id": "com_…", "org": "" }
{ "tbl": "learnings", "op": "insert", "id": "lrn_…", "org": "" }
{ "tbl": "attention_rules", "op": "update", "id": "rule_…", "org": "" }
{ "tbl": "attention_rule_firings", "op": "insert", "id": "fire_…", "org": "" }

Each change frame is { org, op, tbl, id }:

FieldMeaning
orgThe substrate organization the change belongs to. The stream is org-scoped — a connection only ever receives changes for the org bound to its ticket or key.
opinsert, update, or delete.
tblThe physical table that changed (e.g. entities, action_ledger, decisions).
idThe changed row’s id.

The stream does not include payloads — clients are expected to re-fetch the affected row by id.

CodeMeaning
1000Normal close (initiated by client)
1008Ticket missing, expired, reused, or token rejected
  • CLI: see butterbase substrate for every command.
  • TypeScript SDK: substrate calls are namespaced under butterbase.substrate.* (mirror of the HTTP surface).
  • Inside a function: ctx.substrate.* (propose, getEntity, findEntities, searchMemory, listMemory) — see Substrate.