Sealing Claude Code sessions with the Sigill SDK
The previous post introduced sigill-python and sigill-dotnet: two SDKs for building, sealing, and verifying AI evidence envelopes without hand-rolling canonical JSON, hash binding, RFC 3161 timestamp plumbing, or verification logic.
This post is the smallest useful example of why that matters.
Claude Code already records a rich local transcript of your AI coding sessions. Every prompt, response, tool call, tool result, and model entry is written to disk as JSONL. That transcript is useful while you work, but by itself it is still just a local file. It can be edited, deleted, truncated, or copied without leaving any cryptographic trace.
With the SDK, turning that transcript into sealed evidence is a small integration:
- Claude Code finishes a turn.
- A
Stophook runs a Python script. - The script reads the latest transcript exchange.
sigill-pythonbuilds anAiEvidenceEnvelopeV1.- The SDK seals the envelope with an RFC 3161 timestamp.
- The sealed envelope is written locally to
~/.claude/sigill-audit/. Sigill's Evidence Store records timestamp evidence for the envelope hash: TSA, signing time, certificate status, and optionally the TSR depending on tenant configuration.
Important boundary: Sigill does not store the customer's prompt, response, Claude transcript, or full evidence envelope in the Evidence Store. Those remain application-owned data. Sigill stores evidence about the hash, not the customer content behind it.
No custom evidence format. No direct TSA integration. No OpenSSL glue. No canonicalization code in your hook.
Just the SDK doing the boring cryptographic work.
Why Claude Code sessions are a useful SDK example
AI coding assistants sit close to the software supply chain. They read repositories, inspect files, propose changes, explain unfamiliar code, suggest patches, and sometimes produce code that is committed almost directly.
Your normal delivery pipeline may already have strong evidence around the final artifact: Git commits, CI logs, build artifacts, SBOMs, signatures, deployment records. But the AI-assisted development session often sits before all of that as an unsealed local transcript.
That creates a gap between two questions:
| Question | Usual source |
|---|---|
| What changed in the repository? | Git |
| What built and shipped? | CI/CD |
| What dependencies were present? | SBOM |
| What did the AI assistant suggest before the change? | Local Claude Code transcript |
The last one is the weak link. Not because Claude Code does anything wrong — the transcript is there — but because a transcript is not the same as evidence.
A sealed envelope gives the transcript entry a stronger shape. It records what was asked, what was answered, which model was used, which session it belonged to, and when that exact envelope was timestamped. Later, the SDK can verify whether the envelope still matches the timestamped bytes.
This is not about claiming that every developer session needs heavy compliance treatment. Most do not. It is about showing the SDK pattern on something developers already use: a local AI tool with a hook system and a transcript file.
What the SDK contributes
The hook itself is simple. The important part is what you do not have to implement.
Without the SDK, you would need to define an evidence object, serialize it consistently, hash the exact bytes, send those bytes or hashes to a timestamping service, store the returned .tsr, and later reproduce the same byte sequence during verification. Small differences in JSON formatting, key ordering, whitespace, number handling, or proof encoding can break verification.
With sigill-python, the integration code can stay focused on the application:
from sigill_sdk import SigillClient, EnvelopeBuilder
envelope = (
EnvelopeBuilder()
.with_purpose(category="coding-assistance", business_context="claude-code-session")
.with_actor(type="user", id="you@example.com")
.with_activity(name="session.turn", correlation_id=session_id)
.with_model(provider="anthropic", name=model)
.with_prompt_inline(user_text)
.with_output_inline(assistant_text)
.build()
)
with SigillClient(api_key=api_key) as client:
sealed = client.seal(envelope)
That is the value of the SDK: the application supplies domain facts; the SDK handles the evidence mechanics. The customer content remains under the application's control; Sigill's Evidence Store only needs the resulting hash evidence and timestamp metadata.
How Claude Code hooks work
Claude Code supports lifecycle hooks: shell commands that run in response to events such as tool calls, notifications, and session stops.
The hook used here is Stop. It fires when Claude has completed a turn and control returns to the user. The hook receives a JSON payload on stdin:
{
"hook_event_name": "Stop",
"session_id": "87395cb5-e37d-44a5-afca-f85b683870a4",
"cwd": "/home/you/your-project",
"stop_hook_active": false
}
Hooks can be configured globally in ~/.claude/settings.json, or per project in .claude/settings.json. For this example we use the global file, because the point is to seal sessions across all projects without repeating setup in every repository.
Claude Code also writes the full session transcript to disk as JSONL:
~/.claude/projects/<sanitized-cwd>/<session_id>.jsonl
Where <sanitized-cwd> is the working directory with / replaced by -. The hook payload gives us the session_id and cwd, which is enough to locate the transcript.
The transcript format
Each line in the transcript is a JSON object with a type field. The types that matter for this example are:
| Type | Meaning |
|---|---|
user | Human messages and tool results fed back to Claude |
assistant | Claude output blocks, including text, tool use, and other internal blocks |
A naive parser will get this wrong.
Not every user entry is a human prompt. Tool results can also appear as user entries. Not every assistant entry is a user-facing answer. A single Claude turn can include thinking blocks, tool calls, and final text blocks.
For this first SDK integration, we seal the last human-facing exchange:
- the latest human prompt
- the latest assistant text response
- the model name recorded in the transcript
- the Claude Code session ID as the envelope correlation ID
That keeps the example understandable. A stricter version could seal tool calls, tool results, file-read context, or the entire transcript tail as additional envelope context.
The hook script
Save this as ~/.claude/sigill_seal.py:
#!/usr/bin/env python3
"""Claude Code Stop hook — seal each turn as a Sigill evidence envelope."""
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
def find_transcript(session_id: str, cwd: str) -> Path | None:
projects_root = Path.home() / ".claude" / "projects"
sanitized = cwd.replace("/", "-")
candidate = projects_root / sanitized / f"{session_id}.jsonl"
if candidate.exists():
return candidate
# Fallback for cases where Claude Code changes the sanitisation format,
# or where cwd differs slightly from the transcript path.
for p in projects_root.rglob(f"{session_id}.jsonl"):
return p
return None
def extract_text_content(content: object) -> str:
if isinstance(content, str):
return content.strip()
if isinstance(content, list):
parts = [
block["text"]
for block in content
if isinstance(block, dict)
and block.get("type") == "text"
and isinstance(block.get("text"), str)
]
return "\n".join(parts).strip()
return ""
def extract_last_exchange(transcript_path: Path) -> tuple[str, str, str] | None:
"""Return (user_text, assistant_text, model) for the last human-facing turn."""
lines = transcript_path.read_text().splitlines()
entries = [json.loads(line) for line in lines if line.strip()]
turns: list[tuple[str, str, str]] = []
for entry in entries:
entry_type = entry.get("type")
message = entry.get("message", {})
if entry_type == "user" and not entry.get("toolUseResult"):
text = extract_text_content(message.get("content", ""))
if text:
turns.append(("human", text, ""))
elif entry_type == "assistant":
model = message.get("model", "")
content = message.get("content", [])
text = extract_text_content(content)
if text:
# Claude can emit multiple assistant text blocks in one turn.
# Merge adjacent assistant blocks so the envelope gets the full answer.
if turns and turns[-1][0] == "assistant":
prev = turns[-1]
turns[-1] = ("assistant", prev[1] + "\n" + text, model or prev[2])
else:
turns.append(("assistant", text, model))
for i in range(len(turns) - 1, 0, -1):
if turns[i][0] == "assistant" and turns[i - 1][0] == "human":
return turns[i - 1][1], turns[i][1], turns[i][2] or "unknown"
return None
def main() -> None:
api_key = os.environ.get("SIGILL_API_KEY")
if not api_key:
print("[sigill_seal] SIGILL_API_KEY not set — skipping", file=sys.stderr)
return
event = json.load(sys.stdin)
session_id = event.get("session_id", "unknown")
cwd = event.get("cwd", os.getcwd())
transcript_path = None
# Newer Claude Code hook payloads may include transcript_path directly.
if raw_transcript_path := event.get("transcript_path"):
candidate = Path(raw_transcript_path)
if candidate.exists():
transcript_path = candidate
if transcript_path is None:
transcript_path = find_transcript(session_id, cwd)
if transcript_path is None:
print(f"[sigill_seal] transcript not found for {session_id}", file=sys.stderr)
return
exchange = extract_last_exchange(transcript_path)
if exchange is None:
print("[sigill_seal] no complete exchange found", file=sys.stderr)
return
user_text, assistant_text, model = exchange
from sigill_sdk import SigillClient, EnvelopeBuilder
envelope = (
EnvelopeBuilder()
.with_purpose(category="coding-assistance", business_context="claude-code-session")
.with_actor(type="user", id=os.environ.get("SIGILL_ACTOR_ID", "local-user"))
.with_activity(name="session.turn", correlation_id=session_id)
.with_model(provider="anthropic", name=model)
.with_prompt_inline(user_text)
.with_output_inline(assistant_text)
.build()
)
with SigillClient(api_key=api_key) as client:
sealed = client.seal(envelope)
audit_dir = Path.home() / ".claude" / "sigill-audit" / session_id
audit_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
out_file = audit_dir / f"{timestamp}.json"
out_file.write_text(json.dumps(sealed, indent=2))
proof = sealed.get("proofs", [{}])[0]
tsa = proof.get("tsaName", "unknown TSA")
gen_time = proof.get("genTime", "?")
print(
f"[sigill] sealed → {out_file.name} tsa={tsa} genTime={gen_time}",
file=sys.stderr,
)
if __name__ == "__main__":
main()
The script is intentionally small. Most of it is transcript extraction. The evidence-specific part is the SDK builder and the client.seal() call.
Install the SDK
Install sigill-python in the Python environment used by the hook:
pip install sigill-sdk
If your Claude Code environment runs hooks with a different Python than your shell, use the full interpreter path in the hook command or install the package for that interpreter explicitly:
python3 -m pip install sigill-sdk
Configure the global hook
Add this to ~/.claude/settings.json:
{
"env": {
"SIGILL_API_KEY": "your-api-key-here",
"SIGILL_ACTOR_ID": "you@example.com"
},
"hooks": {
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "python3 /home/you/.claude/sigill_seal.py"
}
]
}
]
}
}
The empty matcher means the hook runs for every Stop event. Because this is in the user-level settings file, it applies to every Claude Code project.
If you prefer project-level sealing, put the same hook under .claude/settings.json in a repository instead. That lets you use different actor IDs, purpose fields, or API keys per project.
What gets written
After each completed Claude Code turn, the script writes one sealed envelope to:
~/.claude/sigill-audit/<session_id>/<timestamp>.json
A real envelope from this hook is more interesting than the path. It is the point of the integration. The hook does not merely save a transcript copy; it turns one Claude Code exchange into a structured evidence object with identity, purpose, model metadata, canonical integrity data, and an RFC 3161 proof.
A shortened, redacted example:
{
"schemaName": "AiEvidenceEnvelope",
"schemaVersion": "1",
"evidenceId": "a9542a7a-cba6-45eb-9224-c7a5d2e086a3",
"createdAt": "2026-05-10T14:29:37Z",
"purpose": {
"category": "coding-assistance",
"businessContext": "claude-code-session"
},
"actor": {
"type": "user",
"id": "you@example.com"
},
"activity": {
"name": "session.turn",
"correlationId": "87395cb5-e37d-44a5-afca-f85b683870a4"
},
"model": {
"provider": "anthropic",
"name": "claude-sonnet-4-6"
},
"prompt": {
"contentType": "text/plain",
"encoding": "utf-8",
"inline": "Can we integrate this project with Sigill using the Python SDK?"
},
"output": {
"contentType": "text/plain",
"encoding": "utf-8",
"inline": "Yes, we can integrate this nicely..."
},
"integrity": {
"canonicalization": "RFC8785",
"envelopeHash": {
"alg": "SHA-256",
"hex": "60ca149aac0ce2bdee69769c1a1984ed54518c6c9545929b54d76499571e2e63"
}
},
"proofs": [
{
"type": "rfc3161",
"tsaName": "Sectigo",
"genTime": "2026-05-10T14:29:40Z",
"serial": "1142174367600159794586492084058424721765025013845",
"qualified": false,
"tsrBase64": "..."
}
]
}
There are a few important details in that file.
| Field | Why it matters |
|---|---|
schemaName / schemaVersion | Makes the record explicit and versioned instead of an ad-hoc JSON blob |
evidenceId | Gives this single sealed exchange a stable identifier |
createdAt | Records when the envelope was created by the local integration |
purpose | Explains why this evidence exists: coding assistance in a Claude Code session |
actor | Identifies who or what produced the session evidence |
activity.correlationId | Binds all turn envelopes back to the same Claude Code session ID |
model | Captures the provider and model name recorded in the transcript |
prompt / output | Captures the actual human-facing exchange, either inline or by hash reference |
integrity.canonicalization | States that the envelope was canonicalized using RFC 8785 before hashing |
integrity.envelopeHash | The hash of the canonical envelope bytes; this is what later verification recomputes |
proofs[] | Contains the RFC 3161 timestamp token and metadata from the TSA |
This is where the SDK earns its keep. The hook supplies ordinary application data: prompt, output, model, session ID, actor, and purpose. The SDK turns that into canonical bytes, computes the envelope hash, asks Sigill to timestamp it, attaches the returned timestamp proof, and gives you back a verifier-friendly evidence object.
The distinction between createdAt and proofs[].genTime is useful. createdAt is when the envelope was built locally. genTime is the time asserted by the external Time-Stamping Authority. In the example above, the envelope was created at 2026-05-10T14:29:37Z and timestamped by Sectigo at 2026-05-10T14:29:40Z. That three-second gap is the sealing path: build envelope, canonicalize, hash, timestamp, store.
The tsrBase64 value is not decoration. It is the actual RFC 3161 timestamp response. Even if the local audit directory is copied elsewhere, the envelope carries its own proof. A verifier can recompute the canonical envelope hash and check that the timestamp token binds to the same bytes.
The Evidence Store does not store the whole envelope. That is intentional. In the Sigill UI, the corresponding record is shown by hash, TSA, signing time, certificate validity, and actions such as verify or download .tsr. Depending on tenant configuration, Sigill may also retain the TSR. The customer's prompt, response, Claude transcript, and full AI evidence envelope are not stored in Sigill's Evidence Store. They remain local in ~/.claude/sigill-audit/ or wherever the customer's own application chooses to keep them.
That distinction is important enough to be explicit: Sigill anchors and indexes timestamp evidence; it does not become a prompt store. Your application owns the envelope content, its retention, its access controls, and any deletion policy. For sensitive prompts and outputs, you can keep the full envelope local, use hash references instead of inline content, encrypt local artifacts, or delete cleartext later while preserving timestamp evidence for the hash.
One operational note: do not paste API keys into Claude Code prompts. The example above is redacted for that reason. If a real key ends up in a transcript, rotate it and prefer environment variables, secret stores, or project-local configuration that never enters the model context.
What this proves
This pattern gives you a tamper-evident record of each sealed Claude Code exchange.
| Question | Where it is captured |
|---|---|
| What did the developer ask? | prompt |
| What did Claude answer? | output |
| Which model was recorded? | model.name |
| Which Claude Code session was it part of? | activity.correlationId |
| When was the envelope sealed? | proofs[].genTime |
| Has the envelope changed since sealing? | SDK verification |
The narrow claim matters.
Sealing a Claude Code turn does not prove that the answer was correct. It does not prove the generated code was safe. It does not replace review, tests, SAST, dependency scanning, SBOMs, or signed releases.
It proves that this exact evidence envelope existed at a specific time, and that later modifications are detectable.
That is the same evidence model as the SDK post: application data becomes a canonical envelope, the envelope is hashed, the hash is timestamped, and verification recomputes the same canonical bytes later.
Verifying a sealed session envelope
Any sealed envelope can be verified with the SDK:
from pathlib import Path
from sigill_sdk import SigillClient
import json
sealed = json.loads(
Path("~/.claude/sigill-audit/<session_id>/<turn>.json")
.expanduser()
.read_text()
)
with SigillClient(api_key="...") as client:
result = client.verify(sealed)
assert result.is_valid
print("Stamped at:", result.timestamps[0]["gen_time"])
print("By:", result.timestamps[0]["tsa_name"])
If the envelope has been modified since sealing — prompt changed, output changed, proof corrupted, hash recomputed incorrectly — verification fails with structured issues.
This is the other half of the SDK value. seal() creates the evidence object; verify() gives consumers a consistent way to check it later.
Handling sensitive prompts and outputs
The script above stores prompts and responses inline in the local envelope file. It does not mean Sigill stores those prompts. Sigill's Evidence Store records timestamp evidence for the envelope hash, not the prompt text.
Still, Claude Code sessions can contain customer data, credentials accidentally pasted into context, proprietary code, incident details, or other material you may not want to keep inline even in a local audit artifact.
For those cases, use the SDK's hash-reference model instead of inline content:
envelope = (
EnvelopeBuilder()
.with_purpose(category="coding-assistance", business_context="claude-code-session")
.with_actor(type="user", id="you@example.com")
.with_activity(name="session.turn", correlation_id=session_id)
.with_model(provider="anthropic", name=model)
.with_prompt_ref("prompt")
.with_output_ref("output")
.build()
)
with SigillClient(api_key=api_key) as client:
sealed = client.seal(
envelope,
external_payloads={
"prompt": user_text.encode("utf-8"),
"output": assistant_text.encode("utf-8"),
},
)
The sealed envelope then contains hashes of the prompt and output, not the prompt and output themselves. You can store the original bytes locally, encrypt them, keep them in your own controlled system, or delete them later while preserving proof that the interaction happened.
This is the main reason the SDK exists as more than a thin API wrapper. It gives applications a standard way to bind external payloads into an evidence envelope without making Sigill responsible for storing customer prompts or responses.
Extending the example
This hook seals the last human-facing exchange because it is the cleanest SDK example. More complete variants can use the same envelope pattern with richer context:
| Extension | Why it matters |
|---|---|
| Include tool calls | Shows which commands Claude attempted to run |
| Include tool results | Captures file reads, command output, and execution context |
| Include repository metadata | Binds the session to branch, commit SHA, or working tree state |
| Use hash references for source snippets | Avoids storing proprietary code inline |
| Seal a session summary | Gives one higher-level envelope for the whole session |
| Chain envelopes by previous hash | Makes deletion or reordering of local turn files detectable |
The SDK does not care whether the payload is a prompt, a tool result, a source snippet, or a session manifest. If it can be represented in the envelope or bound as an external payload, it can be sealed and verified.
Where this fits
The earlier SDK post showed the generic pattern: build an evidence envelope, canonicalize it, seal it, verify it. This Claude Code hook is the same pattern applied to a real developer workflow.
It is deliberately small because that is the point. Once the SDK owns the cryptographic mechanics, integrations become ordinary application code:
- read a domain event
- map it into an envelope
- seal it
- store the result
- verify it later
Claude Code sessions are one example. The same shape applies to production agents, RAG answers, CI-generated summaries, support copilots, contract review workflows, model evaluations, and release attestations.
If an AI system produces an output that may matter later, it should be possible to answer two basic questions:
- What exactly was the output and its relevant input context?
- Can we prove this record has not changed since then?
The SDKs are the shortest path from those questions to a working implementation.
Install sigill-python, add the hook, run one Claude Code turn, and inspect the sealed envelope. That is the value proposition in one file.
API keys are available in Sigill under Settings → API Keys. The SDKs are open source under Apache 2.0: sigill-python and sigill-dotnet.