The run is committed, not screenshotted
This workflow really was executed headlessly, and the run is committed in output/sample-run.json — not a screenshot of a canvas.
Be precise about what that file is. It is a summary I wrote from the run (per-node status, item counts, timings, the funnel, the briefs), not n8n’s raw resultData.runData dump. The numbers in it come from an actual n8n execute against the live openFDA API; the schema around them is mine, chosen to be readable. If you want the native output, re-run the command in Reproduce below and compare — the funnel should match, and the per-firm “N days ago” fields should have advanced by however many days have passed since the commit.
The problem it solves
Signal-based outbound only works if the signal refreshes on its own. The moment it depends on someone remembering to re-run a script, it goes stale, and reps start calling accounts whose trigger expired months ago. Worse, when the refresh fails, the queue doesn’t empty — it silently serves yesterday’s data and nobody knows.
So the interesting part of this workflow isn’t the happy path. It’s what happens when the API is down.
The workflow
Daily 07:00 ─┐
├─→ Fetch openFDA ──→ Roll Up + Score ──→ Filter ≥60 ──→ Build Briefs
Manual Run ──┘ │
└── (on error, after 3 retries) ──→ Alert: Fetch Failed
Two entry points: the Schedule Trigger is the intended scheduled entry (daily 07:00); the Manual Trigger exists so the workflow can be executed from CLI and CI, which is how the committed run below was produced. A configured schedule is not presented as proof of sustained production operation.
The manual trigger is there because the first CLI run failed — n8n execute returned “Missing node to start execution”, since a Schedule Trigger alone gives the CLI no entrypoint. Adding the manual trigger fixed it. Recording that because “it worked first time” would be the less useful claim.
Production markers
These are the specific things GTM Engineer postings ask for, and where each lives.
| Marker | Where |
|---|---|
| Fixed-delay retry | retryOnFail: true, maxTries: 3, waitBetweenTries: 5000 on the HTTP node |
| Error branch | onError: continueErrorOutput routes a failed fetch to a dedicated alert node instead of killing the run |
| Rate limiting | batching: { batchSize: 1, batchInterval: 1000 } — deliberate throttling against a public API |
| Timeout | 30s ceiling on the fetch |
| Fail-closed guard | the scoring node throws on an empty response rather than emitting an empty queue — a silent empty queue is worse than a loud failure |
| Observability | alwaysOutputData so a zero-result fetch is still visible in the run log |
The alert node emits a structured payload — severity, stage, message, and an explicit action: "Queue is stale. Do not send from it until this clears." Wiring that to Slack or PagerDuty is scheduler config; it is not claimed here as built.
A dedicated error workflow, verified firing
The node-level alert above only catches a failure in the one node it's wired to. workflows/gtm-error-handler.json is a separate, registered workflow-level handler (via settings.errorWorkflow) that catches a crash anywhere in the run, classifies it, and routes accordingly — and it was proven by deliberately breaking a live workflow, not by reading the docs.
Verified, 2026-08-24. A probe workflow (workflows/_error-path-probe.json) fails on demand. A webhook-mode execution failed; the handler fired 80ms later (mode: error), classified the failure code_defect, and generated a page-intent payload. Both executions are committed at output/error-workflow-fired-2026-08-23.json. No external notification delivery is claimed.
Three things that only surfaced by actually running it
- Error workflows do not fire for CLI executions. Testing via
n8n executeproduces a failed run and nothing else — only webhook and schedule executions trigger the registered handler. Verifying from the CLI alone would look clean and prove nothing. - The handler must itself be published, or n8n silently does nothing. With the handler imported but inactive, the only trace was one server log line:
"Workflow ... is not active and cannot be executed."The worst possible failure mode for an alerting path is one that fails silently. - n8n rewrites a thrown error before the handler sees it. A Code node throwing
new TypeError('probe: ...')arrives at the handler asname: "Error", with the message rewritten and the original text moved toerror.description.
That third gotcha was a real bug in the classifier's first version — it keyed on error.name, so it never matched a code defect and fell through to unclassified. It still emitted a page-intent payload (the conservative default held), but the specific rule was wrong. Re-keyed on the [line N] suffix n8n only appends to code it evaluated itself. Re-tested end to end: category went unclassified → code_defect.
Verified run
Executed via n8n execute on n8n 2.35.7, against the live openFDA API.
| Node | Status | Items out | Time |
|---|---|---|---|
| Manual Run | success | 1 | 1 ms |
| Fetch openFDA Enforcement | success | 1 | 1,886 ms |
| Roll Up + Score Firms | success | 34 | 42 ms |
| Filter ≥ 60 | success | 11 | 42 ms |
| Build Grounded Briefs | success | 10 | 13 ms |
Funnel: 100 raw enforcement records → 34 firms after roll-up → 11 cleared the score threshold → 10 briefs. The 11 → 10 step is not attrition — it is a hardcoded cap. Build Grounded Briefs does rows.slice(0, 10), so the queue never emits more than 10 briefs per run no matter how many firms clear the threshold. That is a deliberate volume guard on a daily job, but it is a code decision and not a property of the data, so it is stated here rather than left to look like a drop-off. The other numbers do move between runs, because the data is live — that is the point of a scheduled signal pipeline, and the committed sample-run.json is one dated snapshot, not a fixed fixture.
Run it yourself
npm install n8n export N8N_USER_FOLDER="$PWD/.n8n-home" npx n8n import:workflow --input=workflows/fda-signal-to-outreach-queue.json npx n8n execute --id=fdaSignalQueue01
No API key and no paid data — openFDA is a public federal endpoint.
Scoring
Mirrors the disclosed rubric in atlas-signal-engine (max 100): severity by worst classification, recency of the most recent event, systemic count of distinct event_ids, a quality-system reason flag, and an ongoing-status bonus. It is a buying-trigger score, not a quality verdict — it measures how acute a firm’s compliance pain is right now, not whether the firm is good or bad.
Honesty guardrails
- Every fact in a generated brief is copied verbatim from the FDA filing. Nothing is inferred or embellished.
- No contact is ever invented. Outreach targets a role (
Chief Quality Officer / VP Quality), never a named person or guessed email. - No email is sent. This workflow produces a queue; delivery is out of scope.
- The committed run is real output from a real execution against the live API.
node_modules/is gitignored — n8n is a 2.5 GB install and is not vendored here.
Files
workflows/fda-signal-to-outreach-queue.json the main workflow (importable) workflows/gtm-error-handler.json dedicated error workflow workflows/_error-path-probe.json fixture that fails on demand, for testing the above output/sample-run.json committed real execution output output/native-execution-2026-08-23.json n8n's own unedited resultData.runData dump output/error-workflow-fired-2026-08-23.json the failure + the handler run, 80ms apart
Built something similar for your stack?
I build signal-detection, enrichment and outbound systems inside your own tools. You own everything — nothing is locked to me.