Agent runtime — anchor an agent's policy, decisions, and outputs

An agent run isn't one artifact — it's a trail. The policy the agent was running under at session start. The decisions it committed to as it ran. The evidence bundle that ties the run together. The handoff packet an auditor uses to verify it later without trusting the operator. This guide covers the four-part agent-session pattern, the Python Session() context manager (agent_anchor.py), and the MCP tool surface for any MCP-compatible client.

Companion docs: API reference · OpenAPI spec · What to hash · Agents implementer spec · Production checklist · Compatibility map · Worked example: ResearchAgent

1. The 60-second framing

A Satsignal agent session binds a whole run into a proof trail:

  1. Policy snapshot. What the agent was allowed to do — model, tools, system policy, budget, permissions. Anchored at session start. category: "policy_snapshot".
  2. Decision commitments. One commitment per meaningful decision or output. Each is a fresh-nonce envelope so timing pre-discloses (the chain says "this hash was known by T") without content pre-disclosing (the payload stays sealed until you reveal it). category: "commitment".
  3. Evidence-bundle manifest. A final Merkle manifest binding the whole decision set into one root, anchored once at session end. category: "evidence_bundle", mode: "manifest".
  4. Handoff JSON. An off-chain file the auditor receives — the labels, the hashes, the txids, the manifest root needed to verify the session offline. agent_anchor.py writes this automatically.

"Session" describes the trail, not the cadence. By default each decision anchors as it happens — N + 2 chain anchors for an N-decision run (one policy, N decisions, one manifest). That's the stronger choice: it proves each decision was committed before it was later disclosed. Anchoring once per session (commit_each=False) is the lighter alternative; the manifest still proves the decision set but timing is proven for the set rather than each individual decision.

2. Use this when

Don't use this when:

3. What you send

Three ways in

routewhenshape
Python Session()runtime is Pythonagent_anchor.py, stdlib only, six-line context manager
MCP toolsruntime speaks Model Context Protocolsatsignal-mcp exposes six tools over stdio
Direct APInon-Python / non-MCP runtimeN + 2 POST /api/v1/anchors calls with the right categories

The four wire shapes (any route)

All three routes ultimately produce the same wire calls. Useful to know the underlying shape even if you're using the helper.

Policy snapshot (one per session, at start):

{
  "folder_slug": "agent-runs-prod",
  "sha256_hex": "<sha256 of policy snapshot JSON>",
  "file_size": <bytes>,
  "category": "policy_snapshot",
  "session_id": "run-2026-05-26-001",
  "label": "agent-alpha policy v3"
}

Decision commitment (N per session, one per decision):

{
  "folder_slug": "agent-runs-prod",
  "sha256_hex": "<sha256 of canonical decision envelope>",
  "file_size": <bytes>,
  "category": "commitment",
  "session_id": "run-2026-05-26-001",
  "label": "decision-3 (tool_call: search)"
}

The decision envelope is a JSON object {nonce, payload} where nonce is fresh 16-byte hex and payload is the decision content. JCS-canonicalize then sha256 — the canonical bytes are what gets anchored. The nonce is what makes the hash unguessable from a small payload space; see What to hash for the recipe.

Evidence-bundle manifest (one per session, at end):

{
  "folder_slug": "agent-runs-prod",
  "items": [
    {"label": "decision-1", "sha256_hex": "<decision 1 sha>"},
    {"label": "decision-2", "sha256_hex": "<decision 2 sha>"}
  ],
  "category": "evidence_bundle",
  "session_id": "run-2026-05-26-001",
  "label": "agent-alpha run 2026-05-26-001 manifest"
}

A manifest-backed proof is detected by the presence of items (not sha256_hex); the API reports mode: "manifest" for it. The policy snapshot is anchored separately (category policy_snapshot) and is not a manifest leaf — the manifest binds only the per-decision commitments. That is the wire shape for a multi-item batch — an evidence shape, not a privacy mode (it works under a Standard or Sealed proof alike). See Manifest for the wire shape in depth — up to 10,000 items per anchor.

Broker-computed commitment (witness the broker's record, not the runtime's story)

The decision commitment above anchors a payload the agent runtime supplies via s.decide(...); a compromised runtime can anchor an internally-consistent commitment decoupled from any real grant or captured API response. When you are an authorization broker that runs as a separate trust domain from the runtime, have the broker (not the runtime) compute the commitment over {session_id, oauth_grant_id, tool, scope, user_ref, request_digest, response_digest, ts} — same category: "commitment" wire shape, same fresh-nonce envelope, same canonicalization. See Grant-bound commitment. This binds the witness to the broker's recorded request/response, not the agent's self-report — it still does not prove the grant decision was correct policy.

session_id is a query label, not the binding

Tag every anchor in a run with the same session_id so you can retrieve them via GET /api/v1/anchors?session_id=run-2026-05-26-001. The 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. Skip the manifest and you have a label but no proof.

4. What you store

The four-part trail, all sides

In your own system, per session:

Handoff JSON shape

agent_anchor.py writes a file shaped like:

{
  "schema": "satsignal-agent-handoff-v1",
  "session_id": "run-2026-05-26-001",
  "folder_slug": "agent-runs-prod",
  "policy": {
    "proof_id": "...",
    "txid": "...",
    "sha256_hex": "...",
    "canonical_bytes_b64": "<base64 of the canonical policy JSON>"
  },
  "decisions": [
    {
      "label": "decision-1",
      "proof_id": "...",
      "txid": "...",
      "sha256_hex": "...",
      "canonical_bytes_b64": "<base64 of the canonical decision envelope>"
    }
  ],
  "manifest": {
    "proof_id": "...",
    "txid": "...",
    "root": "...",
    "leaf_count": 3
  }
}

The auditor receives this file. They (a) re-hash each canonical_bytes_b64, (b) confirm the hash matches sha256_hex, (c) confirm each txid chain-confirms, (d) recompute the manifest root from the decision hashes in order, (e) compare to the on-chain manifest root.

5. What verification needs later

The auditor checks, in order:

  1. Each disclosed decision recomputes to its recorded hash. The canonical envelope is in the handoff JSON; sha256 it, compare to the policy / decision / manifest item hashes.
  2. Each anchor chain-confirms. Resolve each txid against any public BSV node, parse the OP_RETURN, confirm the embedded doc_hash matches the canonical doc in the bundle.
  3. The manifest root recomputes from the ordered decision hashes. Walk the items[] in order, build the Merkle tree, compare the root to what the manifest anchor committed. This is what proves no decision was added or removed after the fact.

If any decision's payload is meant to stay sealed (the commitment was the on-chain part; the reveal is bilateral with the counterparty), the auditor verifies only the timing predicate: "the hash was known by block time T". They can later compare to the revealed payload when it's disclosed.

Cross-link: What to hash — Agent decisions covers the nonce envelope and why hashing a stringified Python dict is the most-common verification-time failure mode.

6. Copy-paste example

Python — Session() context manager

The recommended path. Stdlib only, no SDK to install — drop agent_anchor.py next to your code.

import os
from agent_anchor import Session

SYSTEM_PROMPT = "You are a careful agent..."

with Session(api_key=os.environ["SATSIGNAL_API_KEY"],
             folder_slug="agent-runs-prod") as s:
    # 1. Anchor the policy snapshot at session start.
    s.policy(
        system_policy_text=SYSTEM_PROMPT,
        model_config={"model": "claude-opus-4-7"},
    )

    # 2. Each decide() anchors one decision as it happens
    #    (commit_each=True is the default).
    for step in plan:
        result = run_step(step)
        s.decide(step.label, result)

    # 3. __exit__ anchors the manifest + writes handoff.json
    #    automatically. No further code needed.

agent_anchor.py is at satsignal.cloud/agent_anchor.py. Worked example at satsignal.cloud/example_agent_snapshot.py.

MCP — drop into any MCP host

pip install satsignal-mcp

In your MCP host's server-config block (e.g. claude_desktop_config.json):

{
  "mcpServers": {
    "satsignal": {
      "command": "satsignal-mcp",
      "env": {
        "SATSIGNAL_API_KEY": "sk_..."
      }
    }
  }
}

Important — MCP-host env-var rebinding. Some hosts (notably Claude Desktop) strip or rebind environment variables at server- launch time. A SATSIGNAL_API_KEY exported in your shell does not reach the MCP child process. Bind the key explicitly in the host's server-config env: {...} block — relying on process-env inheritance produces a 401 with no breadcrumb.

The six tools satsignal-mcp exposes:

toolwhat it does
anchor_fileanchor a file by path
anchor_textanchor a text blob (hashed in-process)
anchor_jsonanchor a JSON object (JCS-canonicalized)
lookup_hashresolve a file hash → txid (standard-mode anchors only)
verify_file_against_bundlefull verify (re-hashes original file, detects tampering)
chain_confirm_bundlefast chain-only check; does NOT detect file tampering

verify_file_against_bundle is the full verifier; chain_confirm_bundle is the fast path that confirms a bundle's txid is on chain without re-hashing the artifact. Use the former when the verification guarantee matters; the latter when you just want a "yes the chain side is fine" signal.

Repo: github.com/Steleet/satsignal-mcp. PyPI: pypi.org/project/satsignal-mcp/. stdio transport in v0.1; SSE coming later.

Direct API — non-Python, non-MCP

The wire shapes from §3 wired together in any HTTP client. Pseudo- code (replace with your runtime's HTTP library):

POST /api/v1/anchors {policy snapshot body}    → get policy_proof_id
for each step:
    decision = {"nonce": rand16hex(), "payload": step.payload}
    canonical = jcs(decision)
    POST /api/v1/anchors {sha256(canonical), category: "commitment", session_id, ...}
    persist (label, canonical, proof_id, txid)
POST /api/v1/anchors {items: [...], category: "evidence_bundle", mode: manifest}
    → get manifest_proof_id, manifest_root
write handoff.json

If a broker in a separate trust domain (rather than the runtime) is the trusted boundary, the broker fills payload from its own grant + request/response record instead of step.payload — see Grant-bound commitment.

7. Production notes

Strong timing vs batch

Default is strong timing (commit_each=True): each decision anchors as it happens. N + 2 chain anchors. The strongest property: each decision proven-committed before it was later disclosed. Use this for any flow where the "I committed before I revealed" predicate matters — sealed bids, eval results disclosure, agent self-grading.

Batch (commit_each=False): decisions held locally and only the policy snapshot + final manifest anchor. 2 anchors regardless of N. Lighter on quota and on chain footprint; the manifest still proves the decision set but the timing predicate is proven "by the time of the manifest anchor" for the whole set rather than per-decision.

Pick strong timing unless you have a specific quota / cost reason to batch.

Quota and rate limits

Each session burns N + 2 anchors against your monthly quota (strong timing) or 2 anchors (batch). Wire GET /api/v1/usage into your agent's pre-flight check so a quota exhaustion doesn't strand a session mid-run.

Per-key rate limit: see Files §7 for header semantics. Same rate-limiter applies; agent sessions aren't special-cased.

Idempotency

agent_anchor.py derives a per-decision Idempotency-Key from <session_id>:<decision_label>. Re-running the same labelled decision with the same body returns the cached anchor — useful when a step retries on transient errors. The MCP tools do the same.

Direct-API integrators: derive the key the same way for free idempotency.

Failed-mid-session recovery

If the session helper crashes after some decisions anchored but before the manifest:

# Resume — pass the same session_id; the helper queries existing
# anchors and continues from where it left off.
with Session(api_key=..., folder_slug="...",
             session_id="run-2026-05-26-001",
             resume=True) as s:
    # ...

The manifest's Merkle root depends on the ordered decision hashes. If you re-run decisions out of order on resume, the manifest binds a different root than the one you'd have produced on a single-shot run. Keep ordering stable across resumes.

Key rotation

Mid-session key rotation will fail the session — the cached Idempotency-Key records bind to the original key. If you must rotate, end the session first (write the manifest with the old key), then start a fresh session under the new key.

For the full pre-flight checklist (key rotation, broadcast failure recovery, support flow), see Production checklist.

8. Errors you might see

codenamemeaningwhat to do
400invalid_categorycategory not in the enumuse policy_snapshot, commitment, or evidence_bundle
400manifest_too_many_itemsmanifest carries > 10,000 itemssplit into multiple sub-manifests + a top-level manifest of those
400manifest_emptyitems[] is emptya manifest must carry ≥1 item; send at least one decision leaf (the policy snapshot is anchored separately, not as a manifest leaf)
401unauthorizedAPI key wrong or unset (MCP: env not reaching child)bind the key in the host's env: {...} block
404folder_not_foundfolder doesn't existcreate the folder first
409idempotency_key_reuse_body_mismatchsession resumed with a body changeuse a fresh session_id or align the body
429quota_exceededmonthly anchor budget goneemail hello@satsignal.cloud for a cap lift; sessions need N + 2 slots

9. Legacy field aliases (if your code sends them)

Same as all other paths: responses emit the canonical proof / folder names only; requests written against the legacy spellings keep working forever.

Full canonical/legacy mapping across endpoints, fields, scopes, CLI flags, and error codes: Compatibility map.

10. Where this fits

Worked example: ResearchAgent makes its decisions auditable — full agent-session integration walkthrough with a 6-month-later regulator-audit scenario.

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