# Workers – Plan-Übersicht und gemeinsame Schnittstellen > Dieses Dokument ist die **verbindliche Quelle** für alle Workers-Teilpläne P1–P6. Jeder Teilplan verweist hierauf und darf die hier festgelegten Namen, Felder, Pfade und Signaturen nicht abweichend verwenden. Wer etwas ändern muss, ändert es **hier** und in allen betroffenen Plänen. **Spec:** `docs/superpowers/specs/2026-10-17-workers-design.md` (freigegeben 17.10.2026) **Code-Landkarten (Fakten zum Bestand):** `/root/reports/workers-2026-10-16/codekarten/{woms,portal,infra,mail-claude,daten-spiel}.md` **Pixel-Vorlage:** `/root/reports/workers-2026-10-16/entwuerfe/{sprites.js,roster.js,scene-turm.js,index.html,smoke.js,shot.cjs}` --- ## 1. Teilpläne, Reihenfolge, Orte | Plan | Datei | Inhalt | Repo / Arbeitsort | Hängt ab von | |---|---|---|---|---| | **P1** Dienst-Fundament | `2026-10-17-workers-p1-dienst-fundament.md` | Dienst-Gerüst, Appwrite-Sammlungen + Setup-Skript, Normalisierung + Nie-doppelt-Register + Startbestand, Ereignisstrom, Budget-Wächter + Claude-Hülle, Rollen-Register mit Werkzeug-/Kanal-Sperre, Zeitplan-Tick, Otto (Nachtplan + Morgenbericht), HTTP-API, Container + Betrieb | neues Repo `knso/workers-dienst` | – | | **P2** Hochhaus in WOMS | `2026-10-17-workers-p2-hochhaus-woms.md` | Menüpunkt + Route `/workers`, WorkersPage, Pixel-Engine (Port aus der Vorlage, Etagen/Räume als Daten, Lift, Ereignis-Regie, Leerlauf-/Grau-Zustände), Polling-Feed, Worker-Karte mit Spur, Kennzahlen, Pausenschalter, Einstellungen (Admin) | WOMS `JUSN/tickte-system`, Worktree `dev-workers`, Branch `feature/workers` | P1 (API) | | **P3** Etage „Anfragen“ + Freigabe-Pult | `2026-10-17-workers-p3-anfragen-pult.md` | Rita/Kim/Pia/Finn, Alarm-Postfach lesen, Bea-Bewertung für Anfragen, Clara-Erstantwort, Pult im Dienst + Pult-UI in WOMS | Dienst + WOMS | P1, P2 | | **P4** Gespräche, Werkstatt, Prüfung | `2026-10-17-workers-p4-gespraeche-werkstatt.md` | Max als einziger Absender (SMTP-Tor), Nora-Posteingang (IMAP + Einordnung), Fäden, Theo, Bob/Bianca/Ben-Bau, Paul-Prüfung mit Screenshots, Vorschau-Server `entwurf.project.webklar.com`, Vorschau-Freigabe, Erinnerungen | Dienst (+ WOMS-Pult-Erweiterung) | P1–P3 | | **P5** Kaufweg im Portal | `2026-10-17-workers-p5-portal-kaufweg.md` | „Website übernehmen“-Seite + Routen ohne Login, Stripe-Checkout ohne Kunde, Webhook-Zweig legt Kunde an, Nachweis, K44-Mail, Vorschau → Kundenprojekt, Status zurück an Workers | Portal `knso/Webklar-Kundenbereich`, Worktree `dev-workers-kauf`, Branch `feature/workers-kaufweg` | P1, P4 (Angebots-Doc) | | **P6** Etage „Ohne Website“ + Kaltmail | `2026-10-17-workers-p6-ohne-website.md` | Ali/Hanna/Sven/Lea (Places/OSM/Impressum), Top-3 über alle Räume, Bot-Lead-Liste (Dienst + WOMS-Ansicht), Max-Kaltmail mit allen Pflichtangaben, Nachfassen, Aufbewahrung/Kürzung, Datenschutz-Abschnitt „Interessenten“ | Dienst + WOMS + webklar.com-Rechtstext | P1–P4 | **Reihenfolge der Umsetzung:** P1 → P2 → P3 → P4 → P5 → P6. P5 kann nach P1 parallel zu P3/P4 laufen (eigene Dateien im Portal), solange das Angebots-Dokument aus §4.12 feststeht. ### 1.1 Repos, Checkouts, Branches | Was | Ort | Regel | |---|---|---| | Workers-Dienst (neu) | Gitea `knso/workers-dienst` (**nicht** in der Org WEBklar: deren Org-Webhook legt für jeden Push ein `websiteProjects`-Dokument an). Entwicklung: `/home/webklar/apps/workers-dev` (Branch je Plan: `p1-fundament`, `p3-anfragen`, `p4-gespraeche`, `p6-ohne-website`). Betrieb: `/home/webklar/apps/workers` (Branch `main`). | Repo anlegen per Gitea-MCP `create_repo` (privat). Kenso merged/pusht nach `main`. | | WOMS | `/home/webklar/apps/ticket.webklar.com/dev-workers`, Branch `feature/workers` | Live geht nur, wenn Kenso `git push origin feature/workers:test` ausführt. | | Portal | Worktree `/home/webklar/apps/kundenbereich.webklar.com/dev-workers-kauf` von `origin/main`, Branch `feature/workers-kaufweg` | Live: Kenso pusht nach `main` (Deploy-Webhook). | --- ## 2. Globale Regeln (gelten in jedem Task jedes Plans) 1. **Speicher:** Jeder Node-Lauf (Tests, Skripte) mit `timeout node --max-old-space-size=1024 …`. Vite/esbuild-Bündel nur unter `systemd-run --scope -q -p MemoryMax=1500M -p MemorySwapMax=0`. Playwright/Chromium nur unter `systemd-run --scope -q -p MemoryMax=700M -p MemorySwapMax=0`. Vor schweren Läufen `free -m`. 2. **Sicherheit beim Ausführen:** kein `rm -rf` außerhalb eigener Scratch-Ordner unter `$TMPDIR`; nie `/dev`, `/etc`, `/` anfassen; kein `docker run`, keine globalen Installs; Prozesse nur per PID beenden (`pkill -f` trifft die eigene Shell); `grep` ist ein Wrapper → `command grep -a` oder `rg`. 3. **Git:** nur `git add ` mit Namen, nie `-A`/`.`. Agenten pushen nie und führen kein `docker compose up` aus; das macht Kenso nach einer Betriebsanleitung im Plan. `.env` und `docker-compose.yml` unter `/home/webklar/apps` sind nicht in Git → vor jeder Änderung Backup `*.bak-YYYY-MM-DD-workers`. 4. **WOMS-Worktree:** `node_modules` fehlt → einmal `ln -s ../build/node_modules node_modules` (zeigt als `?? node_modules`, nie committen). Tests: `timeout 600 node --max-old-space-size=1024 --test tests/*.test.mjs tests/unit/*.test.mjs`; reine Libs unter `src/lib/` ohne React/Appwrite-Import, geladen mit `loadModule()` aus `tests/unit/load.mjs`. 5. **Portal:** Tests `timeout 900 node --max-old-space-size=1024 --test test/.test.js` mit `startAppwriteMock`/`startFakeStripe`/`mailSink` (Muster `test/aboRoutes.test.js`). 6. **Dienst:** Node 22 (Container `node:22-alpine`), ESM, keine Semikolons, deutsche Kommentare, Tests `timeout 300 node --max-old-space-size=1024 --test test/*.test.js`. Jede äußere Abhängigkeit (Appwrite, Anthropic, SMTP, IMAP, Uhr) wird per Parameter injiziert, damit Tests ohne Netz laufen. **In Tests nie echte Anthropic-, SMTP- oder Appwrite-Aufrufe.** 7. **Abhängigkeiten des Dienstes (exakt):** `@anthropic-ai/sdk@0.135.0`, `node-appwrite@14.1.0` (wie Portal), `nodemailer@10.1.2`, `imapflow@2.5.2`, `mailparser@3.9.40`, `playwright-core@1.62.1` (passt zu Chromium im Image). Keine weiteren ohne Eintrag hier. 8. **Modelle:** `claude-opus-5-5` (Otto, Bob, Bianca, Ben), `claude-sonnet-5-5` (Bea, Max, Clara, Theo, Paul), `claude-haiku-5-5` (Rita, Kim, Pia, Finn, Ali, Hanna, Sven, Lea, Nora, Arne). Kein `temperature`/`top_p`. Strukturierte Antworten über `output_config.format` (json_schema). Suche und Strukturierung in **zwei** Aufrufen (Suche mit Werkzeugen → freier Text; danach werkzeugloser Aufruf mit Schema), weil Such-Zitate und `output_config.format` kollidieren können. 9. **Budget (Kenso):** 10 $/Tag, 250 $/Monat in den ersten 4 Wochen, Warnung bei 80 %. Workspace-Ausgabenlimit in der Console zusätzlich. Antwort „HTTP 400 … reached your specified … API usage limits“ oder 429 mit `enforced_spend_limit_reached` ⇒ sofort `budget_stopp`. 10. **Mail (Kenso + Spec §8):** Kaltmails **nie** über webklar.com/Hetzner. Höchstens 3 unverlangte Mails/Tag (Erst + Nachfass zusammen), Antworten an Schreibende + Vorschau-Links eigene Grenze 15/Tag, nur werktags 09:00–16:00 Europe/Berlin. Nur Max sendet. Sperrliste vor jedem Versand. Ein SMTP-Login-Fehler ⇒ Versand für den Tag aus (kein Wiederholen; Hetzner-IP-Sperre-Erfahrung gilt sinngemäß). 11. **Keine Umgehung (Kenso):** kein Captcha/Cloudflare-Umgehen, kein Login-Scraping, `robots.txt` beachten (`url_not_allowed` = auslassen). Plattformen mit Bot-Verbot (Kleinanzeigen, Facebook, Upwork, MyHammer, freelancermap-Login-Bereich, freelance.de-Login-Bereich): **nie** direkt abrufen; Lesen nur über deren Alarm-Mails im Alarm-Postfach oder Suchmaschinen-Treffer; Senden nur durch Menschen über das Pult. 12. **Texte nach außen:** Deutsch, „Sie“ in Kaltmails an Betriebe, „du“ nur, wenn der Anfragende duzt. Kein „Sehr geehrte Damen und Herren“, kein Ausrufezeichen im Betreff. Pflichtbausteine (KI-Hinweis, Quelle, Datenschutz-Link, Widerspruch, Abmelde-Link, Angebotssatz) werden **per Code** angehängt, nie dem Modell überlassen. 13. **Keine Bot-Leads in WOMS-Kunden:** Vor einem bezahlten Deal schreibt kein Teil von Workers in `customers`, `workorders`, `websiteProjects`, `customerPortalAccess` oder legt Appwrite-Benutzer an. 14. **Zeit:** Dienst läuft mit `TZ=Europe/Berlin`; gespeichert wird ISO-8601 UTC. --- ## 3. Besetzung (Rollen-Register) – verbindlich `workerId` ist die feste Kennung in Daten, Ereignissen, API und Spiel. | workerId | Name | Etage (`floor`) | Raum (`room`) | Modell | Einzige Rolle | Erlaubte Fähigkeiten (`caps`) | |---|---|---|---|---|---|---| | `otto` | Otto | `bruecke` | `bruecke` | opus | plant die Nacht, verteilt Aufträge, Budget, Morgenbericht | `plan`, `report` | | `rita` | Rita | `anfragen` | `netz` | haiku | sucht frische „Suche Webdesigner“-Posts über Websuche | `web_search`, `web_fetch` | | `kim` | Kim | `anfragen` | `kleinanzeigen` | haiku | liest Kleinanzeigen-Suchauftrags-Mails im Alarm-Postfach | `alert_inbox` | | `pia` | Pia | `anfragen` | `portale` | haiku | liest Projekt-Alerts/RSS der Portale | `alert_inbox`, `rss` | | `finn` | Finn | `anfragen` | `foren` | haiku | sucht in Foren/Frage-Seiten (nur erlaubte) | `web_search`, `web_fetch` | | `ali` | Ali | `ohne_website` | `aerzte` | haiku | Branchen-Scout Ärzte & Praxen | `web_search`, `web_fetch`, `places`, `osm` | | `hanna` | Hanna | `ohne_website` | `handwerk` | haiku | Branchen-Scout Handwerk | wie `ali` | | `sven` | Sven | `ohne_website` | `selbst` | haiku | Branchen-Scout Selbstständige | wie `ali` | | `lea` | Lea | `ohne_website` | `laden` | haiku | Branchen-Scout Läden & Gastro | wie `ali` | | `bea` | Bea | `bewertung` | `waage` | sonnet | bewertet, verteilt Top 3 / Rest | `score` | | `max` | Max | `gespraeche` | `mail` | sonnet | schreibt Mails und ist **einziger Absender** | `draft`, `send_mail` | | `clara` | Clara | `gespraeche` | `forum` | sonnet | schreibt Antworten auf Anfragen | `draft`, `pult` | | `nora` | Nora | `gespraeche` | `posteingang` | haiku | liest + sortiert Eingänge | `read_inbox`, `classify` | | `theo` | Theo | `gespraeche` | `abschluss` | sonnet | Nachrichten rund um den Kauf (Entwürfe) | `draft`, `pult`, `offer` | | `bob` | Bob | `werkstatt` | `tisch1` | opus | baut Vorschauen (nur nach Ja) | `build` | | `bianca` | Bianca | `werkstatt` | `tisch2` | opus | baut Vorschauen | `build` | | `ben` | Ben | `werkstatt` | `tisch3` | opus | baut Vorschauen | `build` | | `paul` | Paul | `pruefung` | `pruefung` | sonnet | prüft Vorschauen (Screenshots) | `review`, `screenshot` | | `arne` | Arne | `keller` | `archiv` | haiku | Nie-doppelt-Register + Bot-Lead-Liste | `register`, `archive` | Etagen von oben nach unten (`FLOORS`): `bruecke`, `anfragen`, `ohne_website`, `bewertung`, `gespraeche`, `werkstatt`, `pruefung`, `freigabe` (Pult, kein Worker), `tresor` (kein Worker), `keller`. Die Tabelle liegt als Daten im Dienst in `src/roles.js` (`ROLES`, `FLOORS`) und als gespiegelte Kopie in WOMS `src/lib/workersRoster.js`. Ein Test in jedem Repo prüft die Kopie gegen eine gemeinsame Fixture `workers-roster.json` (gleicher Inhalt in beiden Repos: `test/fixtures/workers-roster.json` bzw. `tests/fixtures/workers-roster.json`). **Durchsetzung (P1):** `assertCap(workerId, cap)` wirft `CapError`, wenn die Rolle die Fähigkeit nicht hat. Jede Fähigkeit hat genau einen Code-Pfad, der sie vor der Ausführung prüft. `send_mail` existiert **nur** im Sende-Tor (`src/mail/gate.js`, P4). --- ## 4. Datenmodell (Appwrite, Datenbank `woms-database`) Alle Workers-Sammlungen: Rechte `[]` (nur Server-Schlüssel), `documentSecurity=false`. WOMS liest **nur** über die Dienst-API. Angelegt durch `scripts/setup-collections.mjs` im Dienst (P1; idempotent, `--check` = nur prüfen, Exit 1 bei Fehlendem). Schema als reine Daten in `src/schema.js`. Kein Feld heißt `data`. Dokument-IDs ≤ 36 Zeichen. JSON-Felder sind Strings (`…Json`). Zeiten: `datetime`. Typ-Kürzel: `s(n)` String max. n, `i` Integer, `f` Float, `b` Boolean, `dt` Datetime, `e[…]` Enum, `[]` Array. `*` = Pflicht. ### 4.1 `workersSettings` (genau ein Dokument, `$id = "main"`) `paused b*` · `dailyBudgetUsd f*` (10) · `monthlyBudgetUsd f*` (250) · `warnPct i*` (80) · `mailColdPerDay i*` (3) · `mailReplyCapPerDay i*` (15) · `sendWindowStart s(5)*` ("09:00") · `sendWindowEnd s(5)*` ("16:00") · `sendWeekdaysOnly b*` (true) · `runsPerNight i*` (1) · `candidatesPerRun i*` (10) · `activeRooms s(32)[]` (["netz","kleinanzeigen","portale","foren","aerzte","handwerk","selbst","laden"]) · `connectedRooms s(32)[]` (Räume mit echter Quelle; leer bis eingerichtet) · `regions s(64)[]` (["Rheinland-Pfalz"]) · `previewGate e[erste_n,immer,nie]*` ("erste_n") · `previewGateN i*` (10) · `previewsSent i*` (0) · `offerText s(1000)*` (Kensos Satz aus Spec §11.2) · `offerProductKey s(32)*` ("hosting") · `pultStaleDays i*` (3) · `retentionDays i*` (90) · `followupDays i*` (7) · `previewReminderDays i*` (5) · `previewDeleteDays i*` (60) · `reportEmail s(256)*` (Kensos Adresse) · `updatedAt dt` · `updatedBy s(64)` ### 4.2 `workersProspects` – Interessenten (Anfragen und Betriebe) `entrance e[anfrage,ohne_website]*` · `room s(32)*` · `foundBy s(16)*` (workerId) · `source s(64)*` (z. B. `gutefrage`, `kleinanzeigen-alarm`, `places`) · `sourceUrl s(1024)` · `foundAt dt*` · `name s(256)*` · `contactName s(256)` · `email s(256)` · `emailSource s(1024)` (URL, aus der die Adresse stammt) · `phone s(64)` · `phoneSource s(1024)` · `website s(512)` · `street s(256)` · `postalCode s(10)` · `city s(128)` · `placeId s(128)` · `requestText s(4000)` (Wortlaut der Anfrage) · `requestedAt dt` · `score i` (0–100) · `scoreReason s(2000)` · `status e[gefunden,bewertet,botlead,kontaktiert,gespraech,ja,vorschau_bau,vorschau_gesendet,angebot,deal,verloren,gesperrt]*` · `botLeadReason s(500)` · `threadId s(36)` · `previewId s(36)` · `offerId s(36)` · `customerId s(36)` · `isHealth b` (Heilberuf → keine Telefonnummer speichern) · `purgeAfter dt` · `purgedAt dt` · `updatedAt dt*` Indizes: `status`, `entrance+status`, `room`, `foundAt`, `purgeAfter`. ### 4.3 `workersKeys` – Nie-doppelt-Register `$id = sha256(kind + ":" + value).hex.slice(0, 32)`. `kind e[dom,mail,tel,pid,ad,namplz]*` · `prospectId s(36)` (leer bei Startbestand) · `origin e[workers,seed_alt,seed_kunde]*` · `blocked b*` (true: nie anschreiben) · `hint b*` (true nur für `namplz` = kein harter Treffer) · `createdAt dt*`. `value` wird **nicht** gespeichert (nur der Hash), damit die Kürzung nach 90 Tagen nichts übrig lässt. ### 4.4 `workersSuppression` – Sperrliste (für immer) `$id = sha256(kind + ":" + value).hex.slice(0, 32)`. `kind e[mail,dom,tel]*` · `reason e[abmeldung,widerspruch,nein,beschwerde,bounce,kunde,manuell]*` · `createdAt dt*` · `source s(64)` (workerId oder `mensch:`). ### 4.5 `workersSearchLog` `workerId s(16)*` · `room s(32)*` · `region s(64)` · `query s(500)*` · `at dt*` · `found i*` · `fresh i*` (nach Register-Prüfung neu). Index: `room+at`. ### 4.6 `workersJobs` – Aufträge `kind e[suche,alarm_lesen,bewerten,erstantwort,erstmail,nachfass,einordnen,antwort,bau,pruefung,vorschau_link,angebot,erinnerung,bericht,kuerzen]*` · `workerId s(16)*` · `status e[offen,laeuft,warten_batch,fertig,fehler,abgebrochen]*` · `prospectId s(36)` · `threadId s(36)` · `previewId s(36)` · `attempts i*` · `batchId s(64)` · `customId s(64)` · `payloadJson s(16000)` · `resultJson s(16000)` · `error s(2000)` · `createdAt dt*` · `startedAt dt` · `finishedAt dt`. Indizes: `status`, `workerId+status`. ### 4.7 `workersEvents` – Ereignisstrom (Quelle für Spiel, Spur, Kennzahlen) `ts dt*` · `workerId s(16)*` (oder `system`) · `kind s(32)*` (Liste §5) · `jobId s(36)` · `prospectId s(36)` · `text s(300)*` (deutscher Satz für Spur/Blase) · `link s(1024)` · `to s(16)` (Ziel-workerId bei Übergaben) · `tokensIn i` · `tokensOut i` · `costUsd f` · `metaJson s(4000)`. Index: `ts`, `workerId+ts`. ### 4.8 `workersDaily` – Tageszähler (`$id = "YYYY-MM-DD"` Berlin-Datum) `costUsd f*` · `mailsCold i*` · `mailsReply i*` · `searches i*` · `found i*` · `contacted i*` · `deals i*` · `countersJson s(4000)` (pro workerId: `{gelesen, gefunden, entwuerfe, gesendet, …}`) · `warned b*` · `stopped b*`. ### 4.9 `workersThreads` – Gesprächsfäden `prospectId s(36)*` · `channel e[mail,plattform]*` · `platform s(64)` · `platformUrl s(1024)` · `toEmail s(256)` · `subject s(300)` · `status e[offen,wartet_auf_kunde,wartet_auf_uns,ja,nein,abgeschlossen,gesperrt]*` · `lastInboundAt dt` · `lastOutboundAt dt` · `followupDueAt dt` · `followupSent b*` · `reminderDueAt dt` · `reminderSent b*` · `createdAt dt*`. ### 4.10 `workersMessages` `threadId s(36)*` · `direction e[in,out]*` · `author s(64)*` (workerId oder `mensch:` oder `kunde`) · `subject s(300)` · `body s(16000)*` · `messageId s(512)` · `inReplyTo s(512)` · `referencesJson s(4000)` · `sentAt dt` · `channel e[mail,plattform]*` · `contactSource s(1024)` · `classification e[ja,frage,nein,abmelden,abwesend,unzustellbar,beschwerde,unklar]` · `classificationConfidence f` · `countsAsCold b*` · `createdAt dt*`. ### 4.11 `workersPult` – Freigabe-Pult `kind e[plattform_antwort,vorschau_freigabe,unsicher,beschwerde,preisfrage,mensch_gewuenscht,bau_fehlgeschlagen,mail_antwort]*` · `prospectId s(36)` · `threadId s(36)` · `previewId s(36)` · `title s(200)*` · `originalText s(8000)` · `draft s(8000)` · `sourceUrl s(1024)` · `status e[offen,in_arbeit,gesendet,verworfen,veraltet,erledigt]*` · `claimedBy s(64)` · `claimedAt dt` · `doneBy s(64)` · `doneAt dt` · `createdAt dt*` · `staleAt dt`. Index: `status+createdAt`. ### 4.12 `workersOffers` – Kauf-Links (Schnittstelle zum Portal, P4 schreibt, P5 liest/aktualisiert) `tokenHash s(64)*` (sha256 hex des 43-Zeichen-base64url-Tokens; Token selbst nie speichern) · `prospectId s(36)*` · `previewId s(36)*` · `productKey s(32)*` ("hosting") · `businessName s(256)*` · `email s(256)*` · `previewUrl s(1024)*` · `status e[offen,checkout,bezahlt,abgelaufen,widerrufen]*` · `expiresAt dt*` · `stripeSessionId s(128)` · `customerId s(36)` · `paidAt dt` · `createdAt dt*`. Index: `tokenHash` (unique), `status`. Kauf-URL: `https://project.webklar.com/uebernahme.html#t=`. ### 4.13 `workersPreviews` – Vorschauen `token s(43)*` (öffentlicher Pfadteil, base64url, 32 Byte) · `prospectId s(36)*` · `builder s(16)*` · `status e[bau,pruefung,zurueck,fertig,freigabe,gesendet,verworfen,gescheitert]*` · `round i*` · `reviewNotes s(8000)` · `filesJson s(4000)` (Dateiliste) · `createdAt dt*` · `deleteAfter dt*` · `convertedProjectId s(36)` (nach Deal). Öffentliche Adresse: `https://entwurf.project.webklar.com//`. --- ## 5. Ereignisarten (`workersEvents.kind`, geschlossene Liste) | kind | wer | Bedeutung (Spiel-Animation) | |---|---|---| | `auftrag` | otto | Otto vergibt einen Auftrag (`to` = workerId; Blase) | | `leerlauf` | alle | nichts zu tun (Kaffee/Schlafen) | | `gelesen` | rita, kim, pia, finn, nora | Quelle/Postfach gelesen (`metaJson.count`) | | `fund` | Such-Worker | neuer Kandidat (Zettel entsteht) | | `doppelt` | arne | Register-Treffer, Kandidat verworfen (Stempel „schon bekannt“) | | `uebergabe` | alle | Dokument geht an `to` (Lauf + Lift) | | `bewertet` | bea | Punktzahl vergeben | | `top3` | bea | Kandidat bekommt Mail-/Antwort-Platz | | `botlead` | arne | in die Bot-Lead-Liste abgelegt (Keller) | | `entwurf` | max, clara, theo | Nachricht entworfen | | `pult` | clara, theo, nora, paul, system | Eintrag ins Freigabe-Pult (Briefkasten-Badge) | | `gesendet` | max, system | Nachricht verschickt (Max) bzw. Mensch hat gesendet (`system`, `metaJson.by`) | | `eingang` | nora | Antwort eingegangen | | `sortiert` | nora | eingeordnet (`metaJson.classification`) | | `bau_start` / `bau_fertig` | bob, bianca, ben | Vorschau wird gebaut / ist fertig | | `pruefung_zurueck` / `pruefung_ok` | paul | Runde zurück (`metaJson.round`) / bestanden | | `vorschau_link` | max | Vorschau-Link verschickt | | `angebot` | theo | Kauf-Link erzeugt | | `deal` | system | bezahlt (Pokal + Münzen in den Tresor) | | `gesperrt` | system, nora | Kontakt auf Sperrliste | | `budget_warnung` / `budget_stopp` | otto | 80 % erreicht / Grenze erreicht | | `pause` | system | Pausenschalter umgelegt (`metaJson.paused`) | | `fehler` | alle | Fehler (rotes Ausrufezeichen) | --- ## 6. Dienst – feste Signaturen (P1 liefert, spätere Pläne nutzen) Pfade relativ zum Dienst-Repo. Alle Funktionen `async`, sofern sie I/O machen. `deps` = injizierte Abhängigkeiten `{ db, clock, claude, mailer, imap, log }`. | Datei | Export | Signatur / Vertrag | |---|---|---| | `src/config.js` | `loadConfig(env)` | → `{ appwrite:{endpoint,projectId,apiKey}, anthropicKey, api:{port:8092}, vorschau:{port:8093, root:'/data/vorschau', baseUrl:'https://entwurf.project.webklar.com'}, smtp:{…}, imap:{…}, alertImap:{…}, reportSmtp:{…}, places:{apiKey}, tz }`; wirft mit Liste fehlender **Namen** (nie Werte). | | `src/db.js` | `createDb({endpoint,projectId,apiKey})` | → `{ get(col,id), list(col,queries), listAll(col,queries), create(col,data,id?), update(col,id,data), remove(col,id) }`; `create` mit vorhandener `$id` wirft `ConflictError` (409). | | `src/schema.js` | `SCHEMA` | Array `{ id, name, attributes:[{key,type,size,required,array,elements,default}], indexes:[…] }` exakt nach §4. | | `src/register/normalize.js` | `normalizeName`, `normalizePhone`, `normalizeEmail`, `companyDomain` (Kopie aus WOMS `src/lib/leadDuplicates.js`, plus Fix „(0)“), `normalizeDomain(input)`, `normalizePhoneE164(input)`, `emailKey(input)`, `placeIdKey(input)`, `namePlzKey(name, plz)` | reine Funktionen; leerer String = kein Schlüssel. | | `src/register/keys.js` | `keysFor(p)` | `p` = Prospect-ähnliches Objekt → `[{kind,value}]` (ohne leere). | | | `keyId(kind, value)` | → 32-Zeichen-Hash (§4.3). | | | `claimKeys(deps, keys, {prospectId, origin, blocked=false})` | legt alle harten Schlüssel an; → `{ fresh: boolean, hits: [{kind, prospectId, blocked, origin}], hints: [...] }`. `fresh=false`, sobald ein harter Schlüssel schon existiert (erste Schreiberin gewinnt, 409 = Treffer). `namplz` erzeugt nur `hints`. | | | `isSuppressed(deps, {email, domain, phone})` | → `{ suppressed: boolean, reason }`. | | | `suppress(deps, {email, domain, phone}, reason, source)` | idempotent. | | `src/events.js` | `emit(deps, {workerId, kind, text, jobId?, prospectId?, link?, to?, tokensIn?, tokensOut?, costUsd?, meta?})` | schreibt `workersEvents`; prüft `kind` gegen §5 und `workerId` gegen `ROLES`; kürzt `text` auf 300. | | `src/roles.js` | `ROLES`, `FLOORS`, `role(workerId)`, `assertCap(workerId, cap)`, `CapError` | §3. | | `src/budget.js` | `costOf(model, usage, {batch})` | → USD nach Preisliste (Opus 4/20, Sonnet 2/10, Haiku 0,10/0,50 $ pro MTok; Cache-Lesen ×0,05 Opus/Sonnet, ×0,1 Haiku; Cache-Schreiben 5 min ×1,25, 1 h ×2; Batch Token ×0,5; Websuche 0,01 $ je Suche, nicht rabattiert). | | | `checkBudget(deps, estUsd)` | → `{ ok, reason: 'tag'|'monat'|'pause'|null, warn: boolean }`. | | | `book(deps, {workerId, usd, counters})` | addiert in `workersDaily`; löst einmal `budget_warnung` bzw. `budget_stopp` aus. | | `src/claude.js` | `createClaude({apiKey, sdk})` | → `{ ask(opts), search(opts), batch }`. | | | `ask(deps, {workerId, model?, system, input, schema?, images?, maxTokens, effort?})` | prüft `assertCap` nur für werkzeuglose Aufrufe nicht; prüft `checkBudget`; ruft Messages API; bucht Kosten; → `{ data|text, usage, costUsd }`. Wirft `BudgetStop` bei Budget-Antworten der API (§2.9). | | | `search(deps, {workerId, system, task, maxUses, allowedDomains?, blockedDomains?})` | `assertCap(workerId,'web_search')`; `web_search_20260318` + `web_fetch_20260318` mit `allowed_callers:['direct']`; folgt `pause_turn` max. 5×; → `{ text, sources:[{url,title}], usage, costUsd }`. | | `src/settings.js` | `getSettings(deps)`, `updateSettings(deps, patch, by)` | Standardwerte nach §4.1; `updateSettings` validiert Grenzen (Budget > 0, `mailColdPerDay` ≤ 3 ohne Ausnahme). | | `src/schedule.js` | `berlinNow(clock)`, `isDue(kind, now, lastRunDay)`, `inSendWindow(now, settings)` | Muster Serverpuls (`src/schedule.js` dort). | | `src/jobs.js` | `enqueue(deps, {kind, workerId, prospectId?, threadId?, previewId?, payload})`, `next(deps, {kinds})`, `finish(deps, jobId, result)`, `fail(deps, jobId, error)` | | | `src/pult.js` (P3) | `addPult(deps, {kind, title, prospectId?, threadId?, previewId?, originalText?, draft?, sourceUrl?})`, `claimPult`, `markSent`, `discard`, `expireStale` | | | `src/mail/gate.js` (P4) | `sendMail(deps, {workerId:'max', prospectId, threadId, to, subject, text, cold: boolean, inReplyTo?, references?})` | **einziger** Versandweg: `assertCap('max','send_mail')` → Sperrliste → Tageszähler/Grenzen → Sendefenster → Pflichtbausteine → SMTP → `workersMessages` + Ereignis `gesendet`. → `{ sent:boolean, reason?, messageId? }`. | | `src/mail/footer.js` (P4/P6) | `appendCompliance(text, {prospect, cold, unsubscribeUrl})` | hängt Pflichtbausteine an (Spec §8.4). | | `src/vorschau/server.js` (P4) | `startVorschauServer(deps, {port, root})` | statisch, nur `GET //…`, Header `X-Robots-Tag: noindex, nofollow`, kein Verzeichnis-Listing, 404 bei unbekanntem Token/abgelaufen. Zusätzlich Abmelden: `GET /abmelden/` (Seite mit Knopf) und `POST /abmelden/` (RFC 8058 One-Click, Body `List-Unsubscribe=One-Click`) → `suppress(...,'abmeldung')` + Ereignis `gesperrt`. `` = `unsubToken(prospectId)` aus `src/mail/unsub.js` (HMAC-SHA256 mit `WORKERS_UNSUB_SECRET`, base64url, 22 Zeichen + `.` + prospectId). | ### 6.1 Prozess und Tick (P1) `src/main.js`: Konfiguration laden → `db` → API-Server (8092) → (P4) Vorschau-Server (8093) → Tick alle 60 s. Der Tick fragt `isDue` für: `nachtplan` (22:00), `nacht2` (01:00), `bericht` (06:30), `kuerzen` (03:00, P6), `pultAlt` (07:00, P3), `posteingang` (alle 2 min, P4), `alarme` (alle 10 min, P3), `senden` (alle 5 min im Sendefenster, P4), `batches` (alle 5 min). Tagesmarker in `workersDaily.countersJson.__runs` (neustartfest). Bei `paused` arbeitet nur `pause`-sichere Arbeit (Posteingang lesen + Sperrliste), nichts wird gesendet oder gebaut. ### 6.2 HTTP-API des Dienstes (P1 Gerüst, spätere Pläne ergänzen) Basis intern: `http://webklar-workers:8092`. Von WOMS über nginx `https://ticket.webklar.com/api/workers/…` (neuer `location`-Block nach Muster `/api/serverpuls/`). Auth: Header `X-Appwrite-JWT` → Appwrite `GET /account` (60 s Cache) → Label `mitarbeiter` oder `admin` nötig; `admin` für Einstellungen. Antworten JSON, Fehler `{ error: '' }`. | Methode + Pfad | Plan | Recht | Antwort | |---|---|---|---| | `GET /api/workers/health` | P1 | keins | `{ ok:true, version }` | | `GET /api/workers/state` | P1 | Team | `{ paused, workers:[{workerId, status:'arbeitet'|'wartet'|'leerlauf'|'grau'|'fehler', task, counters, connected}], kpis:{newAbosMonth, mrrNewEur, dealsMonth, openThreads, pultOpen, botLeads, apiTodayUsd, apiMonthUsd, dailyBudgetUsd, monthlyBudgetUsd}, lastEventTs }` | | `GET /api/workers/events?after=&limit=<≤200>` | P1 | Team | `{ events:[{id, ts, workerId, kind, text, link, to, prospectId, costUsd, meta}] }` aufsteigend nach `ts` | | `GET /api/workers/worker/:workerId/trace?limit=<≤50>` | P1 | Team | `{ worker:{…ROLES-Eintrag, model}, today:{counters, tokens, costUsd}, events:[…] }` | | `POST /api/workers/pause` `{paused:boolean}` | P1 | Team | `{ paused }` (+ Ereignis `pause`) | | `GET /api/workers/settings` / `PUT /api/workers/settings` | P1 | Admin | `workersSettings` ohne interne Zähler | | `GET /api/workers/pult?status=offen` | P3 | Team | `{ items:[…§4.11] }` | | `POST /api/workers/pult/:id/claim` · `/sent` · `/discard` · `/done` | P3 | Team | Eintrag | | `POST /api/workers/pult/:id/reply` `{text}` | P4 | Team | sendet über `sendMail` als `mensch:` (Mail-Fäden) | | `POST /api/workers/pult/:id/approve-preview` | P4 | Team | gibt Vorschau-Link frei | | `GET /api/workers/botleads?room=&q=&page=` | P6 | Team | `{ items:[…], total }` (Telefon nur wenn Quelle vorhanden und nicht `isHealth`) | | `GET /api/workers/prospect/:id` | P3 | Team | Interessent + Faden + Nachrichten + Vorschau | --- ## 7. WOMS – feste Namen (P2 liefert) | Datei | Export / Zweck | |---|---| | `src/lib/navLinks.js` | Eintrag `{ key:'workers', label:'Workers', href:'/workers' }` ersetzt `leads` (nicht mehr `paused`). | | `src/App.jsx` | Route `/workers` → `React.lazy(() => import('./pages/WorkersPage.jsx'))`; `/leads` leitet auf `/workers` um. | | `src/lib/workersApi.js` | `createWorkersApi({ getJwt, fetchImpl })` → `{ state(), events(after), trace(workerId), pause(paused), settings(), saveSettings(patch), pult(status), pultAction(id, action, body?), botleads(params), prospect(id) }`; Header `X-Appwrite-JWT`; Basis `/api/workers`. | | `src/lib/workersRoster.js` | `ROLES`, `FLOORS` (Spiegel von §3), `hatForModel(model)`. | | `src/lib/workersView.js` | reine Ableitungen: `formatUsd`, `formatCount`, `workerStatusLabel`, `kpiTiles(kpis)`. | | `src/lib/workersEvents.js` | `reduceEvents(stateByWorker, events)` → `{ [workerId]: { status, task, lastTs } }`; `eventToSteps(event)` → Animationsschritte für die Regie. | | `src/hooks/useWorkersFeed.js` | Polling: `state` alle 15 s, `events?after=` alle 5 s, Pause bei `document.visibilityState==='hidden'`. | | `src/components/workers/engine/*` | `scene.js`: `mountTower(canvas, { onSelect }) → { destroy(), setState(state), pushEvents(events), select(workerId), setPaused(bool) }` + `sprites.js`, `props.js`, `layout.js`, `building.js`, `lift.js`, `director.js`, `fx.js` (Plain Canvas 2D, Vorlage `scene-turm.js`). | | `src/components/workers/{WorkersStage,WorkerCard,WorkersKpis,WorkersSettingsDialog}.jsx` | UI-Teile. P3 ergänzt `PultList.jsx`, P6 `BotLeadList.jsx`. | | `src/pages/WorkersPage.jsx` | Reiter `Hochhaus` · `Freigabe` (P3) · `Bot-Leads` (P6). | | `src/styles/workers.css` | Klassen mit Präfix `wk-`. Schrift „Pixelify Sans“ selbst gehostet (`public/fonts/PixelifySans-*.woff2`). | | `tests/fixtures/workers-roster.json` | gemeinsame Fixture (§3). | --- ## 8. Portal – feste Namen (P5 liefert) | Datei | Zweck | |---|---| | `public/uebernahme.html` + `public/js/uebernahme.js` | Seite „Website übernehmen“: zeigt Betrieb, Vorschau-Link, Angebotssatz, Abo-Bedingungen v5 (Wortlaut wie normaler Checkout), Widerruf/Leistungsbeginn, Kundenart, Rechnungsangaben; Token aus `location.hash`. | | `server/routes/uebernahme.js` | `GET /api/uebernahme/:token` → Angebotsansicht oder 404; `POST /api/uebernahme/:token/checkout` → Stripe-Checkout-URL. Öffentlich, `X-Robots-Tag: noindex`, `createWriteLimiter`, 404 bei jedem ungültigen/abgelaufenen Token (Muster `routes/auftrag.js`). | | `server/services/workersKauf.js` | `findOfferByToken(token)`, `createWorkersCheckout(offer, consent, buyer)`, `handleWorkersCheckoutEvent(event)`, `isWorkersCheckoutEvent(event)` (Session-`metadata.workersOfferId` vorhanden). | | `server/routes/abo.js` | im Webhook **vor** dem normalen Zweig: `if (isWorkersCheckoutEvent(event)) return handleWorkersCheckoutEvent(event)`. | | `server/mail-templates/K44 Website übernommen.html` | Zugangsdaten + Begrüßung (nächste freie Nummer laut Code-Landkarte; vor dem Anlegen erneut alle Branches prüfen). | | `server/config.js` | `collections.workersOffers`, `collections.workersProspects`, `collections.workersPreviews`, `collections.workersEvents` (Env `APPWRITE_COLLECTION_WORKERS_*`, Standard = Sammlungs-ID). | Ablauf nach Zahlung (`handleWorkersCheckoutEvent`, idempotent über `workersOffers.status`): Kunde mit `createCustomerWithPortalAccess({ name, email, password:, kundenart })` anlegen (Status danach `customer`) → Stripe-Customer + Subscription-Metadaten `webklarCustomerId` setzen → `syncSubscription` → `recordAbschluss` → Nachweis (`featureConsents`) → K44 senden → `forgetStoredPortalPassword` → T02 an Team → Vorschau als Kundenprojekt übernehmen (Repo aus Vorlage in Org WEBklar mit Systembenutzer, Dateien hochladen, `websiteProjects` mit `customerId`, Typ `preview`) → `workersOffers.status='bezahlt'`, `workersProspects.status='deal'` + `customerId` → Ereignis `deal` (workerId `system`). --- ## 9. Betrieb – feste Namen | Was | Wert | |---|---| | Container | `webklar-workers` (Compose-Dienst `workers`, `build: ./workers`, Image `webklar-workers:latest`, Muster Serverpuls-Dockerfile + aircard-api-Härtung) | | Ressourcen | `mem_limit: 768m`, `memswap_limit: 768m`, `cpus: 1.0`, `NODE_OPTIONS=--max-old-space-size=384`, `init: true`, `restart: unless-stopped`, `user: node`, `read_only: true`, `tmpfs: /tmp`, `cap_drop: [ALL]`, `security_opt: [no-new-privileges:true]`, `logging json-file max-size 5m max-file 2`, `TZ=Europe/Berlin` | | Volume | `workers_data:/data` (Vorschauen unter `/data/vorschau//`) | | Netz | `webklar-network`; intern `http://webklar-appwrite/v1` für Appwrite | | Chromium | im Image per `apk add --no-cache chromium` (für Paul), Screenshots seriell, 60 s Zeitlimit | | nginx (WOMS) | neuer Block `location /api/workers/` → `webklar-workers:8092` in `/home/webklar/apps/ticket.webklar.com/nginx/ticket.webklar.com.conf` (Backup + `nginx -s reload` im Container `webklar-ticket`) | | Traefik | Datei `/home/webklar/apps/traefik/dynamic/workers-entwurf.yml`: Router `Host(\`entwurf.project.webklar.com\`)`, Priorität 200, `certResolver: letsencrypt`, Ziel `http://webklar-workers:8093` (P4) | | Reservierte Subdomain | `entwurf` im Portal-Subdomain-Schutz sperren (P5) | | Env (`.env` → Compose) | `WORKERS_APPWRITE_API_KEY`→`APPWRITE_API_KEY`, `WORKERS_ANTHROPIC_API_KEY`→`ANTHROPIC_API_KEY`, `APPWRITE_ENDPOINT=http://webklar-appwrite/v1`, `APPWRITE_PROJECT_ID=6a1058610003c5a13a05`, `WORKERS_SMTP_HOST/PORT/USER/PASS/FROM` (Absender-Postfach der neuen Domain), `WORKERS_IMAP_HOST/PORT/USER/PASS` (gleiches Postfach), `WORKERS_ALERT_IMAP_HOST/PORT/USER/PASS` (Alarm-Postfach), `WORKERS_REPORT_SMTP_*` (interner Morgenbericht über das bestehende webklar.com-Konto erlaubt, da kein Kaltkontakt), `WORKERS_PLACES_API_KEY` (P6), `WORKERS_UNSUB_BASE=https://entwurf.project.webklar.com/abmelden`, `WORKERS_UNSUB_SECRET` (32 Byte zufällig) | | Serverpuls | Gruppe `{"app":"Workers","match":["webklar-workers*"],"folders":["workers"]}` in `apps/serverpuls/config/apps.json` | | Anthropic | eigener Console-Workspace „Workers“, Ausgabenlimit 250 $/Monat (Kenso) | ## 10. Was Kenso vorab einrichtet (Spec §12) Console-Workspace + Schlüssel; Domain `webklar-team.de` (INWX) + Google-Workspace-Postfächer (Absender + Alarme) oder Alternative; Konten + Suchaufträge auf Kleinanzeigen/freelancermap/freelance.de an das Alarm-Postfach; Freigabe des Datenschutz-Abschnitts „Interessenten“. Jeder Plan nennt am Ende seine **Betriebsanleitung** (Schritte, die Kenso ausführt: Skripte, `.env`, Compose, nginx, Push).