Worked example: ResearchAgent makes its decisions auditable

ResearchAgent is a fictional autonomous research agent built on Claude. It receives a research question, decides which tools to invoke (web search, code execution, document retrieval), produces intermediate findings, and writes a final report. ResearchAgent's customers — research labs, due-diligence firms, regulatory teams — need to verify months later: which model version was active, what policy governed the agent, what each individual decision was, and that the final report wasn't tampered with. This walkthrough follows their integration from "we need our agent runs to be auditable" through "a regulator reviews a 6-month-old agent run." Every value below is synthetic — ResearchAgent is a placeholder name, run_2026…_abc12345 / proof_… / txid_… are fake.

Companion docs: Agent runtime path guide · Grant-bound commitment · API reference · What to hash · Production checklist

1. The scenario

ResearchAgent is a 7-person startup selling autonomous research as a service. A customer (a clinical research firm, a regulatory team, a due-diligence shop) submits a research question; ResearchAgent's runtime — built on Claude with tool use — plans a research path, invokes search/code-exec/retrieval tools, gathers intermediate findings, and produces a structured final report. Reports go to the customer's portal; for sensitive customers (clinical and regulatory) the report is sometimes filed with a third-party regulator months later.

The audit problem they hit: regulators don't accept "trust us" for agent-generated reports. The questions they get asked in audit: "What system prompt was the agent running on May 26?" — "Did the agent really decide to invoke web_search for that retracted-paper check?" — "Has the final report been edited since it was filed?" ResearchAgent had logs of all of this, but the logs were in their own database, mutable in principle, and a regulator could reasonably argue any of the three could have been back-edited after the dispute surfaced. The first two regulator inquiries (both 2-3 months after the agent runs) involved engineering spending a week reconstructing evidence from various log stores — not sustainable, not convincing.

Anchoring with Satsignal solves the tamper-evidence problem at four points in the run: the policy active at session start, each significant tool-use decision as it's made, the final output, and a manifest tying the whole session together. What anchoring proves: at chain block time T, ResearchAgent held bytes whose sha256 matched what they're showing the regulator now. Edited bytes wouldn't match. What anchoring does not prove: that the agent's reasoning was correct, that the chosen tools were appropriate, or that the final report's claims are true. It proves what existed at time T — not whether what existed was good. That distinction matters: the regulator's job is to evaluate the reasoning chain; ResearchAgent's job is to give the regulator an honest record of what the chain actually was. If you are an authorization broker running in a separate trust domain from the runtime and need the commitment to witness your recorded request/response (the bytes you captured at the grant boundary) instead of the runtime's self-report, the grant-bound commitment recipe moves the witness to the broker's record — though it still proves what existed at time T, not that the grant decision was correct policy.

2. The four-part agent session pattern

This is the wire pattern that the Agent runtime path guide walks through in detail. Briefly:

  1. Policy snapshot. Anchored BEFORE any decisions — the system prompt, tool permissions, budget caps, model config. Binds "what the agent was allowed to do" before any decision happens. category: "policy_snapshot".
  2. Decision commitments. One commit-reveal envelope per significant tool-use decision, anchored AS the decision is made. The envelope is {nonce_hex, payload} JCS-canonicalized — the nonce makes the hash unguessable from a small payload space, so the chain pre-discloses timing without pre-disclosing content. category: "commitment".
  3. Output anchors. Significant intermediate outputs (or the final report) anchored via the same .decide(label, payload) call — outputs ARE decisions in this pattern, just with a different label convention. The shape on the wire is identical.
  4. Evidence-bundle manifest. A single Merkle manifest binding every decision leaf (decisions + outputs) into one root, anchored at session end. category: "evidence_bundle", mode: "manifest".

A session_id tags every anchor in the run for off-chain queryability. The cryptographic "these anchors belong together" binding is the manifest's Merkle root, not the session_id — the session_id is a workspace-side query label that the chain doesn't care about.

ResearchAgent uses the Python Session() context manager from agent_anchor.py — stdlib only, no SDK install, drops in next to their existing runtime. The context manager handles all four parts: enters → you call s.policy(...) + s.decide(label, payload) as the run proceeds → exits → manifest is anchored + handoff.json is written to disk for the auditor.

3. Day 1: instrumenting the agent

The integration was one engineer over two days. The sequence:

export SATSIGNAL_API_KEY=sk_demo_researchagent_xxxxxx   # synthetic

# 1. Create the folder for prod runs.
curl -X POST https://app.satsignal.cloud/api/v1/folders \
  -H "Authorization: Bearer $SATSIGNAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"slug": "research-runs-prod", "name": "Research runs (prod)"}'

Then they pulled agent_anchor.py next to their runtime code:

curl -O https://app.satsignal.cloud/agent_anchor.py
# (or vendor it into their repo; stdlib-only, ~700 lines, no install needed)

They wrapped their existing agent loop with a small Session() adapter (~30 lines, shown in §4). Each run gets a unique session_id of the shape run_<unix_ts>_<sha8> — the unix timestamp prefix makes runs naturally sortable, the sha8 of the question is a stable per-run handle. After early auditor feedback (§7) they later changed this to <customer_id>_<run_<unix_ts>_<sha8>> so an auditor can pull all of a single customer's runs with one query.

End-to-end smoke test: one synthetic question, three decisions, one report. Verified end-to-end:

# Pull the session's anchors out by session_id (off-chain query).
curl -H "Authorization: Bearer $SATSIGNAL_API_KEY" \
  "https://app.satsignal.cloud/api/v1/anchors?session_id=run_1748400000_test0001"

# Expect: 1 policy_snapshot + N commitments + 1 evidence_bundle.

For the first prod run they ran the agent_anchor.py CLI verify-handoff command against the saved handoff.json to sanity-check that every decision's sha recomputed cleanly and chain-confirmed. After that, the wrapper carries the contract.

4. The wire-up code

ResearchAgent's runtime wrapper (~50 lines, the production version):

import hashlib
import os
import time

from agent_anchor import Session   # vendored from app.satsignal.cloud


def run_research(question: str, policy: dict, model_id: str,
                 customer_id: str) -> dict:
    """One end-to-end research run. Returns the report + the
    handoff.json path for the customer's records."""
    # Stable, sortable, queryable session_id.
    sha8 = hashlib.sha256(question.encode("utf-8")).hexdigest()[:8]
    session_id = f"{customer_id}_run_{int(time.time())}_{sha8}"

    with Session(
        api_key=os.environ["SATSIGNAL_API_KEY"],
        folder_slug="research-runs-prod",
        session_id=session_id,
        agent_name="research-agent",
        agent_version="2.4.1",
        handoff_path=f"./handoffs/{session_id}.json",
    ) as s:
        # 1. Policy snapshot — anchored BEFORE any decision.
        s.policy(
            system_policy_text=policy["system_prompt"],
            user_instruction_text=question,
            tool_permissions=policy["tool_permissions"],
            budget_limits=policy["budget_caps"],
            model_config={"model": model_id,
                          "temperature": policy["temperature"]},
        )

        # 2. Run the agent loop; commit each significant decision.
        context = {"question": question, "history": []}
        for step in range(policy["budget_caps"]["max_steps"]):
            decision = agent.next_action(context, model_id=model_id)
            s.decide(
                f"decision-{step:03d}",
                {
                    "step": step,
                    "tool": decision.tool,
                    "rationale": decision.rationale,
                    "inputs": decision.inputs,
                    "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
                },
            )
            if decision.is_final:
                break
            result = decision.execute()
            context["history"].append(result)

        # 3. Final report — anchored under the 'final-report' label
        #    (still a .decide() call; outputs are decisions in this
        #    pattern, just with a different label convention).
        report = agent.write_report(context, model_id=model_id)
        s.decide("final-report", {"report_text": report,
                                  "len_chars": len(report)})

        # 4. __exit__ here: anchors the evidence-bundle manifest +
        #    writes handoff.json. No further code needed.

    return {
        "session_id": session_id,
        "report": report,
        "handoff_path": s.handoff_path,
        "policy_proof_id": s.policy_anchor.get("proof_id"),
        "manifest_proof_id": s.manifest_anchor.get("proof_id"),
    }

What ResearchAgent persists in their own DB, per run:

CREATE TABLE runs (
  session_id              TEXT PRIMARY KEY,
  customer_id             TEXT NOT NULL,
  question                TEXT NOT NULL,
  model_id                TEXT NOT NULL,
  agent_version           TEXT NOT NULL,
  started_at_utc          TIMESTAMP NOT NULL,
  finished_at_utc         TIMESTAMP NOT NULL,
  policy_proof_id         TEXT NOT NULL,    -- from s.policy_anchor
  policy_txid             TEXT NOT NULL,
  manifest_proof_id       TEXT NOT NULL,    -- from s.manifest_anchor
  manifest_txid           TEXT NOT NULL,
  manifest_root           TEXT NOT NULL,    -- Merkle root over the leaves
  handoff_path            TEXT NOT NULL,    -- where handoff.json lives in S3
  final_report_sha256_hex TEXT NOT NULL     -- queryable independently
);

CREATE INDEX idx_runs_customer ON runs(customer_id);

The handoff.json file itself is persisted to S3 alongside ResearchAgent's existing artifact storage. That file is the single artifact an auditor receives — it carries the policy snapshot's canonical bytes, every decision's nonce + envelope, and the manifest's leaves, so an auditor can re-verify offline against any public BSV node. See the path guide's handoff JSON shape for the schema.

5. What the data looks like

Three concrete payloads from one run (synthetic values).

Policy snapshot — anchored at session start, just before the agent loop begins:

{
  "version": "satsignal-policy-snapshot-v1",
  "snapshot_at_utc": "2026-05-26T14:00:00Z",
  "agent": {"name": "research-agent", "version": "2.4.1"},
  "system_policy_hash": "a1b2c3d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f607182930405060708090",
  "user_instruction_hash": "0102030405060708091011121314151617181920212223242526272829303132ab",
  "tool_permissions_hash": "112233445566778899aabbccddeeff00112233445566778899aabbccddeeff0011",
  "budget_limits_hash": "deadbeef00000000000000000000000000000000000000000000000000000000ab",
  "model_config_hash": "feedface00000000000000000000000000000000000000000000000000000000cd"
}

agent_anchor.py's Session.policy(...) builds this from raw text + dicts; the hashes are sha256 over the JCS-canonicalized bytes of each artifact. The canonical bytes (the system prompt verbatim, the user instruction verbatim, the tool permissions dict, the budget caps dict, the model config dict) are stored in the handoff.json so the auditor can recompute the hashes independently.

Decision commitment — anchored at step 3 of the run, when the agent decided to invoke web_search to verify a claim:

{
  "version": "satsignal-agent-decision-v1",
  "nonce_hex": "a1b2c3d4e5f607081920304050607080",
  "payload": {
    "step": 3,
    "tool": "web_search",
    "rationale": "Verify claim that paper X was retracted",
    "inputs": {"query": "X et al. 2024 retraction notice"},
    "ts": "2026-05-26T14:02:11Z"
  }
}

The nonce makes the canonical bytes unguessable even if the payload's shape is predictable. The chain commits to the sha256 of this JCS-canonicalized envelope. The auditor receives the envelope back in handoff.json and recomputes; chain-side they confirm the recomputed hash matches the on-chain commitment.

Evidence-bundle manifest — anchored at session end, binding every leaf in the session:

{
  "version": "satsignal.manifest-items-v1",
  "session_id": "cust_clinres42_run_1748400000_abc12345",
  "items": [
    {"label": "decision-000",
     "sha256_hex": "<decision 0 envelope sha>"},
    {"label": "decision-001",
     "sha256_hex": "<decision 1 envelope sha>"},
    {"label": "decision-002",
     "sha256_hex": "<decision 2 envelope sha>"},
    {"label": "final-report",
     "sha256_hex": "<final report envelope sha>"}
  ]
}

The notary computes the Merkle root over items[] in order and anchors it via mode: "manifest" — the canonical record's 20-byte doc_hash is the on-chain commitment. The policy snapshot is not a manifest leaf: it is anchored as its own proof (category policy_snapshot); the manifest binds only the per-decision commitments — one leaf per s.decide() call, here including the final report. Up to 10,000 items per manifest (see Manifest path guide for the per-leaf rules and the cap).

6. Six months later: a regulator audit

It's late November 2026. A regulator at a clinical research firm (one of ResearchAgent's customers) requests evidence for a research run dated 2026-05-26. The run produced a report that was filed in a regulatory submission; the regulator wants to verify:

  1. The model + system prompt that governed the run.
  2. The exact sequence of tool-use decisions (which tools fired, on what rationale, in what order).
  3. The final report bytes (unmodified since the report was filed).
  4. Optionally, each intermediate finding.

ResearchAgent's audit-export flow:

  1. Pull the handoff packet. A small ops endpoint: GET https://api.research-agent.example/audit/<session_id> returns a zip containing the handoff.json, every .mbnt bundle (one per anchor: policy + per-decision + manifest), and a one-page README.md explaining how to verify.
  2. Regulator extracts the packet. They either:
    • Use proof.satsignal.cloud/verify (web verifier, no account required) — drop a .mbnt + the corresponding canonical bytes from handoff.json, get a pass/fail in ~3s.
    • Use the offline verifier shipped with agent_anchor.py: python agent_anchor.py verify-handoff --handoff handoff.json — this walks the whole packet automatically: re-hashing every decision envelope and chain-confirming its txid, recomputing the policy-snapshot sha and chain-confirming the policy anchor, and rebuilding the manifest Merkle root to compare against the recorded root.
  3. Verifier confirms anchor 1: the policy snapshot. Block time on the txid is 2026-05-26T14:00:08Z. Sha256 of the policy snapshot canonical bytes matches the on-chain commitment. The regulator reads the unhashed canonical artifact (the system prompt verbatim, the tool permissions, the budget cap, the model id) directly from the handoff JSON and notes what the agent was allowed to do.
  4. Verifier confirms anchors 2 through N+1: each decision. For each decision in order, the recomputed sha matches the on-chain commitment; the block time is between the policy anchor's time and the manifest anchor's time. The regulator reads each decision payload (tool + rationale + inputs) and confirms the chain of tool-use choices.
  5. Verifier confirms the final-report anchor. Sha256 of the final-report envelope from handoff.json matches the on-chain commitment. The report text inside the envelope is the exact bytes ResearchAgent originally produced; if the report file shipped with the audit packet matches the envelope's text, the report hasn't been edited.
  6. Verifier confirms the manifest. Re-walks items[] in order, builds the Merkle tree, recomputes the root, and compares to the on-chain manifest root. If a decision had been silently added or removed after the fact, the root would not match.

Total regulator time end-to-end, with the bundled verifier: about 12 minutes. Engineering escalation at ResearchAgent: zero. What they delivered was the same handoff.json they wrote on 2026-05-26, plus the bundle files that have been on-chain since that day — no reconstruction, no log-archaeology, no "let me get back to you on that one."

7. Production lessons ResearchAgent learned

The integration shipped without major bugs. The post-launch six months surfaced four lessons:

Instrument the four-part pattern on day one, not just outputs. The first version anchored only the final report — "we can always get the rest from the run logs." Two months in, the first regulator inquiry asked decision-by-decision detail. ResearchAgent could produce logs but the logs weren't anchored — the regulator accepted them under protest but flagged it. ResearchAgent retrofit the per-decision anchors going forward. Historical runs couldn't be retroactively anchored — the chain time has to be the original decision time, not now. Lesson: the four-part pattern is cheap to instrument up front, expensive to add later.

Bind session_id to the customer. Their first session_id shape was just run_<unix_ts>_<sha8> — fine for in-engine queries, useless for auditors who only knew their customer id and a date range. They added a <customer_id>_ prefix and rebuilt the off-chain query path around it. The chain doesn't care about session_id format (it's a label, not part of the commitment), so this was a no-downtime change for prod runs going forward.

Manifest item cap is real. Once, a runaway agent run produced 11,400 decision commitments before the budget cap killed it. The end-of-session manifest 400'd with manifest_too_many_items. ResearchAgent split into sub-manifests (10,000 leaves each) + a top-level manifest of the sub-manifests — see the Manifest path guide for the recursive shape. They also tightened their per-run step budget so a 10,000+ decision run can't slip through silently.

MCP integration came later. Initially satsignal-mcp tools were called manually inside the agent loop alongside agent_anchor.py. After a Claude Desktop customer asked "can I drop your anchoring into my own MCP host?" they wired the satsignal-mcp tools as first-class agent-runtime tools, available to any MCP-speaking client. For ResearchAgent's own runs, agent_anchor.py remained the recommended path — it's tighter on session-shape discipline than free-form MCP tool calls. See the MCP section of the path guide for the host-config pattern.

8. Variations on this pattern

Sealed agent runs. When the system prompt or tool permissions are commercially sensitive, anchor sealed commitments at session start instead of plain policy_snapshot anchors. The chain commits to the blinded hash; reveal happens bilaterally with a trusted reviewer (a regulator, an auditor) who receives the salt + the canonical bytes off-chain. The timing predicate (this policy existed at block time T) survives the seal. See Sealed path guide for the wire shape.

Batch mode for high-volume runs. For agents running thousands of short sessions per day, the default commit_each=True — N + 2 chain anchors per session — burns quota fast. Pass commit_each=False to Session(...) and only the policy snapshot + final manifest anchor; decision hashes are held locally and rolled into the manifest at end of session. Trade-off: the timing predicate is proven "by manifest anchor time" for the whole decision set rather than per decision. Use this when only end-state integrity matters; use the default when each decision's "committed before revealed" timing matters.

Multi-agent workflows. When ResearchAgent's main agent invokes a sub-agent (e.g., a specialist code-review agent for one step), each sub-agent run gets its own Session() with its own session_id. The parent session's manifest can include a leaf pointing to the sub-session's manifest root, binding them cryptographically. This is the recursive-manifest pattern from the Manifest path guide.

LangChain integrations. If your runtime is LangChain rather than raw Claude tool use, the langchain-satsignal adapter wraps these primitives as LangChain tools — same on-chain shape, drop-in for agent components. See github.com/Steleet/langchain-satsignal.

Broker-computed grant-bound commitment. For authorization brokers that run as a separate trust domain from the runtime: the broker (not the runtime) anchors the commitment over {oauth_grant_id, tool, scope, user_ref, request_digest, response_digest} (abbreviated — the full payload, including session_id, ts, and call_index, is in the guide), so the proof witnesses the broker's recorded request/response rather than the runtime's self-report. See Grant-bound commitment.

9. Where this fits

Responses emit the canonical proof / folder field names only; requests written against the legacy spellings keep working forever (they are folded into the canonical names at the boundary). The agent_anchor.py Session() constructor parameter is folder_slug=; the legacy keyword is also accepted for older code. Full mapping at Compatibility map.

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