Skip to content

21 – Agent-Tools (Capability-Referenz)

Schicht: packages/agent/src/capabilities/* (Registry), packages/agent/src/commands/* (deterministische Commands), packages/agent/src/notifications/actions.ts (Notification-Buttons) Kanäle: WhatsApp (BullMQ-Worker wa-inbound) und Web-Assistent (/assistent/api/agent/chat) — beide bauen die Registry über dieselbe registerCapabilities() auf. Stand: verifiziert am Code 2026-08-07 (Branch feat/anfragen-prozess).

⚠️ PFLEGEREGEL (verbindlich): Wer ein Agent-Tool anlegt, ändert oder entfernt, aktualisiert dieses Dokument im selben Commit/PR. Details und Checkliste in §8 Pflegeregel. Regel-Datei fürs Tooling: .cursor/rules/agent-tools-dokumentation.mdcSeit 2026-09-12 (Modularisierung Phasen 0–3): Ein neues Tool muss über sein name-Präfix oder Explicit-Name im Manifest-Feld agent.toolPrefixes / agent.toolNames einem Modul zugeordnet sein. Zuordnungs-Test: packages/agent/test/registry-modules.test.ts schlägt fehl, wenn das nicht stimmt. Je Tool-Katalog-Abschnitt (2.x) folgt eine Zeile „Modul: …" unter der Überschrift mit der Zuordnung aus dem Manifest.

1. Funktionsweise (Kurzüberblick)

  • Ein Tool = defineTool({ name, description, parameters (Zod), requiredRole, **module**, handler }) in einer Datei unter capabilities/, registriert in capabilities/index.ts via registerAll(...). Kein Eingriff in Loop/Registry/Channel nötig.
  • Modul-Zuordnung: Jedes Tool gehört über seinen name (Tool-Präfix oder expliziter Name) zu einem Modul laut Manifest (packages/domain/src/modules/manifest.ts). Die Registry (agent/registry.ts) filtert beim Build-Up die aktiven Tools nach der Rolle des Nutzers und den freigeschalteten Modulen des Mandanten. Zu einem gesperrten Tool antwortet der Agent mit einem verständlichen Hinweis. Die verfügbaren Module werden dem LLM im Prompt-Block VERFÜGBARE BEREICHE (§8.3) genannt, sodass das Modell improvisierte Antworten vermeidet.
  • requiredRole = Mindestrolle (kleiner = höher privilegiert): 3 = alle inkl. Mitarbeiter, 2 = Admin + Manager, 1 = nur Admin. Wirkt auf beiden Kanälen identisch.
  • Prompt-Blöcke pro Modul: packages/agent/src/agent/prompt-sections/{module}.ts beschreibt die Regeln für den freigeschalteten Bereich. Der Basis-Block (base.ts) + alle aktiven Module werden beim Systemaufruf zusammengestellt (prompt.ts:51–198).
  • Beide Kanäle automatisch: WhatsApp-Worker und Next.js (ensureAgentRuntime() in apps/app/src/lib/agent/ensure-agent-runtime.ts) registrieren dieselben Capabilities. Kanal-Unterschiede nur bei Directives: form = nur Web, location_request = nur WhatsApp, buttons/list = beide, fields (strukturierte Label/Wert-Paare) = Web-Feldliste bzw. WhatsApp-Textzeilen (siehe .cursor/rules/agent-tools-dual-channel.mdc). Im Web-Assistenten rendert der Chat interaktive Antworten als Panel-Fenster mit lokalem Zurück/Vor-Verlauf; Buttons menu:main/projekt:menu/projekt:edit:* wandern dort in die Header-Leiste, confirm/cancel-Karten in eine Bestätigungs-Fußzeile (apps/app/src/components/assistant/panel-actions.ts).
  • Draft-Mechanik: mehrschrittige Flows (Angebot, Anfrage-Korrektur, Rapport-Entwurf) akkumulieren Zustand über drafts/store.ts bzw. Session-State und enden in einer Vorschau mit Bestätigungs-Buttons — nie Direkt-Schreiben ohne Bestätigung bei geldwirksamen Aktionen.
  • null für optionale Parameter: LLMs schicken für optionale Felder häufig ein explizites null statt sie wegzulassen — Zod .optional() akzeptiert aber nur undefined, das Tool schlug dadurch mit „expected string, received null" ab. Registry.execute entfernt deshalb nach einer fehlgeschlagenen Validierung genau die Pfade, deren Wert null ist, und validiert einmal neu (stripPaths/valueAtPath in agent/registry.ts). Felder mit .nullable() erzeugen nie ein Issue und behalten ihr null; fehlende Pflichtfelder scheitern weiterhin. Ein Tool braucht dafür kein .nullable() mehr nur zur LLM-Absicherung.
  • Die _example.-Dateien sind Referenz-Implementierungen, werden aber mitregistriert (siehe §6 — Status je Datei beachten).

2. Tool-Katalog (73 registrierte Tools)

2.1 Kern & Hilfe — core.ts, main-menu.ts

Modul: core

ToolRolle ≤Zweck
help3Zeigt das Hauptmenü (rollenbewusste Auswahlliste mit Kontext-Kopf, Registry main-menu.ts) — Begrüßung, /help, Unsicherheit.

Hauptmenü (Plan plan-whatsapp-menue-rework.md §4): Sektionen 🔨 Baustelle (zeit, rapport, btb, projekt, foto), 💼 Büro (verkauf, zahlen — Rolle ≤ 2), 👤 Persönlich (urlaub, termine, inbox). Row-IDs menu:<key>, Zurück-Button menu:main („Menü"). Kontext-Kopf zeigt laufende Uhr, aktives Projekt, offene Benachrichtigungen und offene Zettel-Aufgaben (best-effort). Registry-Assert: max. 10 Rows — Rolle ≤ 2 ist mit 10 Rows VOLL, neue Funktionen gehen in Untermenüs, nie in die Wurzel (registerMainMenuEntry mit beforeKey). Jede Karte zeigt Daten (Info-Karten-Prinzip) und endet mit Weiter-Buttons inkl. „Menü". Untermenü-Karten: Zeit (Timer-Status + zeit:woche „Meine Woche"), Verkauf (Kompaktzahlen + Liste inkl. verkauf:angebote:offen/verkauf:rechnungen:offen), Zahlen (Monatsumsatz + zahlen:op Offene Posten).

2.2 Projekt-Kontext — _example.project-context.ts (registriert, produktiv genutzt)

Modul: core

ToolRolle ≤Zweck / Parameter
project.search3Lädt aktive Projekte + Textsuche; das LLM wählt den Treffer (Tippfehler/Umlaute/Teilnamen), nie blind erstes ILIKE-Ergebnis. query
project.ask_confirm3Bestätigungs-Button „Projekt X gemeint?"; optional preview_items → nach Ja direkt Zeiterfassungs-Vorschau. project_id?, message?
project.present_list3WhatsApp-Auswahlliste eingegrenzter Projekte. project_ids?, message?
project.set_active3Setzt das sticky aktive Projekt der Konversation. project_id
project.get_active3Liest das aktive Projekt der Konversation.

Zugehöriger Button: project:confirm:<id> (deterministisch, setzt aktives Projekt; bei offener Zeiterfassung Übergang in die Zeiterfassungs-Vorschau, sonst direkt das Projekt-Menü — kein zusätzlicher „Projekt-Menü"-Klick nötig; time-project-preview.ts, replyAfterProjectConfirm).

2.3 Kunden & Kontakte — sales-customers.ts, customer-from-website.ts

Modul: core

ToolRolle ≤Zweck / Parameter
sales.customer.search3Aktive Kunden + Textsuche (archivierte nie); 1 Treffer → ask_confirm, mehrere → present_list, keiner → Neuanlage anbieten.
sales.customer.ask_confirm3Nur Angebots-Flow (setzt pendingOffer): Bestätigungs-Button für einen Kunden-Treffer, nach Ja folgt die Projekt-Auswahl. Optional new_project_name. customer_id, message?
sales.customer.present_list3Nur Angebots-Flow: Auswahlliste (max. 5 Kandidaten); die Auswahl startet den Angebots-Entwurf. Optional new_project_name. customer_ids, message?
customer.create_from_website2Website abrufen → Impressum extrahieren (SSRF-Guard) → vorbefülltes Kunden-Formular im Chat; Nutzer bestätigt vor dem Speichern. url?, name?, kind?
customer.create_form2Vorbefülltes Kunden-Formular ohne Website-Abruf (alle Stammdaten-Felder als Parameter).

Nach Kundenbestätigung (customer:confirm:<id>, ohne vorgemerkten newProjectName) zeigt der deterministische Handler tryHandleCustomerConfirmButton die aktiven Projekte des Kunden als WhatsApp-Auswahlliste (+ Row „🆕 Neues Projekt") statt einer Freitext-Aufforderung. Button-IDs (Handler tryHandleCustomerProjectButtons): customer:project:pick:<id> (setzt pendingOffer.projectId/projectLabel), customer:project:new (fragt nach dem Namen, Antwort weiter per Freitext neu <Name>).

⚠️ sales.customer.ask_confirm/present_list gehören ausschließlich zum Angebots-Flow. Sie legen einen pendingOffer an, und die Bestätigung mündet in Projekt-Auswahl → „Soll ich das Angebot anlegen?". Für andere Zwecke (Terminanfrage, Standort) darf der Kunde NICHT über diese Tools bestätigt werden — dort geht es von sales.customer.search direkt ins jeweilige Ziel-Tool, dessen eigene Vorschau die Rückfrage übernimmt. Sowohl die Tool-Descriptions als auch die Prompt-Regeln sagen das explizit; ohne diese Abgrenzung landet eine Terminanfrage im Verkaufstrichter.

2.4 Katalog — catalog.ts

Modul: katalog

ToolRolle ≤Zweck / Parameter
catalog.search3Durchsucht aktive Materialien UND Dienstleistungen in einem Aufruf (id, name, unit, unit_price, tax_rate). Vor sales.offer.add_item nutzen; kein Treffer → Freitext-Position. query, limit

2.5 Angebote — sales-offer-create.ts, sales.ts (+ Session-State sales-offer-state.ts)

Modul: angebote

ToolRolle ≤Zweck / Parameter
sales.project.create2Neues Projekt zu bestätigtem Kunden (nur bei ausdrücklichem Wunsch); setzt Projekt im Angebot-Entwurf. kontakt_id, name?, address?, city?, postal_code?
sales.offer.create_draft2Angebot-Entwurf (status=draft) zu Kunde+Projekt; danach Positionen + Preview. kontakt_id?, project_id?, valid_until?
sales.offer.add_item2Position hinzufügen: Katalog-Referenz (material_id/service_id) ODER Freitext. description ist Pflicht, wenn keine Katalog-ID gesetzt ist. offer_id, description?, quantity, unit?, unit_price? (bei Katalogleistung ohne Preis = Kalkulation; genannter Preis = manueller Override), material_id?, service_id?
sales.offer.preview2Entwurfs-Zusammenfassung (Kunde, Projekt, Positionen, Summe) mit Buttons Speichern/Ändern/Abbrechen — danach stoppen, Nutzer entscheidet. offer_id
sales.offer.send2Angebot als PDF per E-Mail versenden → Status sent. offer_id, to?
sales.offer.accept2Angebot annehmen (Interessent wird automatisch Kunde). offer_id
sales.offer.to_invoice2Angenommenes/versendetes Angebot → Rechnungs-Entwurf; Angebot → converted. offer_id?, type?, due_days?

2.6 Rechnungen & Rapporte — sales.ts

Module: rechnungen / rapport

ToolRolle ≤Zweck / Parameter
sales.invoice.send2Rechnung als E-Rechnung (PDF + XML) per E-Mail → Status sent. invoice_id, email?
sales.invoice.record_payment2Zahlung (teilweise/vollständig) erfassen → paid/partial_paid. invoice_id, amount? (ohne = Gesamtbetrag), payment_date?, payment_method?
sales.rapport.sign2DocuSign-Signatur für einen Rapport anfordern; nach Unterschrift automatisch abgeschlossen. rapport_id, signer_name, signer_email
sales.rapport.to_invoice2Einen/mehrere abgeschlossene Rapporte → Rechnungs-Entwurf; Rapporte → billed. rapport_ids?, type?, due_days?

2.7 Auswertung — web-portal.ts

Module: auswertung / core

ToolRolle ≤Zweck / Parameter
sales.invoice.revenue_summary3Umsatz-Zusammenfassung eines Zeitraums (Gesamt, Anzahl, bezahlt, offen). range? (last_month/this_month/last_year/custom), from?, to?, status?
project.create_form2Neues Projekt mit ALLEN Feldern als vorbefülltes Chat-Formular (Web); „ich" → project_manager_id 'me'. kontakt_id, viele optionale Stammdaten-Felder
project.attach_media3Hochgeladene Bilder/Dateien an ein Projekt hängen (media_ids = #<id> aus der Nachricht); Bestätigung vor Ablage. project_query, media_ids?, additional_info?

2.8 Zeiterfassung — time-tracking.ts, time-project-preview.ts (+ Helfer time-hours-delta.ts)

Modul: zeiterfassung

ToolRolle ≤Zweck / Parameter
time.personal.start3Personal-Stoppuhr starten (Kommen).
time.personal.stop3Personal-Stoppuhr stoppen (Gehen/Feierabend).
time.personal.status3Läuft eine Stoppuhr, seit wann?
time.project.list_entries3Bestehende Projektzeit-Positionen (Tätigkeit, Stunden), optional je Arbeitstag; Basis für Tages-Summen-Deltas. project_id?, work_date?, user_id?
time.my_entries3Eigene Zeiteinträge ohne Projektbezug („wann war mein letzter Eintrag", „was habe ich letzte Woche gebucht"): last_entry, getrennte Summen personal/project, neueste Einträge. Standard letzte 3 Monate; from?, to?, entry_type?, limit?, user_id? (Manager). Seit 2026-09-08, siehe Abnahme Freitext
time.project.preview_hours3Projektzeit-Vorschau mit Buttons (Bestätigen/Projekt ändern/Zeit-Tätigkeit) — IMMER vor dem Speichern, nie direkt buchen; nicht für Abwesenheiten.

2.9 Urlaub / Abwesenheit — leave.ts

Modul: urlaub

ToolRolle ≤Zweck / Parameter
leave.list_types3Aktive Abwesenheitsarten des Betriebs (Mapping Freitext → leave_type_key).
leave.preview_request3Antrags-Vorschau mit Buttons (Einreichen/Ändern/Abbrechen) — IMMER vor dem Einreichen; Genehmigung läuft über den Manager (Notification-Action leave.approve). leave_type_key, starts_on, ends_on (Pflicht bei mehreren Tagen), start_time?, end_time?, timezone?, reason?

2.10 Anfragen-Triage — anfrage-triage.ts (Prozess 1)

Modul: anfragen

ToolRolle ≤Zweck / Parameter
anfrage.update_draft2Freitext-Korrektur eines offenen Anfrage-Entwurfs (Status eingegangen), z. B. „Name ist Huber, Objektart Gewerbe"; sendet aktualisierte Zusammenfassung + Buttons erneut. anfrage_id
anfrage.confirm2Entwurf bestätigen (eingegangen → neu), Kontakt laut Vorschlag anlegen/verknüpfen, Anhänge nachladen. anfrage_id
anfrage.ignore2Entwurf verwerfen (nur Status eingegangen). anfrage_id

2.11 Erinnerungen — _example.reminder.ts (registriert; nutzt agent_schedules + Scheduler-Queue)

Modul: core

ToolRolle ≤Zweck / Parameter
reminder.create3Einmalige (when, ISO) oder wiederkehrende (cron) Erinnerung; prompt = Agent führt dann etwas aus (System-Turn), sonst message als reiner Text. name, when? | cron?, prompt?, message?
reminder.list3Aktive Erinnerungen des Nutzers.
reminder.delete3Erinnerung deaktivieren. id

2.12 Rapport (produktiv) — rapport.ts (Roadmap P1-1, Plan Menü-Rework §6)

Modul: rapport

ToolRolle ≤Zweck / Parameter
rapport.draft.start3Rapport-Entwurf starten (DraftStore, akkumuliert über Nachrichten). Projekt: project_id? oder aktives Projekt; setzt Kunde (kontakt) aus dem Projekt. note?
rapport.draft.add_entry3Arbeitszeit-Zeile: Mitarbeiter-Auflösung gegen listTeamMembers (employee_name oder employee_user_id; ohne/„ich" = Nutzer selbst). Rolle 3 nur eigene Stunden (Guard hier + Domain). hours, date?, activity?
rapport.draft.add_material3Material-Zeile: material_id aus catalog.search (Name/Einheit/EK aus Katalog) oder Freitext-name. quantity?, unit?, notes?
rapport.draft.edit3Korrektur-Loop nach der Vorschau: op update_entry/remove_entry/update_material/remove_material + index (1-basiert aus der Vorschau) + geänderte Felder. Rolle 3 nur eigene Einträge.
rapport.draft.submit3Rendert die VOLLE Vorschau (alle Einträge/Materialien/Summen) + Buttons Speichern/Ändern/Abbrechen — speichert selbst nie; das macht erst der Button rapport:submit:confirm (deterministisch).
rapport.preview_send2Schutzstufe 2: Versand-Vorschau mit Empfänger (Name + E-Mail des Kontakts), Dokument und Inhalt + Button „Jetzt senden" — nur auf ausdrücklichen Nutzerwunsch, sendet nie direkt. rapport_id? | rapport_number?; fehlende E-Mail → Hinweis.

Persistenz: rapport:submit:confirm → Domain createRapport mit positions (Einträge + Materialien) in einer Transaktion. Domain-Änderungen: Rolle 3 darf Rapporte anlegen, aber nur mit ausschließlich eigenen employee_user_id-Einträgen; positions.materials trägt jetzt material_id/unit_price (Katalog-Link). Versand: rapport:send:confirm:<id> → Domain sendRapportEmail (PDF-Mail, setzt completed); Abschluss/Versand bleiben Rolle ≤ 2. Nach dem Speichern Buttons „An Kunden senden" (Rolle ≤ 2) / „Neuer Rapport" / „Menü". Button-IDs (Handler tryHandleRapportButtons): rapport:submit:confirm|change|cancel, rapport:send:<id> (Versand-Vorschau), rapport:send:confirm:<id>, rapport:send:cancel; Einstieg auch über Menü-Karte menu:rapport + rapport:new.

2.12b Termine — termine.ts (Phase 4, read-only), termin-anfrage.ts (Kundentermin)

Modul: termine

ToolRolle ≤Zweck / Parameter
termine.list3Termine des Nutzers (Domain-Lift listUserAppointments, appointment_assignments): ohne Parameter heute + 7-Tage-Vorschau, sonst from?/to? (YYYY-MM-DD). Read-only — bestehende Termine ändern bleibt Web/Plantafel.
termine.preview_customer_request2Schutzstufe 2: Terminanfrage an einen Kunden („frag Kunde X, ob morgen 15 Uhr passt“) — Vorschau mit Empfänger, Zeit, Ort, Überschneidungs-Warnung und fertigem Mail-Text + Button „Senden“. Legt NIE direkt an, sendet NIE direkt. kontakt_id, starts_at (ISO mit Offset), duration_minutes? (Default 60), location?, project_id?, note?

Menü-Eintrag menu:termine (Sektion Persönlich, vor Inbox); Button termine:woche = Wochenansicht (Handler tryHandleTermineButtons). Zeitzonen-Arithmetik (Europe/Vienna) in SQL.

Terminanfrage (Handler tryHandleTerminAnfrageButtons, Pending-State pendingAppointmentRequest, 30-min-TTL, in cancel.ts mitgeräumt): Button-IDs termin:anfrage:confirm / :change / :cancel. Erst confirm legt über Domain createCustomerAppointment den Termin an (appointments + appointment_assignments + appointment_status_history, Status planned — der Kunde hat noch nicht zugesagt) und versendet die Mail über sendMailWithAttachment (mit buildOutboundDocumentMail: Nutzer-Signatur, work7-Footer, replyTo = verbundener Posteingang).

Kalender-Einladung — zwei Wege, echte Einladung zuerst:

  1. Outlook-Besprechungsanfrage (Regelfall): sendOutlookMeetingInvite (packages/domain/src/mail-transport/meeting-invite.ts) legt den Termin als Graph-Event im verbundenen Firmenpostfach an (POST /users/{mailbox}/events) — Graph verschickt die Einladung dann selbst. Nur so rendert Outlook Annehmen/Ablehnen; Zu-/Absagen laufen zurück ins Firmenpostfach und der Termin steht zusätzlich im Outlook-Kalender des Betriebs. Entscheidend ist die Body-Form (buildMeetingEventBody, testbar ohne Netz): responseRequested: true, Kunde als attendees[].type: 'required', Zeiten als UTC ohne Z im dateTime (Graph lehnt das bei gesetztem timeZone ab), und isOrganizer nicht mitschicken (read-only). Danach hält linkAppointmentToOutlookEvent die Graph-Event-ID in appointments.external_reference (external_source='microsoft') fest und überschreibt ics_uid mit der iCalUId von Graph.

    ⚠️ Selbsteinladung: Exchange lädt niemanden zu seinem eigenen Termin ein. Ist die Kunden-E-Mail identisch mit dem verbundenen Postfach (resolveOutboundReplyTo), wird der Event zwar angelegt, aber nichts verschickt — ohne Erkennung meldet der Agent fälschlich „Einladung gesendet". Deshalb filtert externalAttendees den Organisator heraus, MeetingInviteResult.invitedCount gibt die Zahl echter Empfänger zurück (0 ⇒ Antwort sagt es klar), und die Vorschau warnt bereits vor dem Senden.

  2. Mail mit .ics-Anhang (Rückfallebene): nur wenn 1. nicht geht (keine aktive Microsoft-Verbindung, fehlende Calendars.ReadWrite-Freigabe, Graph-Fehler). ⚠️ Outlook zeigt einen .ics-Anhang nur als Datei, nicht als Besprechungsanfrage — die Antwort des Nutzers sagt das deshalb ausdrücklich dazu. generateICSContent setzt METHOD:REQUEST, ORGANIZER = Firmenpostfach und den Kunden als ATTENDEE;RSVP=TRUE; UID stabil über appointmentIcsUid(id). Domain-Lift: generateICSContent lebt jetzt in packages/domain/src/calendar-ics.ts (ADR-0002, ergänzt um LOCATION, ATTENDEE, STATUS, RFC-5545-Zeilenfaltung); apps/app/src/lib/calendar/ics.ts ist ein Re-Export-Shim.

Termin und Mail sind entkoppelt: schlägt der Versand fehl, bleibt der Termin bestehen und die Antwort sagt das ehrlich. Überschneidende eigene Termine werden über Domain findOverlappingAppointments nur als ⚠️-Hinweis gezeigt, nie blockiert (Konflikt-Persistenz in appointment_conflicts bleibt der Web-Route vorbehalten).

Absender aus dem Agent-Worker (betrifft ALLE ausgehenden Agent-Mails, auch Angebot/Rechnung/Rapport): Der Worker ist ein eigener Prozess und hatte nie den App-Transport registriert — jede Mail ging deshalb über SYSTEM_MAIL_FROM (no-reply@work7.net) raus. sendMailWithAttachment ist jetzt dreistufig: (1) von der App registrierter Transport (setCompanyMailTransport, unverändert), (2) neu domain-eigener Microsoft-Sendepfad packages/domain/src/mail-transport/ — greift nur, wenn kein Transport registriert ist (hasCompanyMailTransport()), also genau im Worker; (3) Systemversand als Fallback. Stufe 2 wirft nie und fällt bei fehlender/inaktiver/Google-Verbindung sauber auf Stufe 3 durch. Das Gate verhindert im App-Prozess einen zweiten Sendeversuch (Doppelversand-Risiko, wenn Graph den Draft angelegt hat und erst /send scheitert). Nur Microsoft portiert — Gmail-Firmenpostfächer können mangels GOOGLE_SERVICE_ACCOUNT_JSON in dev/test/prod ohnehin nicht senden (auch nicht aus der App); das ist eine offene Deploy-Lücke, kein Regress.

Zeitkontext: buildTimeContext() in agent/prompt.ts stellt dem LLM Wochentag, aktuelles Vienna-Datum/-Uhrzeit sowie „heute"/„morgen" bereit — ohne das kann es relative Angaben nicht auflösen (betrifft auch Urlaub und Bautagebuch).

2.12c Verkaufs-Views — sales-views.ts (Phase 4, Rolle ≤ 2, keine LLM-Tools)

Deterministische Views/Aktionen (Handler tryHandleSalesViewButtons): Listen verkauf:angebote:offen (draft/sent mit Alter + Summe), verkauf:rechnungen:offen und zahlen:op (unbezahlt, überfällige zuerst, ⚠️-Flag) → Detail-Karten verkauf:angebot:<id> / verkauf:rechnung:<id> (fields-Directive inkl. Positionsliste als Footer) mit Aktions-Buttons. Versand = Schutzstufe 2: angebot:send:<id> / rechnung:send:<id> zeigen Empfänger-Vorschau (Name + E-Mail, Dokument, Summe), erst angebot:send:confirm:<id> / rechnung:send:confirm:<id> ruft Domain offers.send / invoices.send; …:send:cancel bricht ab. rechnung:zahlung:<id> fragt den Betrag ab (weiter über sales.invoice.record_payment-Flow). Fehlende Kunden-E-Mail → Hinweis statt Versand.

Status-Aktionen mit Bestätigung: angebot:accept:<id> → Vorschau → angebot:accept:confirm:<id> (Domain offers.accept); angebot:invoice:<id> → Vorschau → angebot:invoice:confirm:<id> (Domain invoices.create_from_offer, Antwort verlinkt die neue Rechnung); verkauf:action:cancel bricht ab. PDF am Web: Buttons mit ID web:open:<pfad> (z. B. /api/pdf/offers/<id>) werden im Web-Panel clientseitig als neuer Tab geöffnet — nur bei ctx.source === "system" emittieren, WhatsApp bekommt sie nie.

Positions-Editor (nur Entwurf): angebot:items:<id> öffnet am Web-Kanal das Formular offer_items_edit — jede Zeile nutzt dieselbe Katalog-Auswahl wie die Angebotsseite (CatalogLineItemFields: Art Freitext/Material/Dienstleistung, serverseitig durchsuchbares Dropdown; Auswahl befüllt Beschreibung/Einheit/Preis, bestehende Zeilen sind anhand material_id/service_id vorbelegt). Zeilen hinzufügen/löschen, Zeilensumme live; Pending pendingOfferItemsForm, 30-min-TTL, /abbrechen räumt mit. Der Commit in /api/agent/chat schickt nur die Differenz (changed/added/removed, Schema offerItemsEditSchema): Änderungen via Domain updateOfferItem (behält Kalkulations-Logik/price_overridden), Neuzugänge via addOfferItem (inkl. material_id/service_id), gewechselte Katalog-Referenz einer bestehenden Zeile = Relink als removed+added, Löschungen per SQL + recalculateOfferTotals; danach wird das Angebot-Detail neu gerendert. WhatsApp: Freitext-Hinweis auf den sales.offer.add_item-Chat-Flow.

2.12e Rapport-Views — rapport-views.ts (Rolle ≤ 2)

Modul: rapport

ToolRolle ≤Zweck
sales.rapporte.list_view2Öffnet die Rapportliste zum ANSEHEN — Detail zeigt Einträge/Material + Aktionen. Erfassen bleibt rapport.draft.*.

Deterministische Views (Handler tryHandleRapportViews, Keywords rapporte//rapporte/rapportliste, Verkauf-Untermenü-Row Rapporte): Liste rapporte:list (letzte 9, Status 📝 offen / ✅ abgeschlossen / 🔁 verrechnet aus completed + billing_status) → Detail rapport:detail:<id> als fields-Directive (Kunde, Projekt, Stunden, Unterschrift; Einträge + Material als Footer). Aktionen: An Kunden senden → bestehender rapport:send:<id>-Flow (rapport.ts, Schutzstufe 2); Verrechnen (rapport:invoice:<id> → Vorschau → rapport:invoice:confirm:<id> → Domain invoices.create_from_rapports, verlinkt die Rechnung); PDF ansehen via web:open:/api/pdf/rapport/<id> (nur Web).

2.12d CRM-Views — crm-views.ts (Rolle ≤ 2)

Module: core / anfragen

ToolRolle ≤Zweck
sales.customer.list_view2Öffnet die interaktive Kundenliste zum ANSEHEN („zeig mir die Kunden") — Auswahl führt zu den Kunden-Details. NICHT der Angebots-Flow (sales.customer.search bleibt dafür).
sales.anfragen.list_view2Öffnet die Anfragen-Pipeline als interaktive Liste („welche Anfragen haben wir").

Deterministische Views (Handler tryHandleCrmViews, Einstieg über die Tools oben, das Verkauf-Untermenü oder Ganz-Wort-Keywords kunden/kundenliste//kunden bzw. anfragen//anfragen):

  • Kundenliste kunden:list (+ Pagination kunden:list:<offset>, WhatsApp 8 / Web 50 pro Seite, Domain listCustomers mit topLevel; im Web-Panel ab 9 Zeilen mit clientseitigem Suchfeld): Rows kunde:detail:<id> (Nummer · Ort · Gruppe).
  • Kunden-Detail kunde:detail:<id> — fields-Directive (Nummer, Art, E-Mail, Telefon, Mobil, Adresse, Website, UID, Zahlungsziel, Gruppe) + letzte Notizen als Footer; darunter das Verknüpft-Menü (nur Bereiche mit Einträgen, mit Zählern): Projekte, Angebote, Rechnungen, Anfragen + Row Bearbeiten.
  • Kunden-Projekte kunde:projekte:<id> — Auswahlliste der aktiven Projekte des Kunden; Rows nutzen projekt:pickactive:<id> (bestehender Handler: Sticky setzen + Projekt-Menü öffnen).
  • Kunden-Angebote/-Rechnungen kunde:angebote:<id> / kunde:rechnungen:<id> — alle Belege des Kunden (neueste zuerst, max. 9); Rows öffnen die bestehenden Detail-Karten verkauf:angebot:<id> / verkauf:rechnung:<id> (inkl. Senden/Zahlung-Aktionen). Kunden-Anfragen kunde:anfragen:<id> analog → anfrage:detail:<id>.
  • Kunde bearbeiten kunde:edit:<id> — am Web-Kanal ein vorbefülltes customer_edit-Formular im Panel (Felder CUSTOMER_EDIT_FORM_FIELDS: Name, E-Mail, Telefon, Mobil, Straße+Hausnummer mit Google-Adresssuche, PLZ, Ort, Website, UID, Zahlungsziel; Pending pendingCustomerEditForm, 30-min-TTL, /abbrechen räumt mit). Commit in /api/agent/chat schreibt via Domain customers.update und rendert das Kunden-Detail neu. WhatsApp: Freitext-Hinweis.
  • Anfragen-Pipeline anfragen:list — alle nicht-terminalen Anfragen (neueste zuerst, max. 9) → Detail anfrage:detail:<id> als fields-Directive (Status, Quelle, Kontakt, Objektart, Leistungen, Objektadresse, Wunschtermin; Beschreibung als Footer) + Aktions-Menü je Status: direkte Übergänge (anfrage:status:<id>:<status>, aus ANFRAGE_NEXT_DIRECT), Angebot erstellen (anfrage:makeoffer:<id> → Bestätigung → Domain createOfferFromAnfrage), Gewonnen (anfrage:win:<id> → Bestätigung → winAnfrage, legt das Projekt an), Verloren (anfrage:lose:<id> → Bestätigung → setAnfrageStatus); anfrage:action:cancel:<id> bricht ab.

2.13 Bautagebuch — bautagebuch.ts

Modul: bautagebuch

ToolRolle ≤Zweck / Parameter
bautagebuch.get_fields3Lädt Gewerke-Felder, Bautagebuch-Optionen und Projektphasen für den aktuellen Betrieb.
bautagebuch.preview_upsert3Erstellt aus Text oder Sprachnachricht eine bestätigungspflichtige Tagesbericht-Vorschau. work_description ist Pflicht; project_id?, work_date?, Phase, Bereiche, Material, Behinderungen, Mängel und trade_details? sind optional. Ohne Projekt-ID wird das aktive Projekt verwendet.

Die Button-IDs bautagebuch:confirm, bautagebuch:change und bautagebuch:cancel werden vor dem LLM-Loop verarbeitet. Erst confirm ruft upsertBautagebuchEntry im Domain-Paket auf. Der Pending-State läuft nach 30 Minuten ab.

2.14 Projekt-Menü & Bearbeitung — project-menu.ts, project-edit.ts (Phase 1, Plan)

Modul: core

ToolRolle ≤Zweck / Parameter
project.get_details3Voller Projekt-Datensatz via Domain projects.get (Stammdaten, Status, Phase, Termine, Budget, Ist-Werte, Adresse, Notizen) für präzise Freitext-Antworten. project_id?, project_number? (ohne = aktives Projekt)
project.menu3WhatsApp-Auswahlliste aller Projekt-Ansichten (Sektions-Registry, Phase 1: 5 Rows, Endausbau max. 10); setzt das Projekt sticky. project_id?
project.show_section3Rendert eine Sektion als formatierte Ansicht (chunked >1000 Zeichen) mit Kontext-Buttons. sectionstamm|status|termine|planung|notizen, project_id?
project.preview_update2Diff-Vorschau (alt → neu) für Stammdaten-Änderungen + Bestätigungs-Buttons — schreibt NIE direkt. Editierbar: name, description, status, type, phase/sub_phase (validiert gegen Trade-Config), start/end/deadline, estimated_hours, budget, Adresse, actual_costs, note_append. Ausgeschlossen: kontakt_id, project_number, project_manager_id (→ project.team.preview_change) sowie die abgeleiteten actual_hours und completion_percentage (siehe 04-projekte „Fortschritt").

Deterministische Button-/Row-IDs (Handler tryHandleProjektMenu + tryHandleProjectEditButtons, vor dem LLM): projekt:menu[:<id>] (Menü), projekt:sec:<sec>:<id> (Sektions-View, revalidiert + refresht Sticky), projekt:edit:<sec>:<id> (Bearbeiten: status/phase → Picker-Liste; sonst am Web-Kanal ein vorbefülltes project_edit-Formular im Panel (PROJECT_EDIT_FORM_SECTIONS, Pending pendingProjectEditForm, Commit in /api/agent/chat schreibt via Domain projects.update und rendert die Sektion neu), am WhatsApp Freitext-Hinweis), projekt:pick:status:<value> / projekt:pick:phase:<id> / projekt:pick:subphase:<id|-> (Picker-Rows, wirken auf pendingProjectEdit), projekt:edit:confirm / projekt:edit:cancel (Diff bestätigen → Domain projects.update / verwerfen). Pending-State pendingProjectEdit läuft nach 30 Minuten ab; /abbrechen räumt ihn mit. Nach Projektbestätigung (project:confirm) ohne offene Zeiterfassung wird der Button Projekt-Menü angeboten.

2.15 Projekt-Zeiten — project-zeiten.ts (Phase 2, Plan)

Modul: zeiterfassung

ToolRolle ≤Zweck / Parameter
time.project.overview3Projektzeiten-Übersicht: Monats-Summen je Mitarbeiter + paginierte Einträge mit IDs (Manager sehen alle, Rolle 3 nur eigene — Rollenfilter aus dem Domain-Service). Auch Renderer der Menü-Row Zeiten. project_id?, month? (YYYY-MM), page?
time.entry.preview_change3Bestätigungs-Vorschau, um einen Eintrag zu ändern (hours, work_date, activity, description) oder zu löschen (action: update|delete). Guards: fremde Einträge nur Manager, Rapport-gebundene und laufende Timer gesperrt. entry_id

Button-IDs (Handler tryHandleZeitenButtons): zeiten:entry:confirm / zeiten:entry:cancel (wirken auf pendingTimeEntryEdit, 30-min-TTL; Confirm → Domain updateTimeEntry/deleteTimeEntry + syncProjectActualHours + project_hours_updated-Event wie die Web-API), projekt:zeiten:more:<projectId>:<YYYY-MM>:<page> (Pagination). Domain-Lift: Der komplette Zeiten-Service (listTimeEntries, summarizeTimeEntries, update/deleteTimeEntry, Rapport-Import …) lebt jetzt in packages/domain/src/time-entries-service.ts (ADR-0002); apps/app/src/lib/time-entries/time-entries-service.ts ist ein reiner Re-Export-Shim. Die App-time-entries-utils.ts bleibt bewusst eine App-Kopie (Client-Komponenten dürfen kein @work7/domain/pg bundlen).

2.16 Projekt-Material, Belege & Ablage — project-material.ts (Phase 3, Plan)

Module: lager / core

ToolRolle ≤Zweck / Parameter
project.material.overview3Material-Plan mit Plan-IDs (geplant/bereitgestellt, Menge × Gebinde). Auch Renderer der Menü-Row Material. project_id?
project.material.preview_plan2Material einplanen mit Vorschau + Bestätigung; Material via catalog.search. Ohne package_id bei Gebinde-Material → automatischer Gebinde-Picker (material:pkg:<id|->). material_id, quantity, package_id?, note?
project.material.preview_fulfill3Bereitstellen (bucht Lager-Entnahme aufs Projekt, Domain prüft Rolle-3-Projektzuordnung). Bei >1 aktiven Lagerorten → automatischer Picker (material:ort:<lagerId>). plan_id, quantity?, lager_id?, note?
project.material.preview_remove2Geplanten Eintrag entfernen (nur Status „geplant"), Vorschau + Bestätigung. plan_id
project.documents.overview3Verknüpfte Belege: Angebote/Rechnungen/Rapporte mit Nummer + Status, inkl. Organisations-Belege ohne Projektzuordnung (Kontakt-Fallback). Auch Renderer der Menü-Row Belege. project_id?
project.media.expect_upload3Foto/Video für ein Projekt angekündigt, aber noch nicht geschickt („ich will für X ein Bild dokumentieren"): merkt das Zielprojekt als pendingMediaUpload.stage=await_media vor, setzt es aktiv und bittet um das Medium. Das nächste Bild/Video landet ohne Projekt-Rückfrage dort. project_id? (sonst aktives Projekt), note?. Seit 2026-09-08, project-media-upload.ts

Menü-Rows Material, Belege, Ablage (Ablage-View listet WhatsApp-Dateien via Domain listProjectDocuments, kein Medienversand in v1). Button-IDs (Handler tryHandleMaterialButtons): material:plan:confirm / material:plan:cancel (wirken auf pendingMaterialPlanAction, 30-min-TTL; Confirm → Domain addProjectMaterialPlan/fulfillProjectMaterialPlan/deleteProjectMaterialPlan), material:pkg:<pkgId|-> (Gebinde-Picker), material:ort:<lagerId> (Lagerort-Picker). Domain-Lift: loadProjectLinkedDocuments lebt jetzt in packages/domain/src/project-linked-documents.ts (ADR-0002); apps/app/src/lib/projects/load-linked-documents.ts ist ein Re-Export-Shim.

2.17 Bautagebuch-Ansicht & Projekt-Team — project-btb-view.ts, project-team.ts (Phase 4, Plan)

Module: bautagebuch / core

ToolRolle ≤Zweck / Parameter
bautagebuch.view3Bautagebuch ANSEHEN: ohne work_date Übersicht der letzten Tage (Timeline mit Tages-Auswahlliste projekt:btb:day:<id>:<datum>, Flags ⚠️/❗/📷, Wetter im Detail), mit work_date das Tages-Detail (chunked). Mitarbeiter sehen nur eigene Einträge (Domain). project_id?, work_date?, days? (1–31, Default 7)
project.team.overview3Internes Team (👑 Leitung) + Ansprechpartner des Kunden inkl. IDs. Team wird zusätzlich in der Menü-Sektion Planung & Budget angezeigt. project_id?
company.team.list2Team-Mitglieder des Betriebs (user_id + Name) zum Namensauflösen für Änderungen. query?
project.team.preview_change2Team/Ansprechpartner ändern mit Vorschau + Bestätigung: target team|person, action add|remove|lead (lead = „Projektleiter ändern", Lead-Sync mit project_manager_id macht die Domain). member_id

Menü-Row Bautagebuch (Endausbau: exakt 10 Rows, Registry-Assert). Button-IDs: projekt:btb:day:<projectId>:<YYYY-MM-DD> (Tages-Detail, Handler tryHandleBtbDayButtons), team:change:confirm / team:change:cancel (wirken auf pendingTeamChange, 30-min-TTL, Handler tryHandleTeamChangeButtons → Domain linkUserToProject/unlinkUserFromProject/linkPersonToProject/unlinkPersonFromProject). Konsolidierung: Die vier duplizierten 6-Feld-Projekt-SELECTs der Capabilities laufen jetzt über findProjectById in orchestrator/project-context.ts.

2.18 Standort / Route — navigation.ts

Modul: core

ToolRolle ≤Zweck / Parameter
navigation.send_location3Schickt einen WhatsApp-Standort-Pin für „ich möchte zum Kunden X" / „wie komme ich zu Projekt Y". Genau eine ID angeben, project_id fällt ohne Angabe auf das aktive Projekt zurück. project_id?, kontakt_id?

Adresse aus projects/kontakte, Geocoding via Google Geocoding API (GOOGLE_PLACES_API_KEY, Agent-Package hat dafür eine eigenständige Kopie von apps/app/src/lib/geo/google-places.ts in geo/geocode.ts — kein Cross-Package-Import). Ohne API-Key/Treffer: Fallback auf einen Google-Maps-Suchlink als Text statt Standort-Pin. Kein Bestätigungs-Button nötig (read-only, keine Seiteneffekte).

2.19 Auswertung & Diagramme — charts.ts (+ charts/spec.ts, charts/analytics.ts)

ToolRolle ≤Zweck / Parameter
analytics.query3Aggregierte Firmenzahlen als labels + series (Vorlage für chart.render). Whitelist statt freiem SQL: dataset (umsatz, angebote, projekte, stunden, rapporte, anfragen, abwesenheiten), dimension, breakdown?, measure? (summe/anzahl/durchschnitt), from?, to?, status?, limit?, sort?
chart.render3Zeichnet ein interaktives Diagramm im Chat. Parameter = die ChartSpec: type (15 Typen), title, labels, series, optional stacked, format, unit, goal, min/max, showValues, source, query

Datenweg: analytics.query → Zahlen → Directive { kind: "chart", spec } → Web-Karte. Die Tools halten den Loop nicht an; das Modell ordnet das Diagramm danach in 1–2 Sätzen ein.

Diagramm ohne Aufforderung (nur Web): Der Nutzer muss nie „zeig mir ein Diagramm" sagen. Diese Tools hängen ihr Bild selbst an — deterministisch im Handler, nicht als Bitte an das Modell:

ToolAutomatisches BildBedingung
analytics.queryTyp nach Datenform (charts/auto.ts: Zeitachse → line/area, Anteile → donut, lange Namen → hbar, sonst bar)ctx.source === 'system', abschaltbar mit chart: false
sales.invoice.revenue_summaryRing „Umsatz nach Zahlstatus"Web, ohne status-Filter, ≥ 2 Buckets > 0
time.project.overviewBalken „Stunden je Mitarbeiter"Web, ≥ 2 Mitarbeiter
diagram.plan / diagram.orgGantt bzw. Organigrammimmer (das ist ihr Zweck)

Am WhatsApp bleibt es beim Text — die Zahlen stehen dort schon in der Antwort, ein zweiter Block wäre Wiederholung. Ruft das Modell zusätzlich chart.render mit denselben Daten auf, gewinnt seine bewusste Wahl: dedupeChartDirectives in agent/loop.ts wirft das automatische Duplikat raus (Signatur = labels + series).

Diagrammtypen: line, area, bar, hbar, pie, donut, scatter, bubble, radar, heatmap, funnel, gauge, waterfall, treemap, combo. Live-Katalog: /styleguide/charts.

Kanal-Unterschied: Web rendert die Spec interaktiv (apps/app/src/components/charts, Tooltip, Legende zum Ein-/Ausblenden, Typ-Umschalter, Tabellenansicht, CSV/PNG-Export). WhatsApp kann kein SVG — channel/render.ts gibt dieselbe Spec über chartToText() als Text mit Balkenzeichen aus.

Mandanten-/Rollenschutz: runAnalyticsQuery setzt company_id immer serverseitig, alle Filterwerte laufen als Query-Parameter ($1, $2, …). Rolle ≤ 2 für umsatz, angebote, projekte, anfragen (betriebswirtschaftlich — ein Mitarbeiter bekommt gar keine Abfrage, auch nicht über die Chart-Karte; analyticsMeta(role) blendet sie im Katalog aus). Bei stunden und abwesenheiten sehen Mitarbeiter (Rolle 3) nur eigene Zeilen; der Hinweis steht in notes.

Nutzer stellt selbst um: Trägt die Spec das Feld query (kommt 1:1 aus dem analytics.query-Ergebnis), zeigt die Chart-Karte Bedienelemente für Zeitraum, Gruppierung, Aufteilung und Kennzahl. Die laden über POST /api/analytics/query nach (Katalog: GET /api/analytics/meta) — dieselbe Whitelist, Session-gebunden, ohne erneuten Assistenten-Aufruf.

Regeln im Renderer (nicht verhandelbar, sonst lügt das Bild): genau eine Werteachse, feste Farbreihenfolge (nie zyklisch, ab der 8. Reihe „Sonstige"), ab 2 Reihen immer Legende, Nulllinie bleibt im Bild. Die Palette ist gegen die Panel-Fläche gerechnet (Farbfehlsichtigkeit ΔE 9.4, Normalsicht 19.3, Kontrast ≥ 3:1).

2.20 Schaubilder — diagrams.ts (+ diagrams/spec.ts, diagrams/data.ts)

ToolRolle ≤Zweck / Parameter
diagram.render3Erklärendes Schaubild aus Mermaid-Quelltext: type flow/sequence/state/mindmap/journey/er/class, title, code (Rumpf ohne Kopfzeile — die setzt der Server)
diagram.plan3Zeitplan (Gantt) aus echten Daten: source projekte/termine/abwesenheiten, from?, to?, status?, project_id?, limit?; alternativ tasks selbst übergeben
diagram.org3Organigramm aus echten Daten: source firma (Abteilungen/Rollen) oder projekt (Leitung, Team, Ansprechpartner); alternativ nodes selbst übergeben

Trennung Mermaid ↔ Daten (wichtig): Mermaid-Bilder sind Modell-Text und gelten nie als Datenauskunft — sie erklären Abläufe. Alles, was Termine, Namen oder Teams behauptet, kommt über diagram.plan/diagram.org aus der Datenbank und wird nativ gezeichnet (apps/app/src/components/diagrams: Gantt mit Heute-Linie/Status/Hover, Organigramm als Tidy-Tree).

Absicherung des Mermaid-Quelltexts: Das Modell liest Mails, Webseiten und Dokumente — was von dort kommt, darf im Browser nichts auslösen. validateDiagramSpec lehnt click-Anweisungen, %%{init:…}-Direktiven, HTML, href, javascript: und script ab (Länge max. 6000 Zeichen); der Renderer läuft zusätzlich mit securityLevel: 'strict' und ohne HTML-Labels. Tests dazu in test/diagrams.test.ts.

Mandanten-/Rollenschutz: company_id serverseitig, Werte als Query-Parameter, Datumsangaben gegen YYYY-MM-DD geprüft. source=projekte ist Rolle ≤ 2; bei termine und abwesenheiten sehen Mitarbeiter nur die eigenen Einträge (Hinweis in notes). Projekte ohne Start-/Enddatum werden gezählt, nicht geraten.

Nutzer stellt selbst um: Der Gantt trägt seine Herkunft als query und lädt Zeiträume über POST /api/diagrams/plan nach — wie die Chart-Karte, ohne erneuten Assistenten-Aufruf.

Kanal: Web zeichnet, WhatsApp bekommt diagramToText() (Zeitplan als Datumsliste, Organigramm als Einrückung, Mermaid als Rumpf). Live-Katalog: /styleguide/diagrams.

3. Deterministische Commands (ohne LLM) — commands/*

Ganze-Nachricht-Matching bzw. Button-IDs; kein Regex/Keyword-Matching für freie Sprache (Regel .cursor/rules/whatsapp-agent.mdc).

Kein-Dead-End-Regel (Plan Menü-Rework §3.4): tryHandleSlashCommand hängt an Text-only-Antworten von Buttons, deren ID auf :confirm/:cancel endet, zentral einen „Menü"-Button (menu:main) an (appendMenuAffordance in commands/index.ts). /abbrechen antwortet ebenfalls mit Menü-Button.

AuslöserVerhaltenDatei
/whoamiProfil-/Session-Info (User, Firma, Rolle, aktives Projekt)commands/whoami.ts
/inbox oder inboxNotification-Hub: offene Benachrichtigungen als Liste (max. 10), Detail über Button notif:open:<id> mit Action-Buttonscommands/inbox.ts
/abbrechen, /cancel oder ganze Nachricht abbrechenLaufende Dialoge/Entwürfe abbrechen (alle Pending-States + Draft). Zettel-bewusst: bei laufendem Auftragszettel wird nur die AKTUELLE Aufgabe abgebrochen — die restlichen werden mit „Weiter mit Nr. X" / „Alles verwerfen" angeboten.commands/cancel.ts
Auftragszettel (Plan Menü-Rework §7): Sprachnachricht oder Text > 200 Zeichen mit ≥ 2 Aufgaben → Extraktions-Turn (json_object, Zod) erstellt den Zettel; Buttons zettel:start / zettel:edit / zettel:cancel; ganze Nachricht zettel, /zettel, merkzettel zeigt den StandAufgaben werden EINZELN abgearbeitet (eine pro Runde, je mit normaler Vorschau/Bestätigung). Nach jedem Confirm/Cancel-Button rückt advanceQueueAfterFlowEnd automatisch vor („▶️ Aufgabe 2 von 3 …") bzw. sendet die Bilanz (✅/⏭️). zettel:edit → nächste Nachricht ersetzt den Zettel (Re-Extraktion mit Alt-Zettel als Kontext). Erledigte Ergebnisse (z. B. Rapport-Nummer) gehen als Kontext in Folge-Aufgaben. Aufgaben ohne Bestätigungs-Schritt (kein Confirm-Button/Pending, z. B. Erinnerung) bekommen einen manuellen Anstoß „Weiter (Zettel)" — zettel:start schließt dann die aktive Aufgabe ab und startet die nächste. TTL 24 h; Einzel-Aufgaben-Nachrichten verhalten sich unverändert. Nur WhatsApp-Kanal (Portal: normale Verarbeitung).orchestrator/intake-queue.ts + handle-inbound.ts
Zeiterfassung: Slash /start /kommen /stop /stopp /gehen /status /zeit; Ganz-Wort-Keywords (kommen, feierabend, bin da, …); Phrasen („starte die Zeiterfassung"); Buttons time:start/time:stop/time:statusDirekt time.personal.* bzw. Menü mit Buttons (Kommen/Gehen/Status); Menü-Wörter: zeiterfassung, personalzeit, stoppuhr, projektzeit(erfassung)commands/time.ts
Bild/Video-Nachricht (bzw. Fortsetzung eines Upload-Dialogs)Stage-Maschine Medien-Upload: Was gemeint ist (Projekt/Notiz/Ja-Nein) klärt ein LLM-Extraktor (media-upload-llm.ts), Bestätigung per Buttons, Ablage via Domain+Storagecommands/index.ts + capabilities/project-media-upload.ts
Ganze Nachricht menü, menu, hauptmenü, start, hilfe, help, ?, /menu, /menü, /hilfe; Button menu:main; Rows menu:<key>; Karten-Buttons rapport:new, btb:erfassen, btb:ansehen, foto:ablage, verkauf:angebot:neu, verkauf:rechnung:aus-rapport, verkauf:zahlung, zahlen:letzter-monatHauptmenü (rollenbewusst, Kontext-Kopf) und Untermenü-Karten; Büro-Buttons Rolle ≤ 2. Achtung: start allein ist jetzt Menü-Trigger (Uhr weiter über /start, kommen, bin da, Phrasen, Buttons).capabilities/main-menu.ts
Ganze Nachricht projekt, projektmenü, projekt menü oder /projekt; Buttons/Rows projekt:menu, projekt:sec:*, projekt:edit:*, projekt:pick:*, projekt:pickactive:<id|other>Projekt-Menü öffnen (aktives Projekt; ohne → Zuletzt-Liste der letzten aktiven Projekte statt Freitext-Rückfrage), Sektions-Views rendern, Status-/Phasen-Picker, Edit-Diff bestätigen/verwerfen (§2.14). Sektions-Buttons heißen „Projekt-Menü" („Menü" = Hauptmenü).capabilities/project-menu.ts + project-edit.ts

4. Notification-Actions (Push mit Buttons) — notifications/actions.ts

Button-ID-Schema notif:action:<action_key>:<ref>; Fanout über Queue notif-fanout (inkl. 24-h-Fenster-Handling), Anlage via createNotificationAndFanout (notifications/store.ts).

action_keyButtonsZweck
leave.approveleave.approve / leave.declineUrlaubsantrag direkt aus dem Push genehmigen/ablehnen
anfrage.confirmanfrage.approve / anfrage.ignoreAnfrage-Entwurf aus der Mail-Triage anlegen/verwerfen (Race-Handling: erster Klick gewinnt)

Dispatcher: capabilities/notifications.ts (deterministisch, kein LLM).

5. Hilfsmodule ohne eigene Tools

DateiRolle im System
sales-offer-state.tsSession-State pendingOffer für den Angebots-Entwurfs-Flow
time-hours-delta.tsStunden-Delta vs. Tages-Summe (Domain addiert immer auf Bestehendes)
media-upload-llm.tsStrukturierter LLM-Turn für den Medien-Upload-Dialog (Zod-Schema statt Keyword-Maps)
project-media-upload.tsMehrschrittiger Upload-Dialog (Stages, Bestätigungs-Buttons, Ablage)
notifications.tsButton-Dispatcher für Notification-Actions

6. Status der _example.-Dateien

DateiRegistriert?Status
_example.project-context.tsfunktional, produktiv genutzt (sticky project) — Umbenennung (Präfix weg) empfohlen
_example.reminder.tsfunktional (persistiert in agent_schedules) — Umbenennung empfohlen

_example.rapport-draft.ts wurde entfernt — produktiver Nachfolger ist rapport.ts (§2.12, P1-1 erledigt).

7. Abgelöste Alt-Dokumentation

docs/api/api-mcp-overview.md, api-mcp-business-tools.md, api-mcp-system-and-messaging-tools.md (2026-04, JSON-RPC/MCP) beschreiben den entfernten MCP-Server des alten Agenten (adr-0003-whatsapp-agent-rebuild) und sind kein gültiger Tool-Katalog mehr. Maßgeblich ist dieses Dokument.

8. Pflegeregel: Neue Tools = Doku-Pflicht

Regel: Jede Änderung an Agent-Tools wird im selben Commit/PR in diesem Dokument nachgezogen. Ein Tool, das hier nicht steht, gilt als undokumentiert und ist nicht „fertig".

Konkret dokumentationspflichtig:

  1. Neues Tool (defineTool + Registrierung in capabilities/index.ts) → Zeile im passenden Katalog-Abschnitt (§2): Name, Mindestrolle, Zweck (1–2 Sätze), wichtigste Parameter, Seiteneffekte (Statuswechsel, Versand, Buttons/Directives).
  2. Geändertes Tool (Name, Parameter, requiredRole, Verhalten/Seiteneffekte) → Zeile aktualisieren.
  3. Entferntes Tool → Zeile löschen (nicht auskommentieren).
  4. Neuer Command (commands/*) → §3 ergänzen (Auslöser exakt: Slash/Keyword/Button-ID).
  5. Neue Notification-Action (notifications/actions.ts) → §4 ergänzen (action_key, Buttons, Zweck).
  6. Stub → produktiv (z. B. rapport.draft.submit) → Warnhinweise hier UND in 11-whatsapp-agent entfernen; _example.-Umbenennung nachziehen.
  7. Zähler in der Überschrift von §2 („43 registrierte Tools") mitpflegen.

Durchsetzung/Erinnerung:

  • Regel-Datei: .cursor/rules/agent-tools-dokumentation.mdc (greift bei Änderungen unter packages/agent/src/capabilities|commands|notifications).
  • Schritt „Dokumentieren" ist Teil der Tool-Checkliste in .cursor/rules/agent-tools-dual-channel.mdc.
  • Hinweis im Kopf von packages/agent/src/capabilities/index.ts.
  • Review-Kriterium: PR mit Capability-Diff ohne Diff an dieser Datei → zurückweisen.

Verwandte Notes

Work7 · Software für Handwerksbetriebe