DutyGate
Catch the legal obligations hiding in ordinary support messages before your chatbot replies.
"pls stop texting me" is an opt-out. "I want everything you have on me" is a privacy access request. "My lawyer will hear about this" is a legal threat. Topic triage files these under billing or angry customer, keyword lists miss the way people actually write, and an LLM bot that answers "Done, you're unsubscribed!" when nothing happened creates the liability itself.
DutyGate is a small, chatbot-agnostic gate placed in front of the reply step. It reads every inbound message, asks TypeSafe Jev a batch of yes/no questions in one call, applies versioned rules from a YAML policy pack, and tells your code what to do:
inbound message ──► DUTYGATE ──► continue ──► bot replies normally
├── route ──► holding reply + case in the right queue
└── review ──► bot may reply; a human takes a look
- Flags and routes, never acts. It does not unsubscribe, delete, refund or reply on its own. Your people and workflows do that.
- Recall-first. A miss can cost a statutory deadline; a false alarm costs a reviewer a few
seconds. Low-confidence signals go to
review, not tocontinue. - Fails safe. If TypeSafe is down, slow, or returns something malformed, you get a
reviewdecision with an error code, never an exception or a dropped message. - Auditable. Rules are versioned YAML, testable offline, with a published evaluation harness and a cross-language conformance suite.
Not legal advice. Which categories matter, their deadlines, and how they are routed depend on your jurisdiction and business. Have counsel review the packs and your holding replies before production use.
Quickstart
Offline, with no API key. The reference packs, their sample datasets and keyword lists ship
inside the package, so they can be used by name. The keyword backend here is the naive
baseline, which exists only for evaluation and demos.
pip install 'dutygate[server]'
dutygate validate legal-triggers outbound-claims
dutygate run legal-triggers --backend keyword --state "pls stop texting me"
dutygate eval legal-triggers --backend keyword
With TypeSafe Jev, the real backend:
export TYPESAFE_API_KEY=... # from typesafe.ai
export DUTYGATE_JEV_MODEL=jev-1.13.0 # pin the model in production
dutygate run legal-triggers --state "I want everything you have on me"
dutygate eval legal-triggers # scores Jev on the bundled sample dataset
Make it yours
Copy a reference pack, with its sample dataset and keyword list, into your project and edit it. No code is involved: questions, thresholds, queues and new categories are all YAML.
dutygate init legal-triggers # writes legal-triggers.yaml, .dataset.jsonl, .keywords.yaml
# edit legal-triggers.yaml (and give it your own `name`), add rows to the dataset
dutygate validate legal-triggers.yaml
dutygate eval legal-triggers.yaml legal-triggers.dataset.jsonl --save-answers answers.jsonl
dutygate eval legal-triggers.yaml legal-triggers.dataset.jsonl --replay answers.jsonl --sweep
Then point your bot at your file: Gate.from_pack("legal-triggers.yaml"). To measure it on
real traffic, add your own labeled messages to the dataset; see
docs/labeling.md. The format is in SPEC.md.
Use it from your bot
Python
from dutygate import Gate, default_holding_reply
gate = Gate.from_pack("packs/legal-triggers.yaml") # TypeSafe Jev, configured from env
decision = await gate.check_async(message, conversation_id=cid, channel="sms")
if decision.action == "route":
await create_case(decision.flags, message) # your ticketing / queue
reply = default_holding_reply(decision) # neutral; no bot improvisation
elif decision.action == "review":
await create_review_task(decision.flags, message)
if decision.error: # the gate could not judge it
await enqueue_rescan(message)
reply = await bot.reply(message)
else:
reply = await bot.reply(message)
GatedHandler (in dutygate.adapters.webhook) wraps exactly this logic for any web
framework, with_legal_gate does the same for LangChain runnables, and
gate_node / outbound_node add DutyGate to a LangGraph graph. See
docs/adapters.md.
Any language: the HTTP sidecar
DUTYGATE_SIDECAR_KEYS=change-me TYPESAFE_API_KEY=... \
dutygate serve packs/legal-triggers.yaml packs/outbound-claims.yaml --host 0.0.0.0
curl -H 'Authorization: Bearer change-me' -H 'content-type: application/json' \
-d '{"message": "pls stop texting me", "channel": "sms"}' localhost:8080/v1/gate
Or use the Docker image, the TypeScript client (dutygate-client), or
any no-code tool (n8n / Zapier recipe). See
docs/sidecar.md.
The decision
{
"id": "dec_4f1c2a9e8b7d4c3fa1e2b3c4d5e6f708",
"action": "route",
"primary": "privacy_request",
"flags": [
{"category": "privacy_request", "rule_id": "privacy-request", "queue": "privacy",
"priority": "high", "confidence": 0.93, "level": "confident"},
{"category": "opt_out", "rule_id": "opt-out", "queue": "compliance",
"priority": "high", "confidence": 0.31, "level": "grey"}
],
"error": null,
"policy": {"name": "legal-triggers", "version": "0.1.0"},
"backend": {"name": "typesafe-jev", "model": "jev-1.13.0"},
"conversation_id": "c_123",
"latency_ms": 212
}
action |
meaning | your bot should |
|---|---|---|
continue |
no trigger | reply normally |
route |
at least one confident trigger | create a case, send a neutral holding reply, and not improvise about the trigger |
review |
a grey-zone signal, or the gate could not judge (error is set) |
reply if you like, queue a human review, never auto-close; re-scan later if error is set |
primary is the first confident flag in the pack's rule order (or the first grey one).
Error codes and every field are specified in SPEC.md.
What it detects
| pack | categories |
|---|---|
legal-triggers (inbound) |
legal_threat, regulator_complaint, privacy_request, chargeback_dispute, opt_out, accessibility_request |
outbound-claims (the bot's own reply) |
admits_liability, claims_action_completed, legal_statement, promises_outcome |
One message can raise several flags ("erase my data and stop the emails"). Queues and priorities are examples: map them to your own systems. Packs are plain YAML, so you can add categories or tighten questions without touching code (SPEC.md).
Surfaces
| surface | status |
|---|---|
Python library (Gate, evaluate) |
✅ |
CLI: validate, run, eval, serve |
✅ |
| HTTP sidecar + Docker image | ✅ |
TypeScript client dutygate-client |
✅ passes the conformance suite end to end |
GatedHandler for any web framework (FastAPI, Flask examples) |
✅ |
LangChain with_legal_gate |
✅ |
| LangGraph gate and outbound nodes | ✅ |
Every surface must reproduce conformance/cases.json.
Evaluation
The repo ships synthetic labeled datasets, a deliberately naive keyword baseline, and a harness that scores any backend, saves raw answers, and sweeps thresholds offline.
On the bundled legal-triggers set, the keyword baseline catches 48% of triggers
(0% of non-English ones) and false-alarms on 13% of clean messages. The datasets were
written by the same author as the baseline, so they are a sanity check, not a benchmark:
measure on your own traffic before you rely on this. See evals/README.md.
If Jev does not beat a keyword list on your data, do not pay for it.
Status
| item | state |
|---|---|
| Packs, engine, validator, CLI, eval harness, sidecar, TS client, adapters | built and tested offline (Python coverage ≥ 90%) |
| Live TypeSafe Jev integration | built against the published API reference and tested with mocked HTTP; not yet verified against the live API (pytest -m live) |
| Question wording and thresholds | untuned; tune with dutygate eval --save-answers then --replay --sweep |
Docs
- SPEC.md: pack format, evaluation algorithm, decision contract (normative)
- docs/integration.md: host contract, holding replies, failure behavior, latency patterns
- docs/sidecar.md: HTTP API, auth, metrics, audit log, Docker
- docs/adapters.md:
GatedHandler, LangChain, LangGraph - docs/privacy.md: what leaves your system, redaction, prompt injection
- docs/labeling.md: building a real evaluation set
- CONTRIBUTING.md · SECURITY.md · CHANGELOG.md
License
MIT. See LICENSE.
Metadata
Release files for dutygate 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| dutygate-0.1.0.tar.gz | 89.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| dutygate-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 152.6 kB
Release files / dutygate-0.1.0.tar.gz
| Download URL | dutygate-0.1.0.tar.gz |
|---|---|
| Size | 89.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
082fcd96a584cc604efd492aef924daf4ba19f73d187548195fd6a10af8db635
|
|
BLAKE2b-256 checksum How to use checksums |
2d4c3c2f08f7b833e1069ae0ec8eb5d439f621aa0fb247ef2bd25e818fd9c404
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 28, 2026.
Transparency logRelease files / dutygate-0.1.0-py3-none-any.whl
| Download URL | dutygate-0.1.0-py3-none-any.whl |
|---|---|
| Size | 63.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f0f50729e0f2c769f7dfbafdcbb9c548fd2d3d68c7d8f4d58be0fe8a62ae7e34
|
|
BLAKE2b-256 checksum How to use checksums |
01d4c70036b059b8f6c059622239dfd0182bb7ca95d6868dd2a79a33c25791c4
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 28, 2026.
Transparency log