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.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).In v0.3.1: multi-operator quorum approval (
quorum: N), tamper-evident signed approval receipts (draftcat audit-verify), and OTLP + Prometheus exporters.
| 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. Token caps say how much it thought; these answer what it costs. - Human-in-the-loop — every outbound action requires explicit operator approval.
- Durable approval gates — every gate is written to SQLite before the draft goes out, so an approval in flight survives a restart and its outcome always lands in the audit trail.
- 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 receiptsdraftcat 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.





