Build an agent approval flow that survives a restart
By Flavio
Build a local approval queue with exact message previews, expiring decisions, rejection, and crash recovery. Test it safely with a fake email provider.
An agent has drafted an email. It asks for approval, then its process restarts before you answer.
The next process needs more than a message saying “the user approved.” It needs to know which email you saw, whether the decision is still valid, and whether the send already happened.
We’ll build that boundary as a small local program. It stores an immutable email proposal in SQLite, waits for a decision, and resumes in a later process. The provider is fake: it records accepted messages in another table and sends nothing over the network.
The earlier side effects tutorial explains why a crash can leave a missing receipt. Here we’ll add the actual approval flow around that failure.
Approve an exact operation
An approval should identify an operation and the content you reviewed. “Send the reply” is too loose if the agent can rewrite it after you say yes.
Our proposal contains the recipient, subject, and body. We hash their serialized representation and show the digest with the preview. The approval command requires that digest.
The proposal is immutable. A different message needs a new operation ID and a new decision. A SHA-256 digest identifies the bytes; it doesn’t authenticate the approver. This local demo trusts the person using the terminal and filesystem.
Create the local queue
Use a current Node LTS release with the built-in node:sqlite module. Make an empty directory and save this complete file as approval.mjs.
It uses two tables. proposals stores the intent and decision. accepted stands in for an email service with a stable idempotency key. The two writes happen separately so we can stop after acceptance and before the local result is saved.
import { createHash } from 'node:crypto'
import { DatabaseSync } from 'node:sqlite'
const [command, id, argument, seconds = '300'] = process.argv.slice(2)
if (!/^[a-z0-9-]{1,60}$/.test(id ?? '')) {
throw new Error('Pass an operation ID using lowercase letters, digits, or -')
}
const db = new DatabaseSync('approval.sqlite')
db.exec(`
CREATE TABLE IF NOT EXISTS proposals (
id TEXT PRIMARY KEY,
intent TEXT NOT NULL,
digest TEXT NOT NULL,
state TEXT NOT NULL,
approved_digest TEXT,
expires INTEGER
);
CREATE TABLE IF NOT EXISTS accepted (
id TEXT PRIMARY KEY,
digest TEXT NOT NULL,
receipt TEXT NOT NULL
);
`)
const read = () => db.prepare(
'SELECT * FROM proposals WHERE id = ?'
).get(id)
const setState = (state) => db.prepare(
'UPDATE proposals SET state = ? WHERE id = ?'
).run(state, id)
const transaction = (fn) => {
db.exec('BEGIN IMMEDIATE')
try {
const result = fn()
db.exec('COMMIT')
return result
} catch (error) {
db.exec('ROLLBACK')
throw error
}
}
if (command === 'propose') {
if (!argument) throw new Error('Pass the message body')
const intent = JSON.stringify({
to: '[email protected]',
subject: 'Your draft',
body: argument,
})
const digest = createHash('sha256').update(intent).digest('hex')
db.prepare(`
INSERT INTO proposals (id, intent, digest, state)
VALUES (?, ?, ?, 'pending')
`).run(id, intent, digest)
} else if (command === 'approve') {
const ttl = Number(seconds)
if (!Number.isInteger(ttl) || ttl < 1 || ttl > 3600) {
throw new Error('Approval lifetime must be 1 to 3600 seconds')
}
transaction(() => {
const row = read()
if (!row || row.state !== 'pending' || argument !== row.digest) {
throw new Error('Approval must match the pending proposal digest')
}
db.prepare(`
UPDATE proposals
SET state = 'approved', approved_digest = ?, expires = ?
WHERE id = ?
`).run(argument, Date.now() + ttl * 1000, id)
})
} else if (command === 'reject' || command === 'cancel') {
transaction(() => {
const row = read()
if (!row || !['pending', 'approved'].includes(row.state)) {
throw new Error('Already started or settled: reconcile before cancelling')
}
setState(command === 'reject' ? 'rejected' : 'cancelled')
})
} else if (command === 'run') {
const receipt = transaction(() => {
const row = read()
if (!row) throw new Error('Unknown proposal')
const accepted = db.prepare(
'SELECT * FROM accepted WHERE id = ?'
).get(id)
if (accepted) {
if (accepted.digest !== row.digest) throw new Error('Receipt mismatch')
setState('completed')
return accepted.receipt
}
if (row.state === 'executing') {
throw new Error('Ambiguous operation: investigate before another send')
}
if (row.state !== 'approved' || row.approved_digest !== row.digest) {
throw new Error('No matching approval')
}
if (Date.now() >= row.expires) throw new Error('Approval expired')
setState('executing')
return null
})
if (receipt) {
console.log('Reconciled receipt:', receipt)
} else {
const row = read()
db.prepare(`
INSERT INTO accepted (id, digest, receipt) VALUES (?, ?, ?)
`).run(id, row.digest, `fake-${id}`)
console.log('Fake provider accepted:', id)
if (argument === '--crash') process.exit(23)
setState('completed')
}
} else if (command !== 'show') {
throw new Error('Use propose, show, approve, reject, cancel, or run')
}
console.log(read())
console.log('Accepted messages:', db.prepare(
'SELECT COUNT(*) AS count FROM accepted'
).get().count)
db.close()
This is the policy boundary a model would call. We don’t need a live model to test whether it permits an unapproved send. Keeping these checks in ordinary application code also prevents a model’s wording from redefining them.
Pause and inspect the proposal
Create a message:
node approval.mjs propose draft-42 'Your draft is ready for review.'
The process saves the intent and exits. Start another process whenever you’re ready to review it:
node approval.mjs show draft-42
Read the full intent and its digest. To approve, copy that digest into the command. For convenience, you can set a shell variable interactively:
read APPROVAL_DIGEST
Paste the digest and press Enter. Then approve the proposal for five minutes:
node approval.mjs approve draft-42 "$APPROVAL_DIGEST" 300
That decision is now in SQLite. The proposing process doesn’t need to remain alive. A wrong digest fails, as does trying to replace draft-42 with another proposal.
The shell variable is only a convenience for copying what you reviewed. The database keeps the decision across process restarts.
Resume, crash, and reconcile
We’ll deliberately stop after the fake provider accepts the email:
node approval.mjs run draft-42 --crash
The command exits with code 23. The proposal says executing, while the fake provider has one accepted message.
Start it again:
node approval.mjs run draft-42
The program finds the existing receipt and marks the proposal completed. Running it again keeps the accepted-message count at one.
Recovery checks for an existing receipt before checking whether approval has expired. Recording what already happened needs no new send. If no receipt exists for an executing proposal, the program stops for investigation instead of guessing that a retry is safe.
This is intentionally conservative. In a real integration, an authoritative provider lookup or idempotent retry may settle that ambiguity. An eventual-consistency delay or an expired idempotency window can make the answer less clear.
Test the decisions that must stop a send
Create another proposal and reject it:
node approval.mjs propose draft-43 'A reply I do not want to send.'
node approval.mjs reject draft-43
node approval.mjs run draft-43
The final command fails without adding an accepted message. Cancellation has the same effect while a proposal is pending or approved:
node approval.mjs propose draft-44 'A reply that is no longer needed.'
node approval.mjs cancel draft-44
node approval.mjs run draft-44
To test expiry, create draft-45, inspect it, and approve its digest with a lifetime of one second. Wait two seconds before running it. The program rejects the expired approval, and the provider count stays unchanged.
Rejecting and cancelling are terminal decisions in this demo. A fresh proposal requires another operation ID. The run command also claims execution inside a SQLite transaction, so another process can’t independently claim the same approved proposal.
Put a real approval interface around it
For a web application, the server needs to authenticate the reviewer and check whether they can approve this particular operation. Store their identity with the decision. Keep that endpoint separate from the agent’s tool permissions, or the agent could approve its own request.
Cloudflare Workflows can wait for an event. Workflow SDK can wait on a hook. Both provide a place to pause; your application still checks the person and exact intent before delivering the decision.
A real email provider also needs its own deduplication contract. Use the stable operation ID within the provider’s documented scope and retention window. Our accepted table models an indefinitely retained receipt, which many external services won’t offer.
Once execution starts, the demo refuses cancellation because it cannot promise to stop an already accepted operation. A production UI should show that distinction. “Cancellation requested” and “nothing was sent” require different evidence.
Keep approval.sqlite and any journal files on persistent storage and out of Git. The approval queue survives restarting the process because that file remains available. Host failure, reviewer authentication, backups, and provider availability are additional responsibilities to address before connecting a real send tool.
Want me to talk about your product? You can sponsor this site.
Related posts about ai: