How to Build an Approval Workflow App With AI
AI builders model approval as a boolean. Build it as a state machine with an append-only event trail instead, with the schema and the five failure modes.
How to Build an Approval Workflow App With AI
Ask an AI builder for an approval app and you will get a table with an approved boolean, a button that flips it to true, and a screen listing everything where it is false. It will work in the demo and fall apart the first week a real business uses it.
Approval is not a boolean. It is a state machine with an audit trail, and the difference shows up the moment someone asks who approved the thing, when, and what it looked like at the time. Get the model right and the rest of the build is straightforward.
The model to specify before you prompt anything
Four states cover almost every real approval process. Draft, pending, approved, rejected. The transitions between them are where the rules live.
From | To | Who can do it | What must be recorded |
|---|---|---|---|
draft | pending | The requester | Submitted at, the full request content |
pending | approved | An approver who is not the requester | Decider, timestamp, optional note |
pending | rejected | An approver who is not the requester | Decider, timestamp, reason (required) |
pending | draft | The requester, withdrawing | Withdrawn at |
approved or rejected | anything | Nobody | Terminal. Changes create a new request. |
That last row is the one that gets argued about and the one you should hold. Once a decision is made, it is a historical fact. If circumstances change, the answer is a new request that references the old one, not an edit to a record somebody already relied on.
The schema
Give your builder the tables rather than a description of them. Specifying the schema yourself is the single highest-leverage thing you do in a build like this, because everything downstream inherits its shape.
create table approval_requests (
id uuid primary key default gen_random_uuid(),
title text not null,
payload jsonb not null, -- what is being approved
amount_cents bigint, -- null when not financial
requested_by uuid not null references users(id),
state text not null default 'draft'
check (state in ('draft','pending','approved','rejected')),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table approval_events (
id uuid primary key default gen_random_uuid(),
request_id uuid not null references approval_requests(id),
from_state text,
to_state text not null,
actor_id uuid not null references users(id),
note text,
payload_hash text not null, -- what the payload was at decision time
created_at timestamptz not null default now()
);
create index on approval_events (request_id, created_at);Two tables, and the second one is the point. approval_requests holds current state so your screens are fast. approval_events holds every transition that ever happened, append only, never updated. Your state column is a cache of the last event, and if the two ever disagree the events win.
The payload_hash column earns its place the first time someone approves a purchase order for 4,000 and the requester later edits it to 40,000. Hash the payload at decision time and you can prove what was approved.
What to actually ask the builder for
Work in slices that each end somewhere testable, rather than describing the whole app and hoping. A sequence that works:
The two tables above, exactly as written, plus a users table if there is not one.
A single server-side function
transition(request_id, to_state, actor_id, note)that validates the transition against the table above, writes the event, and updates the request. Every other part of the app calls this and nothing else writes to state.A submit form that creates a draft and moves it to pending.
An approver queue filtered to pending requests the current user is allowed to decide.
A request detail page showing the payload and the full event history, oldest first.
Notifications last, once the states are provably correct.
Insisting on one transition function is the instruction that saves you most. Left alone, an AI builder will write state updates inline on four different screens, and by the third feature they will have drifted apart. How to plan your data model before building an app with AI covers the general habit.
The five things AI builders get wrong here
Self-approval. Nothing stops the requester approving their own request unless you say so. Put the check in the transition function, not in the UI, because the UI is not where the request arrives.
Editable history. Builders love a tidy single table. If the model can update an approval record, your audit trail is a suggestion.
Concurrent decisions. Two approvers open the same pending request and both click. Without a conditional update, you get two events and an undefined winner. The fix is one line: update the state only where it is still the state you read.
Silent failure on the second click. Related, and worse. The losing approver should see a clear message that somebody already decided, not a success screen.
Deleting instead of rejecting. A rejected request that disappears takes the reason with it, and the reason is usually the most valuable thing in the record.
The concurrency fix in Postgres is a conditional update, and it is worth checking the generated code contains something equivalent:
update approval_requests
set state = 'approved', updated_at = now()
where id = $1
and state = 'pending' -- the guard
returning id;No row returned means someone beat you to it. Handle that case explicitly. This is optimistic concurrency control, and the PostgreSQL documentation on UPDATE is worth five minutes if you have not met the pattern before.
Where approval rules get complicated
Most small businesses need one approver. Some need thresholds, and thresholds are where scope quietly triples.
If a request under 1,000 needs one approver and anything above needs two, you are no longer modelling a state machine with four states. You are modelling a policy. Keep the policy in data rather than in code branches: a small table of rules with an amount range and a required approver count, read by the transition function. Then a change in the rules is a row, not a deploy.
Resist building delegation, escalation and out-of-office routing in version one. Each is reasonable, each adds states, and together they turn a two week build into a quarter. Ship the simple version, find out which one the business actually asks for, and add that one. What to leave out of your first app version makes the case at length.
One more rule worth writing down early: an approver needs to see enough to decide without leaving the app. If they have to open an email thread and a spreadsheet to work out what they are approving, they will approve everything, and your careful state machine becomes an expensive rubber stamp. Put the payload, the requester, the amount and any prior related requests on the decision screen itself.
How to test it before anyone relies on it
Try every transition the table forbids and confirm each is refused by the server, not just hidden in the interface.
Approve as the requester. It should fail.
Open the same request in two browsers and decide in both. Exactly one should succeed, and the other should say so clearly.
Approve, then edit the payload directly in the database, then look at the detail page. The hash mismatch should be visible.
Check the event history reads as a story a non-technical person could follow in an audit.
If you are building this on Swarmz, the same sequence applies: describe the two tables and the transition function first, then build screens against them. How to test an AI-built app before launch covers the broader pass, and how to build an app with AI covers the ground before this one.
FAQ
How long does this take to build?
The core, meaning both tables, the transition function, a submit form and an approver queue, is an afternoon with an AI builder if you specify the schema up front. Thresholds, notifications and reporting add a few days each.
Do I need a separate events table, or can I use a log?
Use the table. Application logs get rotated, are not queryable from your app, and cannot be shown to the person asking who approved something. The events table is a feature, not diagnostics. How to add an audit log to an AI-built app covers the general pattern.
Should approvals expire?
Often yes, and it is cheap to add later: a deadline column and a scheduled job that moves stale pending requests to rejected with a system actor. Do not add it in version one unless the business has asked.
Can AI decide the approvals themselves?
It can score or triage them, which is genuinely useful for routing. Letting a model make the final decision on anything financial or contractual puts an unaccountable actor in the record, and the record is the reason the app exists. Keep the decision with a person and use the model to prepare it. Should an AI agent have its own user account is the related question.
How did this land?
About the author

Developer Advocate
Steve builds something with Swarmz every week and writes up what worked, what broke, and what he'd do differently. Tutorials and hands-on guides are his lane.


