Nodes, Expressions und der erste Workflow ohne KI

Nodes, Expressions und der erste Workflow ohne KI

Artikel 4 · Serie: Einstieg in n8n

Das ist der erste Artikel der Reihe, in dem ein Workflow tatsächlich läuft. Wir empfangen einen HTTP-POST, der Body wird erarbeitet, ein Ticket klassifiziert und eine JSON-Antwort zurückschickt. Der Workflow nutzt drei Kern-Knotentypen: Set, Code und Switch. Zu verstehen, was jeder davon kann — und was nicht — ist das primäre Lernziel dieses Artikels.

Das sekundäre Ziel: am Ende läuft ein regelbasierter Classifier auf dem gepinnten Datensatz aus Artikel 3, und seine strukturellen Grenzen sind explizit dokumentiert — nicht als Designfehler, sondern als bewusstes Argument für den Wechsel zur KI-Klassifikation in Artikel 6.

Der Code zu diesem Artikel liegt auf Codeberg, Tag v0.4: codeberg.org/rotecodefraktion/n8n-einstieg.

Das Item-Modell in Bewegung

Artikel 1 hat das Item-Modell theoretisch eingeführt: n8n übergibt Daten zwischen Knoten als Arrays von Items. Jedes Item ist ein JSON-Objekt. Ein Workflow verarbeitet jedes Item einzeln oder als Batch, je nach Konfiguration.

In der Praxis macht diese Unterscheidung an zwei Stellen konkret einen Unterschied.

In Expressions — allem zwischen {{ }} — steht $json für das JSON-Objekt des aktuellen Items. Das funktioniert erwartungsgemäß:

{{ $json.body.subject }}

ergibt den Wert unter body.subject im aktuellen Item.

Im Code-Node ist $json nicht verfügbar. Die Laufzeitumgebung im Code-Node ist JavaScript mit einem eigenen Scope — nicht der Expression-Evaluator. Die äquivalente Zugriffsform ist:

const itemData = $input.item.json;

Der Monaco-Editor in n8n 2.21.4 unterstreicht $json im Code-Node rot. Regel für diesen Workflow: In Expressions $json.field, im Code-Node-Body $input.item.json.field.

Expressions — Syntax und Fallstricke

Expressions in n8n folgen Template-Literal-Syntax: {{ ausdruck }}. Die Expression-Engine wertet JavaScript-ähnlichen Code innerhalb der doppelten geschweiften Klammern aus.

Drei Fehlerquellen treten im Alltag regelmäßig auf.

Undefined-Felder. Wenn $json.body.language nicht existiert, wertet der Ausdruck zu undefined aus, nicht zu einem leeren String. Ein nachgelagerte Knoten, der einen String erwartet, bekommt undefined und verhält sich möglicherweise unerwartet. Defensiv: {{ $json.body.language ?? 'de' }}.

Typ-Mismatches. $json.body.sla_hours kann als String "8" ankommen statt als Zahl 8, wenn der Set-Node ein Feld als Fixed String gesetzt hat. Mathematische Operationen scheitern dann oder produzieren NaN. Der Set-Node hat im Fixed-Modus separate Typen für Strings und Zahlen — der falsche Typ ist schnell gewählt.

Fixed vs. Expression im Set-Node. Der Set-Node hat pro Feld einen Toggle zwischen Fixed (Literalwert) und Expression (ausgewertetes {{ }}). In n8n 2.21.4 erscheint dieser Toggle nur bei Hover und persistiert visuell nicht. Die zuverlässige Methode, ein Feld als Expression zu setzen: das Feld aus dem INPUT-Panel links direkt in das Zielfeld ziehen. Das funktioniert aber nur nach einem erfolgreich ausgeführten Vorgängerknoten.

Ablauf für den Normalize-Knoten in diesem Workflow:

  1. Webhook-Knoten anlegen, „Listen for Test Event" klicken
  2. Test-POST per curl schicken
  3. Normalize-Knoten öffnen — das INPUT-Panel zeigt jetzt die echte Webhook-Payload
  4. Felder aus dem INPUT-Panel per Drag-and-Drop in die Set-Node-Felder ziehen

Ohne vorherigen Test-Run ist das INPUT-Panel leer, Drag-and-Drop funktioniert nicht.

Set-Node oder Code-Node?

Beide Knoten transformieren Daten. Das Entscheidungskriterium ist einfach:

SituationWahl
Felder umbenennen, zusammenführen, herausfilternSet-Node
Einzelnen Wert berechnen, z. B. .toLowerCase()Set-Node mit Expression
Bedingte Logik, Schleifen, reduce, mapCode-Node
Keyword-Matching über mehrere KategorienCode-Node
Externe Bibliothek aufrufenCode-Node

Der Set-Node ist deklarativ: man definiert, wie der Output aussehen soll, nicht wie man ihn berechnet. Das ist direkt im Canvas lesbar, ohne den Knoten zu öffnen. Der Code-Node ist imperativ: man schreibt JavaScript, und im Canvas steht nur der Knotenname.

Für den Normalize-Schritt — Felder aus dem Webhook-Body extrahieren und text_normalized aufbauen — ist der Set-Node die richtige Wahl. Fünf Feldzuweisungen, eine davon eine einfache String-Konkatenation mit .toLowerCase(). Keine Conditions, keine Schleifen.

Für den Classify-Keywords-Schritt — text_normalized gegen 30+ Keywords über sechs Kategorien matchen, jede Kategorie bewerten, den Top-Match wählen — ist der Code-Node die richtige Wahl. Das ist eine Reduce-Operation über eine Liste. Der Set-Node kann das nicht ausdrücken.

Routing mit dem Switch-Node

Der Switch-Node leitet jedes Item an einen von mehreren Outputs weiter, basierend auf Bedingungen. Modus Rules: jede Regel ist ein if-Zweig. Der Knoten wertet die Regeln der Reihe nach aus und schickt das Item an den ersten matchenden Zweig.

Konfiguration für diesen Workflow:

  • Regel 1: {{ $json.category }} gleich sap-basis → Output: sap-basis
  • Regel 2: {{ $json.category }} gleich sap-functional → Output: sap-functional
  • Regel 3: {{ $json.category }} gleich infrastruktur → Output: infrastruktur
  • Regel 4: {{ $json.category }} gleich cloud → Output: cloud
  • Regel 5: {{ $json.category }} gleich security-pki → Output: security-pki

Das linke Feld jeder Regel muss im Expression-Modus stehen. Der gleiche Hover-Toggle wie beim Set-Node gilt hier — sicherstellen, dass das Feld {{ ... }}-Syntax zeigt und nicht den Literal-String $json.category.

Fallback-Zweig. Ein Switch-Node ohne Default-Zweig lässt Items, die keiner Regel entsprechen, stumm fallen. Die Option heißt in n8n 2.21.4 „Extra Output" (die Dokumentation nennt sie „Fallback Output" — gleiche Funktion, anderes Label). Mit aktivem Extra Output erscheint ein sechster Output, der alles auffängt, was die fünf Regeln nicht matchen — in diesem Workflow Tickets mit category: sonstiges.

Die Option „Extra Output" befindet sich unten im Settings-Panel des Knotens. Aktivieren. Einen sechsten Output verbinden mit dem Label: Sonstiges-Set-Node.

UI-Hinweis: die Option „Convert types where required" in n8n 2.21.4 entspricht „Less Strict Type Validation" in der Dokumentation. Gleiche Semantik, anderes Label.

Der Workflow Schritt für Schritt

Workflow-Canvas: Webhook → Normalize → Classify Keywords → Routing Rules → 6 Label-Nodes → Respond to Webhook

Der Workflow hat 11 Knoten. Bauen in dieser Reihenfolge.

Knoten 1 — Webhook

Knotentyp: „Webhook"

  • HTTP Method: POST
  • Path: ticket-classify
  • Respond: Using Respond to Webhook Node
  • Authentication: None

Production-URL nach dem Publish: https://<host>/webhook/ticket-classify. Test-URL: https://<host>/webhook-test/ticket-classify. Die Test-URL ist nur aktiv, solange der Workflow im „Listen for Test Event"-Modus ist.

Knoten 2 — Normalize

Knotentyp: „Edit Fields (Set)"

  • Mode: Manual Mapping
  • „Include in Output: All Input Fields" → aus

Fünf Felder, alle im Expression-Modus:

FeldExpression
id{{ $json.body.id }}
subject_raw{{ $json.body.subject }}
body_raw{{ $json.body.body }}
language{{ $json.body.language }}
text_normalized{{ ($json.body.subject + ' ' + $json.body.body).toLowerCase() }}

Den Knoten über Doppelklick auf den Panel-Titel in Normalize umbenennen.

Knoten 3 — Classify Keywords

Knotentyp: „Code"

  • Mode: Run Once for Each Item
  • Language: JavaScript
const itemData = $input.item.json;
const text = itemData.text_normalized || '';

const CATEGORIES = [
  {
    id: 'sap-basis',
    keywords: [
      'sm50', 'stms', 'abap-dump', 'transport', 'rfc-verbindung',
      'rfc connection', 'kernel', 'basis', 'sap-gui', 'workprozess',
      'work process', 'sap login', 'sap-login',
    ],
  },
  {
    id: 'sap-functional',
    keywords: [
      'buchungsbeleg', 'me21n', 'bestellung', 'lieferung', 'abrechnung',
      'purchase order', 'vendor', 'material', 'sd', 'fi', 'mm', 'hr',
      'delivery', 'invoice',
    ],
  },
  {
    id: 'infrastruktur',
    keywords: [
      'server', 'raid', 'nfs', 'backup', 'latenz', 'netzwerk', 'storage',
      'sicherung', 'disk', 'network', 'bandwidth', 'outage',
    ],
  },
  {
    id: 'cloud',
    keywords: [
      'azure', 'kubernetes', 'pod', 'terraform', 'aks', 'subscription',
      'pipeline', 'container', 'devops', 'cloud', 'deployment',
    ],
  },
  {
    id: 'security-pki',
    keywords: [
      'zertifikat', 'certificate', 'cve', 'su53', 'berechtigung',
      'penetration', 'pki', 'ssl', 'tls', 'auth', 'permission',
      'vulnerability',
    ],
  },
];

const scored = CATEGORIES.map(c => ({
  id: c.id,
  matches: c.keywords.filter(kw => text.includes(kw)).length,
}));

const best = scored.reduce((a, b) => (b.matches > a.matches ? b : a));

const scores = {};
scored.forEach(s => { scores[s.id] = s.matches; });

return {
  json: {
    ...itemData,
    category: best.matches > 0 ? best.id : 'sonstiges',
    match_count: best.matches,
    scores,
  },
};

$input.item.json, nicht $json. Den Knoten in Classify Keywords umbenennen.

Knoten 4 — Routing Rules

Knotentyp: „Switch", Modus: Rules

Fünf Regeln, je:

  • Value 1: {{ $json.category }} (Expression-Modus)
  • Operation: „is equal to"
  • Value 2: Kategorie-ID (Fixed-Modus)

„Extra Output" am Ende des Settings-Panels aktivieren. Jeden Output über „Rename Output" beschriften: sap-basis, sap-functional, infrastruktur, cloud, security-pki. Der Fallback ist der sechste Output. Knoten in Routing Rules umbenennen.

Knoten 5a–5f — Label-Nodes

Sechs Set-Nodes, einer pro Output des Switch:

Knotenlabelsla_hoursresponse_text
Label: SAP BasisSAP Basis8Ticket der Kategorie SAP Basis. Bearbeitung durch Basis-Team innerhalb von 8 Stunden.
Label: SAP FunctionalSAP Functional16Ticket der Kategorie SAP Functional. Bearbeitung durch Functional-Team innerhalb von 16 Stunden.
Label: InfrastrukturInfrastruktur12Ticket der Kategorie Infrastruktur. Bearbeitung durch Infra-Team innerhalb von 12 Stunden.
Label: CloudCloud12Ticket der Kategorie Cloud. Bearbeitung durch Cloud-Team innerhalb von 12 Stunden.
Label: SecuritySecurity / PKI4Ticket der Kategorie Security/PKI. Eskalation innerhalb von 4 Stunden.
Label: SonstigesSonstiges24Ticket ohne Kategorie. Manuelle Sichtung innerhalb von 24 Stunden.

Jeder Set-Node: „Include in Output: All Input Fields" → an. Drei zusätzliche Felder als Fixed String: label, sla_hours (als Zahl), response_text.

Knoten 6 — Respond to Webhook

Knotentyp: „Respond to Webhook"

  • Response Mode: First Incoming Item
  • Response Code: 200

Alle sechs Label-Nodes verbinden mit diesem einen Knoten. n8n erlaubt mehrere eingehende Verbindungen, weil der Switch-Node sicherstellt, dass pro Item nur ein Zweig feuert.

Publish. Nach dem Verbinden aller Knoten: Cmd+S zum Speichern, dann „Publish". Die Production-URL ist erst nach dem Publish aktiv — Cmd+S allein reicht nicht.

Smoke-Test gegen die Production-URL:

curl -k -X POST https://localhost/webhook/ticket-classify \
  -H 'Content-Type: application/json' \
  -d '{
    "id": "TKT-9999",
    "subject": "SAP-Login nach Passwortreset",
    "body": "SM50 meldet Verbindungsausfall, RFC-Verbindung tot",
    "language": "de"
  }'

Erwartete Antwort (gekürzt):

{
  "id": "TKT-9999",
  "category": "sap-basis",
  "match_count": 2,
  "label": "SAP Basis",
  "sla_hours": 8
}

Tickets einspeisen

Das Script scripts/seed-tickets.py lädt Tickets aus testdata/tickets.parquet und schickt jeden Eintrag per POST an den Webhook. Ergebnis: Accuracy und Statistik pro Kategorie.

cd n8n-einstieg
uv run scripts/seed-tickets.py \
  --webhook-url https://localhost/webhook/ticket-classify \
  --limit 30 \
  --insecure

--insecure überspringt die TLS-Prüfung für das selbst-signierte Caddy-Zertifikat. --limit 30 nimmt die ersten 30 Tickets aus dem gepinnten Datensatz.

Das Script nutzt PEP 723 Inline-Abhängigkeiten: httpx und pyarrow sind im Script-Header deklariert und werden von uv run beim ersten Aufruf automatisch installiert. Kein separates requirements.txt nötig.

Auf 30 Tickets aus dem gepinnten Datensatz: 29 von 30 korrekt klassifiziert (97%). Latenz p50 rund 10 ms, Maximum unter 120 ms — weit unter dem Limit von 500 ms aus der Spec.

Replay-Test

Die pytest-Suite in tests/ läuft dieselben 20 Tickets durch den laufenden Workflow und prüft Kategorie-Korrektheit und Antwortzeit.

cd n8n-einstieg/tests
uv run --with pytest --with httpx --with pyarrow pytest

Die Webhook-URL kommt aus der Umgebungsvariable N8N_WEBHOOK_URL (Default: https://localhost/webhook/ticket-classify).

40 Test-Cases insgesamt:

  • 20× test_classifier_category_matches_ground_truth — Kategorie gegen Ground-Truth aus tickets.parquet
  • 20× test_classifier_latency_under_500ms — Antwortzeit unter 500 ms

TKT-0005 ist mit pytest.mark.xfail(strict=True) markiert. Der Test wird erwartet zu scheitern. Wenn er nicht scheitert, meldet pytest einen Fehler — weil dann ein bekannter Miss plötzlich korrekt wäre, was eine Regression in umgekehrter Richtung bedeutet. Bei korrekt laufendem Classifier: 39 passed, 1 xfailed.

Die Grenzen des Keyword-Matchings

TKT-0005 ist der dokumentierte Lehrfall.

Ticket (Englisch, Kategorie sap-basis, Persona admin-precise):

  • Subject: „SAP GUI connection timeout after VPN change"
  • Body: „Since the VPN gateway migration, SAP GUI shows timeout after 2 minutes idle. RFC destination PRD_RFC is affected."

Klassifikations-Ergebnis: sonstiges, match_count: 0.

Der Classifier findet kein einziges Keyword. Warum:

Im TicketKeyword in der ListeTreffer?
SAP GUI (mit Leerzeichen)sap-gui (mit Bindestrich)
RFC destinationrfc connection / rfc-verbindung
VPN gatewayvpn-verbindung / vpn connection

Die Keyword-Liste ist deutsch-zentriert mit Bindestrich-Tokens. Das Ticket schreibt dieselben Konzepte mit Leerzeichen und leicht abweichender Vokabel. Substring-Match ohne semantisches Verständnis scheitert.

Das ist kein Fehler im Classifier-Design — es ist die Eigenschaft, die regelbasierte Klassifikation von KI-Klassifikation trennt. Ein Sprachmodell erkennt, dass „SAP GUI", „SAP-GUI" und „SAPgui" denselben Begriff meinen. Der Keyword-Classifier kann das ohne expliziten Eintrag nicht.

Drei weitere Muster, die ihn in der Praxis treffen:

Abkürzungen. „Berechtigungsproblem" matched su53. „User kann T-Code nicht aufrufen" matched gar nicht — obwohl der Sachverhalt identisch ist. Die Verbindung zwischen Beschreibung und Code existiert nur, wenn sie explizit in der Keyword-Liste steht.

Vokabular-Drift. Englische Tickets in SAP-Domänen schreiben work process statt Workprozess, transport request statt Transportauftrag. Jede Abweichung von der Liste ist ein potenzieller Miss. Der Datensatz aus Artikel 3 ist bewusst 60% Deutsch, 40% Englisch — genau damit diese Schwäche sichtbar wird.

Fehlender Eintrag. Dump in SM37 matched nicht sm37, weil sm37 nicht in der Keyword-Liste steht. Wer die Liste pflegt, pflegt im Grunde ein zweites System neben dem Ticket-System. Jede neue SAP-Transaktion, jedes neue Cloud-Produkt, jede neue Abkürzung braucht einen manuellen Eintrag.

Diese Lücken ließen sich durch Erweiterung der Keyword-Listen teilweise schließen. Der Aufwand skaliert aber mit der Domänentiefe: für jede Sprache, jeden Dialekt, jede Abkürzungsvariante wächst die Liste. Das ist die strukturelle Grenze regelbasierter Klassifikation.

Artikel 5 kommt zuerst: der Webhook ist bislang offen — kein API-Key, keine IP-Beschränkung. Authentication gehört vor den Schritt zur KI.

Artikel 3: Testdaten, weil echte nicht gehen → Artikel 5: Webhook absichern (erscheint demnächst)