Agent integration

Terminology. The canonical vocabulary on every surface is proof (grouped in folders); API responses emit the canonical names only (proof_id, proof_url, folder_slug). Where this spec shows receipt / matter / bundle_id, it is documenting a frozen on-disk/on-chain format, a stable filename (RECEIPTS.md), or a legacy route that remains accepted inbound — proof and receipt denote the same record. Full alias map: the compatibility map.

In plain words: if you run an AI agent and need an auditable, tamper-evident record of what it was allowed to do, what it decided, and what it produced — provable later without trusting the agent's operator — this page is the deep implementer reference; the integration story and quickstart live in the docs. The pattern is: anchor the agent's policy/config at the start, anchor a commitment for each meaningful decision as it happens, anchor a manifest binding the whole run at the end, and hand the auditor a small JSON file plus the chain anchors. What an anchor does and does not prove is stated canonically in the bundle spec; what this specific pattern gets you is in What this gets you (and what it doesn't).

This is the implementer cookbook for the agent-session pattern: the full handoff schema, CI transport testing, lifecycle categories, and the friction points an agent specifically runs into that a human integrator might miss. For the integration overview and the four ways to wire Satsignal in, start at the docs. For the underlying canonical-doc shape and on-chain wire format, read /spec.


TL;DR

Anchor four things per agent run:

  1. A policy snapshot at the start. What was the agent allowed to do? Which model, which tools, which budget. (category: "policy_snapshot")
  2. One commitment per meaningful decision during the run. (category: "commitment", wrapped in a fresh-nonce envelope so timing pre-discloses without content pre-disclosing.)
  3. One evidence-bundle manifest at the end. The Merkle root that binds all the decisions together cryptographically. (category: "evidence_bundle", mode: "manifest".)
  4. A handoff JSON file that an auditor receives alongside the chain anchors so they can reconstruct the session offline.

The first three parts are chain anchors via POST /api/v1/anchorsN + 2 in total: one policy, one per decision, one manifest. The fourth part, the handoff JSON, is the off-chain file the helper below writes for the auditor.


The minimal integration

import os
from agent_anchor import Session

with Session(api_key=os.environ["SATSIGNAL_API_KEY"], folder_slug="agent-runs-prod") as s:
    s.policy(system_policy_text=SYSTEM_PROMPT, model_config={"model": "claude-opus-4-7"})
    for step in plan:
        s.decide(step.label, run_step(step))
    # __exit__ anchors the manifest + writes handoff.json

That's the whole thing. Each decide() call is one chain anchor. The manifest at the end binds them together with a Merkle root. The context manager writes handoff.json so the auditor has everything they need.

The helper is stdlib-only Python, no SDK install:

LangChain · MCP · OpenTelemetry · blob storage · plain CLI all have first-party packages so you don't hand-roll the agent-session pattern — see the integration map.


Onboarding (agent-capable, with one interactive step left)

Satsignal's agent surface is fully self-integrating after you have an API key — and the account itself no longer needs a human:

Agent self-serve signup. POST /api/v1/signup accepts an anti-bot proof object in place of the browser form's CAPTCHA. Two lanes are live: an invite code (minted by an existing workspace owner in their dashboard) or a proof-of-work challenge (hashcash). Start with the discovery document — GET https://app.satsignal.cloud/api/v1/signup — which is the source of truth for the lanes this host currently accepts and the current difficulty; then follow the agent signup guide for the full flow. Signup still ends in a single-use, 15-minute magic-link email, so the agent needs a readable email inbox; the account is created when the link is first used.

The classic human path works unchanged:

  1. A human visits https://app.satsignal.cloud/login and enters an email address.
  2. They click the magic-link in the inbox.
  3. They mint an API key with the anchors:create and proofs:read scopes.
  4. They hand the bearer string (sk_...) to the agent runtime.

The first key is the one interactive step left on either path: after the magic-link signin, the bootstrap API key is minted in the dashboard at /w/<workspace>/keys, and that first mint is gated to a signed-in session. But a key carrying the keys:admin scope (grantable only in the dashboard) can then mint scoped sub-keys programmatically via POST /api/v1/keys — so a platform provisioning proof for its own tenants mints one keys:admin key by hand, then issues per-tenant sub-keys headlessly (decision 0047; keys:admin itself is never API-mintable, and sub-key scopes are bounded to a subset of the parent's). This programmatic-mint lane is operator-enabled per host and defaults off — where it is not enabled, POST /api/v1/keys returns a uniform 404; confirm or request it for your host via hello@satsignal.cloud (the GET /api/v1/signup discovery doc reports key_mint_api.enabled). Once the agent has a key, every subsequent integration step (creating a folder, anchoring policy + decisions + manifests, listing sibling anchors, reading individual proofs) is a Bearer: HTTP call — no further human in the loop.


Categories and the agent lifecycle

The six anchor categories map to a session timeline:

CategoryWhenWho reads it
policy_snapshotOnce, at startThe auditor reconciling "what was this agent allowed to do"
commitmentPer decisionThe auditor proving "this output was committed at THIS time, before disclosure"
evidence_bundleOnce, at endThe auditor verifying "all of these decisions belong to this session"
outputAnywhereThe default — produced artifacts that aren't decisions per se
memory_checkpointPer long-run state saveThe auditor reconstructing rolling state across sessions
documentAnywhereA neutral tag for document-shaped anchors (contracts, reports, evidence files) that aren't agent runs at all

Most agent runs will use the first three. output is the catch-all default (it's what category resolves to when you omit the field entirely). memory_checkpoint is for agents that maintain state across sessions and want a tamper-evident trail of how that state evolved. document exists for the non-agent case — anchoring a contract, report, or evidence file under a label that says what it is, rather than borrowing an agent-shaped one.


Cross-anchor correlation: session_id

Every anchor request accepts an optional session_id field in the body — a freeform string (1-128 chars, [A-Za-z0-9_.-]). When set, the field is stored alongside the proof and indexed for retrieval via GET /api/v1/anchors?session_id=<X>.

curl -H "Authorization: Bearer sk_..." \
     -H "Content-Type: application/json" \
     -d '{"folder_slug":"agent-runs","sha256_hex":"...","file_size":1234,
          "category":"commitment","session_id":"run-2026-05-09-001"}' \
     https://app.satsignal.cloud/api/v1/anchors

Important: session_id is off-chain by design. The cryptographic "these anchors belong together" binding is the evidence-bundle manifest's Merkle root, anchored at end-of-session; session_id is just the human-readable index key for the same grouping. Skip the manifest and you have a label but no proof; skip session_id and you have proof but no convenient query — the Session() helper does both.


Anchor-per-decision vs batch-only

The default Session() flow anchors every decide() call individually (N decisions → N + 2 anchors total: 1 policy + N commitments + 1 manifest). Use this when disclosure timing matters — when the auditor needs to know each decision was committed before it was revealed.

For end-state-only audits, instantiate with commit_each=False. The helper holds decisions locally and only anchors the policy + manifest (2 anchors total). Fewer anchors, weaker timing claim.

# Anchor every decision (default — strong timing, N+2 anchors)
with Session(api_key=..., folder_slug=...) as s: ...

# Batch only — manifest binds the set, no per-decision timing
with Session(api_key=..., folder_slug=..., commit_each=False) as s: ...

Anchor by default. For high-assurance workflows, anchor each meaningful decision as it happens (the default). For end-state-only audits, batch decisions into the final manifest. The first gives stronger timing evidence per decision; the second produces a lighter proof trail that still binds the full set.


Selective-disclosure proofs (proof_set)

A standard-mode anchor body may carry an optional proof_set instead of just sha256_hex. proof_set.byte_exact is required and must equal the submitted sha256_hex + file_size; you may add content_canonical and/or chunk_merkle (a Merkle root over per-chunk leaves — page / row / record), with the leaf hashes in the companion proof_leaves:

curl -H "Authorization: Bearer sk_..." -H "Content-Type: application/json" \
     -d '{"folder_slug":"agent-runs","sha256_hex":"<h>","file_size":1234,
          "proof_set":{"byte_exact":{"algo":"sha256","size":1234,"hash":"<h>"},
                       "chunk_merkle":{"scheme":"pdf-page-v1","algo":"sha256",
                                       "leaf_count":12,"root":"<r>"}},
          "proof_leaves":{"scheme":"pdf-page-v1","merkle_leaves":["..."],
                          "metadata":{"leaf_count":12}}}' \
     https://app.satsignal.cloud/api/v1/anchors

Only the envelope (scheme / algo / leaf_count / root) is committed on-chain; the leaves ride off-chain in the bundle's proofs.json, and the verifier recomputes leaf→root from the original file — the commitment stays opaque, there is nothing for the operator to reconstruct. To attach a proof_set to a hash already anchored in the folder, pass force_new: true (default dedup keys on sha256_hex and otherwise returns 409 proof_set_requires_force_new rather than dropping the richer proofs). Scheme registry + the canonicalization each scheme implies: the bundle spec (bundle-v1.md §5). Top-level chunk_merkle / content_canonical 400 (they belong inside proof_set). Sealed mode accepts the same envelope under HMAC algos — see below.

Sealed-mode proof_set (mirror + blind)

mode=sealed accepts the same proof_set field name; the inner shapes carry HMAC algos and the byte_exact key is commitment (not hash). The commitment in the envelope must equal the top-level byte_exact_commitment. Works in both Mirror (with salt_b64) and Blind (omit salt_b64) — the wire envelope is identical between them; the response shape differs (mirror returns bundle_b64, blind returns canonical_b64 + doc_hash):

curl -H "Authorization: Bearer sk_..." -H "Content-Type: application/json" \
     -d '{"folder_slug":"sealed-runs","mode":"sealed",
          "salt_b64":"<32-byte salt, base64url — omit for blind>",
          "byte_exact_commitment":"<HMAC-SHA256(salt, file)>",
          "file_size":1234,
          "proof_set":{
            "byte_exact":{"algo":"hmac-sha256","commitment":"<same HMAC>"},
            "chunk_merkle":{"algo":"merkle-hmac-sha256",
                            "scheme":"pdf-page-v1",
                            "leaf_count":12,"root":"<Merkle-HMAC root>"}},
          "proof_leaves":{"scheme":"pdf-page-v1",
                          "merkle_leaves":["<leaf HMAC>", "..."],
                          "metadata":{"leaf_count":12}}}' \
     https://app.satsignal.cloud/api/v1/anchors

As in the sealed file flow above, only the envelope (scheme / algo / leaf_count / root / salt_version) is committed on-chain; the leaf HMACs ride off-chain in proofs.json. Selective disclosure works the same way — revealing one chunk's HKDF-derived salt + canonicalized bytes lets a recipient recompute that leaf and re-derive the root; siblings stay opaque. file_size stays top-level — sealed canonical docs strip leak fields, and the size does not enter the on-chain envelope. Cross-mode algo mistakes (sha256 in a sealed body) 400 before the wallet is touched. No force_new 409 in sealed — sealed entries don't index a naked file hash, so there is no default-dedup gate to escape. See SPEC_v2_sealed.md §4.2 for the HMAC-tagged canonical-doc shape and §11 for the Blind submission protocol.


Handoff JSON schema (satsignal-agent-handoff-v1)

The helper writes this to handoff.json on session exit. An auditor receiving the file can verify every chain anchor + recompute every decision sha without contacting the agent operator. It carries the session metadata, the policy anchor + snapshot, every decision (with its envelope, sha, nonce, and txid), and the manifest anchor (txid + Merkle root + leaf count) — the full field-by-field shape is in the collapsed reference at the end of this section.

Auditor verification path:

  1. For each decision: recompute sha256(canonicalize(envelope)), compare against decision_sha256_hex. Then chain-confirm by resolving each decision_sha256_hex via GET /lookup_hash?sha=... and confirming the resolver returns the same txid as the handoff. The helper's verify-handoff subcommand does both steps by default. Use chain confirmation for any external proof claim; local-only verification is for CI or offline tests, or when the verifier confirms the transaction another way — on its own it is not a chain-anchor proof.
  2. For deeper confirmation: fetch each txid via any BSV block explorer's tx-lookup and confirm the OP_RETURN matches the canonical doc reconstructed from each decision's envelope.
  3. For manifest_anchor.root: recompute the Merkle root over the ordered decision_sha256_hex values, compare. The root binds the decision set — an auditor can prove no decision was added or removed after the fact without breaking this match.

Full schema below. Skip unless you're generating or parsing handoff.json yourself. Reference shape of the satsignal-agent-handoff-v1 file the helper writes; not needed for the integration above. (The file format's bundle_id keys are the proof id under its frozen on-disk spelling.)

{
  "version": "satsignal-agent-handoff-v1",
  "session_id": "run-2026-05-09-001",
  "folder_slug": "agent-runs",
  "agent": {"name": "my-evaluator-bot", "version": "1.4.2"},
  "started_at_utc": "2026-05-09T12:00:00Z",
  "ended_at_utc":   "2026-05-09T12:03:42Z",

  "policy_anchor": {
    "txid": "e8eb2e63...",
    "bundle_id": "abcd1234deadbeef",
    "snapshot_sha256_hex": "...",
    "anchored_at_utc": "..."
  },
  "policy_snapshot": { "version": "satsignal-policy-snapshot-v1", "...": "..." },

  "decisions": [
    {
      "label": "step-1",
      "decision_sha256_hex": "...",
      "nonce_hex": "<128-bit hex>",
      "envelope": { "version": "satsignal-agent-decision-v1",
                    "nonce_hex": "...", "payload": { "...": "..." } },
      "txid": "beca54bb...",
      "bundle_id": "...",
      "anchored_at_utc": "..."
    }
  ],

  "manifest_anchor": {
    "txid": "b39d795a...",
    "bundle_id": "...",
    "root": "<merkle root hex>",
    "leaf_count": 3,
    "anchored_at_utc": "..."
  }
}

Testing without the network

Testing note. Skip unless you're writing CI for an integration that wraps Session(). Not first-read; the integration above works without any of this.

Session(...) accepts a transport= callable so framework CI can exercise the wrapper without hitting the API. Default is urllib; your test passes a closure that returns canned (status, response_bytes) tuples. APIError is still raised on non-2xx, so 401 / 429 branches stay testable.

from agent_anchor import Session, APIError

def fake_transport(method, url, headers, body, timeout):
    # assert on body if you want; return any canned response
    return 200, b'{"txid":"deadbeef","bundle_id":"abcd"}'

with Session(api_key="sk_test", folder_slug="m",
             transport=fake_transport, write_handoff=False) as s:
    s.decide("step-1", {"x": 1})

assert s.decisions[0]["txid"] == "deadbeef"

Signature: transport(method, url, headers, body_bytes, timeout) -> (status_int, response_bytes). Same shape as a requests adapter; trivial to wire up against any HTTP mocking library.


Choosing a folder_slug

Terminology. The canonical organizing unit is a folder (folder_slug, /api/v1/folders, 404 folder_not_found). The legacy matter_slug body key and /api/v1/matters route remain accepted inbound forever and resolve identically — see the compatibility map. New code should use the folder names.

A folder is the workspace's organizing unit; agents typically anchor under one long-lived folder per environment:

Create folders via POST /api/v1/folders ({slug, name}), or in the dashboard UI. The folder must exist before you anchor; otherwise POST /api/v1/anchors returns 404 folder_not_found.


What this gets you (and what it doesn't)

You get:

What this does not do:

Questions about this specification? Email hello@satsignal.cloud.