← All guides

Add DocketProof to a LangGraph agent

What you're adding

@guarded_tool wraps one Python function — typically a LangGraph tool node — with a local, declared policy check and a signed, append-only receipt chain. An allow verdict runs your function immediately and records a signed action receipt. A deny verdict never runs your function and records a signed denial receipt instead. A review_required verdict pauses the enclosing LangGraph run with LangGraph's own interrupt() until a human resumes it — see below.

Install

docketproof has not been published to PyPI yet, so for now install it from a local checkout of the package (the docketproof/ directory in its repository):

shell
$ pip install -e .

Define a policy

A policy is an exact tool-name → verdict mapping. There's no wildcard or expression syntax — every tool you guard needs its own named entry, and a tool with no entry evaluates to deny, not allow: silence in the policy is not permission. As a YAML file:

policy.yaml
delete_resource: review_required
read_resource: allow
wipe_everything: deny

Or built inline as a Python dict, with MandatePolicy.from_dict — shown in the next section's example.

Wrap the tool

Pass exactly one of policy (a MandatePolicy you built once) or policy_path (a file path, reloaded and re-validated on every call — useful if an operator needs to change the policy without restarting the process). chain_path is always required: it's where the signed receipt chain is written.

tools.py
from docketproof import MandatePolicy, guarded_tool

policy = MandatePolicy.from_dict({
    "delete_resource": "review_required",
    "read_resource": "allow",
    "wipe_everything": "deny",
})

@guarded_tool(policy=policy, chain_path="./mandate-chain.jsonl")
def delete_resource(resource_id: str) -> str:
    ...  # your existing tool logic, unchanged
    return f"deleted {resource_id}"

To reload the policy file on every call instead, and get notified when the gate itself breaks:

tools.py
@guarded_tool(
    policy_path="./policy.yaml",   # reloaded and re-validated on every call
    chain_path="./mandate-chain.jsonl",
    on_failure_webhook="https://alerts.example.com/gate-failures",
)
def delete_resource(resource_id: str) -> str:
    ...

What happens on review_required

The graph pauses via LangGraph's interrupt(), surfacing a dict with tool_name, args, and policy_name to whatever is driving the graph. Resume the run with Command(resume=...), using the two convenience helpers this package provides:

resume.py
from docketproof import Command, resume_approved, resume_denied

# Inside the code that's driving the graph (a CLI, a server handler, a
# human-in-the-loop console) once you've decided what to do with the
# paused call:
graph.invoke(Command(resume=resume_approved("looks right")), config)
# or:
graph.invoke(Command(resume=resume_denied("not today")), config)

Whichever way the human decides, the resulting receipt — action for an approval, denial for a rejection — gets the same signature and chain treatment. There's no separate, lesser record for a rejection: it's the artifact that proves the block actually happened.

Where the local docket file ends up

chain_path is a plain JSON Lines file — one signed record per line, append-only. If you don't pass keypair= yourself, a signing key is generated once and persisted next to it as <chain_path>.key (file mode 0600). Run docketproof doctor before your first real session — and again in CI or session-start scripts — to catch a broken key, an unwritable docket, or a malformed policy before it silently starts fail-closing every call:

shell
$ docketproof doctor --docket-path ./mandate-chain.jsonl --policy ./policy.yaml

No signing key yet? Generate one explicitly:

shell
$ docketproof init-key --docket-path ./mandate-chain.jsonl

Optional: point it at the hosted dashboard

Pass hosted_api_key and hosted_api_url together to mirror every signed receipt to a hosted dashboard after it has already been appended to the local chain. This is a best-effort, fire-and-forget mirror — local filing is always authoritative, and the wrapped call's return value, raised exception, and local chain content are identical whether the sync succeeds, fails, or isn't configured at all:

tools.py
import os

@guarded_tool(
    policy=policy,
    chain_path="./mandate-chain.jsonl",
    hosted_api_key=os.environ.get("DOCKETPROOF_API_KEY"),
    hosted_api_url=os.environ.get("DOCKETPROOF_API_URL"),
)
def delete_resource(resource_id: str) -> str:
    ...

There's no polling for a remote human decision here — review_required is always resolved locally via interrupt()/Command(resume=...), above. Hosted sync only ever mirrors an already-finalized receipt afterward.

Verify a chain independently

Export the chain your guarded tool wrote to, and check it with the separate, independently-implemented TypeScript verifier — not the same codebase that signed it:

export.py
chain = delete_resource.mandate_chain   # the LocalChain this tool writes to
bundle = chain.to_bundle()

import json
open("bundle.json", "w").write(json.dumps(bundle, indent=2))
shell
$ mandate-verify bundle.json

What an entry proves — and what it doesn't

✓ What it proves

  • This exact tool call, with this exact input, was evaluated at this exact time.
  • The named policy produced this named verdict — allow, deny, or review.
  • If allowed, this is the exact call that proceeded — nothing was altered after filing.
  • The entry has not been modified since it was signed. Any edit breaks verification.

✗ What it doesn't prove

  • That the policy itself was correct or well-designed.
  • That the agent's underlying intent was safe, legal, or sensible.
  • Anything about actions outside this specific docket.
  • That the person who approved it should have.

This is the same scope statement as the landing page's own honesty section — see docketproof.dev/#scope for the full context.

What this guide doesn't cover

This package has no expression language for policies (exact tool-name match only), no budgets or spend limits, no risk scoring of a call's arguments, and no support for agent frameworks other than LangGraph. None of that is planned as an extension of this specific decorator — see the package's own README for the full list of what it deliberately does not do.