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.
Authentication
Section titled “Authentication”All routes accept either:
- A substrate-scoped API key:
Authorization: Bearer bb_sub_…(generate withbutterbase 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": "…" } }Settings
Section titled “Settings”| Method | Path | Purpose |
|---|---|---|
| GET | /v1/me/substrate/settings | Get yolo mode and other per-user toggles |
| PUT | /v1/me/substrate/settings/yolo | Toggle yolo mode |
PUT /v1/me/substrate/settings/yolo{ "yolo_mode": true }Actions
Section titled “Actions”| Method | Path | Purpose |
|---|---|---|
| POST | /v1/me/substrate/actions/propose | Propose a new action |
| GET | /v1/me/substrate/actions | List actions in the ledger |
| GET | /v1/me/substrate/actions/{action_id} | Fetch one action |
| POST | /v1/me/substrate/actions/{action_id}/approve | Approve a pending action |
| POST | /v1/me/substrate/actions/{action_id}/reject | Reject a pending action |
Propose
Section titled “Propose”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 — deduplicating retries
Section titled “idempotency_key — deduplicating retries”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 + ":" + capabilityis 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, sameverdict(withconflictsalways empty on replay), sameresult. The replay reflects the action’s current status: if the original action was later approved,requires_approvalon the replay will befalse. A top-level"replay": truefield is added so callers can distinguish a replay from a fresh propose.
Example — same key sent twice:
// First call — fresh proposePOST /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 userPOST /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 param | Type | Default |
|---|---|---|
status | proposed | executed | rejected | all |
capability | string | all |
limit | int (1–500) | 100 |
before | ISO timestamp | now |
source_app_id | string | all |
source_rule_id | string | all |
Approve / reject
Section titled “Approve / reject”POST /v1/me/substrate/actions/{action_id}/approvePOST /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.
Entities
Section titled “Entities”| Method | Path | Purpose |
|---|---|---|
| GET | /v1/me/substrate/entities | List 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 param | Type | Default |
|---|---|---|
type | person | company | fund | workspace | team | project | event | agent | self | all |
q | string (display-name search) | none |
limit | int (1–200) | 50 |
count | true to include total in response | false |
Source artifacts
Section titled “Source artifacts”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.
| Method | Path | Purpose |
|---|---|---|
| GET | /v1/me/substrate/source-artifacts | List / 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 param | Type | Default |
|---|---|---|
kind | string | all |
q | string (FTS over title + summary + content) | none |
limit | int (1–200) | 50 |
count | true to include total in response | false |
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.
Memory search
Section titled “Memory search”Full-text search across decisions, commitments, learnings, and source_artifacts.
GET /v1/me/substrate/memory?q=billing&kinds=decisions,commitments&limit=20kinds accepts any subset of decisions, commitments, learnings, source_artifacts (comma-separated). Omitting kinds searches all of them.
| Query param | Type | Default |
|---|---|---|
q | string (FTS query; omit or pass * to list all) | none |
kinds | comma-separated subset of decisions, commitments, learnings, source_artifacts | all |
limit | int (1–200) | 20 |
match | and | or | phrase | and |
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:
| Field | Type | Notes |
|---|---|---|
source_artifact_id | string | null | Back-pointer to the source artifact this memory was extracted from. null for source artifacts themselves and for items ingested without one. |
supersedes | string | null | Only set on kind: 'decision' rows where this decision replaces an earlier one (holds the replaced decision ID). |
status | string | null | 'active' / 'superseded' / 'reversed' / 'expired' for decisions; 'proposed' / 'confirmed' / 'fulfilled' / 'expired' / 'broken' / etc. for commitments; null for learnings and source_artifacts. |
Memory list (chronological browse)
Section titled “Memory list (chronological browse)”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 param | Type | Default |
|---|---|---|
kinds | comma-separated subset of decisions, commitments, learnings, source_artifacts | all |
source_artifact_id | string | none |
superseded | true | false | include all |
before | ISO timestamp (keyset cursor from next_before) | none |
limit | int (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=50Response:
{ "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}Capabilities
Section titled “Capabilities”A few capabilities accept fields that aren’t obvious from their name:
| Capability | Optional fields | Notes |
|---|---|---|
record_commitment | source_artifact_id, attrs | source_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_artifact | id, external_system, external_id, summary, content, storage_object_id, url, links, attrs | Required: 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 }. |
Capability payloads
Section titled “Capability payloads”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.
Memory writes
Section titled “Memory writes”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_requiredEntity writes
Section titled “Entity writes”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_requiredSource artifacts
Section titled “Source artifacts”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 omittedRevert / bulk-revert
Section titled “Revert / bulk-revert”revert_action required: action_id (string) default_policy: auto
bulk_revert_actions required: action_ids (string[], 1–200 items), reason (string) default_policy: approval_requiredSide-effecting
Section titled “Side-effecting”send_email_draft required: to (string), subject (string), body (string) optional: target_role (string) default_policy: approval_requiredDaily snapshots
Section titled “Daily snapshots”Snapshots are the basis for attention-rule snapshot_predicate conditions.
GET /v1/me/substrate/snapshots?days=7Response:
{ "snapshots": [ { "snapshot_date": "2026-05-31", "entity_count": 12, "decision_count": 8, "…": "…" } ]}Attention rules
Section titled “Attention rules”| Method | Path | Purpose |
|---|---|---|
| GET | /v1/me/substrate/attention-rules | List rules |
| GET | /v1/me/substrate/attention-rules/{rule_id} | Fetch one rule |
| POST | /v1/me/substrate/attention-rules | Create 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}/enable | Enable |
| POST | /v1/me/substrate/attention-rules/{rule_id}/disable | Disable |
| POST | /v1/me/substrate/attention-rules/preview | Dry-run a rule body against today’s snapshot |
| GET | /v1/me/substrate/attention-rules/{rule_id}/firings | List firings |
Rule body
Section titled “Rule body”{ "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}| Field | Required | Notes |
|---|---|---|
name | yes | Display name. |
trigger_cron | yes | Standard 5-field cron expression (UTC). |
condition_mode | yes | snapshot_predicate evaluates a JSON-Logic predicate against today’s snapshot. row_query runs the condition as a row-query (advanced). |
condition | yes | JSON-Logic object. Available variables depend on condition_mode. |
action_capability | yes | The capability to propose when the rule fires. |
action_payload_template | yes | Object with {{var}} placeholders interpolated from the matched binding. |
enabled | no | Defaults to true. |
max_fires_per_day | no | Caps daily proposals. |
Preview
Section titled “Preview”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.
Outbox targets
Section titled “Outbox targets”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.
| Method | Path | Purpose |
|---|---|---|
| GET | /v1/me/substrate/outbox-targets | List 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.
Webhook delivery
Section titled “Webhook delivery”POST https://example.com/hooks/substrateContent-Type: application/jsonX-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.
WebSocket stream
Section titled “WebSocket stream”Live push of every change to the caller’s substrate.
GET /v1/me/substrate/streamBrowser flow
Section titled “Browser flow”Browsers can’t send custom headers on a WebSocket upgrade, so the stream accepts a one-shot ticket.
-
Mint a ticket (Cognito or
bb_sub_Bearer):POST /v1/me/substrate/ws-ticket{ "ticket": "wst_…", "expires_in": 60 } -
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.
Server-side flow
Section titled “Server-side flow”Programmatic / server clients can put the substrate-scoped key in the Authorization header on the upgrade:
GET /v1/me/substrate/streamUpgrade: websocketAuthorization: Bearer bb_sub_…Or as a fallback query string:
wss://api.butterbase.ai/v1/me/substrate/stream?token=bb_sub_…App-function flow
Section titled “App-function flow”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.
Frames
Section titled “Frames”// 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 }:
| Field | Meaning |
|---|---|
org | The 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. |
op | insert, update, or delete. |
tbl | The physical table that changed (e.g. entities, action_ledger, decisions). |
id | The changed row’s id. |
The stream does not include payloads — clients are expected to re-fetch the affected row by id.
Close codes
Section titled “Close codes”| Code | Meaning |
|---|---|
| 1000 | Normal close (initiated by client) |
| 1008 | Ticket missing, expired, reused, or token rejected |
SDK / CLI
Section titled “SDK / CLI”- CLI: see
butterbase substratefor 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.