Darstellung
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.mdc— Seit 2026-09-12 (Modularisierung Phasen 0–3): Ein neues Tool muss über seinname-Präfix oder Explicit-Name im Manifest-Feldagent.toolPrefixes/agent.toolNameseinem Modul zugeordnet sein. Zuordnungs-Test:packages/agent/test/registry-modules.test.tsschlä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 untercapabilities/, registriert incapabilities/index.tsviaregisterAll(...). 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-BlockVERFÜ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}.tsbeschreibt 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()inapps/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; Buttonsmenu: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.tsbzw. Session-State und enden in einer Vorschau mit Bestätigungs-Buttons — nie Direkt-Schreiben ohne Bestätigung bei geldwirksamen Aktionen. nullfür optionale Parameter: LLMs schicken für optionale Felder häufig ein explizitesnullstatt sie wegzulassen — Zod.optional()akzeptiert aber nurundefined, das Tool schlug dadurch mit „expected string, received null" ab.Registry.executeentfernt deshalb nach einer fehlgeschlagenen Validierung genau die Pfade, deren Wertnullist, und validiert einmal neu (stripPaths/valueAtPathinagent/registry.ts). Felder mit.nullable()erzeugen nie ein Issue und behalten ihrnull; 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
| Tool | Rolle ≤ | Zweck |
|---|---|---|
help | 3 | Zeigt 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
| Tool | Rolle ≤ | Zweck / Parameter |
|---|---|---|
project.search | 3 | Lädt aktive Projekte + Textsuche; das LLM wählt den Treffer (Tippfehler/Umlaute/Teilnamen), nie blind erstes ILIKE-Ergebnis. query |
project.ask_confirm | 3 | Bestätigungs-Button „Projekt X gemeint?"; optional preview_items → nach Ja direkt Zeiterfassungs-Vorschau. project_id?, message? |
project.present_list | 3 | WhatsApp-Auswahlliste eingegrenzter Projekte. project_ids?, message? |
project.set_active | 3 | Setzt das sticky aktive Projekt der Konversation. project_id |
project.get_active | 3 | Liest 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
| Tool | Rolle ≤ | Zweck / Parameter |
|---|---|---|
sales.customer.search | 3 | Aktive Kunden + Textsuche (archivierte nie); 1 Treffer → ask_confirm, mehrere → present_list, keiner → Neuanlage anbieten. |
sales.customer.ask_confirm | 3 | Nur 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_list | 3 | Nur Angebots-Flow: Auswahlliste (max. 5 Kandidaten); die Auswahl startet den Angebots-Entwurf. Optional new_project_name. customer_ids, message? |
customer.create_from_website | 2 | Website abrufen → Impressum extrahieren (SSRF-Guard) → vorbefülltes Kunden-Formular im Chat; Nutzer bestätigt vor dem Speichern. url?, name?, kind? |
customer.create_form | 2 | Vorbefü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
| Tool | Rolle ≤ | Zweck / Parameter |
|---|---|---|
catalog.search | 3 | Durchsucht 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
| Tool | Rolle ≤ | Zweck / Parameter |
|---|---|---|
sales.project.create | 2 | Neues 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_draft | 2 | Angebot-Entwurf (status=draft) zu Kunde+Projekt; danach Positionen + Preview. kontakt_id?, project_id?, valid_until? |
sales.offer.add_item | 2 | Position 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.preview | 2 | Entwurfs-Zusammenfassung (Kunde, Projekt, Positionen, Summe) mit Buttons Speichern/Ändern/Abbrechen — danach stoppen, Nutzer entscheidet. offer_id |
sales.offer.send | 2 | Angebot als PDF per E-Mail versenden → Status sent. offer_id, to? |
sales.offer.accept | 2 | Angebot annehmen (Interessent wird automatisch Kunde). offer_id |
sales.offer.to_invoice | 2 | Angenommenes/versendetes Angebot → Rechnungs-Entwurf; Angebot → converted. offer_id?, type?, due_days? |
2.6 Rechnungen & Rapporte — sales.ts
Module: rechnungen / rapport
| Tool | Rolle ≤ | Zweck / Parameter |
|---|---|---|
sales.invoice.send | 2 | Rechnung als E-Rechnung (PDF + XML) per E-Mail → Status sent. invoice_id, email? |
sales.invoice.record_payment | 2 | Zahlung (teilweise/vollständig) erfassen → paid/partial_paid. invoice_id, amount? (ohne = Gesamtbetrag), payment_date?, payment_method? |
sales.rapport.sign | 2 | DocuSign-Signatur für einen Rapport anfordern; nach Unterschrift automatisch abgeschlossen. rapport_id, signer_name, signer_email |
sales.rapport.to_invoice | 2 | Einen/mehrere abgeschlossene Rapporte → Rechnungs-Entwurf; Rapporte → billed. rapport_ids?, type?, due_days? |
2.7 Auswertung — web-portal.ts
Module: auswertung / core
| Tool | Rolle ≤ | Zweck / Parameter |
|---|---|---|
sales.invoice.revenue_summary | 3 | Umsatz-Zusammenfassung eines Zeitraums (Gesamt, Anzahl, bezahlt, offen). range? (last_month/this_month/last_year/custom), from?, to?, status? |
project.create_form | 2 | Neues Projekt mit ALLEN Feldern als vorbefülltes Chat-Formular (Web); „ich" → project_manager_id 'me'. kontakt_id, viele optionale Stammdaten-Felder |
project.attach_media | 3 | Hochgeladene 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
| Tool | Rolle ≤ | Zweck / Parameter |
|---|---|---|
time.personal.start | 3 | Personal-Stoppuhr starten (Kommen). |
time.personal.stop | 3 | Personal-Stoppuhr stoppen (Gehen/Feierabend). |
time.personal.status | 3 | Läuft eine Stoppuhr, seit wann? |
time.project.list_entries | 3 | Bestehende Projektzeit-Positionen (Tätigkeit, Stunden), optional je Arbeitstag; Basis für Tages-Summen-Deltas. project_id?, work_date?, user_id? |
time.my_entries | 3 | Eigene 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_hours | 3 | Projektzeit-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
| Tool | Rolle ≤ | Zweck / Parameter |
|---|---|---|
leave.list_types | 3 | Aktive Abwesenheitsarten des Betriebs (Mapping Freitext → leave_type_key). |
leave.preview_request | 3 | Antrags-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
| Tool | Rolle ≤ | Zweck / Parameter |
|---|---|---|
anfrage.update_draft | 2 | Freitext-Korrektur eines offenen Anfrage-Entwurfs (Status eingegangen), z. B. „Name ist Huber, Objektart Gewerbe"; sendet aktualisierte Zusammenfassung + Buttons erneut. anfrage_id |
anfrage.confirm | 2 | Entwurf bestätigen (eingegangen → neu), Kontakt laut Vorschlag anlegen/verknüpfen, Anhänge nachladen. anfrage_id |
anfrage.ignore | 2 | Entwurf verwerfen (nur Status eingegangen). anfrage_id |
2.11 Erinnerungen — _example.reminder.ts (registriert; nutzt agent_schedules + Scheduler-Queue)
Modul: core
| Tool | Rolle ≤ | Zweck / Parameter |
|---|---|---|
reminder.create | 3 | Einmalige (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.list | 3 | Aktive Erinnerungen des Nutzers. |
reminder.delete | 3 | Erinnerung deaktivieren. id |
2.12 Rapport (produktiv) — rapport.ts (Roadmap P1-1, Plan Menü-Rework §6)
Modul: rapport
| Tool | Rolle ≤ | Zweck / Parameter |
|---|---|---|
rapport.draft.start | 3 | Rapport-Entwurf starten (DraftStore, akkumuliert über Nachrichten). Projekt: project_id? oder aktives Projekt; setzt Kunde (kontakt) aus dem Projekt. note? |
rapport.draft.add_entry | 3 | Arbeitszeit-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_material | 3 | Material-Zeile: material_id aus catalog.search (Name/Einheit/EK aus Katalog) oder Freitext-name. quantity?, unit?, notes? |
rapport.draft.edit | 3 | Korrektur-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.submit | 3 | Rendert 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_send | 2 | Schutzstufe 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
| Tool | Rolle ≤ | Zweck / Parameter |
|---|---|---|
termine.list | 3 | Termine 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_request | 2 | Schutzstufe 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:
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 alsattendees[].type: 'required', Zeiten als UTC ohneZimdateTime(Graph lehnt das bei gesetztemtimeZoneab), undisOrganizernicht mitschicken (read-only). Danach hältlinkAppointmentToOutlookEventdie Graph-Event-ID inappointments.external_reference(external_source='microsoft') fest und überschreibtics_uidmit deriCalUIdvon 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 filtertexternalAttendeesden Organisator heraus,MeetingInviteResult.invitedCountgibt die Zahl echter Empfänger zurück (0 ⇒ Antwort sagt es klar), und die Vorschau warnt bereits vor dem Senden.Mail mit
.ics-Anhang (Rückfallebene): nur wenn 1. nicht geht (keine aktive Microsoft-Verbindung, fehlendeCalendars.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.generateICSContentsetztMETHOD:REQUEST,ORGANIZER= Firmenpostfach und den Kunden alsATTENDEE;RSVP=TRUE; UID stabil überappointmentIcsUid(id). Domain-Lift:generateICSContentlebt jetzt inpackages/domain/src/calendar-ics.ts(ADR-0002, ergänzt umLOCATION,ATTENDEE,STATUS, RFC-5545-Zeilenfaltung);apps/app/src/lib/calendar/ics.tsist 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
| Tool | Rolle ≤ | Zweck |
|---|---|---|
sales.rapporte.list_view | 2 | Ö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
| Tool | Rolle ≤ | Zweck |
|---|---|---|
sales.customer.list_view | 2 | Ö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_view | 2 | Ö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(+ Paginationkunden:list:<offset>, WhatsApp 8 / Web 50 pro Seite, DomainlistCustomersmittopLevel; im Web-Panel ab 9 Zeilen mit clientseitigem Suchfeld): Rowskunde: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 nutzenprojekt: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-Kartenverkauf:angebot:<id>/verkauf:rechnung:<id>(inkl. Senden/Zahlung-Aktionen). Kunden-Anfragenkunde:anfragen:<id>analog →anfrage:detail:<id>. - Kunde bearbeiten
kunde:edit:<id>— am Web-Kanal ein vorbefülltescustomer_edit-Formular im Panel (FelderCUSTOMER_EDIT_FORM_FIELDS: Name, E-Mail, Telefon, Mobil, Straße+Hausnummer mit Google-Adresssuche, PLZ, Ort, Website, UID, Zahlungsziel; PendingpendingCustomerEditForm, 30-min-TTL,/abbrechenräumt mit). Commit in/api/agent/chatschreibt via Domaincustomers.updateund rendert das Kunden-Detail neu. WhatsApp: Freitext-Hinweis. - Anfragen-Pipeline
anfragen:list— alle nicht-terminalen Anfragen (neueste zuerst, max. 9) → Detailanfrage: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 → DomaincreateOfferFromAnfrage), 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
| Tool | Rolle ≤ | Zweck / Parameter |
|---|---|---|
bautagebuch.get_fields | 3 | Lädt Gewerke-Felder, Bautagebuch-Optionen und Projektphasen für den aktuellen Betrieb. |
bautagebuch.preview_upsert | 3 | Erstellt 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
| Tool | Rolle ≤ | Zweck / Parameter |
|---|---|---|
project.get_details | 3 | Voller 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.menu | 3 | WhatsApp-Auswahlliste aller Projekt-Ansichten (Sektions-Registry, Phase 1: 5 Rows, Endausbau max. 10); setzt das Projekt sticky. project_id? |
project.show_section | 3 | Rendert eine Sektion als formatierte Ansicht (chunked >1000 Zeichen) mit Kontext-Buttons. section ∈ stamm|status|termine|planung|notizen, project_id? |
project.preview_update | 2 | Diff-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
| Tool | Rolle ≤ | Zweck / Parameter |
|---|---|---|
time.project.overview | 3 | Projektzeiten-Ü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_change | 3 | Bestä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
| Tool | Rolle ≤ | Zweck / Parameter |
|---|---|---|
project.material.overview | 3 | Material-Plan mit Plan-IDs (geplant/bereitgestellt, Menge × Gebinde). Auch Renderer der Menü-Row Material. project_id? |
project.material.preview_plan | 2 | Material 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_fulfill | 3 | Bereitstellen (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_remove | 2 | Geplanten Eintrag entfernen (nur Status „geplant"), Vorschau + Bestätigung. plan_id |
project.documents.overview | 3 | Verknü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_upload | 3 | Foto/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
| Tool | Rolle ≤ | Zweck / Parameter |
|---|---|---|
bautagebuch.view | 3 | Bautagebuch 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.overview | 3 | Internes Team (👑 Leitung) + Ansprechpartner des Kunden inkl. IDs. Team wird zusätzlich in der Menü-Sektion Planung & Budget angezeigt. project_id? |
company.team.list | 2 | Team-Mitglieder des Betriebs (user_id + Name) zum Namensauflösen für Änderungen. query? |
project.team.preview_change | 2 | Team/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
| Tool | Rolle ≤ | Zweck / Parameter |
|---|---|---|
navigation.send_location | 3 | Schickt 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)
| Tool | Rolle ≤ | Zweck / Parameter |
|---|---|---|
analytics.query | 3 | Aggregierte 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.render | 3 | Zeichnet 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:
| Tool | Automatisches Bild | Bedingung |
|---|---|---|
analytics.query | Typ 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_summary | Ring „Umsatz nach Zahlstatus" | Web, ohne status-Filter, ≥ 2 Buckets > 0 |
time.project.overview | Balken „Stunden je Mitarbeiter" | Web, ≥ 2 Mitarbeiter |
diagram.plan / diagram.org | Gantt bzw. Organigramm | immer (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)
| Tool | Rolle ≤ | Zweck / Parameter |
|---|---|---|
diagram.render | 3 | Erklärendes Schaubild aus Mermaid-Quelltext: type flow/sequence/state/mindmap/journey/er/class, title, code (Rumpf ohne Kopfzeile — die setzt der Server) |
diagram.plan | 3 | Zeitplan (Gantt) aus echten Daten: source projekte/termine/abwesenheiten, from?, to?, status?, project_id?, limit?; alternativ tasks selbst übergeben |
diagram.org | 3 | Organigramm 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öser | Verhalten | Datei |
|---|---|---|
/whoami | Profil-/Session-Info (User, Firma, Rolle, aktives Projekt) | commands/whoami.ts |
/inbox oder inbox | Notification-Hub: offene Benachrichtigungen als Liste (max. 10), Detail über Button notif:open:<id> mit Action-Buttons | commands/inbox.ts |
/abbrechen, /cancel oder ganze Nachricht abbrechen | Laufende 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 Stand | Aufgaben 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:status | Direkt 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+Storage | commands/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-monat | Hauptmenü (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_key | Buttons | Zweck |
|---|---|---|
leave.approve | leave.approve / leave.decline | Urlaubsantrag direkt aus dem Push genehmigen/ablehnen |
anfrage.confirm | anfrage.approve / anfrage.ignore | Anfrage-Entwurf aus der Mail-Triage anlegen/verwerfen (Race-Handling: erster Klick gewinnt) |
Dispatcher: capabilities/notifications.ts (deterministisch, kein LLM).
5. Hilfsmodule ohne eigene Tools
| Datei | Rolle im System |
|---|---|
sales-offer-state.ts | Session-State pendingOffer für den Angebots-Entwurfs-Flow |
time-hours-delta.ts | Stunden-Delta vs. Tages-Summe (Domain addiert immer auf Bestehendes) |
media-upload-llm.ts | Strukturierter LLM-Turn für den Medien-Upload-Dialog (Zod-Schema statt Keyword-Maps) |
project-media-upload.ts | Mehrschrittiger Upload-Dialog (Stages, Bestätigungs-Buttons, Ablage) |
notifications.ts | Button-Dispatcher für Notification-Actions |
6. Status der _example.-Dateien
| Datei | Registriert? | Status |
|---|---|---|
_example.project-context.ts | ✅ | funktional, produktiv genutzt (sticky project) — Umbenennung (Präfix weg) empfohlen |
_example.reminder.ts | ✅ | funktional (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:
- Neues Tool (
defineTool+ Registrierung incapabilities/index.ts) → Zeile im passenden Katalog-Abschnitt (§2): Name, Mindestrolle, Zweck (1–2 Sätze), wichtigste Parameter, Seiteneffekte (Statuswechsel, Versand, Buttons/Directives). - Geändertes Tool (Name, Parameter,
requiredRole, Verhalten/Seiteneffekte) → Zeile aktualisieren. - Entferntes Tool → Zeile löschen (nicht auskommentieren).
- Neuer Command (
commands/*) → §3 ergänzen (Auslöser exakt: Slash/Keyword/Button-ID). - Neue Notification-Action (
notifications/actions.ts) → §4 ergänzen (action_key, Buttons, Zweck). - Stub → produktiv (z. B.
rapport.draft.submit) → Warnhinweise hier UND in 11-whatsapp-agent entfernen;_example.-Umbenennung nachziehen. - Zähler in der Überschrift von §2 („43 registrierte Tools") mitpflegen.
Durchsetzung/Erinnerung:
- Regel-Datei:
.cursor/rules/agent-tools-dokumentation.mdc(greift bei Änderungen unterpackages/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
- 11-whatsapp-agent (Laufzeit/Infrastruktur des Agenten) · 12-ai-speech-realtime · 15-notifications-realtime
- adr-0002-shared-domain-layer · adr-0003-whatsapp-agent-rebuild
- Roadmap IST/SOLL (P1-1 Rapport-Diktat, P2-13 Web-Assistent)