Governed AI pipelines where the LLM can't fire actions — one Go binary, self-hosted, human-in-the-loop.
AI suggests. Deterministic code decides. The operator signs off.
Draftcat runs YAML-defined pipelines that triage email, qualify leads, draft replies, extract data from PDFs, and govern self-hosted voice AI. Every outbound action passes an operator approval gate, every LLM call is budget-checked, and every fetched item is deduped against a SQLite state store. One business per instance, self-hosted, auditable.
New in v0.5.0: approvals reach any operator surface via the
hitl/v0protocol — Microsoft Teams through a Power Automate flow in your own tenant, with no bot and no Azure app registration. Plus a tool-call gate for an agent's MCP/SDK calls (POST /gate/tool-call), risk tiers with pre-declaredapproval_policyexemptions, run-correlated audit rows, spend shown at the moment of decision, andescalate_afterreminders before a gate times out.In v0.4.0: approval gates survive a restart, spend caps in money (
per_day_cost), per-stepapprovers, body-signed webhooks (require_signature), config validated on the boot path with did-you-mean hints,draftcat runsto read the audit trail back, and WhatsApp intake (whatsapp_intake).
| Draftcat | n8n | LangChain agents | Agent harnesses (Flue, Claude Code) | |
|---|---|---|---|---|
| AI execution model | Deterministic boundary; AI cannot fire actions | Bolt-on LLM nodes in visual workflows | Agent decides next action freely | Agent acts autonomously in a sandbox |
| Human-in-the-loop | Required on every outbound step | Optional manual nodes | Optional; not the default | Optional (dispatch a message mid-run) |
| Token budgets | Per-step / pipeline / day, enforced | None | None | App-managed, not built in |
| Prompt-injection defense | Input sanitization + output schema validation | None | None | Sandbox isolation; app-managed |
| State & dedup | SQLite-backed; items processed at most once | DB-backed | In-memory | Session store / Durable Objects |
| Runtime | Single Go binary | Node.js + Postgres | Python + dependency tree | TypeScript, runtime-agnostic |
Use n8n for drag-drop integrations across 400+ services. Use LangChain for research and open-ended exploration. Use an agent harness like Flue when you want an agent to roam a sandbox and choose its own steps. Use Draftcat when a wrong LLM choice means a real customer gets emailed.
However your agent runs, draftcat sits between it and your customer systems as a mandatory approval gate — not a tool the model can route around. The same gate holds in both setups:
You only talk to your agent. You don't control its runtime, so it hands work to draftcat over a webhook — but it can only start a gated pipeline, never fire a customer-facing action itself.
You control the harness. Your runtime (n8n, your own agent loop, Dograh) does the roaming and integrations, then routes every outbound action through draftcat — the one gate it can't bypass — and gets the result plus an audit trail back.
- Token budgets — per-step / pipeline / day; any breach halts the run immediately.
- Cost budgets —
per_day_cost/per_pipeline_costcap spend in money, using the same unit as your model rates. The approval prompt shows what the run has spent, so the person releasing the action sees the number first. - Human-in-the-loop — every outbound action requires an explicit operator decision, made live or declared in advance.
- Any operator channel — the
hitl/v0protocol keeps draftcat as the gate and lets an untrusted relay own presentation. Teams runs through a Power Automate flow in your own tenant: no bot, no Azure app registration, no admin consent. Check yours withdraftcat hitl verify <relay-url>. - Tool-call gate —
POST /gate/tool-callputs an agent's MCP or SDK calls through the same gate as a pipeline step. Denies by default; the approval binds to a hash of the exact arguments. - Risk tiers — steps declare
risk: low | normal | high, andapproval_policycan pre-approve a declared class. High risk never qualifies, and each exemption is audited aspolicy_approvewith the rule that fired. - Escalation —
escalate_afterre-notifies before a gate times out;escalate_towidens who is told, never who may decide. - Durable, run-correlated gates — every gate is written to SQLite before the draft goes out, so an approval in flight survives a restart, and each decision records the run it released.
- Approver scoping —
approvers:on a step narrows who may decide it to a subset ofallowed_users. Quorum says how many; this says which ones. It can only narrow, never widen. - Input sanitization — operator input is scrubbed for prompt-injection patterns before the LLM.
- Output validation — AI output is checked against the skill's
output_schema(field types, numericmin/max,enummembership) and rejected if it doesn't conform. - Checked action receipts — approval decisions can be tied to a payload hash and verified later; see
docs/action-receipts.md. - Rate limiting — per-user, per-minute caps on operator interactions.
- Channel security — allowed-user lists + input-length limits enforced at startup; the engine refuses to start without them.
- Config validated on boot — the engine runs the same checks as
draftcat validateat startup and refuses to start on errors, so problems surface at boot rather than mid-run.DRAFTCAT_SKIP_VALIDATE=1overrides. - Observability — opt-in structured JSON spans, one per pipeline and step (duration, status, tokens, cost). Off by default;
observability.spans: trueorDRAFTCAT_TRACE=1.
git clone https://github.com/renezander030/draftcat.git && cd draftcat
cp secrets.yaml.example secrets.yaml # operator IDs + API keys
go build -o draftcat . && ./draftcatOr with Docker (no Go toolchain needed):
git clone https://github.com/renezander030/draftcat.git && cd draftcat
cp secrets.yaml.example secrets.yaml
docker compose upPipelines live in config.yaml, prompts in skills/. A SQLite store opens at ./state.db on first boot. To add the EU-resident voice AI plugin: go build -tags voice -o draftcat . — the lean binary is unchanged when the tag is off.
draftcat is a service you self-host, not a plugin an agent loads. It runs as a long-lived process and pings you on Telegram to approve each action. Pick the path that fits.
Click, sign in, and paste three values — your Telegram bot token, an OpenRouter key, and your Telegram user ID. Render runs it always-on with a persistent disk: no VPS, no shell, no TLS to configure. (It deploys as a background worker, so it has no public URL — ideal for the "watch my inbox, approve on Telegram" job. For inbound agent webhooks, use a host you control, below.)
curl -fsSL https://raw.githubusercontent.com/renezander030/draftcat/master/install.sh | shPulls the image, scaffolds ~/draftcat/.env + a compose file with a state volume, and prints the two steps left (fill the .env, then docker compose up -d). Config and skills are baked into the image.
docker run -d --restart unless-stopped \
-v draftcat-state:/data -e DRAFTCAT_STATE_PATH=/data/state.db \
-e DRAFTCAT_TG_TOKEN -e OPENROUTER_API_KEY \
-e DRAFTCAT_TG_ALLOWED_USERS=<your-telegram-id> \
ghcr.io/renezander030/draftcatOr docker compose up from a clone — builds the same image and mounts your local config.yaml/skills/ so you can edit pipelines.
The setups above run the always-on operator loop. To let an external agent or harness trigger pipelines, enable the webhook in config.yaml (schedule: webhook on the pipeline), publish the port, and put Caddy/nginx in front for TLS:
webhook:
enabled: true
addr: 0.0.0.0:8088
secret_env: DRAFTCAT_WEBHOOK_SECRETcurl -X POST https://draftcat.yourco.eu/hooks/<pipeline> \
-H "Authorization: Bearer $DRAFTCAT_WEBHOOK_SECRET" \
-d '{ "lead": "..." }'The POST only starts a gated pipeline — the approval step still runs, so inbound can never make the LLM fire a customer-facing action.
Each pipeline is a fixed sequence of typed steps. The LLM never chooses the next action — it produces structured output, the engine validates it against a schema, and an operator approves before anything reaches a customer.
| Step type | What it does |
|---|---|
deterministic |
Plain Go — fetch emails, parse PDFs, dedup, route, notify |
ai |
LLM inference with a skill template, budget-checked, schema-validated |
approval |
Operator reviews via Telegram: approve / edit / reject |
pipelines:
- name: invoice-due-diligence
schedule: 1h
steps:
- {name: parse-pdf, type: deterministic, action: pdf_extract, vars: {path: /inbox/invoice.pdf}}
- {name: extract, type: ai, skill: extract-line-items}
- {name: verify, type: deterministic, action: pdf_verify_cite, vars: {fail_on_unresolved: "true"}}
- {name: review, type: approval, mode: hitl, channel: telegram}| Action | What it does |
|---|---|
gmail_unread |
Fetch unread Gmail messages (deduped per pipeline) |
whatsapp_intake |
Normalize inbound WhatsApp JSON into governed pipeline input |
ghl_new_contacts |
Fetch recent GoHighLevel contacts (deduped) |
ghl_stale_opportunities |
Fetch stalled GHL opportunities |
ghl_unread_conversations |
Fetch unread GHL conversations |
pdf_extract |
Parse a PDF into text + per-fragment bounding boxes (pure-Go) |
pdf_verify_cite |
Resolve <cite> tags in AI output against the parsed PDF |
notify |
Send AI output to the operator channel |
voice_* / dograh_* |
Voice plugin actions (-tags voice) |
Add an action by appending a case to the deterministic switch in main.go and registering its name in internal/validate/. See internal/ghl/ and internal/dograh/ for connector patterns.
For WhatsApp, run a small whatsmeow receiver as the session owner and POST its
normalized message JSON into a schedule: webhook pipeline that starts with
whatsapp_intake; see docs/whatsapp.md.
provider:
type: openrouter
api_key_env: OPENROUTER_API_KEY
models:
haiku: {model: anthropic/claude-haiku-4-5, max_tokens: 1024}
budgets:
per_step_tokens: 2048
per_pipeline_tokens: 10000
per_day_tokens: 100000
per_day_cost: 5.00 # money cap, same unit as your model rates (0 = off)
per_pipeline_cost: 0.50
observability: {spans: false} # or DRAFTCAT_TRACE=1
state: {path: ./state.db}An approval step can narrow who may decide it:
- name: release-payment
type: approval
channel: telegram
quorum: 2 # how many must approve
approvers: [111111, 222222] # which ones (subset of allowed_users)Cost caps are checked between calls: a call is refused once spend has reached the cap. Pair them with per_step_tokens to bound the size of any single call.
Skills are YAML prompt templates in skills/ with an output_schema the engine enforces. With -tags voice, a voice: block configures the webhook receivers, Dograh endpoints, and pre-call lookup — see docs/voice.md.
draftcat # run the engine (validates config first; refuses to start on errors)
draftcat validate [--strict] # lint config + skills
draftcat test <pipeline> # dry-run against fixtures/<pipeline>/ (never touches real APIs)
draftcat runs [pipeline] # recent runs + the approval decisions in each (--json to archive)
draftcat audit-verify # verify signed approval receipts
draftcat hitl verify <url> # run the hitl/v0 conformance suite against a relaydraftcat runs reads the governance record back out of SQLite: what ran, when, and who decided what.
2026-07-26T05:37:31Z invoices ok 60.0s
release-payment adjust by 111 (0/2)
release-payment approve by 222 (2/2) [signed]
Per-step timings and token counts live in the observability spans (observability.spans, OTLP/Prometheus). This is the durable record of decisions.
Pre-commit hooks (lefthook) run gofmt, go vet, go build, go test -short, and golangci-lint on new code; pre-push runs draftcat validate.
State persists to SQLite (./state.db by default): fetched item IDs are deduped per (pipeline, scope) so items process at most once, every run is recorded (started_at / ended_at / status), and writes use WAL mode for crash safety without per-write fsync.
Approval rows can be made tamper-evident with signed receipts, so a later audit
can verify which operator approved which payload hash. See
docs/action-receipts.md.
A pipeline's schedule decides when it runs — an interval (1h), manual (operator /run only), or webhook. The webhook server is opt-in and opens no port unless enabled:
webhook: {enabled: true, addr: 127.0.0.1:8088, secret_env: DRAFTCAT_WEBHOOK_SECRET}curl -X POST http://127.0.0.1:8088/hooks/invoice-due-diligence \
-H "Authorization: Bearer $DRAFTCAT_WEBHOOK_SECRET" -d '{"path": "/inbox/invoice.pdf"}'The body reaches the pipeline as {{webhook_body}} / {{input}}; bearer auth is constant-time, and a second trigger while the pipeline is running gets 409. A webhook only starts a pipeline — the approval gate still runs, so an inbound request can never make the LLM fire an outbound action.
Signed requests. Bind each trigger to its exact body and a timestamp with an HMAC receipt, on top of the bearer token:
webhook:
enabled: true
secret_env: DRAFTCAT_WEBHOOK_SECRET
require_signature: true
max_skew_seconds: 300 # defaultX-Draftcat-Signature: t=<unix>,v1=<hex hmac-sha256(t + "." + body)>
Requests outside the skew window are refused, and each signature is spent once (recorded in the dedup table), so a captured request cannot be re-fired. A signature header is always verified when present, even with require_signature: false.
Hook up your Dograh to your draftcat instance!
Built with -tags voice, Draftcat becomes the EU-resident writeback + governance layer for self-hosted voice agents (Dograh, Pipecat, or any orchestrator that posts JSON webhooks): 5 lifecycle webhook receivers, sub-300ms pre-call context lookup, a 7-step Learning-Item review pipeline before any prompt/KB change ships, Dograh REST admin actions, and per-day call/minute budgets with bearer-auth webhooks. Full wiring recipe and runnable DACH fixtures in docs/voice.md.
The deterministic-boundary architecture is documented in the Production AI Automation Notes gist series, each mapping to draftcat code:
- #1 Agent Approval Gates — proposed actions, schema validation, audit log
- #2 Token Budgets — per-step / pipeline / day enforcement
- #5 SQLite Dedup + Crash Safety — WAL mode,
seen_items, run audit - #6 Prompt-Injection Defense — input sanitization + output schema validation
- #7 PDF Cite Verification — auditable LLM extraction with per-fragment bounding boxes
- #11 Pipeline Fixture Testing — dry-run pipelines from JSON fixtures; zero API calls in CI
- #12 LLM Skills as YAML — prompt + output_schema + role in versioned YAML, validated by a linter
- #13 Inbound Agent Webhook Auth — constant-time bearer token, fail-closed on empty secret, async 202 dispatch
- #14 Self-Improving Voice Agent — harvest Learning-Items, group, propose a minimal workflow diff, two approval gates, git commit + auto-versioned Dograh publish
- #15 AI Action Audit Trail — append-only
action_approvalstable + queries: who approved which payload, when; find gated actions that ran with no approval (GDPR Art. 22)
- capcut-cli — edit CapCut / JianYing video drafts from the CLI. Same DNA: single binary, no API, structured JSON boundary between agent and tool.
MIT. See LICENSE.





