← All posts

Sealing Claude Code sessions with the Sigill SDK

The Sigill SDKs are not just for production AI calls. This post shows how a small Claude Code Stop hook can use sigill-python to turn local AI coding sessions into sealed, verifiable evidence envelopes.

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:

  1. Claude Code finishes a turn.
  2. A Stop hook runs a Python script.
  3. The script reads the latest transcript exchange.
  4. sigill-python builds an AiEvidenceEnvelopeV1.
  5. The SDK seals the envelope with an RFC 3161 timestamp.
  6. 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:

QuestionUsual 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:

TypeMeaning
userHuman messages and tool results fed back to Claude
assistantClaude 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.

FieldWhy it matters
schemaName / schemaVersionMakes the record explicit and versioned instead of an ad-hoc JSON blob
evidenceIdGives this single sealed exchange a stable identifier
createdAtRecords when the envelope was created by the local integration
purposeExplains why this evidence exists: coding assistance in a Claude Code session
actorIdentifies who or what produced the session evidence
activity.correlationIdBinds all turn envelopes back to the same Claude Code session ID
modelCaptures the provider and model name recorded in the transcript
prompt / outputCaptures the actual human-facing exchange, either inline or by hash reference
integrity.canonicalizationStates that the envelope was canonicalized using RFC 8785 before hashing
integrity.envelopeHashThe 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.

QuestionWhere 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:

ExtensionWhy it matters
Include tool callsShows which commands Claude attempted to run
Include tool resultsCaptures file reads, command output, and execution context
Include repository metadataBinds the session to branch, commit SHA, or working tree state
Use hash references for source snippetsAvoids storing proprietary code inline
Seal a session summaryGives one higher-level envelope for the whole session
Chain envelopes by previous hashMakes 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:

  1. What exactly was the output and its relevant input context?
  2. 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.

More from the blog