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.

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

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

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

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…"
}
]
}

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_…
// First frame on open:
{ "type": "hello", "ts": 1780198304 }
// Subsequent frames, one per change:
{ "tbl": "action_ledger", "op": "insert", "id": "act_…", "user": "" }
{ "tbl": "entities", "op": "update", "id": "ent_…", "user": "" }
{ "tbl": "attention_rules", "op": "update", "id": "rule_…", "user": "" }
{ "tbl": "attention_rule_firings", "op": "insert", "id": "fire_…", "user": "" }

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) — see Substrate.