Guide
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):
$ 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:
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.
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:
@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:
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:
$ docketproof doctor --docket-path ./mandate-chain.jsonl --policy ./policy.yaml
No signing key yet? Generate one explicitly:
$ 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:
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:
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))$ 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.