Production AI Automation Notes #1: Agent Approval Gates — JSON schema, human review, deterministic dispatch, audit logs
Production AI Automation Notes #1: Agent Approval Gates — JSON schema, human review, deterministic dispatch, audit logs
Production AI Automation Notes #1: Agent Approval Gates — JSON schema, human review, deterministic dispatch, audit logs
Production AI Automation Notes #1: Agent Approval Gates — JSON schema, human review, deterministic dispatch, audit logs
Production AI Automation Notes #1: Agent Approval Gates — JSON schema, human review, deterministic dispatch, audit logs
Production AI Automation Notes #1: Agent Approval Gates — JSON schema, human review, deterministic dispatch, audit logs
Updated 2026-04-28 — JSON schemas, human review, deterministic dispatch, and audit logs for AI agents that touch real systems.
The most common shape of an "AI agent" demo is: model decides → model calls API → side effect happens. That works in a sandbox. It does not work the moment the agent is allowed to send an email to a customer, update a CRM record, or trigger an n8n workflow that hits production.
The pattern that does work is older than agents and is borrowed from financial systems: draft → validate → approve → dispatch → audit. Five steps, five contracts, no exceptions.
This is part 1 of Production AI Automation Notes — a series on shipping AI agents outside demos. The reference implementation for this post is in agent-approval-gate (MIT, JSON Schema + n8n + email examples).
AI Agent
│
▼ ProposedAction (serialized)
Schema validation
│
▼ validated
Approval queue (human or policy)
│
▼ ApprovalRecord
Deterministic dispatcher
│
▼ side effect
Append-only audit log
The agent never reaches the side-effect surface. The dispatcher does. The two communicate only through validated documents.
A ProposedAction is a JSON document. It describes what the agent wants to do, in a shape the dispatcher can route on. The agent does not have credentials for the email API, the CRM API, or the n8n trigger URL. It cannot bypass the gate even if it tries.
{
"proposal_id": "prop_2026-04-28_b3f1a2c4",
"agent": { "name": "support-triage", "version": "1.4.2" },
"action_type": "email.send",
"payload": {
"to": ["customer@example.com"],
"subject": "Re: cannot export invoices",
"body_text": "Thanks for the report..."
},
"rationale": "Customer's export issue maps to the rate-limit incident from 2026-04-22.",
"tenant": "acme-corp",
"expires_at": "2026-04-28T17:14:23Z",
"risk": "low"
}Two reasons this matters:
- Schema enforcement is possible. A serialized contract can be validated. A function call cannot. Agents that call APIs directly drift their parameter shape across runs and you only notice when production breaks.
- Bypass prevention. If only the dispatcher has the API key, the approval step is the path. Not the recommended path — the only path.
Every action_type has a schema. Reject malformed drafts the moment they arrive at the queue, not later.
import Ajv from "ajv";
import addFormats from "ajv-formats";
import schema from "./proposed-action.schema.json";
const ajv = addFormats(new Ajv({ strict: true }));
const validate = ajv.compile(schema);
function acceptProposal(proposal) {
if (!validate(proposal)) {
throw new Error("Invalid ProposedAction: " + JSON.stringify(validate.errors));
}
return proposal;
}The dispatcher gets to assume well-formed input. That assumption is paid for once, at the boundary.
Approvals are not a flag on the proposal. They are a separate, append-only record:
{
"proposal_id": "prop_2026-04-28_b3f1a2c4",
"decision": "approved",
"decided_by": { "kind": "human", "identifier": "rene@acme-corp" },
"decided_at": "2026-04-28T09:18:51Z",
"channel": "telegram",
"comment": "Confirmed against postmortem-2026-04-22, send."
}This shape supports:
- multi-approver flows (each approver writes their own record),
- policy auto-approval (
decided_by.kind = "policy",identifier = "low-risk-internal-tenant"), - per-channel trust differences (signed Slack buttons vs. Telegram replies vs. authenticated web UI).
If you store the decision as proposal.approved = true, you have lost the audit case where someone approved, the proposal expired, and the dispatcher rejected it. Keep the events separate.
The dispatcher reads (ProposedAction, ApprovalRecord), re-validates both, and executes. It is intentionally boring code. No prompt, no model call, no chain-of-thought.
async function dispatch(proposal, approval) {
if (approval.proposal_id !== proposal.proposal_id) throw new Error("ID mismatch");
if (approval.decision !== "approved" && approval.decision !== "auto_approved") return;
if (proposal.expires_at && new Date(proposal.expires_at) < new Date()) return;
switch (proposal.action_type) {
case "email.send": return sendEmail(proposal.payload);
case "crm.update_record": return updateCrm(proposal.payload);
case "n8n.trigger_workflow": return triggerN8n(proposal.payload);
default: throw new Error("Unknown action_type");
}
}If you find yourself wanting the model to "decide how to dispatch," the action_type enum is too coarse. Split it into more specific types instead of asking the model to branch.
For every proposal that leaves the agent, the log grows by at least three append-only rows:
| event | refs |
|---|---|
proposed |
proposal_id, full payload, agent identity |
decided |
proposal_id, channel, decided_by, decision |
dispatched |
proposal_id, side-effect outcome (success/failure, external IDs) |
If the proposal is rejected or expires, the third row is not_dispatched with the reason. The log should be queryable by proposal_id so the full lifecycle is one fetch.
The phrase usually means the human reads what the model said and clicks OK. That click happens, but three failures slip through anyway:
- Schema drift. The model emits a slightly different shape next week. The approver doesn't notice. The dispatcher silently does the wrong thing.
- Dispatch coupling. The agent itself calls the API, so an approval step exists but the model can also bypass it.
- No audit. You cannot answer "why did this email go out" three weeks later.
The five contracts close all three.
| Channel | Trust | Latency | Best for |
|---|---|---|---|
| Web UI (authenticated) | High | Low | Frequent approvers |
| Slack DM (signed buttons) | High | Low | Existing Slack-first teams |
| Telegram bot | Medium | Very low | Solo operators, fast on mobile |
| Low | High | One-off / out-of-band only | |
| n8n form | Medium | Low | When the rest of the pipeline is in n8n |
Signed ApprovalRecord is mandatory for high-risk actions on Telegram and email — both can be spoofed. Authenticated web UIs and signed Slack buttons provide the signature implicitly.
- It does not prevent prompt injection. That is a different layer (input sanitization, system-prompt isolation). The gate stops a successful injection from causing real-world damage; it does not stop the injection from happening.
- It does not replace rate limits. A pipeline drafting 10k proposals/minute will overwhelm the approver. Cap drafts at the source.
- It does not handle compensation. If a dispatched action turns out wrong, the gate does not roll it back. Plan rollback per action_type.
- Read-only agents (search, summarize, analyze).
- Internal-only agents whose entire surface is a sandbox.
- Single-user prototypes where you are the agent's user and the side-effect target.
The moment the agent's output reaches a customer, an external API, or a multi-tenant database, you need the gate. There is no in-between.
agent-approval-gate ships:
schemas/proposed-action.schema.json— the agent's draft contract (JSON Schema 2020-12)schemas/approval-record.schema.json— the approval decision contractexamples/email-reply-approval.json— a realistic support-triage proposalexamples/n8n-approval-workflow.json— importable n8n workflow: webhook → schema validation → Telegram approver → audit logdocs/architecture.md— long-form rationale, channel matrix, threat model
MIT license. Drop the schemas into your stack — this is opinion + contracts, not a framework.
- Production AI Automation Notes #4: Driving CapCut / JianYing video drafts from an LLM agent — the same draft → validate → dispatch shape, where the dispatcher is a zero-dep Node CLI (
capcut-cli) and the side effect is a video draft on disk. The agent emits JSON command arrays; the shell executes;capcut lintaudits. - Claude Code persistent memory between sessions (PAAN #3) — the task app you already curate, exposed to Claude Code over MCP as agent memory: hybrid + RRF retrieval with provenance, TickTick + Obsidian adapters. Reference repo: agentic-task-system.
- agentproof-react — the same gate-first pattern applied to AI-generated React and Next.js code. Instead of approving side effects, it blocks common frontend shipping failures with deterministic checks.
- Claude Code with local LLMs —
ANTHROPIC_BASE_URLsetup with Ollama / LM Studio / vLLM, tool-call failure modes, current model picks (gpt-oss, qwen3-coder, glm-4.7). Where you'd actually deploy the approval gate. - Self-improving voice AI agent: human-approved prompt diffs (PAAN #14) — the approval-gate pattern applied to a voice agent's own prompts: harvest Learning-Items, group them, propose a minimal workflow diff, and gate it behind two human approvals before a git commit + auto-versioned Dograh publish. Reference repo: draftcat.
- AI agent action audit trail in SQLite (PAAN #15) — the append-only table the approval gates in #1/#14 should write to: who approved which exact payload, when, and a query that finds gated actions which executed with no approval on record. GDPR Art. 22 provenance. Reference repo: draftcat.
- CLAUDE.md — 10 rules for Claude Code, edit-time and runtime — runtime rules #7 (HITL) and #8 (schema validation) are the same discipline applied inside Claude Code.
- Context7 v2 — enterprise GraphQL MCP pattern — what changes when an MCP server can write, not just read. Approval envelopes show up there too.
- draftcat — Go pipeline engine that enforces these rules in production.
This is Production AI Automation Notes #1. The series covers approval gates, MCP server security, Claude Code policies for company repos, n8n workflows with human approval, and audit-log schemas. Follow @renezander030 for the next entry.
The Production AI Automation Notes series has grown to 16 entries:
- Agent Approval Gates — proposed actions, schema validation, audit log
- Token Budgets — per-step, per-pipeline, per-day enforcement
- Claude Code persistent memory between sessions (Agentic Task System) — your task app as the agent's memory layer; TickTick + Obsidian adapters, hybrid RRF retrieval
- Driving CapCut / JianYing from an LLM agent — deterministic JSON command boundary
- SQLite dedup + crash safety — WAL mode, seen_items, audit log
- Prompt-injection defense — input sanitization, schema validation, deterministic boundary
- PDF cite verification — auditable LLM extraction with per-fragment bounding boxes
- Stateless JSONL queue runner — wire a CLI into n8n / Make / Coze without an HTTP server
- LLM cost tracking — per-model price model + dollar spend on top of token budgets
- Deterministic step pipelines — fixed typed steps; the LLM never picks the next action
- Pipeline fixture testing — dry-run pipelines from JSON fixtures; zero API calls, deterministic CI
- LLM skills as YAML — prompt + output_schema + role in versioned YAML, validated by a linter
- Inbound agent webhook auth — constant-time bearer token, fail-closed on empty secret, async 202 dispatch
- Self-improving voice AI agent — human-approved prompt diffs, Dograh learning loop
- AI agent action audit trail in SQLite — who approved what and when, GDPR Art. 22 provenance
- Retrieval that degrades instead of failing — keyword fallback + RRF when the embedding provider dies; per-source status
Reference implementation for entries #1, #2, #5, #6, #7, #9, #10, #11, #12, #13: draftcat (Go, MIT).
Follow @renezander030 for new entries.
The action boundary is the right place for approval.
If you ask too early, people approve a vague future. If you ask too late, the damage is done. The useful gate sits right where the agent is about to run the command, send the data, or cross the trust boundary.
That is also where the audit trail gets teeth, because the record matches a real decision instead of a rough intention.