Wenn n8n nicht mehr das richtige Tool ist: eine Pipeline für chaotischen Input als Claude-Code-Harness neu gebaut

Wenn n8n nicht mehr das richtige Tool ist

Ein Post-Mortem darüber, wie ich eine Automations-Pipeline für eingehende E-Mails neu geschrieben habe, die über den Punkt hinausgewachsen war, an dem ein visueller Workflow noch geholfen hat.

TL;DR

Ich hatte einen n8n-Workflow, der unordentliche, von Menschen geschriebene E-Mails eingelesen hat — verschiedene Anhangstypen, verschiedene Anliegen, jede Menge Sonderfälle —, sie klassifiziert und in ein paar nachgelagerte Systeme geschrieben hat. Er lief, bis er nicht mehr lief. Die Fehler waren keine Bugs, die ich punktuell fixen konnte; sie waren die strukturelle Folge davon, eine Pipeline voller Ermessensentscheidungen in einem visuellen Workflow-Tool zu bauen. Ich habe sie neu geschrieben: eine kleine Python-Grenzschicht plus ein Claude-Code-Orchestrator. Gleiche Inputs, gleiche Outputs, ein Zehntel der beweglichen Teile, und jeder Fehlermodus ist jetzt mechanisch ausgeschlossen statt „das behalten wir im Auge“. Das hier habe ich dabei gelernt.

Das Setup

Der Großteil der spannenden Arbeit, die ich gerade mache, sieht so aus: Irgendwo empfängt ein Postfach E-Mails von Menschen. Die E-Mails sind uneinheitlich — unterschiedliche Formate, unterschiedliche Anliegen, mal mit Anhang, mal ohne, mal in Sprache A, mal in Sprache B. Die Aufgabe der Pipeline ist es, jede E-Mail zu verstehen und richtig zu routen: strukturierte Daten extrahieren, Dokumente am richtigen Ort ablegen, Datensätze im richtigen System aktualisieren und Unklarheiten an einen Menschen weitergeben, wenn sie selbst nicht entscheiden kann.

Die ursprüngliche Implementierung war ein n8n-Workflow mit 53 Nodes. IMAP-Trigger → Verzweigung nach Anhangstyp → KI-Extraktions-Node → Anreicherung über eine HTTP-API → Klassifikator → API-Writes → Benachrichtigung. Drei Nebenflows auf einem VPS erledigten Subroutinen, die nicht sauber in n8n passten. Das Ganze lief per Cron, sah auf dem Canvas beeindruckend aus und stand nach etwa zwei Wochen als funktionierende v1.

Einen Monat lang war das großartig.

Wo es aufgehört hat, großartig zu sein

Sechs wiederkehrende Fehlermodi, ungefähr in der Reihenfolge, in der sie aufgetaucht sind:

1. Duplikate bei erneuter Erwähnung

Ein Datensatz, der schon im Zielsystem lag, bekam bei der nächsten Erwähnung derselben Entität eine zweite Kopie. Der Workflow hatte zwar einen Schritt „bestehenden Datensatz nachschlagen“, aber das war ein unscharfer Namensabgleich — gut, solange sich der kanonische Identifier (eine URL, eine Domain, eine E-Mail-Adresse) extrahieren ließ, fragil, wenn nicht. Der Fallback-Pfad lief auf „neu anlegen“ hinaus, und das Duplikat rutschte durch. Aufgefallen ist es erst, wenn jemand das Zielsystem geöffnet und zweimal dasselbe nebeneinander gesehen hat.

2. Stille Verluste im LLM-Extraktionsschritt

Bei langen Inputs lieferte der LLM-Extraktions-Node abgeschnittenes JSON zurück. Der nachgelagerte Parser hat das anstandslos hingenommen — er hat geparst, was sich parsen ließ, und den Rest verworfen. Aus Sicht der Nutzer tauchten Einträge, die sie im Input sehen konnten, im Output einfach nicht auf. Es gab kein Log darüber, was verworfen wurde oder warum.

3. Übermütige Defaults bei unsicheren Klassifizierungen

Der Klassifikator musste eine von mehreren Routen wählen. War er unsicher, fiel er auf die häufigste zurück. Genau dieser Default ging in die falsche Richtung: Die häufigste Route war gleichzeitig die, bei der ein Fehler am teuersten war (sie schrieb in einen produktiven Datenbestand). Der richtige Default für einen unsicheren Klassifikator ist „frag einen Menschen“, nicht „raten und schreiben“.

4. Stille Auth-Fehler bei nachgelagerten Aufrufen

Einige der nachgelagerten Aktionen liefen gegen Endpunkte von Drittanbietern, die gelegentlich eine interaktive Authentifizierung verlangten (ein Einmalpasswort, einen Token-Refresh). Traf eine Aktion auf so eine Sperre, schlug sie fehl, und der Workflow machte einfach weiter, ohne ein klares Signal zu geben. Bemerkt hat es erst jemand, als er nach dem Ergebnis fragte, das eigentlich hätte entstehen sollen.

5. Keine Möglichkeit für Menschen, Unklarheiten aufzulösen

Die größte Quelle für Reibung bei den Nutzern war kein Bug, sondern eine Lücke. Wenn der Agent wirklich nicht entscheiden konnte — mehrere plausible Entitäten in einer Nachricht, fehlender Kontext, niedrige Konfidenz —, gab es keinen Kanal, über den der Nutzer zurückantworten konnte. Der Agent hatte keinen Eingang für Rückfragen. Mehrdeutige E-Mails landeten in einem „Failed“-Ordner und blieben dort, bis jemand sie von Hand durchging.

6. Kein Replay, keine Fixtures

Jeder Fix musste getestet werden, indem man eine echte E-Mail durch die Live-Pipeline schickte und zusah, was passiert. Es gab keine Möglichkeit zu sagen: „Hier ist genau die Nachricht, die es kaputt gemacht hat; spiel sie lokal gegen meinen Fix ab und sag mir, ob jetzt das Richtige passiert.“ Die Entwicklungsgeschwindigkeit an einer Pipeline, die du nicht erneut abspielen kannst, liegt ungefähr bei null.

Das ist kein n8n-Problem

Kurz innehalten, denn die naheliegende Lesart dieser Liste ist „n8n ist schlecht“. Ist es nicht. n8n ist für eine große Klasse von Automatisierungsproblemen wirklich das richtige Tool. Drei strukturelle Unstimmigkeiten haben es für dieses Problem zum falschen gemacht:

Der Graph war keine nützliche Darstellung mehr. Der Großteil der interessanten Logik steckte in Function-Nodes mit JavaScript. Der Canvas mit 53 Nodes war eine Fassade über ~600 Zeilen imperativen Code, verteilt auf zwei Dutzend Schnipsel, die man auf den ersten Blick nicht sieht. Sobald du Hunderte Zeilen Code pflegst, die über versteckte Kästen verteilt sind, ist der visuelle Workflow eine Last statt ein Gewinn. Du zahlst die Kosten eines visuellen Tools, ohne seinen Nutzen zu bekommen.

Der State lag an der falschen Stelle. Der Workflow verließ sich auf IMAP-Protokoll-Flags, um zu entscheiden, was neu war. Jeder Mensch, der das Postfach las, konnte diese Flags umlegen und damit still das Verhalten des Workflows ändern. Es gab keine persistente Idempotenz-Schicht, die dem Workflow gehörte. Jeder externe Write war für sich idempotent (oder eben nicht), je nachdem, welchen Integrations-Node man benutzt hatte.

Es gab keinen Orchestrator. „Orchestrator“ im Agent-Sinn — etwas, das den Lebenszyklus einer einzelnen Arbeitseinheit verantwortet, anhand der Ergebnisse entscheidet, was als Nächstes passiert, und einen strukturierten Status nach außen gibt. n8n-Nodes reichen Daten nach unten weiter; sie prüfen nicht, ob die angestoßene Aktion erfolgreich war. Wenn mitten im Graphen etwas schiefging, zuckte der Workflow mit den Schultern und machte weiter.

Die Diagnose: Dieser Workload brauchte (a) einen echten Orchestrator mit persistentem State und Replay, (b) einen ehrlichen Umgang mit Unsicherheit inklusive eines Outputs „Ich weiß es nicht“ und (c) die Möglichkeit, inline einen Menschen um Hilfe zu bitten. Nichts davon gehört zu den Stärken von n8n. All das sind Grundfunktionen eines Code-first-Agent-Harness.

Was ich stattdessen gebaut habe

Zwei Prozesse, ein Entscheidungspunkt:

[ inbound poller (Python) ]
            │
            ▼
   materialize raw + parsed + signals to disk
            │
            ▼
[ claude -p "/process-event <basePath>" ]
            │
            ▼
   classifier skill  →  { route, confidence, reason, signals_used }
            │
            ▼
   hard safety rails (Python, post-LLM)
            │
            ▼
   ┌─────────────┬───────────────┬──────────────────┐
   │             │               │                  │
 route A      route B          route C        NeedsAttention
   │             │               │                  │
   ▼             ▼               ▼                  ▼
  …             …               …             ask the human

Konkret:

  • Eine deterministische Python-Grenzschicht läuft per Cron. Kein LLM, keine Ermessensentscheidungen. Sie holt neue Nachrichten ab, schreibt jede davon nach inbox/<id>/ als Rohartefakt + geparstes JSON + ein atts/-Verzeichnis, berechnet ein Signals-Dict (Allow-List-Treffer, Anhangstypen, Registry-Lookups, Keyword-Treffer, strukturelle Zählwerte) und speichert die Message-ID für die Idempotenz in SQLite.
  • Für jede neue Nachricht startet sie genau einen Aufruf claude -p "/process-event <basePath>". Das ist der Orchestrator. Claude liest die materialisierten Dateien und führt einen Klassifikator-Skill aus, der { route, confidence, reason, signals_used } ausgibt.
  • Harte Leitplanken laufen nach dem LLM, in Python. Sie prüfen die Konfidenzschwelle, die Allow-List der Absender und die Plausibilität zwischen behaupteter Route und den Artefakten auf der Platte. Alles, was an den Leitplanken scheitert, wird auf unknown gezwungen und einem Menschen vorgelegt.
  • Für jede akzeptierte Route kapselt ein Route-Handler-Skill die eigentliche Arbeit. Skills geben am Ende eine JSON-Statuszeile aus; der Orchestrator parst sie und aktualisiert den externen Zustand (SQLite + Queue-Label).

Insgesamt: ~3000 Zeilen Python, fünf Skills, ein Slash-Command, eine Cron-Zeile.

Wie jeder Fehlermodus mechanisch ausgeschlossen wird

Das ist der Teil, auf den es ankommt. Ein Rewrite, der die Tools austauscht, aber dieselben Fehler reproduziert, ist Theater. Jedes Problem von oben entspricht einer konkreten Architekturentscheidung:

Duplikate: deterministische external_id

Jeder Write leitet eine stabile external_id aus kanonischen Feldern ab, über eine geordnete Fallback-Kette — primärer Identifier (URL oder Domain) → sekundärer Identifier → Name als Slug. Der Write-Aufruf macht einen Upsert auf diese ID. Läuft derselbe Input also noch einmal durch — aus Versehen, per Replay oder weil ein Flag umgelegt wurde —, wird der bestehende Datensatz aktualisiert, statt ein Duplikat anzulegen. Auch der Sonderfall „primärer Identifier fehlt“ geht glimpflich aus, weil die Fallback-Kette für denselben Input so oder so dieselbe deterministische ID erzeugt.

Stille Verluste: jede Zeile aufzählen, jedes Überspringen loggen

Der Extraktionsschritt schreibt pro Input-Einheit, die er sieht, eine Zeile in das Audit-Log extract.jsonl — geparst oder verworfen. Verworfene Zeilen tragen einen skip_reason. Zehn Einträge rein, zehn Einträge im Log — selbst wenn der nachgelagerte Klassifikator einige überspringt, bleibt der Grund dafür nachvollziehbar. Ein täglicher Digest macht ungewöhnliche Verwerfungsquoten sichtbar.

Übermütige Defaults: unknown ist ein vollwertiger Output

Die Rubrik des Klassifikators definiert confidence ≥ 0.95 für „mehrere starke Signale stimmen überein“, 0.85–0.94 für „ein starkes Signal, keine Widersprüche“ und alles unter 0,85 → unknown, unabhängig vom besten Tipp. Die Leitplanken nach dem LLM erzwingen die Schwelle, auch wenn das LLM versucht, sie zu ignorieren. Das Standardverhalten bei Unsicherheit ist „den Menschen anpingen“, nicht „raten und in Produktion schreiben“.

Stille Auth-Fehler: Needs-Attention ist strukturierter State

Wenn ein nachgelagerter Aufruf auf eine interaktive Sperre trifft, die er nicht auflösen kann, gibt die Route needs_attention mit einem strukturierten Feld reason zurück. Die Nachricht wandert in eine eigene Queue, der tägliche Digest markiert sie ausdrücklich, und der Orchestrator hält die Sperre in SQLite fest, damit der nächste Replay weiß, wonach er fragen muss.

Kein Mensch im Loop: Reply-to-Act

Bei mehrdeutigen Nachrichten antwortet der Agent dem ursprünglichen Absender mit einer klaren Bitte: „Antworte mit <option A>, <option B>, <option C> oder <param>: <value>, um fortzufahren.“ Der Absender antwortet direkt im Thread. Der Inbound-Poller greift die Antwort auf, ordnet sie über In-Reply-To dem Base-Path der ursprünglichen Nachricht zu, parst den Befehl und gibt ihn als erzwungene Route oder gespeicherten Parameter an den Orchestrator zurück. Betreffzeilen und E-Mail-Threading übernehmen die Zuordnung — kein separates Portal, kein Link-Tracking. Genau diese eine Änderung hat die Nutzererfahrung von „der Bot ist unzuverlässig“ zu „der Bot hilft und fragt ab und zu nach einer Bestätigung“ gedreht.

Kein Replay: jeder Lauf lässt sich von der Platte wieder abspielen

Weil die deterministische Grenzschicht jede Nachricht nach inbox/<id>/ materialisiert, bevor irgendetwas anderes passiert, ist Replay trivial: python scripts/replay.py --eml fixtures/<case>.eml reproduziert jeden Vorfall Byte für Byte gegen deinen lokalen Code. Bug fixen, Fixture erneut laufen lassen, neuen Output prüfen, ausliefern.

Was ich beim nächsten Projekt wieder so machen würde

Ein paar Muster haben sich als tragend erwiesen, weit über dieses eine Projekt hinaus:

Materialize-then-dispatch. Erledige die deterministische Arbeit an der Grenze — Protokoll-I/O, Parsing, Berechnung der Signale — in einfachem Code, bevor das LLM irgendetwas sieht. Das LLM liest aus einer bekannten Verzeichnisstruktur. Das entkoppelt „woher die Nachrichten kommen“ von „wie sie verarbeitet werden“, und Replay gibt es gratis dazu.

Signals + LLM, nie nur eins von beiden. Reine Regeln verpassen neuartige Inputs. Ein reines LLM halluziniert bei mehrdeutigen Inputs. Berechne im Code ein deterministisches Signals-Dict und gib es dem LLM als Ground Truth mit; das LLM gewichtet die Signale und zieht daraus seine Schlüsse. Immer beides.

Leitplanken nach dem LLM, nicht davor. Wer Regeln als Gate vor das LLM setzt, erfindet den reinen Regel-Klassifikator neu, nur mit Extraschritten. Setzt du sie danach, schränkst du den Output ein, ohne den Input einzuschränken — das LLM kann weiter über neuartige Fälle nachdenken, aber seine Schlüsse werden geprüft.

Die Exit-Codes von claude -p lügen. Sie spiegeln den Exit von Claude wider, nicht das Ergebnis des angestoßenen Skills. Jeder Skill im Harness muss am Ende eine JSON-Statuszeile ausgeben, die der Orchestrator parst. Sonst glaubt Cron, alles sei in Ordnung, während jeder Skill still scheitert.

Namen statt IDs. Hartcodierte Ressourcen-IDs aus der UI eines Anbieters verrotten in dem Moment, in dem jemand eine Ressource neu anlegt. Schlag über die API per Namen nach, cache die Zuordnung lokal und aktualisiere sie regelmäßig. Ein Request mehr, null Verrottung.

State extern führen, nicht in Protokoll-Flags. \Seen-Flags in E-Mails, Ack-Zustände in Queues, „gesehen“-Marker von File-Watchern — all das kann von Menschen, anderen Clients oder Neustarts umgelegt werden. Halte die Idempotenz in deinem eigenen Store, mit einem stabilen Identifier als Schlüssel.

Den Menschen früh in den Loop holen. Eine Pipeline, die um Hilfe bitten kann, ist grundlegend etwas anderes als eine, die nur gelingen oder scheitern kann. Der Reply-to-Act-Loop war mit großem Abstand das Feature mit dem größten Hebel in diesem Rewrite, und ihn später nachzurüsten hätte mehr gekostet, als ihn von Anfang an einzubauen.

Wann visuelle Workflows, wann Code-first-Agent-Harnesses

Ein visuelles Workflow-Tool ist die richtige Wahl, wenn:

  • der Flow hauptsächlich „API → API → API“ ist, mit wenig Ermessen in der Mitte.
  • die Datenstruktur stabil ist und die Inputs sauber sind.
  • auch Nicht-Engineers den Flow ansehen oder bearbeiten müssen.
  • die Kosten eines Ersatzes vor allem im Integrationskleber zwischen APIs stecken.

Ein Code-first-Agent-Harness ist die richtige Wahl, wenn:

  • der Workload Ermessensentscheidungen über unordentlichen, von Menschen geschriebenen Input verlangt.
  • Idempotenz, Replay und Audit-Logging wichtig sind.
  • Fehler strukturiert und so, dass man handeln kann, bei einem Menschen ankommen müssen.
  • der Fußabdruck „Logik in Function-Nodes“ anfängt, den Fußabdruck „Logik in der Graph-Topologie“ zu übersteigen.

Bei dieser Pipeline habe ich diese Schwelle irgendwo um die 30 Nodes überschritten. Als der Workflow bei 53 Nodes angekommen war, war die visuelle Darstellung aktiv irreführend — das meiste interessante Verhalten steckte in drei oder vier Function-Nodes —, und der Neubau war unvermeidlich geworden.

Was die Migration gekostet hat

Der Ehrlichkeit halber: etwa fünf Stunden konzentrierte Arbeit, von Anfang bis Ende.

Ohne Kontext führt diese Zahl in die Irre. Dass es keine drei Wochen waren, liegt daran, dass die n8n-Workflow-Datei schon das Designdokument war. Das exportierte JSON beschreibt jeden Node, jede Verbindung, den Quellcode jedes Function-Nodes und jede Credential-Referenz. „IMAP-Trigger → Verzweigung nach Anhangstyp → Function-Node mit diesem JavaScript → HTTP-Write“ in „Python-Poller schreibt auf die Platte → Klassifikator-Skill → Route-Handler-Skill“ zu übersetzen, ist eine mechanische Portierung, keine Architekturübung. Das meiste Nachdenken war schon passiert — es lag nur im Canvas statt in einer Markdown-Datei.

Den größten Brocken dieser fünf Stunden hat der Reply-to-Act-Loop gefressen, denn bei diesem Teil konnte das n8n-JSON nicht helfen: ein Bot-Postfach zu bauen, das über verschiedene Provider hinweg zuverlässig senden und empfangen kann, und Antworten ihrem ursprünglichen Thread zuzuordnen. Das war wirklich neuer Code, ohne Vorbild im Workflow.

Auf der Habenseite: Der tägliche Digest zeigt jetzt null Zeilen „Failed (silent)“. Alles ist entweder Processed, NeedsAttention (mit strukturiertem Grund und einem angehängten /replay-Befehl) oder — sehr selten — Failed mit einer expliziten Fehlermeldung. Das Vertrauen in die Pipeline ging von „Ich prüfe sie täglich, weil ich ihr nicht traue“ zu „Ich schaue einmal pro Woche in den Digest, um zu sehen, was durchgelaufen ist“.

Das ist der Teil, für den ich diesen Post schreiben würde. Visuelle Workflow-Tools sind großartig — bis zu dem Moment, in dem sich deine Messlatte für Zuverlässigkeit von „funktioniert meistens“ zu „ich muss jeden Fehler erklären können“ verschiebt. Wenn das passiert, ist das Günstigste, was du tun kannst, den Workflow als Code neu zu schreiben, mit einem LLM, das in der Mitte die Ermessensentscheidungen trifft — und wenn du schon eine funktionierende n8n-Version hast, startest du von einem viel besseren Punkt als mit einer leeren Datei.


Wenn du über eine ähnliche Migration nachdenkst und jemanden zum Sparring suchst: Du erreichst mich unter matthias@lifeisapitch.io.