A human-in-the-loop control tower for n8n. Workflows pause, you decide, they resume.
n8n is very good at running things without you. It is much worse at the moment where somebody has to look at what it produced and say yes. The usual answers — an email with two links, a Slack button, a shared inbox — lose the context, lose the audit trail, and leave executions parked for days with nobody able to see them.
flowdeck is the missing screen: one live queue of every paused run, with the context attached, the payload inspectable, and the values editable before you let it continue.
n8n workflow
|
|-- HTTP Request --> POST /api/hooks/approval (sends $execution.resumeUrl)
|-- Wait node --> execution parks, costing nothing
| ^
| |
| POST <resumeUrl> ---------+
| |
`-- carries on ------------------ flowdeck <-- you clicked Approve
It closes the loop, not just opens it. The approval carries n8n's own resume URL, so a decision restarts the exact execution that parked itself — with your edits, your note, and your name attached.
You can change your mind about the details. An approval can declare editable fields. Lower the amount, rewrite the draft, correct the cost centre; the workflow reads your version, not the original.
Nothing is a black box. Every request, decision, expiry and failed resume lands in a live feed with timestamps. The history page shows who decided what and how long the workflow waited for them.
Failure is visible instead of silent. If n8n cannot be reached when you approve, the card does not pretend it worked — it goes red, keeps your decision, and offers a retry.
Requires Node 20 or newer.
npm install
cp .env.example .env # set FLOWDECK_INBOUND_SECRET
npm run dev # netlify dev on http://localhost:8888
npm run seed # queue four demo approvalsnpm run seed needs no n8n at all. It queues realistic approvals whose resume URLs point
at /api/demo/resume, a stand-in for a Wait node — so approving a seeded card really does
complete the round trip and write the closing event.
- Push this repo and create a Netlify site from it.
netlify.tomlalready configures the build, the publish directory, and the functions directory. - Set
FLOWDECK_INBOUND_SECRET(and ideallyFLOWDECK_DASHBOARD_TOKEN) under Site configuration → Environment variables. - Deploy. Netlify Blobs needs no provisioning — it is there the moment the site exists.
There is no second service to host. State lives in Blobs, the UI is static, and the API is seven small functions.
| Variable | Required | Notes |
|---|---|---|
FLOWDECK_INBOUND_SECRET |
yes | n8n sends it as x-flowdeck-secret. Without it the hooks reject everything. |
FLOWDECK_DASHBOARD_TOKEN |
recommended | Gates the dashboard API. Blank means anyone with the URL can approve. |
N8N_BASE_URL / N8N_API_KEY |
no | Powers the Workflows page only. |
DEFAULT_TTL_MINUTES |
no | Expiry for requests that do not set ttlMinutes. Default 240. |
MAX_EVENTS |
no | Feed retention. Default 400. |
Three nodes, and only the middle one is mandatory.
1. Ask. An HTTP Request node, POST to /api/hooks/approval, header
x-flowdeck-secret, JSON body:
{
"title": "Publish: {{ $json.headline }}",
"summary": "{{ $json.draft }}",
"workflow": "{{ $workflow.name }}",
"executionId": "{{ $execution.id }}",
"risk": "medium",
"ttlMinutes": 120,
"resumeUrl": "{{ $execution.resumeUrl }}",
"payload": { "source": "{{ $json.link }}" },
"fields": [
{ "key": "draft", "label": "Post text", "type": "textarea", "value": "{{ $json.draft }}" }
]
}$execution.resumeUrl is the load-bearing field. It must be sent from inside the same
execution that is about to wait.
2. Wait. A Wait node with Resume set to On webhook call. The execution stops there at no cost until a decision arrives.
3. Branch. After it resumes, the decision is on $json.body:
| Field | What it holds |
|---|---|
decision |
"approve" or "reject" |
approved |
boolean, for a one-condition IF node |
note |
free text the approver typed |
decidedBy |
the name set on the dashboard |
edits |
only the fields that were actually changed |
Read edits before your original values and the approver's changes are what ships.
Optionally POST to /api/hooks/event with kind of run.started, run.step,
run.finished or run.failed to narrate the run in the live feed.
workflows/ holds two importable examples. Use Import from File in n8n, then fill in
the two values in the flowdeck config node at the top of each.
- content-radar — reads a feed, drafts a social post with an LLM, and holds it for approval. Edit the draft on the card and that version is what publishes.
- expense-guard — auto-approves anything under a threshold and escalates the rest, with the amount editable so you can part-approve.
| Piece | Choice | Why |
|---|---|---|
| Frontend | React 18 + TypeScript + Vite, shadcn-ui on Tailwind | Components are source in this repo, not a dependency you cannot patch |
| Backend | Netlify Functions (v2) | No second service to deploy or pay for |
| State | Netlify Blobs | Zero-config, persists across deploys, free tier |
| Live updates | Polling every 3s with backoff | See below |
Why polling and not SSE. A free-tier function held open for a stream burns an invocation for its whole life and still gets cut at 60 seconds, so the best case is a reconnect storm. A 3 second poll of a single blob is cheaper, survives sleep/wake, needs no reconnection logic, and pauses itself when the tab is hidden.
Why one blob and not a database. Approvals arrive at human speed. A read-modify-write
against one JSON blob is honest at that rate, and store.ts says so in a comment — it is
the first thing to replace if this ever sees machine-rate traffic.
netlify/
functions/ hooks-approval, hooks-event, feed, approvals, decide,
workflows, trigger, health, demo-resume
lib/ settings + auth guards, blob store, validation
shared/
types.ts the contract between n8n, the functions, and the UI
src/
components/ approval card, event feed, stat cards, payload viewer
hooks/ use-feed (the polling engine), use-now
pages/ dashboard, history, workflows, setup
workflows/ importable n8n examples
scripts/ generate-icons.mjs, seed-demo.mjs
flowdeck is a PWA — install it from the address bar, or Share → Add to Home Screen on iOS. Worth doing: an approval queue is exactly the thing you want one tap away, and the shell is cached so it opens instantly.
- Expiry is evaluated when someone reads the queue, not on a timer. There is no cron on the free tier, and an approval that is stale for a few seconds harms nobody.
- Two people deciding the same card in the same second will both post a resume. n8n answers the second one with a 404, which the UI reports honestly.
- There is no per-user login. The dashboard token is a shared secret, and
decidedByis a name you type — good enough for a small team, not an audit-grade identity.