Zum Inhalt springen

This page is available in English. Switch to English

Arbeit, die jede Woche wiederkommt? Muss kein Mensch machen. Mehr zu KI und Automatisierung

Automatisierung

n8n-Workflow-Beispiel aus dem Echtbetrieb: eine Architektur, die Wiederholungen übersteht

Teil 2 von 6 der Serie Die Follow-up-Maschine

Getrennte polierte Metallbalken unterschiedlicher Größe nebeneinander, mit dunklen Lücken dazwischen, keiner berührt den nächsten.

Das ist Teil 2 einer sechsteiligen Serie über die Follow-up-Maschine: ein Zusammenspiel mehrerer n8n-Workflows, die ein CRM auf liegengebliebene Deals prüfen und Aufgaben zum Nachfassen anlegen. Was sie tut und warum, steht in Teil 1. Die übrigen Teile zeigen, wie sie gebaut ist, mit dem echten Code und den echten Fehlern.

Fünf Workflows, die sich nie gegenseitig aufrufen

Das System besteht aus fünf n8n-Workflows:

WorkflowAuslöserAufgabe
Followup 1: RegisterCRM-WebhookEinen Deal beobachten, sobald er in Angebot oder Verhandlung wechselt
Followup 2: EvaluateStündlich, werktagsMit dem CRM abgleichen, Fälliges bestimmen, Aufgaben anlegen
Followup 3: ReportZwei ZeitpläneMorgenliste und Freitagsübersicht
Followup 4: EnrichStündlich, zur halben StundeAktuelle E-Mails pro Deal in zwei Zeilen zusammenfassen
Followup 0: AlertsError TriggerFehler protokollieren, wiederholte Warnungen begrenzen
Gemeinsamer Zustand. Getrennte Workflows.D1 / FOLLOW-UP MACHINEGemeinsamer Zustand. Getrennte Workflows.Daten fließen über Postgres; Fehlerbehandlung löst n8n separat aus. Watchful Loop: Architecture EspoCRMmaßgebliche Daten · nur lesenFollowup 1: RegisterFollowup 2: EvaluateFollowup 4: EnrichFollowup 3: ReportPostgreswatches · watch_stepscadences · holidaysrun_logClickUpAufgabe als HinweisOutlookBerichte + WarnungenFollowup 0: AlertswebhookDeals + Aktivitäten lesenletzte 3 E-Mails lesenBeobachtung + Termine anlegenclaim · reconcilemark firedanlegen / übernehmencontext · suggested_nextlesenMorgenliste / Freitagsübersichtlog + deduprelease claimWarnmailSTEUERUNG / n8n startet den Error-WorkflowRegisterEvaluateReportEnrichAlertsKeine direkten Datenpfeile zwischen den vier verarbeitenden Workflows.
Die Workflows tauschen Geschäftsdaten über Postgres aus. Unabhängig davon startet n8n den Alerts-Workflow, wenn einer der anderen Workflows fehlschlägt.

Sie teilen sich eine Postgres-Datenbank und sonst nichts. Daten wandern zwischen ihnen nur über Tabellen, und kein Workflow startet einen anderen. Was n8n selbst tut, ist davon zu trennen: Scheitert ein Lauf, startet n8n den Error Workflow, und das ist der einzige Weg, auf dem ein Workflow einen anderen auslöst. Die Regel dahinter: Ein neuer Workflow hinzuzufügen darf die bestehenden nicht kaputt machen. Enrich kam einen Tag nach dem Entwurf der anderen vier dazu und läuft, ohne dass sie davon wissen. Damit seine Ergebnisse in Aufgaben und Berichten erscheinen, brauchten Evaluate und Report kleine Änderungen. Kaputt war ohne sie aber nichts.

Drei Systeme, drei verschiedene Aufgaben

Die erste Entscheidung war, welches System wofür zuständig ist.

  • EspoCRM ist die maßgebliche Datenquelle. Deals, Phasen und Aktivitäten liegen dort. Die Maschine liest nur. In Version 1 schreibt nichts ins CRM, also kann ein Fehler der Maschine keinen Deal beschädigen.
  • Postgres hält nur den Ablaufzustand. Welche Deals beobachtet werden, welche Termine fällig sind, welche Nachfassschritte bereits ausgeführt wurden. Ginge die Datenbank verloren, blieben die Geschäftsdaten im CRM erhalten. Der Nachfassverlauf und die Laufhistorie wären weg.
  • ClickUp zeigt Aufgaben, es hält keinen maßgeblichen Bestand. Eine Aufgabe sagt mir, dass ich handeln soll. Die Aufgaben liegen natürlich in ClickUp, aber weder Geschäftsdaten noch Ablaufzustand werden dort geführt.

Diese Aufteilung entscheidet, was bei einem Ausfall passiert.

Fällt Postgres aus, fehlen Erinnerungen, aber keine CRM-Daten. Fällt ClickUp aus, fehlt eine Benachrichtigung, aber kein Zustand.

Das Datenmodell

Fünf Tabellen. Die zwei wichtigsten:

create table watches (
  id             bigserial primary key,
  subject_type   text not null,           -- 'opportunity' | 'contact' (später)
  subject_id     text not null,           -- ID im CRM
  subject_name   text not null,           -- kopiert, damit Berichte auch ohne CRM lesbar sind
  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
  -- gekürzt
);

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)
);

Dazu kommen cadences (ein Nachfass-Rhythmus als Liste von Werktagen, standardmäßig {5,12,21}), holidays und run_log, das jeden Lauf jedes Workflows festhält.

Zwei Details sind Absicht. subject_name wird aus dem CRM kopiert, damit die Freitagsübersicht auch dann lesbar bleibt, wenn das CRM nicht erreichbar ist. Und subject_type existiert, obwohl heute nur Deals beobachtet werden: Um später auch Kontakte einzubeziehen, soll eine Änderung an einem Workflow genügen; eine Datenbankmigration soll dafür nicht nötig sein.

Regel 1: Zwei Ausführungen müssen dasselbe Ergebnis liefern wie eine

CRMs wiederholen Webhooks. Zeitpläne überlappen. Netzwerke brechen mitten in einer Anfrage ab. Also ist jeder Workflow so geschrieben, als würde er mit denselben Daten zweimal laufen.

Bei der Registrierung erledigt das der partielle eindeutige Index oben. Ein Deal hat höchstens eine offene Beobachtung. Existiert schon eine, tut der Insert einfach nichts:

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, nicht do update. Ein wiederholter Webhook darf einen laufenden Rhythmus nicht zurücksetzen.

Für Aufgaben in ClickUp gibt es keine Datenbankregel, auf die man sich stützen kann. Deshalb fragt Evaluate vor dem Anlegen nach, ob die Aufgabe schon existiert. Dazu mehr in Teil 4.

Regel 2: immer nur ein Lauf

Zwei gleichzeitige Läufe von Evaluate würden denselben fälligen Termin sehen und beide eine Aufgabe anlegen. Die Sperre ist ein Eintrag 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;

Kommt eine Zeile zurück, darf dieser Lauf die Verarbeitung übernehmen. Kommt nichts zurück, läuft schon ein anderer, und dieser endet leise. Das 55-Minuten-Fenster ist die Absicherung für einen Lauf, der abstürzt, ohne seine Sperre freizugeben: kürzer als der Stundentakt, länger als jeder echte Lauf.

Falle. Genau dieses Fenster hat auch einen Fehler versteckt: Zwischen zwei stündlichen Läufen liegen 60 Minuten, also fünf mehr, als die Sperre dauert. Mehr dazu in Teil 4.

Regel 3: Der Webhook ist eine Beschleunigung

Register reagiert innerhalb von Sekunden auf einen CRM-Webhook. Webhooks gehen aber verloren: Das CRM sendet nicht, n8n startet gerade neu, oder Postgres ist ausgefallen, nachdem der Webhook schon bestätigt war.

Deshalb beginnt jeder Lauf von Evaluate mit einem Abgleich. Er fragt das CRM nach allen Deals in den relevanten Phasen und vergleicht die Liste mit den offenen Beobachtungen. Jeder Deal ohne Beobachtung bekommt eine. Ein verlorener Webhook ist damit spätestens nach einer Stunde bedeutungslos, und das System funktioniert sogar ganz ohne Webhooks, nur langsamer.

Dieser Abgleich ist die wichtigste Entscheidung der ganzen Maschine und zugleich das, was man am ehesten als „Optimierung“ streichen möchte. Er bleibt.

Regel 4: Datenbankfehler brechen den Lauf ab, Fremdsysteme lassen sich überspringen

ZielsystemBei endgültigem Fehler
PostgresLauf abbrechen. Der Zustand ist unklar, nicht weitermachen.
die CRM-APIDiesen Eintrag überspringen, weitermachen, Lauf als partial markieren
die ClickUp-APIDiesen Eintrag überspringen, weitermachen, Lauf als partial markieren
OutlookLauf abbrechen und warnen

Die Begründung ist kurz. Kann die Maschine nicht festhalten, was sie getan hat, muss sie aufhören, sonst handelt der nächste Lauf auf Basis eines falschen Bildes. Lässt sich ein Deal nicht lesen, lassen sich die anderen trotzdem lesen, und dieser eine kommt in der nächsten Stunde wieder dran.

Regel 5: Dry-Run blockiert nur Schreibzugriffe nach außen

Jeder schreibende Pfad hat einen Dry-Run-Modus, aber der blockiert nicht alles. DRY_RUN=true verhindert ClickUp-Aufgaben und E-Mails. Postgres-Schreibvorgänge verhindert er nicht. Ein Dry-Run registriert Beobachtungen, markiert fällige Nachfassschritte als erledigt (mit dem Text 'dry-run' statt einer Aufgaben-ID) und schreibt run_log, genau wie der Echtbetrieb.

Dry-Run. Sonst testet ein Dry-Run eine andere Strecke als die, die später live geht. Der Modellaufruf in Enrich hängt gar nicht am Dry-Run: Er geht auch dann an die API von Mistral und kostet dort Geld.

Wo die Logik steckt

Die separat getestete Logik, also Werktagsrechnung, Entscheidungslogik, Berichtstexte, Prompt und Parser für den KI-Schritt, liegt als TypeScript mit vitest-Tests außerhalb von n8n. Ein Build-Skript bündelt jeden Einstiegspunkt zu einem Codeblock, der in einen Code-Node eingefügt wird. Das SQL für jeden Postgres-Node liegt in einer eigenen Datei.

Zwei Eigenheiten von n8n 2.x haben dabei echte Zeit gekostet:

  1. Code-Nodes laufen in einem externen Task Runner, und der kann $env nicht lesen. Jedes Geheimnis, jede Basis-URL und jeder Schalter, den ein Code-Node braucht, kommt über einen Set-Node davor, der im Hauptprozess läuft. Zusätzlich blockiert n8n 2.x $env in Ausdrücken standardmäßig. N8N_BLOCK_ENV_ACCESS_IN_NODE=false hebt das auf.
  2. Die Sandbox des Task Runners ignoriert Object.defineProperty stillschweigend. Die IIFE- und CommonJS-Ausgabe von esbuild definiert ihre Exporte genau damit, also starb der erste Live-Lauf mit __node.run is not a function. Der Build als ESM, ohne Export-Helfer, hat das behoben.

Was diese Architektur nicht löst

Sie verhindert nicht, dass das System ohne Fehlermeldung läuft und trotzdem das Falsche tut. Der schlimmste Fehler beim Bau war ein Filter mit dem falschen Phasennamen. Alle Regeln oben hielten, jeder Lauf war erfolgreich, und die Maschine übersah fünf Tage lang jeden Deal in der Angebotsphase. Diese Geschichte erzählt Teil 3.

Weiter: Teil 3, Register.