Skip to content

Work that comes back every week? No human needed. More on AI and automation

Automation

An n8n Workflow Example Built to Survive Retries: The Follow-Up Machine Architecture

Part 2 of 6 in the series The Follow-Up Machine

Separate polished metal bars of different sizes standing side by side with dark gaps between them, none touching the next.

This is part 2 of a six-part series on the follow-up machine: a set of n8n workflows that watches a CRM for deals that have gone quiet and creates follow-up tasks. Part 1 explains what it does and why. The remaining parts explain how, with the real code and the real mistakes.

Five workflows that never call each other

The system is five n8n workflows:

WorkflowTriggerJob
Followup 1: RegisterCRM webhookStart watching a deal that enters Proposal or Negotiation
Followup 2: EvaluateHourly, weekdaysReconcile with the CRM, decide what is due, create tasks
Followup 3: ReportTwo schedulesMorning list and Friday digest
Followup 4: EnrichHourly, at :30Summarise recent emails into two lines per deal
Followup 0: AlertsError triggerLog failures, send deduplicated alerts
Shared state. Independent workflows.D1 / FOLLOW-UP MACHINEShared state. Independent workflows.Data paths through Postgres; error handling is a separate n8n control path. Watchful Loop: Architecture EspoCRMsource of truth · read onlyFollowup 1: RegisterFollowup 2: EvaluateFollowup 4: EnrichFollowup 3: ReportPostgreswatches · watch_stepscadences · holidaysrun_logClickUptask notificationOutlookdigests + alertsFollowup 0: Alertswebhookread deals + activitiesread last 3 emailsinsert watch + stepsclaim · reconcilemark firedcreate / adoptcontext · suggested_nextreadmorning list / Friday digestlog + deduprelease claimalert mailCONTROL PATH / n8n starts the error workflowRegisterEvaluateReportEnrichAlertsNo direct data arrows connect the four processing workflows.
The workflows exchange business data through Postgres. Separately, n8n invokes Alerts when one of the other workflows fails.

They share one Postgres database and nothing else. No workflow passes data to another except through a table, and none of them starts another. What n8n itself does is separate: when a run fails, n8n starts the error workflow, and that is the one path on which one workflow’s failure invokes another. The rule behind the rest: adding a workflow must not break the existing ones. Enrich was added a day after the other four were designed. It runs without them knowing it exists. Showing its output in tasks and digests did take small changes to Evaluate and Report, but nothing broke while it was missing.

Three systems, three different jobs

The first design decision was which system owns what.

  • EspoCRM is the authoritative source. Deals, stages and activities live there. The machine only reads it. Nothing in version 1 writes to the CRM, so a bug in the machine cannot corrupt a deal.
  • Postgres holds orchestration state only. Which deals are being watched, which follow-ups are due, which have fired. If this database were lost, the business data in the CRM would survive. The follow-up history and the run log would not.
  • ClickUp presents tasks, it is not a record of anything. A task tells me to act. The tasks are of course stored in ClickUp, but neither business data nor workflow state is kept there.

That split decides what happens when something fails.

Losing Postgres costs reminders, not CRM data. Losing ClickUp costs a notification, not state.

The data model

Five tables. The two that matter:

create table watches (
  id             bigserial primary key,
  subject_type   text not null,           -- 'opportunity' | 'contact' (future)
  subject_id     text not null,           -- CRM record id
  subject_name   text not null,           -- denormalised, for digests when the CRM is down
  cadence        text not null default 'default',
  registered_at  timestamptz not null default now(),
  last_reset_at  timestamptz,
  status         text not null default 'open',
  closed_reason  text                     -- won | lost | exhausted | subject_gone | out_of_scope
  -- trimmed
);

create unique index watches_one_open_per_subject
  on watches (subject_type, subject_id)
  where status = 'open';

create table watch_steps (
  id              bigserial primary key,
  watch_id        bigint not null references watches(id) on delete cascade,
  step_no         int not null,
  due_on          date not null,
  fired_at        timestamptz,
  skipped_at      timestamptz,
  clickup_task_id text,
  unique (watch_id, step_no)
);

The others are cadences (a follow-up schedule as an array of business-day offsets, {5,12,21} by default), holidays, and run_log, which records every run of every workflow.

Two details are there on purpose. subject_name is copied from the CRM so the Friday digest stays readable when the CRM is unreachable. And subject_type exists although only opportunities are watched today, because adding contacts later should be a change to one workflow, not a migration.

Rule 1: running twice must equal running once

CRMs retry webhooks. Schedules overlap. Networks drop halfway through a request. So every workflow is written on the assumption that it will run twice with the same input.

For registration, the partial unique index above does the work. A deal can have at most one open watch. The insert simply does nothing when one exists:

insert into watches (subject_type, subject_id, subject_url, subject_name, account_name, context, cadence)
select j ->> 'subject_type', j ->> 'subject_id', j ->> 'subject_url', j ->> 'subject_name',
       nullif(j ->> 'account_name', ''), nullif(j ->> 'context', ''), j ->> 'cadence'
from p
on conflict (subject_type, subject_id) where status = 'open' do nothing
returning id

do nothing, not do update. A retried webhook must not reset a cadence that is already running.

For task creation in ClickUp there is no database constraint to lean on, so Evaluate checks ClickUp for an existing task before creating one. Part 4 covers that.

Rule 2: one run at a time

Two runs of Evaluate at the same moment would both see the same due follow-up and both create a task. The guard is a claim row in run_log:

insert into run_log (workflow, run_id, started_at, status)
select 'wf2', gen_random_uuid(), now(), 'running'
where not exists (
  select 1 from run_log
  where workflow = 'wf2'
    and status = 'running'
    and started_at > now() - interval '55 minutes'
)
returning id as run_log_id, run_id;

If the insert returns a row, this run owns the work. If it returns nothing, another run is live and this one ends quietly. The 55-minute window is the backstop for a run that crashed without releasing its claim: shorter than the hourly schedule, longer than any real run.

Trap. That window also hid a bug, because an hourly schedule clears a 55-minute window with five minutes to spare. More on that in part 4.

Rule 3: the webhook is an optimisation

Register reacts to a CRM webhook within seconds. But webhooks get lost: the CRM can fail to send, n8n can be restarting, Postgres can be down after the webhook was already acknowledged.

So every Evaluate run starts with a reconciliation pass. It asks the CRM for every deal currently in an in-scope stage and compares that list with the open watches. Any deal without a watch gets one. That makes a lost webhook harmless within the hour, and it means the whole system works with no webhooks at all, only slower.

This pass is the most important design decision in the machine and the most tempting thing to remove as an “optimisation”. It stays.

Rule 4: Postgres failure is fatal, third-party failure is skippable

A node that touchesOn final failure
PostgresFail the run. State is unknown, do not continue.
The CRM APISkip that item, continue, mark the run partial
The ClickUp APISkip that item, continue, mark the run partial
OutlookFail the run and alert

The reasoning is short. If the machine cannot record what it did, it must stop, because the next run would act on a false picture. If one deal cannot be read, the others still can, and that deal is retried next hour.

Rule 5: dry-run blocks external writes only

Every write path has a dry-run mode, but it does not block everything. DRY_RUN=true stops ClickUp tasks and emails. It does not stop Postgres writes. A dry run registers watches, marks due follow-up steps as processed (with the string 'dry-run' in place of a task ID) and writes run_log, exactly as production would.

Dry run. Otherwise a dry run tests a different pipeline from the one that goes live. The model call in Enrich is not gated by dry-run at all: it still goes to Mistral’s API, and it still costs money there.

Where the logic lives

The rules that are worth testing, business-day arithmetic, the routing decision, the digest text, the prompt and parser for the AI step, live in TypeScript with vitest tests, outside n8n. A build script bundles each entry point into a snippet that is pasted into a Code node. SQL for every Postgres node lives in its own file.

Two platform details from n8n 2.x cost real time here:

  1. Code nodes run in an external task runner, and it cannot read $env. Every secret, base URL or flag a Code node needs is injected by a Set node in front of it, which runs in the main process. On top of that, n8n 2.x blocks $env in expressions by default. N8N_BLOCK_ENV_ACCESS_IN_NODE=false turns that off.
  2. The task runner sandbox silently ignores Object.defineProperty. esbuild’s IIFE and CommonJS output defines its exports with exactly that, so the first live run died with __node.run is not a function. Building as ESM, with no export helpers, fixed it.

What this architecture does not solve

It does not prevent a system that runs without errors and does the wrong thing. The worst bug in the build was a stage filter that looked for the wrong stage name. Every rule above held, every run succeeded, and the machine ignored every deal at proposal stage for five days. Part 3 tells that story.

Next: Part 3, Register.