draftcat 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rene Zander
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,446 @@
1
+ <p align="center">
2
+ <img src="logo.png" alt="Draftcat" width="440">
3
+ </p>
4
+
5
+ <p align="center"><b>Governed AI pipelines where the LLM can't fire actions — one Go binary, self-hosted, human-in-the-loop.</b></p>
6
+
7
+ <p align="center">
8
+ <a href="https://github.com/renezander030/draftcat/stargazers"><img src="https://img.shields.io/github/stars/renezander030/draftcat?style=flat-square" alt="Stars"></a>
9
+ <a href="LICENSE"><img src="https://img.shields.io/github/license/renezander030/draftcat?style=flat-square" alt="License"></a>
10
+ <img src="https://img.shields.io/badge/Go-1.25-00ADD8?style=flat-square&logo=go" alt="Go 1.25">
11
+ <a href="docs/voice.md"><img src="https://img.shields.io/badge/voice%20AI-EU%20residency%20%C2%B7%20Dograh-00D4AA?style=flat-square" alt="Voice AI plugin"></a>
12
+ <a href="https://render.com/deploy?repo=https://github.com/renezander030/draftcat"><img src="https://img.shields.io/badge/Deploy-Render-46E3B7?style=flat-square&logo=render&logoColor=white" alt="Deploy to Render"></a>
13
+ </p>
14
+
15
+ > **AI suggests. Deterministic code decides. The operator signs off.**
16
+
17
+ 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.
18
+
19
+ ## Let a server count votes it cannot read
20
+
21
+ A normal approval server sees how every person voted. Draftcat's experimental **FHE encrypted tally** lets three or more reviewers turn `approve` or `reject` into unreadable ciphertext on their own machines. A collector combines those files without opening them; only the key owner can reveal the final count and learn whether quorum was met.
22
+
23
+ ```text
24
+ reviewers encrypt votes → collector adds unreadable ballots → key owner opens one total
25
+ collector never sees yes or no
26
+ ```
27
+
28
+ ```bash
29
+ # Once per vote: create the private key and the public key reviewers receive.
30
+ ./draftcat fhe-vote keygen
31
+
32
+ # Each reviewer encrypts locally. The readable vote is never sent.
33
+ ./draftcat fhe-vote encrypt --public fhe-public.json --context invoice-4821 \
34
+ --ballot <unique-random-invite> --vote approve --out reviewer.vote.json
35
+
36
+ # The collector combines 3+ encrypted ballots; the owner alone opens the result.
37
+ ./draftcat fhe-vote tally --public fhe-public.json --context invoice-4821 \
38
+ --out tally.json alice.vote.json bob.vote.json carol.vote.json
39
+ ./draftcat fhe-vote decrypt --secret fhe-secret.json --context invoice-4821 \
40
+ --expected 3 --quorum 2 tally.json
41
+ ```
42
+
43
+ **Use it when** separate teams, companies, or committee members need a shared approval but the tally host must not know individual votes. Keep the collector separate from the key owner and give the key owner only the final tally. **Skip it when** the same trusted Draftcat owner may see the votes, fewer than three people vote, or you need a public audit receipt—the zero-knowledge feature below is for that. Ciphertext files are still sent; the plaintext votes are not. Read the [encrypted vote walkthrough and threat model](docs/fhe-vote-tally.md) before evaluating it.
44
+
45
+ ## Prove approval without sharing the customer data
46
+
47
+ Sometimes a customer, auditor, or partner needs evidence that a human approved an AI action — but should **not** receive the message, the reviewer's identity, or your internal workflow. Draftcat can turn a signed approval row into a zero-knowledge proof:
48
+
49
+ | The verifier learns | What stays private |
50
+ | --- | --- |
51
+ | A direct human approval was recorded | Customer message and payload hash |
52
+ | The required reviewer quorum was met | Reviewer identity and exact vote counts |
53
+ | The proof came from the Draftcat instance key they pinned | Pipeline, step, time, nonce, and instance secret |
54
+
55
+ ```bash
56
+ # Operator: publish this commitment once through a trusted channel.
57
+ ./draftcat zk-receipt key-id
58
+
59
+ # Operator: create a shareable proof for the latest human approval.
60
+ ./draftcat zk-receipt prove --out approval.proof.json invoice-due-diligence
61
+
62
+ # Customer or auditor: verify it without DRAFTCAT_APPROVAL_SECRET or database access.
63
+ ./draftcat zk-receipt verify --expect-key <pinned-key-commitment> approval.proof.json
64
+ ```
65
+
66
+ This is an **experimental cryptographic preview**, not a production compliance claim. It uses an embedded BN254/Groth16 circuit and a development single-party setup; the circuit has not received an independent audit. Use it to evaluate the disclosure model, then replace the setup through a ceremony before relying on it in production. See [zero-knowledge approval proofs](docs/zk-approval-proofs.md) for the trust model, exact statement, and limitations.
67
+
68
+ > **New in v0.8.0:** webhook retries can carry a durable `Idempotency-Key`, signed replay identities are claimed atomically, and tool permits recheck their policy before execution. Requests reject oversized or ambiguous data and retain exact numbers. Older state stores upgrade safely; completed and failed runs carry exact approval identities. Audit commands read without modifying the database, and `draftcat receipts verify` checks exported JSONL offline. See the [upgrade and reliability guide](docs/reliability.md).
69
+ >
70
+ > **New in v0.7.0:** execution decisions now carry their proof. Every tool-gate route is authenticated, each request has a stable action identity and exact policy binding, and an allowed decision becomes an atomic consume-once permit before the side effect runs. Webhook acceptance is durable before HTTP 202 and can be polled after handoff. Versioned receipts bind action, payload, policy, and expiry, with `draftcat receipts list|show|export` for verification-ready JSONL. Ordered `model_policy` rules can deny or send matching model input/output to a human, while `/healthz` and `/readyz` give orchestrators a safe listener contract.
71
+ >
72
+ > **In v0.6.0:** the gate holds under load. The [tool-call gate](docs/tool-gate.md) answers asynchronously (`mode: async`, `wait:`) so a harness with a short HTTP timeout never loses a decision, and a tool call waiting on a human is durable across a restart. Rules constrain arguments (`args:` - glob, regex, `one_of`, `min`/`max`) and never widen on a mismatch. A repeat guard stops an agent that loops on one call from paging you, the operator hears about denials the gate made on its own, `/pending` and `draftcat pending` list every open gate, `/status` shows spend against caps, cost caps enforce the provider's real charge, rate limits back off instead of failing the run - and one Telegram update pump fixes taps that were silently lost while two gates were open at once.
73
+
74
+ ![Demo](demo.gif)
75
+
76
+ ## Why Draftcat
77
+
78
+ | | **Draftcat** | **n8n** | **LangChain agents** | **Agent harnesses** (Flue, Claude Code) |
79
+ | ---------------------------- | ---------------------------------------------- | ------------------------------------- | -------------------------------- | --------------------------------------- |
80
+ | **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 |
81
+ | **Human-in-the-loop** | Required on every outbound step | Optional manual nodes | Optional; not the default | Optional (dispatch a message mid-run) |
82
+ | **Token budgets** | Per-step / pipeline / day, enforced | None | None | App-managed, not built in |
83
+ | **Prompt-injection defense** | Input sanitization + output schema validation | None | None | Sandbox isolation; app-managed |
84
+ | **State & dedup** | SQLite-backed; items processed at most once | DB-backed | In-memory | Session store / Durable Objects |
85
+ | **Runtime** | Single Go binary | Node.js + Postgres | Python + dependency tree | TypeScript, runtime-agnostic |
86
+
87
+ Use n8n for drag-drop integrations across 400+ services. Use LangChain for research and open-ended exploration. Use an agent harness like [Flue](https://github.com/withastro/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.
88
+
89
+ ## How draftcat fits
90
+
91
+ 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:
92
+
93
+ <p align="center">
94
+ <img src="assets/fit-usecase-a.png" alt="Use case A: you only talk to your agent — it hands off to draftcat, which holds the boundary" width="860">
95
+ </p>
96
+
97
+ **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.
98
+
99
+ <p align="center">
100
+ <img src="assets/fit-usecase-b.png" alt="Use case B: you control the harness — it routes every outbound action through draftcat" width="860">
101
+ </p>
102
+
103
+ **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.
104
+
105
+ ## Governance
106
+
107
+ - **Token budgets** — per-step / pipeline / day; any breach halts the run immediately.
108
+ - **Cost budgets** — `per_day_cost` / `per_pipeline_cost` cap spend in money. On OpenRouter the caps are enforced on the charge the provider reports for each call (cached and reasoning tokens included); elsewhere on your configured per-1k rates. The approval prompt shows what the run has spent, and `/status` shows the day against every cap.
109
+ - **Human-in-the-loop** — every outbound action requires an explicit operator decision, made live or declared in advance.
110
+ - **Any operator channel** — the [`hitl/v0` protocol](docs/hitl-protocol.md) 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 with `draftcat hitl verify <relay-url>`.
111
+ - **Consume-once tool permits** - `POST /gate/tool-call` puts an agent's MCP or SDK call through the same gate as a pipeline step. Bearer authentication covers ask, poll, and consume. A stable `action_id` makes retries idempotent; the binding covers the exact arguments, policy, and expiry; only the first successful `POST /gate/tool-call/<id>/consume` carries `permit: execute`. See [`docs/tool-gate.md`](docs/tool-gate.md).
112
+ - **Repeat guard** — inside `repeat_window` an identical tool call (same agent, tool, arguments) gets the gate's remembered answer instead of a new prompt: a denied call stays denied, an in-flight call joins the open prompt, and `max_repeats` stops a looping agent from paging you.
113
+ - **Denial notices** — a refusal the gate makes on its own (unlisted tool, argument outside a rule, repeat guard) is reported to the operator channel, one notice per agent, tool and reason per window, so nothing is refused silently.
114
+ - **Open gates** — `/pending` on the channel and `draftcat pending` on the host list every approval waiting on a human, pipeline steps and tool calls alike, with how long each has waited and how long it has left.
115
+ - **Risk tiers** — steps declare `risk: low | normal | high`, and `approval_policy` can pre-approve a declared class. High risk never qualifies, and each exemption is audited as `policy_approve` with the rule that fired.
116
+ - **Escalation** — `escalate_after` re-notifies before a gate times out; `escalate_to` widens who is told, never who may decide.
117
+ - **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.
118
+ - **Approver scoping** — `approvers:` on a step narrows who may decide it to a subset of `allowed_users`. Quorum says *how many*; this says *which ones*. It can only narrow, never widen.
119
+ - **Model I/O policy** - ordered `model_policy` regex rules check exact input before it reaches the provider and output before it leaves Draftcat. A match can deny or enter the existing human approval gate, and the decision is written as a versioned receipt.
120
+ - **Input sanitization** — operator input is scrubbed for prompt-injection patterns before the LLM.
121
+ - **Output validation** — AI output is checked against the skill's `output_schema` (field types, numeric `min`/`max`, `enum` membership) and rejected if it doesn't conform.
122
+ - **Checked action receipts** - v2 receipts bind immutable action ID, payload hash, policy digest, validity window, run, and decision. List, inspect, or stream JSONL from SQLite with `draftcat receipts`; see [`docs/action-receipts.md`](docs/action-receipts.md).
123
+ - **Durable webhook admission** - Draftcat writes a body-hash-only admission row before returning HTTP 202. The response includes `admission_id` and an authenticated poll URL; unfinished admissions become `interrupted` after restart.
124
+ - **Health contract** - `GET /healthz` reports process liveness and `GET /readyz` succeeds only while the SQLite decision store is available.
125
+ - **Private approval proofs** — share proof that a direct human approval met quorum without sharing the action, approver, or counts; see [`docs/zk-approval-proofs.md`](docs/zk-approval-proofs.md).
126
+ - **Encrypted approval tally** — combine three or more encrypted votes without letting the collector read any individual vote; see [`docs/fhe-vote-tally.md`](docs/fhe-vote-tally.md).
127
+ - **Rate limiting** — per-user, per-minute caps on operator interactions.
128
+ - **Channel security** — allowed-user lists + input-length limits enforced at startup; the engine refuses to start without them.
129
+ - **Config validated on boot** — the engine runs the same checks as `draftcat validate` at startup and refuses to start on errors, so problems surface at boot rather than mid-run. `DRAFTCAT_SKIP_VALIDATE=1` overrides.
130
+ - **Observability** — opt-in structured JSON spans, one per pipeline and step (duration, status, tokens, cost). Off by default; `observability.spans: true` or `DRAFTCAT_TRACE=1`.
131
+
132
+ ## Quickstart
133
+
134
+ Install the native binary through npm (Node.js 18 or newer):
135
+
136
+ ```bash
137
+ npm install -g draftcat
138
+ draftcat --help
139
+ ```
140
+
141
+ The installer downloads the matching Linux, macOS, or Windows binary and verifies it against the checksums attached to the GitHub release. No Go toolchain is required.
142
+
143
+ With a Go toolchain, install the CLI from the module:
144
+
145
+ ```bash
146
+ go install github.com/renezander030/draftcat@latest
147
+ ```
148
+
149
+ Or build from source:
150
+
151
+ ```bash
152
+ git clone https://github.com/renezander030/draftcat.git && cd draftcat
153
+ cp secrets.yaml.example secrets.yaml # operator IDs + API keys
154
+ go build -o draftcat . && ./draftcat
155
+ ```
156
+
157
+ **Or with Docker** (no Go toolchain needed):
158
+
159
+ ```bash
160
+ git clone https://github.com/renezander030/draftcat.git && cd draftcat
161
+ cp secrets.yaml.example secrets.yaml
162
+ docker compose up
163
+ ```
164
+
165
+ Pipelines 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.
166
+
167
+ ## Deploy — where it runs
168
+
169
+ 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.
170
+
171
+ ### No server? One click on Render
172
+
173
+ [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/renezander030/draftcat)
174
+
175
+ 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.)
176
+
177
+ ### Have a VPS with Docker? One line
178
+
179
+ ```bash
180
+ curl -fsSL https://raw.githubusercontent.com/renezander030/draftcat/master/install.sh | sh
181
+ ```
182
+
183
+ Pulls 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.
184
+
185
+ ### Run the container yourself
186
+
187
+ ```bash
188
+ docker run -d --restart unless-stopped \
189
+ -v draftcat-state:/data -e DRAFTCAT_STATE_PATH=/data/state.db \
190
+ -e DRAFTCAT_TG_TOKEN -e OPENROUTER_API_KEY \
191
+ -e DRAFTCAT_TG_ALLOWED_USERS=<your-telegram-id> \
192
+ ghcr.io/renezander030/draftcat
193
+ ```
194
+
195
+ Or `docker compose up` from a clone — builds the same image and mounts your local `config.yaml`/`skills/` so you can edit pipelines.
196
+
197
+ ### Receiving inbound from an agent or harness
198
+
199
+ 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:
200
+
201
+ ![Deploy: an agent or harness POSTs over HTTPS to draftcat behind Caddy/nginx](assets/deploy.png)
202
+
203
+ ```yaml
204
+ webhook:
205
+ enabled: true
206
+ addr: 0.0.0.0:8088
207
+ secret_env: DRAFTCAT_WEBHOOK_SECRET
208
+ ```
209
+
210
+ ```bash
211
+ curl -X POST https://draftcat.yourco.eu/hooks/<pipeline> \
212
+ -H "Authorization: Bearer $DRAFTCAT_WEBHOOK_SECRET" \
213
+ -d '{ "lead": "..." }'
214
+ ```
215
+
216
+ The POST only **starts** a gated pipeline — the approval step still runs, so inbound can never make the LLM fire a customer-facing action.
217
+ Draftcat writes the admission to SQLite before returning `202`:
218
+
219
+ ```json
220
+ {"admission_id":"wh_...","status":"accepted","poll":"/hooks/status/wh_..."}
221
+ ```
222
+
223
+ Poll that path with the same bearer token. `GET /healthz` is a liveness check;
224
+ `GET /readyz` verifies that the decision store is reachable.
225
+
226
+ ## How it works
227
+
228
+ 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.
229
+
230
+ | Step type | What it does |
231
+ | --------------- | -------------------------------------------------------------------- |
232
+ | `deterministic` | Plain Go — fetch emails, parse PDFs, dedup, route, notify |
233
+ | `ai` | LLM inference with a skill template, budget-checked, schema-validated |
234
+ | `approval` | Operator reviews via Telegram: approve / edit / reject |
235
+
236
+ ```yaml
237
+ pipelines:
238
+ - name: invoice-due-diligence
239
+ schedule: 1h
240
+ steps:
241
+ - {name: parse-pdf, type: deterministic, action: pdf_extract, vars: {path: /inbox/invoice.pdf}}
242
+ - {name: extract, type: ai, skill: extract-line-items}
243
+ - {name: verify, type: deterministic, action: pdf_verify_cite, vars: {fail_on_unresolved: "true"}}
244
+ - {name: review, type: approval, mode: hitl, channel: telegram}
245
+ ```
246
+
247
+ ## Built-in actions
248
+
249
+ | Action | What it does |
250
+ | ---------------------------- | ------------------------------------------------------------------------ |
251
+ | `gmail_unread` | Fetch unread Gmail messages (deduped per pipeline) |
252
+ | `whatsapp_intake` | Normalize inbound WhatsApp JSON into governed pipeline input |
253
+ | `ghl_new_contacts` | Fetch recent GoHighLevel contacts (deduped) |
254
+ | `ghl_stale_opportunities` | Fetch stalled GHL opportunities |
255
+ | `ghl_unread_conversations` | Fetch unread GHL conversations |
256
+ | `pdf_extract` | Parse a PDF into text + per-fragment bounding boxes (pure-Go) |
257
+ | `pdf_verify_cite` | Resolve `<cite>` tags in AI output against the parsed PDF |
258
+ | `notify` | Send AI output to the operator channel |
259
+ | `voice_*` / `dograh_*` | Voice plugin actions (`-tags voice`) |
260
+
261
+ 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.
262
+
263
+ For WhatsApp, run a small whatsmeow receiver as the session owner and POST its
264
+ normalized message JSON into a `schedule: webhook` pipeline that starts with
265
+ `whatsapp_intake`; see [`docs/whatsapp.md`](docs/whatsapp.md).
266
+
267
+ ## Configuration
268
+
269
+ ```yaml
270
+ provider:
271
+ type: openrouter
272
+ api_key_env: OPENROUTER_API_KEY
273
+
274
+ models:
275
+ haiku: {model: anthropic/claude-haiku-4-5, max_tokens: 1024}
276
+
277
+ budgets:
278
+ per_step_tokens: 2048
279
+ per_pipeline_tokens: 10000
280
+ per_day_tokens: 100000
281
+ per_day_cost: 5.00 # money cap, same unit as your model rates (0 = off)
282
+ per_pipeline_cost: 0.50
283
+
284
+ observability: {spans: false} # or DRAFTCAT_TRACE=1
285
+ state: {path: ./state.db}
286
+ ```
287
+
288
+ An approval step can narrow who may decide it:
289
+
290
+ ```yaml
291
+ - name: release-payment
292
+ type: approval
293
+ channel: telegram
294
+ quorum: 2 # how many must approve
295
+ approvers: [111111, 222222] # which ones (subset of allowed_users)
296
+ ```
297
+
298
+ 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. A transient provider failure (429, 408, 5xx) is retried with backoff — honouring `Retry-After` — before it fails a step; `provider.max_retries` sets the budget.
299
+
300
+ The tool-call gate is configured the same way, per tool:
301
+
302
+ ```yaml
303
+ tool_gate:
304
+ enabled: true # served on the webhook listener
305
+ repeat_window: 10m # identical call → same answer, no second prompt
306
+ tools:
307
+ - name: read_calendar # listed = allowed, audited
308
+ - name: send_email
309
+ risk: high
310
+ require_approval: true
311
+ args:
312
+ to: {glob: "*@example.com"} # inside the rule: ask as usual
313
+ on_mismatch: deny # outside it: refuse without asking
314
+ ```
315
+
316
+ Every gate request uses the webhook bearer token. Send a stable `action_id`,
317
+ then consume an allowed binding exactly once before running the side effect:
318
+
319
+ ```bash
320
+ curl -X POST http://127.0.0.1:8088/gate/tool-call \
321
+ -H "Authorization: Bearer $DRAFTCAT_WEBHOOK_SECRET" \
322
+ -H 'Content-Type: application/json' \
323
+ -d '{"action_id":"send-invoice-4821","tool":"send_email","args":{"to":"billing@example.com"}}'
324
+
325
+ curl -X POST http://127.0.0.1:8088/gate/tool-call/send-invoice-4821/consume \
326
+ -H "Authorization: Bearer $DRAFTCAT_WEBHOOK_SECRET" \
327
+ -H 'Content-Type: application/json' \
328
+ -d '{"binding_hash":"sha256:..."}'
329
+ ```
330
+
331
+ Model input and output policy is ordered and deterministic. `deny` fails the
332
+ LLM call closed; `review` pauses at the configured operator channel:
333
+
334
+ ```yaml
335
+ model_policy:
336
+ max_preview_chars: 800
337
+ rules:
338
+ - id: credentials-in-input
339
+ phase: input
340
+ pattern: '(?i)(api[_ -]?key|password)'
341
+ action: review
342
+ reason: Credentials require an explicit operator decision.
343
+ - id: unsupported-claim
344
+ phase: output
345
+ roles: [drafter]
346
+ pattern: '(?i)guaranteed results'
347
+ action: deny
348
+ reason: Do not send unsupported guarantees.
349
+ ```
350
+
351
+ 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](docs/voice.md).
352
+
353
+ ## Commands
354
+
355
+ ```bash
356
+ draftcat # run the engine (validates config first; refuses to start on errors)
357
+ draftcat validate [--strict] # lint config + skills
358
+ draftcat test <pipeline> # dry-run against fixtures/<pipeline>/ (never touches real APIs)
359
+ draftcat runs [pipeline] # recent runs + the approval decisions in each (--json to archive)
360
+ draftcat pending # approval gates waiting on a human right now (--json)
361
+ draftcat receipts list # approval receipts and verification status (--json)
362
+ draftcat receipts show <id> # one versioned receipt
363
+ draftcat receipts export # JSONL to stdout (--out path writes mode 0600)
364
+ draftcat audit-verify # verify signed approval receipts
365
+ draftcat hitl verify <url> # run the hitl/v0 conformance suite against a relay
366
+ ```
367
+
368
+ `draftcat runs` reads the governance record back out of SQLite: what ran, when, and who decided what.
369
+
370
+ ```
371
+ 2026-07-26T05:37:31Z invoices ok 60.0s
372
+ release-payment adjust by 111 (0/2)
373
+ release-payment approve by 222 (2/2) [signed]
374
+ ```
375
+
376
+ Per-step timings and token counts live in the observability spans (`observability.spans`, OTLP/Prometheus). This is the durable record of decisions.
377
+
378
+ Pre-commit hooks (lefthook) run `gofmt`, `go vet`, `go build`, `go test -short`, and `golangci-lint` on new code; pre-push runs `draftcat validate`.
379
+
380
+ ## State, dedup & triggers
381
+
382
+ 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.
383
+
384
+ Approval rows can be made tamper-evident with signed receipts, so a later audit
385
+ can verify which operator approved which payload hash. See
386
+ [`docs/action-receipts.md`](docs/action-receipts.md).
387
+
388
+ 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:
389
+
390
+ ```yaml
391
+ webhook: {enabled: true, addr: 127.0.0.1:8088, secret_env: DRAFTCAT_WEBHOOK_SECRET}
392
+ ```
393
+
394
+ ```bash
395
+ curl -X POST http://127.0.0.1:8088/hooks/invoice-due-diligence \
396
+ -H "Authorization: Bearer $DRAFTCAT_WEBHOOK_SECRET" -d '{"path": "/inbox/invoice.pdf"}'
397
+ ```
398
+
399
+ 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`. Before `202`, Draftcat stores an admission ID, pipeline, body hash, and status in SQLite. `GET /hooks/status/<admission_id>` returns the authenticated status without retaining the request body. A webhook only *starts* a pipeline - the approval gate still runs, so an inbound request can never make the LLM fire an outbound action.
400
+
401
+ **Signed requests.** Bind each trigger to its exact body and a timestamp with an HMAC receipt, on top of the bearer token:
402
+
403
+ ```yaml
404
+ webhook:
405
+ enabled: true
406
+ secret_env: DRAFTCAT_WEBHOOK_SECRET
407
+ require_signature: true
408
+ max_skew_seconds: 300 # default
409
+ ```
410
+
411
+ ```
412
+ X-Draftcat-Signature: t=<unix>,v1=<hex hmac-sha256(t + "." + body)>
413
+ ```
414
+
415
+ 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`.
416
+
417
+ ## Voice AI plugin
418
+
419
+ > **Hook up your Dograh to your draftcat instance!**
420
+
421
+ ![Dograh runs the call in realtime; draftcat is the governed back-office that harvests every outcome and holds it at your approval gate before anything writes back](assets/voice-flow.png)
422
+
423
+ 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](fixtures/voice-dach-screener/pipeline.yaml) in [docs/voice.md](docs/voice.md).
424
+
425
+ ## Patterns explained
426
+
427
+ The deterministic-boundary architecture is documented in the **Production AI Automation Notes** gist series, each mapping to draftcat code:
428
+
429
+ - [#1 Agent Approval Gates](https://gist.github.com/renezander030/9069db775e494ffd2cdd5a09adf83add) — proposed actions, schema validation, audit log
430
+ - [#2 Token Budgets](https://gist.github.com/renezander030/a7d99ad94b97f7943a9a04016d62faaa) — per-step / pipeline / day enforcement
431
+ - [#5 SQLite Dedup + Crash Safety](https://gist.github.com/renezander030/8a23e32cde0c882a5aa069c4bfdf697f) — WAL mode, `seen_items`, run audit
432
+ - [#6 Prompt-Injection Defense](https://gist.github.com/renezander030/213ffdf1ab1bdb169881927bc7080270) — input sanitization + output schema validation
433
+ - [#7 PDF Cite Verification](https://gist.github.com/renezander030/7780cbc0b3ad4e802e8fba8bfc1c3a66) — auditable LLM extraction with per-fragment bounding boxes
434
+ - [#11 Pipeline Fixture Testing](https://gist.github.com/renezander030/a058fc0d5e7e7fa209d30cfa48e82ebb) — dry-run pipelines from JSON fixtures; zero API calls in CI
435
+ - [#12 LLM Skills as YAML](https://gist.github.com/renezander030/a28f118dec07d275ccc825aa833aba92) — prompt + output_schema + role in versioned YAML, validated by a linter
436
+ - [#13 Inbound Agent Webhook Auth](https://gist.github.com/renezander030/26d46d4c7fb9ab1b43fe19bc5bad6d07) — constant-time bearer token, fail-closed on empty secret, async 202 dispatch
437
+ - [#14 Self-Improving Voice Agent](https://gist.github.com/renezander030/262d8b8c44b4cddf51b3b84c40f3f669) — harvest Learning-Items, group, propose a minimal workflow diff, two approval gates, git commit + auto-versioned Dograh publish
438
+ - [#15 AI Action Audit Trail](https://gist.github.com/renezander030/ad81c7a805a09a844983f881e2c487e5) — append-only `action_approvals` table + queries: who approved which payload, when; find gated actions that ran with no approval (GDPR Art. 22)
439
+
440
+ ## Related projects
441
+
442
+ - [capcut-cli](https://github.com/renezander030/capcut-cli) — edit CapCut / JianYing video drafts from the CLI. Same DNA: single binary, no API, structured JSON boundary between agent and tool.
443
+
444
+ ## License
445
+
446
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+
3
+ "use strict";
4
+
5
+ const { spawnSync } = require("node:child_process");
6
+ const fs = require("node:fs");
7
+ const path = require("node:path");
8
+
9
+ const executable = process.platform === "win32" ? "draftcat.exe" : "draftcat";
10
+ const binary = path.join(__dirname, "bin", executable);
11
+
12
+ if (!fs.existsSync(binary)) {
13
+ console.error("Draftcat is not installed. Reinstall the package to download its binary.");
14
+ process.exit(1);
15
+ }
16
+
17
+ const result = spawnSync(binary, process.argv.slice(2), { stdio: "inherit" });
18
+ if (result.error) {
19
+ console.error(`Unable to start Draftcat: ${result.error.message}`);
20
+ process.exit(1);
21
+ }
22
+ if (result.signal) {
23
+ process.kill(process.pid, result.signal);
24
+ } else {
25
+ process.exit(result.status === null ? 1 : result.status);
26
+ }
package/npm/install.js ADDED
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+
3
+ "use strict";
4
+
5
+ const crypto = require("node:crypto");
6
+ const fs = require("node:fs");
7
+ const https = require("node:https");
8
+ const path = require("node:path");
9
+ const zlib = require("node:zlib");
10
+ const { version } = require("../package.json");
11
+
12
+ const repository = "renezander030/draftcat";
13
+ const maximumDownloadBytes = 128 * 1024 * 1024;
14
+ const supported = new Set([
15
+ "darwin-arm64",
16
+ "darwin-x64",
17
+ "linux-arm64",
18
+ "linux-x64",
19
+ "win32-arm64",
20
+ "win32-x64",
21
+ ]);
22
+
23
+ function artifactFor(platform, arch, releaseVersion = version) {
24
+ const target = `${platform}-${arch}`;
25
+ if (!supported.has(target)) {
26
+ throw new Error(`Unsupported platform: ${target}`);
27
+ }
28
+ const extension = platform === "win32" ? ".exe" : "";
29
+ return `draftcat-v${releaseVersion}-${target}${extension}.gz`;
30
+ }
31
+
32
+ function parseChecksums(contents) {
33
+ const checksums = new Map();
34
+ for (const line of contents.trim().split(/\r?\n/)) {
35
+ const match = /^([a-f0-9]{64})\s+\*?(.+)$/.exec(line.trim());
36
+ if (match) {
37
+ checksums.set(match[2], match[1]);
38
+ }
39
+ }
40
+ return checksums;
41
+ }
42
+
43
+ function download(url, redirects = 0) {
44
+ if (redirects > 5) {
45
+ return Promise.reject(new Error("Too many redirects while downloading Draftcat"));
46
+ }
47
+ return new Promise((resolve, reject) => {
48
+ const request = https.get(url, {
49
+ headers: { "User-Agent": `draftcat-npm/${version}` },
50
+ }, (response) => {
51
+ if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
52
+ response.resume();
53
+ const next = new URL(response.headers.location, url);
54
+ if (next.protocol !== "https:") {
55
+ reject(new Error("Refusing a non-HTTPS release redirect"));
56
+ return;
57
+ }
58
+ download(next, redirects + 1).then(resolve, reject);
59
+ return;
60
+ }
61
+ if (response.statusCode !== 200) {
62
+ response.resume();
63
+ reject(new Error(`Download failed with HTTP ${response.statusCode}`));
64
+ return;
65
+ }
66
+ const chunks = [];
67
+ let size = 0;
68
+ response.on("data", (chunk) => {
69
+ size += chunk.length;
70
+ if (size > maximumDownloadBytes) {
71
+ request.destroy(new Error("Draftcat release asset is unexpectedly large"));
72
+ return;
73
+ }
74
+ chunks.push(chunk);
75
+ });
76
+ response.on("end", () => resolve(Buffer.concat(chunks)));
77
+ });
78
+ request.on("error", reject);
79
+ request.setTimeout(30_000, () => request.destroy(new Error("Draftcat download timed out")));
80
+ });
81
+ }
82
+
83
+ async function install() {
84
+ const artifact = artifactFor(process.platform, process.arch);
85
+ const releaseBase = `https://github.com/${repository}/releases/download/v${version}`;
86
+ const [archive, checksumFile] = await Promise.all([
87
+ download(`${releaseBase}/${artifact}`),
88
+ download(`${releaseBase}/SHA256SUMS`),
89
+ ]);
90
+
91
+ const expected = parseChecksums(checksumFile.toString("utf8")).get(artifact);
92
+ if (!expected) {
93
+ throw new Error(`No checksum was published for ${artifact}`);
94
+ }
95
+ const actual = crypto.createHash("sha256").update(archive).digest("hex");
96
+ if (!crypto.timingSafeEqual(Buffer.from(actual), Buffer.from(expected))) {
97
+ throw new Error(`Checksum verification failed for ${artifact}`);
98
+ }
99
+
100
+ const executable = process.platform === "win32" ? "draftcat.exe" : "draftcat";
101
+ const binaryDirectory = path.join(__dirname, "bin");
102
+ const destination = path.join(binaryDirectory, executable);
103
+ fs.mkdirSync(binaryDirectory, { recursive: true });
104
+ fs.writeFileSync(destination, zlib.gunzipSync(archive), { mode: 0o755 });
105
+ if (process.platform !== "win32") {
106
+ fs.chmodSync(destination, 0o755);
107
+ }
108
+ console.log(`Installed Draftcat v${version} for ${process.platform}-${process.arch}`);
109
+ }
110
+
111
+ if (require.main === module) {
112
+ install().catch((error) => {
113
+ console.error(`Unable to install Draftcat: ${error.message}`);
114
+ process.exit(1);
115
+ });
116
+ }
117
+
118
+ module.exports = { artifactFor, parseChecksums };
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "draftcat",
3
+ "version": "0.8.0",
4
+ "description": "Governed AI pipelines with human approval gates",
5
+ "license": "MIT",
6
+ "author": "Rene Zander",
7
+ "homepage": "https://github.com/renezander030/draftcat#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/renezander030/draftcat.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/renezander030/draftcat/issues"
14
+ },
15
+ "keywords": [
16
+ "ai",
17
+ "approval",
18
+ "governance",
19
+ "human-in-the-loop",
20
+ "llm"
21
+ ],
22
+ "bin": {
23
+ "draftcat": "npm/draftcat.js"
24
+ },
25
+ "files": [
26
+ "npm/draftcat.js",
27
+ "npm/install.js",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "scripts": {
32
+ "postinstall": "node npm/install.js",
33
+ "test": "node --test npm/*.test.js"
34
+ },
35
+ "engines": {
36
+ "node": ">=18"
37
+ },
38
+ "os": [
39
+ "darwin",
40
+ "linux",
41
+ "win32"
42
+ ],
43
+ "cpu": [
44
+ "x64",
45
+ "arm64"
46
+ ],
47
+ "publishConfig": {
48
+ "access": "public"
49
+ }
50
+ }