examples/support-agent · Readproof 0.3.2 · open model on Ollama
A support agent you can replay, diff, and prove.
A customer-support agent answers tickets from three policy documents. Every ticket is one Readproof run: the agent mounts the policies, hands the model exactly those bytes, commits a manifest, and stores the manifest id next to the answer. When a policy changes, diff names which document moved and why, replay reconstructs the exact text the old answer was based on, and evidence exports a bundle the Go CLI verifies — while the house-style document, mounted by tag, does not move until someone promotes it.
What makes this example worth reading
Open model via Ollama
Any chat model Ollama serves: local (llama3.2), remote, or Ollama Cloud (deepseek-v4-flash:cloud was used for the transcript). A deterministic fake model runs the whole story with no network at all.
Same agent, different rules per document
refunds is require_fresh (money), shipping is allow_stale for an hour, tone is mounted as @prod — edits are inert until promoted.
Replay, diff, evidence
Every guarantee is a property of Readproof, not the model: swap the model or use the fake one and the replay still matches, the diff still names the revision, the Go verifier still passes.
Architecture
What talks to what
Four moving parts, all local. The agent is a TypeScript CLI built on the Readproof TypeScript SDK; it talks HTTP to a readproofd server (the scenario starts a throwaway one) and to Ollama for the model. The Go CLI readproof appears once, as the independent auditor that verifies the evidence bundle.
The governed documents
Three policies, three freshness contracts
The documents are deliberately tiny so the whole story fits in one screen. What matters is that each one gets a different freshness rule, declared once in src/config.ts and applied by readproofd on every resolve.
| Resource | File | Policy (on resolve) | How the agent mounts it | Why |
|---|---|---|---|---|
readproof://acme/policies/refunds | context/policies/refunds.md | require_fresh | bare URI | Money is involved: re-verify the source on every ticket. Unchanged bytes dedupe to the same hash. |
readproof://acme/policies/shipping | context/policies/shipping.md | allow_stale, max_age_seconds: 3600 | bare URI | Changes rarely; an hour-old copy is fine and saves a fetch. |
readproof://acme/policies/tone | context/policies/tone.md | require_fresh | …/tone@prod (tag) | House style is a deployment decision: a tag delivers exactly the promoted snapshot, no fetch, policy not consulted. |
Their contents (soft-wrapped here; each file is one paragraph on one line)
# Refund policy
Products can be refunded within 30 days of delivery. Refunds go
to the original payment method within 5 business days.
# Shipping policy
Orders ship within 2 business days. Standard delivery takes 3-5
business days, express 1-2. Tracking is emailed as soon as the
label is created.
# House style for support replies
Keep replies under 120 words. Use plain language and no jargon.
Name the policy you relied on, in the form "per the refund
policy". Close with one concrete next step the customer can take.
They are registered as filesystem sources with absolute paths (computed from import.meta.url), because readproofd resolves a filesystem path relative to its own working directory, never the example's. SUPPORT_CONTEXT_DIR points at a different directory — the tests use that to register a throwaway copy they can edit freely.
The core loop
How a ticket is answered
Open a run named after the ticket
rp.run({ id: "ticket-1001" }). Run ids are deterministic (runIdFor), soshow/replayneed no lookup table. The run starts lazily on the first mount.Mount the three policies, in a fixed order
mountSpecs()returns["readproof://acme/policies/refunds", "readproof://acme/policies/shipping", "readproof://acme/policies/tone@prod"]. Eachrun.mount(spec)resolves the resource under its policy (or by tag) and records it as the next ordered manifest entry. The agent keeps{uri, ref?, snapshot_id, content_hash, content}per mount. Order is a hard Readproof invariant — it is committed to the manifest and folded into the evidence Merkle root.Build the prompt from the mounted bytes and nothing else
No second read of the files, no retrieval of its own. The system message is the tone document's text plus a fixed instruction; the user message is every document under a header naming its URI and the first 12 hex of its content hash, then the question.
system: <contents of tone.md> Answer only from the policies given. Name the policy you relied on. If the policies do not cover it, say so. user: ### readproof://acme/policies/refunds (sha256:72be2c034713) <contents of refunds.md> ### readproof://acme/policies/shipping (sha256:e8178eaf5ca5) <contents of shipping.md> ### readproof://acme/policies/tone (sha256:c04b1f6dbc3c) <contents of tone.md> I bought headphones 20 days ago. Can I still get a refund?Those headers are what tie a sentence in the answer back to a line in the manifest, for about a dozen tokens.
Stream the answer
ollama.chat({ model, messages, stream: true }); tokens go to stdout as they arrive so a slow local model still looks alive.model: <name>is printed once so the record can store which model actually answered.Commit the manifest
run.commit()freezes the three entries into an immutable manifest and returns its id.Append the ticket record
One JSON line in
data/tickets.jsonl: ticket, question, answer, model,manifest_id,run_id, the entries (without content), and a timestamp. The manifest id next to the answer is the whole trick: months laterreplay 1001reconstructs the exact policy text that produced that reply.
src/model.ts
The model layer
Model resolution
1. OLLAMA_MODEL wins. 2. Otherwise ollama.list() and the first model whose name does not contain embed (Ollama happily lists nomic-embed-text, which cannot chat). 3. Otherwise: set OLLAMA_MODEL or: ollama pull llama3.2.
Where Ollama is
The ollama JS client (0.6.3) does not read OLLAMA_HOST — it hardcodes 127.0.0.1:11434. The example reads the variable itself and passes new Ollama({ host }), so a remote or cloud-backed Ollama works with one env var.
Errors
Connection failures (ECONNREFUSED, fetch failed, DNS) become one line naming OLLAMA_HOST and asking whether ollama serve is running. Everything else is passed through.
Fake mode
SUPPORT_FAKE_MODEL=1 replaces the chat call with a deterministic function that answers from the refund policy's first sentence, byte-identical output for byte-identical input. The run, mounts, manifest, tag, diff, replay and evidence are the same code path — which is the point: the guarantees are Readproof's, not the model's. Tests and CI always run this way.
Products can be refunded within 30 days of delivery.
(per readproof://acme/policies/refunds, sha256:72be2c034713)
— asked: I bought headphones 20 days ago. Can I still get a refund?
Use it
Run it
Prerequisites
- Go 1.26+ — the scenario builds
readproofandreadproofdfrom this repo. - Node 18+ — the agent is TypeScript and uses the global
fetch. - Ollama with a chat model for the real run (
ollama serve,ollama pull llama3.2), orOLLAMA_HOSTpointing at a remote/cloud instance — or skip it with the fake model.
One command
cd examples/support-agent
npm install
# needs Ollama; picks the first chat model it finds
npm run scenario
# or name the model explicitly
OLLAMA_MODEL=llama3.2 npm run scenario
# what produced the transcript below (Ollama Cloud)
OLLAMA_MODEL=deepseek-v4-flash:cloud npm run scenario
# no Ollama and no network — deterministic fake model
SUPPORT_FAKE_MODEL=1 npm run scenario
scripts/scenario.sh is self-contained: it builds readproof and readproofd, builds the SDK if needed, starts a throwaway readproofd on :18090 with its own data directory, runs the twelve steps, and restores the two policy files it edits — always, including on failure and on Ctrl‑C (it backs them up by copy, not with git, so it can never clobber edits you had in flight). It leaves its scratch directory behind (evidence bundle, readproofd log) and prints the path.
Against your own readproofd
# 1. a host-side readproofd, so it can read the policy files
go build -o readproofd ./cmd/readproofd
./readproofd --addr :8080 --data-dir ~/.readproof &
# 2. point the example at it (READPROOF_SERVER_URL also works)
export READPROOF_ENDPOINT=http://localhost:8080
# 3. register the policies, answer a ticket, then replay it
npm run agent -- setup
npm run agent -- ask 1001 \
"I bought headphones 20 days ago. Can I still get a refund?"
npm run agent -- replay 1001
docker compose up's readproofd cannot see your host filesystem, so the filesystem sources would fail there. Run readproofd on the host, or switch the policies to GitHub/HTTP sources (see Extend it).Real output
The scenario, step by step
Everything below is real output from OLLAMA_MODEL=deepseek-v4-flash:cloud bash scripts/scenario.sh on 2026‑08‑21, ids and hashes trimmed. The two answers are the model's own words.
setup — put the policies under Readproof
# register the three policies and pin the house style as @prod $ npm run agent -- setup readproofd http://localhost:18090 ok register readproof://acme/policies/refunds -> …/policies/refunds.md (require_fresh) register readproof://acme/policies/shipping -> …/policies/shipping.md (allow_stale, max age 3600s) register readproof://acme/policies/tone -> …/policies/tone.md (require_fresh) tag readproof://acme/policies/tone@prod -> snap_01M0HW18V0… 3 policies governed by Readproof, 3 registered just now.
Idempotent: run it twice and it registers nothing and moves no tag. If a URI is already registered against a different path, it warns with both paths.
ask 1001 — while the policy says 30 days
# one ticket = one run: mount three policies, answer, commit $ npm run agent -- ask 1001 \ "I bought headphones 20 days ago. Can I still get a refund?" ticket: 1001 question: I bought headphones 20 days ago. Can I still get a refund? model: deepseek-v4-flash:cloud Yes, you can still get a refund. Per the refund policy, products can be refunded within 30 days of delivery, and your purchase is 20 days old, so you're within the window. The refund will go to your original payment method within 5 business days. To proceed, please start a return request through your account or reply with your order number. manifest: manifest_01M0HW1AQ9… POS URI@REF SNAPSHOT HASH 0 …/acme/policies/refunds snap_01M0HW194M… sha256:72be2c034713 1 …/acme/policies/shipping snap_01M0HW194S… sha256:e8178eaf5ca5 2 …/acme/policies/tone@prod snap_01M0HW18V0… sha256:c04b1f6dbc3c
Somebody edits the refund policy
# 30 days → 14 days, straight on the file $ sed 's/within 30 days/within 14 days/' \ context/policies/refunds.md > refunds.new $ mv refunds.new context/policies/refunds.md
Nobody told the agent. Nothing was redeployed.
ask 1002 — same question, different answer
# same question, nothing redeployed — require_fresh re-verified $ npm run agent -- ask 1002 \ "I bought headphones 20 days ago. Can I still get a refund?" model: deepseek-v4-flash:cloud Per the refund policy, products can be refunded within 14 days of delivery. Since your purchase was 20 days ago, it's past that window, so a refund isn't possible. If you have a different issue, like a defect, please check the warranty or contact support for options. Next step: reply with your order number if you'd like us to review any other concerns. manifest: manifest_01M0HW1CFV… POS URI@REF SNAPSHOT HASH 0 …/acme/policies/refunds snap_01M0HW1AZH… sha256:3117512b66c3 1 …/acme/policies/shipping snap_01M0HW194S… sha256:e8178eaf5ca5 2 …/acme/policies/tone@prod snap_01M0HW18V0… sha256:c04b1f6dbc3c
The decision flipped.
require_freshis why: Readproof re-verified the source and delivered a new snapshot. Shipping and tone are byte-identical to ticket 1001.diff 1001 1002 — which document moved, and why
# which document moved between the two tickets, and why $ npm run agent -- diff 1001 1002 --- ticket 1001 (manifest_01M0HW1AQ9…) +++ ticket 1002 (manifest_01M0HW1CFV…) ~ readproof://acme/policies/refunds (snap_01M0HW194M… -> snap_01M0HW1AZH…) why: source revision sha256:72be2c034713 -> sha256:3117512b66c3; observed 2026-08-21T09:57:02Z -> 2026-08-21T09:57:04Z --- a/readproof://acme/policies/refunds +++ b/readproof://acme/policies/refunds @@ -1,4 +1,4 @@ # Refund policy -Products can be refunded within 30 days of delivery. Refunds go to the original payment method within 5 business days. +Products can be refunded within 14 days of delivery. Refunds go to the original payment method within 5 business days. = readproof://acme/policies/shipping (snap_01M0HW194S…) = readproof://acme/policies/tone (snap_01M0HW18V0…) 1 resource changed, 0 added, 0 removed, 2 unchanged
"Why did the agent answer differently?" — answered with a document name, a revision, and a timestamp.
replay 1001 — the bytes the old answer was actually based on
# rebuild the bytes ticket 1001 was actually answered from $ npm run agent -- replay 1001 ticket: 1001 manifest: manifest_01M0HW1AQ9… (answered 2026-08-21T09:57:04Z) [0] readproof://acme/policies/refunds recorded sha256:72be2c034713… replayed sha256:72be2c034713… MATCH | # Refund policy | | Products can be refunded within 30 days of delivery. | Refunds go to the original payment method within 5 | business days. live source: CHANGED -> sha256:3117512b66c3… [1] readproof://acme/policies/shipping … MATCH live source: unchanged [2] readproof://acme/policies/tone … MATCH live source: unchanged Replay verified: 3/3 entries match. 1 of them no longer matches the live source — the manifest, not the source, is what a replay reads.
The file on disk says 14 days. The replay says 30, because that is what the agent read at 09:57:04, reconstructed from Readproof's store with no fetch. Replay is strict: any hash mismatch exits non-zero.
evidence 1001 — for someone who doesn't trust you
# 1. the TypeScript SDK builds the bundle $ npm run agent -- evidence 1001 \ --out ticket-1001.bundle.json --with-content evidence bundle written to …/ticket-1001.bundle.json entries: 3 (with embedded content) merkle root: e8d8572c0d97… replay: all entries match verify it with the Go CLI: readproof --server http://localhost:18090 \ evidence verify …/ticket-1001.bundle.json # 2. the Go CLI checks it independently $ readproof --server http://localhost:18090 \ evidence verify ticket-1001.bundle.json evidence verified: 3 entries, merkle root e8d8572c0d97…, embedded content 3/3 re-hashed, replay match 3/3
The bundle is an in-toto Statement built by the TypeScript SDK's
buildEvidence(); the Go verifier recomputes the Merkle root, re-hashes the embedded bytes, and cross-checks the store by replay. Flip one base64 character and it exits non-zero — the tests assert exactly that.Edit the house style — and watch nothing happen
# 1. edit the house style document $ printf '\nAlways open with a one-line summary of the decision.\n' \ >> context/policies/tone.md # 2. ask again — the tone entry does not move $ npm run agent -- ask 1003 \ "I bought headphones 20 days ago. Can I still get a refund?" model: deepseek-v4-flash:cloud Per the refund policy, products can be refunded within 14 days of delivery. Since your headphones were delivered 20 days ago, the refund window has passed, so a refund is not available. As a next step, please contact our support team if you have any other questions or concerns. manifest: manifest_01M0HW1GMK… POS URI@REF SNAPSHOT HASH 0 …/acme/policies/refunds snap_01M0HW1E27… sha256:3117512b66c3 1 …/acme/policies/shipping snap_01M0HW194S… sha256:e8178eaf5ca5 2 …/acme/policies/tone@prod snap_01M0HW18V0… sha256:c04b1f6dbc3c
The tone entry is
snap_01M0HW18V0…— the same snapshot ticket 1001 used, three edits ago. A tag delivers exactly the snapshot it points at: no fetch, policy not consulted. Editing the file is not deploying it.Deploying it is: promote
# 1. move the prod tag to the resource's current snapshot $ npm run agent -- promote tone readproof://acme/policies/tone@prod -> snap_01M0HW6DMN… # 2. the move is recorded in the snapshot history $ npm run agent -- history tone readproof://acme/policies/tone SNAPSHOT OBSERVED CONTENT_HASH TAGS snap_01M0HW6DMN… 2026-08-21T09:59:51Z sha256:8ed369c0dc9b prod snap_01M0HW6D8B… 2026-08-21T09:59:50Z sha256:c04b1f6dbc3c
promotewith no snapshot id resolves the resource first, then promotes what its policy says is current. The previous snapshot is still there;promote tone <old-snapshot-id>rolls back.
src/cli.ts
Command reference
All commands: npm run agent -- <command> [args]; npm run agent -- --help prints this. Runtime errors print one line and exit 1 (no usage dump); misuse prints usage and exits 2.
| Command | What it does | Reads / writes |
|---|---|---|
setup | Checks /healthz, registers the three policies idempotently (get → 404 → create; warns on a path mismatch), resolves tone once and tags it prod if no tag exists. | readproofd |
ask <ticket> <question…> | One Readproof run: mount ×3 → model → commit → append. Prints the streamed answer, the manifest id and the entries table. | readproofd, Ollama, tickets.jsonl |
show <ticket> | The stored record plus the manifest read back from readproofd (getManifest). | tickets.jsonl, readproofd |
replay <ticket> | rp.replay(manifest_id); asserts every entry matches (exit 1 otherwise), prints recorded vs replayed hashes and the bytes, then resolves each URI live and prints unchanged / CHANGED -> hash. | readproofd |
diff <a> <b> | rp.diff(manifestA, manifestB); per entry: status, the why: line (source revision, observed-at, ref — only fields present), unified diff; summary counts. | readproofd |
evidence <ticket> [--out f] [--with-content] | buildEvidence + encodeEvidence; prints merkle root and the exact Go verify command. | readproofd, writes the bundle |
promote <policy> [snapshot-id] | Moves the prod tag. With no id: resolves first, then promotes the resource's current snapshot. | readproofd |
history <policy> | Snapshots newest first with their tags. | readproofd |
<policy> accepts a full readproof:// URI or a short name (refunds, shipping, tone).
Configuration
Environment variables
| Variable | Default | Effect |
|---|---|---|
READPROOF_ENDPOINT | http://localhost:8080 | Base URL of the readproofd to use. READPROOF_SERVER_URL (the Go CLI's variable) is accepted as a fallback so one export serves both halves. |
READPROOF_API_KEY | — | Bearer token, if readproofd runs with --api-key. |
OLLAMA_HOST | http://localhost:11434 | Where Ollama is. Read by the example and passed to the client explicitly. |
OLLAMA_MODEL | first non-embedding model listed | Which chat model answers. |
SUPPORT_FAKE_MODEL | — | 1/true/yes/on = deterministic fake model, no Ollama. |
SUPPORT_CONTEXT_DIR | ./context/policies | Where the policy files live (tests point it at a throwaway copy). |
SUPPORT_DATA_DIR | ./data | Where tickets.jsonl is written (the scenario uses its scratch dir). |
Under the hood
Every file
Everything under examples/support-agent/:
| Path | What is in it |
|---|---|
package.json | Scripts build · agent (node dist/src/cli.js) · scenario · test · clean. Dependencies: @readproof/sdk (file:../../sdk/typescript), ollama 0.6.3; dev: typescript 5.9.3, @types/node 22.20.1. |
tsconfig.json | Strict, noUncheckedIndexedAccess, ESM output to dist/. |
.gitignore | node_modules/, dist/, data/, *.bundle.json. |
README.md | The same story as this page, in markdown. |
context/policies/ | refunds.md · shipping.md · tone.md — the three governed documents (fixtures). |
src/config.ts | Every knob in one place: READPROOF_ENDPOINT and API key, OLLAMA_HOST/MODEL, FAKE_MODEL, POLICY_DIR, DATA_DIR, the POLICY_RESOURCES table, mountSpecs(), resolvePolicyURI(). |
src/model.ts | answer(): Ollama streaming chat or the fake model. systemPrompt() = tone + instruction; userPrompt() = documents with uri and hash headers; resolveModel(); ollamaError(). |
src/agent.ts | setup() (idempotent registration + prod tag), ask() (one run per ticket), loadTicket(), appendTicket(), checkHealth(). |
src/cli.ts | The eight commands, the table/diff/replay printers, one-line runtime errors. |
scripts/scenario.sh | Builds readproof, readproofd, the SDK and the example; runs a throwaway readproofd on :18090; the twelve steps; restores the fixtures via trap. |
test/agent.test.ts | Seven end-to-end tests against a real readproofd with the fake model (below). |
data/tickets.jsonl
The ticket record
Append-only, one JSON object per line, written by ask and read by show/replay/diff/evidence (last record for a ticket wins). The bytes the model saw are not stored here — they live in Readproof, addressed by the manifest.
{
"ticket": "1001",
"question": "I bought headphones 20 days ago. Can I still get a refund?",
"answer": "Yes, you can still get a refund. Per the refund policy, …",
"model": "deepseek-v4-flash:cloud",
"manifest_id": "manifest_01M0HW1AQ9…",
"run_id": "ticket-1001",
"entries": [
{ "uri": "readproof://acme/policies/refunds",
"snapshot_id": "snap_01M0HW194M…",
"content_hash": "sha256:72be2c03…" },
{ "uri": "readproof://acme/policies/shipping",
"snapshot_id": "snap_01M0HW194S…",
"content_hash": "sha256:e8178eaf…" },
{ "uri": "readproof://acme/policies/tone", "ref": "prod",
"snapshot_id": "snap_01M0HW18V0…",
"content_hash": "sha256:c04b1f6d…" }
],
"at": "2026-08-21T09:57:04.512Z"
}
Field names follow the SDK types exactly: resource.uri is always the bare URI and the tag is reported separately as resource.ref — the same split the manifest uses, so a moved tag can never change what a committed manifest replays.
test/agent.test.ts · CI
Tests and CI
npm test builds, then runs node --test dist/test/*.test.js with SUPPORT_FAKE_MODEL=1. The suite go builds readproof and readproofd, starts a throwaway readproofd on a free port, copies the three fixtures into a temp directory and registers those, then asserts:
- setup registers three resources with their declared policies and creates
tone@prod— and is idempotent. - ask commits a manifest with three entries in mount order, the third carrying
ref === "prod". - Editing
refunds.mdproduces exactly onechangeddiff entry withsource_revision_a !== source_revision_band bothobserved_atfields; the others are unchanged. - Replaying the first ticket still matches and returns the old bytes while a live resolve returns the new ones.
- Editing
tone.mdleaves the tone entry's snapshot identical (pinned by the tag) while the refunds entry moves. - The evidence bundle's
subject[0].digest.sha256equalsmerkleRoot()recomputed from its entries;readproof evidence verifyexits 0 — and exits non-zero after one byte ofcontent_b64is flipped. - The repository's own fixtures were never touched.
CI job support-agent-example (.github/workflows/ci.yml): set up Go and Node, build the SDK, npm ci, npm run build, npm test. No Ollama in CI, by design.
Concepts
How it maps to Readproof
| Readproof concept | In this example |
|---|---|
| Identity | readproof://acme/policies/{refunds,shipping,tone} — stable names, independent of the files behind them. |
| Policy | require_fresh on refunds (money), allow_stale 1h on shipping (rarely changes), require_fresh on tone but mounted by tag, so it never fetches. |
| Tag | tone@prod, moved only by promote — an explicit, revertible deployment of house style. |
| Run | One per ticket, id ticket-<id>; mount() resolves and records. |
| Manifest | The three ordered entries commit() freezes; its id is stored with the answer. |
| Diff | diff 1001 1002, with per-side source_revision / observed_at / ref. |
| Replay | replay 1001 — strict SHA256 reconstruction, no source fetch. |
| Evidence | evidence 1001 — in-toto Statement, Merkle root over the entries, verified by the Go CLI. |
Honest notes
Gotchas and limits
promoteresolves first.current_snapshot_idonly moves on resolve, so "promote the current snapshot" right after an edit would otherwise re-promote the pre-edit one. The example resolves, then tags.- The tone document is sent twice — as the system prompt (it is the house style) and in the documents list (so "exactly the mounted bytes" stays literal). A few dozen tokens; drop the duplicate if you prefer.
- The ticket log is append-only JSONL; re-asking a ticket id appends a second record and the last one wins. It is a demo store, not a database.
- The real run depends on your Ollama. Model quality and latency are the model's; the transcript used Ollama Cloud's
deepseek-v4-flash:cloud. Tests never touch a model. - The scenario leaves its scratch directory (bundle + readproofd log) and prints the path; delete it when done.
- Filesystem sources need a host-side readproofd. A containerized readproofd cannot read the example's files.
Next
Extend it
Add a policy
Append an entry to POLICY_RESOURCES in src/config.ts (uri, name, file, policy, optional mountRef) and drop the file in context/policies/. setup registers it; mountSpecs() mounts it in order; the manifest, diff, replay, and evidence pick it up unchanged.
Govern a GitHub or HTTP document
Swap policySource() to { kind: "github", github: { owner, repo, path, ref } } or { kind: "http", http: { url, headers } }; the source revision becomes the commit SHA or ETag, and diff names it. Then a containerized readproofd works too.
Swap the model
OLLAMA_MODEL=… for any Ollama model; for another provider replace answer() in src/model.ts — keep the rule that the prompt is built only from entries.
Let an agent harness do the mounting
The same tools exist over MCP (readproof mcp) for Claude Code, Cursor, or DeepSeek Harness, and as a LangGraph node in examples/langgraph-ts — the manifest id lands in the checkpoint instead of a JSONL line.
Page: examples/support-agent/guide.html · code: examples/support-agent/ · Readproof 0.3.2 · output captured 2026‑08‑21.