# Voice Agent – iOS Frontend + PoC Handoff (CLAUDE.md)

> **Zweck:** Vollständige Arbeitsanweisung für Claude Code, um das native SwiftUI-Frontend der
> Außendienst-2.0-Voice-App im **PoC-Stand** zu bauen. Dieses Dokument beschreibt das **Frontend**
> und die **lokale PoC-Datenschicht (JSON-Dateien)**. Die Agenten-Orchestrierung, der Voice-Provider
> und die JSON-Extraktion leben im separaten **Backend-Track**.

---

## 0. Was du zusammen mit diesem Dokument an Claude Code übergibst

Dieses Dokument allein reicht für Frontend + PoC-Datenschicht. Für die exakte Verzahnung mit
deinem Backend gib **zusätzlich aus dem Voice-Agent-Chat** mit:

1. **Die drei JSON-Schemas** (echte Feldnamen inkl. ID-Felder):
   `PrepReport` (= `preparation.json`), `Transkript` (= `transkript.json`),
   `VisitReport` (= `report.json`, mit `aktualisierte_objekte`, `neue_objekte`,
   `naechste_schritte[]`, `offen_geblieben[]` – siehe Abschnitt 6d).
2. **Die PoC-Stammdaten-Strukturen:** `users.json` und `visits.json` (siehe Abschnitt 6b –
   Feldnamen bitte aus deinem Backend-Track übernehmen/abgleichen).
3. **HTTP-API des Backends** (Details + Shapes in `contract-ios.md`):
   - Daten lesen: `GET /visits` (Liste + Karte), `GET /visits/:id/preparation` (Briefing),
     `GET /visits/:id/report` (Report; **Platzhalter statt 404**, solange keiner erzeugt ist),
     `GET /visits/:id/transcript` (Gespraech als Chat auf Screen C; gleicher Platzhalter-Fluss).
   - Voice: `POST /token` (LiveKit-JWT; Agent wird per Token-Dispatch automatisch geholt).
   - Freigabe: `POST /visits/:id/freigabe` → `status: freigegeben`.
   - `agentState` + Transkript kommen über **LiveKits native** Agent-State + Transcription-Streams
     (kein Custom-Data-Channel); `activeTopic` wird im PoC nicht emittiert.
   - Base-URL PoC: `http://<host>:8787` (Simulator: `localhost`; echtes Gerät: LAN-IP des Macs).
4. **Backend-Architektur-Kurzfassung:** LiveKit Agents (**Node/TypeScript**, pnpm-Turborepo),
   Supervisor + vier Spezialisten, xAI Grok Voice, Trennung Live-Gespräch vs. deterministische
   Extraktion, Human-in-the-Loop-Freigabe.

> Faustregel für den Agenten: **Dieses Dokument** gibt Struktur, Arbeitsweise und Seams vor.
> **Die gepasteten JSONs** sind die Quelle der Wahrheit für Feldnamen und IDs.

---

## 1. Kontext & Abgrenzung

**Was diese App ist:** Native iOS-App (SwiftUI, iOS 26+), mit der ein Außendienstler
Kundenbesuche per Sprache dokumentiert: Briefing lesen → Gespräch führen → Bericht prüfen →
freigeben → (später) CRM-Write-back.

**PoC-Stand (aktuell):** Alle strukturierten Daten liegen in **lokalen JSON-Dateien** auf dem
Gerät. Kein Supabase, kein Live-CRM. Der Umstieg auf Supabase/CRM kommt später und darf die
UI nicht berühren (Seam siehe Abschnitt 6).

**Was hier NICHT gebaut wird (Backend-Track):** Supervisor-Routing, Spezialisten-Agenten,
Voice-Provider, deterministische JSON-Extraktion.

**Grundprinzip, das die UI respektieren muss:** Live-Gespräch und strukturierte Extraktion sind
getrennt. Während des Gesprächs gibt es **keinen** Bericht – der entsteht erst danach.

---

## 2. Arbeitsweise des Agenten (verbindlich)

1. **Plan-first.** Vor jeder Implementierung kurzen Plan vorlegen, auf Freigabe warten.
2. **Ein Commit pro logischem Schritt.**
3. **Hold Points** nach jedem markierten Meilenstein (Abschnitt 8).
4. **Design-Loop mit Auswahl:** Bei Layout/Optik liefere ich Scribble + Beschreibung, du baust
   **2–3 lauffähige SwiftUI-Varianten als `#Preview`** (mit Mock-Daten). **Nie nur eine.** Ich wähle.
5. **Previews sind Pflicht:** je View Light + Dark, große Dynamic-Type-Stufe, sowie
   Empty / Loading / Error / Success.
6. **CLAUDE.md pro Package.**

---

## 3. Design-Sprache (iOS 26 / Liquid Glass)

- **Native APIs first:** `.glassEffect()`, `GlassEffectContainer`, `glassEffectID(_:in:)`. Keine Fremd-Glasbibliothek.
- **Liquid Glass NUR auf dem Navigations-/Control-Layer** (Mic-Button, Statusleiste, Toolbar,
  Floating Controls). **NIE auf Content** (Transkript, Bericht, Listen). Content bleibt opak und lesbar.
- **Design Tokens als eigenes Swift-Package** (Farbe/Spacing/Radii/Typografie/Motion).
  Farbe kodiert **Bedeutung/Status**, nicht Deko.
- **Accessibility ist Teil der Definition of Done:** Dynamic Type, VoiceOver, **Reduce-Transparency-Fallback**,
  Reduce-Motion, Kontrast auch bei aktivem Glas.

Referenzen bei Glas-Fragen: `conorluddy/LiquidGlassReference`, `GetStream/awesome-liquid-glass`,
`GonzaloFuentes28/LiquidGlassCheatsheet`.

---

## 4. Kern-Screens & Zustände

| Screen | Zweck | Zustände |
|---|---|---|
| **Login** | E-Mail/Passwort → userID auflösen | leer / prüfe / Fehler / eingeloggt |
| **A · Besuchsliste** | Die dem User zugeordneten Besuche (Liste + Karte via `geo`) | loading / leer / geladen / Fehler |
| **B · Live-Gespräch** | Voice-Session | verbinde / bereit / listening / thinking / speaking / unterbrochen / Fehler |
| **C · Report-Review** | Bericht in 5 Abschnitten: geänderte + neue CRM-Objekte, nächste Schritte, „offen geblieben" (Abschnitt 6d) | wird erstellt / fertig / Fehler |
| **D · Freigabe** | Prüfen → freigeben → (PoC: lokal speichern) | ungeprüft / freigegeben / gespeichert / Fehler |

**Global:** LiveKit-Verbindungsstatus, Mikrofon-Permission, Fehler-/Retry-Pfade, eingeloggter User.

---

## 5. Voice-spezifische UI-Muster

- **Agent-Status klar unterscheidbar:** `idle / listening / thinking / speaking` – Farbe + Motion + Waveform.
- **Waveform** aus dem echten Audio-Level (nicht faken).
- **Barge-in / Tap-to-interrupt** jederzeit erreichbar.
- **Multi-Agent dezent:** optionales, unaufdringliches Label des aktiven Themas; Default subtil/aus.
- **Trennung Live vs. Extraktion sichtbar:** Übergang B → C ist ein bewusster Schritt.

---

## 6. Der Seam – zwei entkoppelte Schnittstellen

Die Features kennen **nur Protokolle**, nie LiveKit, nie das Dateisystem, nie Supabase.
Das erlaubt volle UI-Entwicklung gegen Mock/JSON, bevor irgendein Live-Backend steht.

### 6a. Voice-Session (Echtzeit-Audio)

```swift
// Package: VoiceAgentKit
protocol VoiceAgentSession: AnyObject {
    var connectionState: ConnectionState { get }   // connecting/connected/failed/disconnected
    var agentState: AgentState { get }             // idle/listening/thinking/speaking
    var audioLevel: Float { get }                  // 0...1 für Waveform
    var activeTopic: AgentTopic? { get }
    var transcript: [TranscriptEntry] { get }

    func connect(token: String) async throws
    func startTurn(); func stopTurn(); func interrupt()
    func endConversation() async throws            // Übergang B → C
    func disconnect()
}
```
- `MockVoiceAgentSession` (Skript-Playback) → **zuerst**, treibt Previews & UI-Entwicklung.
- `LiveKitVoiceAgentSession` → **zuletzt**, echte Anbindung ans LiveKit Swift SDK.

### 6b. Datenschicht (PoC = lokale JSON-Dateien)

```swift
// Package: DataStore
protocol AuthRepository {
    func signIn(email: String, password: String) async throws -> User   // löst userID auf
    var currentUser: User? { get }
    func signOut()
}

protocol VisitRepository {
    func visits(forUser userID: String) async throws -> [Visit]         // Zuordnung!
    func prepReport(visitID: String) async throws -> PrepReport
    func saveTranscript(_ t: Transcript, visitID: String) async throws
    func saveReport(_ r: VisitReport, visitID: String) async throws
    func approveReport(visitID: String) async throws                    // PoC: markiert + persistiert lokal
}
```
- **PoC-Implementierung:** `JSONFileStore` – liest/schreibt JSON im App-Documents-Verzeichnis;
  gebundelte Seed-JSONs für Read-only-Daten (Stammdaten, PrepReports).
- **Später:** `SupabaseStore` – **dieselben Protokolle**, ohne UI-Änderung.

---

## 6c. Authentifizierung & Zuordnung (PoC)

**Modell:**
1. **Dev-Login** ohne echtes Passwort: der Nutzer wählt sich per E-Mail (bzw. Auswahlliste)
   ein – rein zum **Auflösen der `userID`**. Kein Geheimnis auf dem Gerät (siehe Hinweise).
   `AuthRepository.signIn(email:password:)` behält den `password`-Parameter als Supabase-tauglichen
   Seam, verifiziert ihn im PoC aber nicht.
2. Die `userID` ist **identisch zur CRM-User-GGUID** (`benutzer[].gguid`). Das ist der
   **stabile Join-Key** und der einzige Vertrag, der beim Supabase-Umstieg konstant bleibt.
3. Über die `userID` wird aufgelöst, **welcher Nutzer welche Kundenbesuche betreut**:
   `VisitRepository.visits(forUser:)` filtert `visits.json` nach `owner_gguid`.

**PoC-Stammdaten (Feldnamen final aus dem Backend-Track):**
```jsonc
// benutzer.json  — Nutzerverzeichnis, userID == gguid (kein Passwort im PoC)
[ { "gguid": "0x…", "email": "…", "anzeigename": "…", "rolle": "…", "aktiv": true } ]

// visits.json  — spiegelt die spätere Supabase-Tabelle "visits" (RLS auf owner_gguid)
[ { "visitId": "…", "owner_gguid": "0x…", "kunde": "…", "datum": "…",
    "geo": { "breitengrad": 53.0793, "laengengrad": 8.8017 },   // für die Kartenansicht (Screen A)
    "prep_ref": "visits/<visitId>/preparation.json" } ]
```
- `visits[].owner_gguid` == `benutzer[].gguid` == `userID` → das ist die gesamte Zuordnungslogik.

**Hinweise (Security):**
- **Kein passwordHash im App-Bundle.** Ein gebündelter Hash wird mitausgeliefert und ist offline
  brute-forcebar – *scheinbare* Sicherheit. Im PoC daher **gar kein Geheimnis** auf dem Gerät;
  echte Auth kommt mit **Supabase Auth**.
- **Ownership-Filter ist im PoC nicht durchsetzend** (Gerät filtert lokal). Server-seitige
  Erzwingung kommt erst über **Supabase Row-Level-Security** auf `owner_gguid`.
- **Der `userID`-Vertrag bleibt konstant:** `SupabaseStore.signIn` liefert später dieselbe `gguid`
  als `userID`, die Zuordnungslogik ändert sich nicht.
- **ID-Erhalt ist heilig:** IDs kommen aus den Daten; das Frontend zeigt/gibt frei, erzeugt oder
  verändert IDs nie.

---

## 6d. Report-Review-Modell (Screen C/D) — der PoC-Prüfpunkt

Der Bericht wird **nicht** zu `tasks[]/notDiscussed[]` verflacht. Screen C rendert die echte
`report.json`-Struktur in fünf Abschnitten; der Sinn des Screens ist, dass der AD prüft, **was der
Agent interpretiert hat**.

**Fünf Abschnitte (1:1 zur `report.json`):**
1. **Zusammenfassung** – `zusammenfassung` (read-only).
2. **Geänderte CRM-Objekte** – `aktualisierte_objekte.{aufgaben,verkaufschancen,kundenanfragen}`;
   je Zeile Aktion-Badge (`status_update|update|anlegen`) + Notiz.
3. **Neue Objekte** – `neue_objekte.{…}`; Badge „NEU" + `temp_id`.
4. **Nächste Schritte** – `naechste_schritte[]`; Badge `typ` (`aufgabe|strategisch`), Bearbeiter,
   Fälligkeit, Verknüpfung.
5. **Offen geblieben** – `offen_geblieben[]` → „nächster Besuch".

**Review-Signale steuern die UI (Kern des PoC):**
- `abgeleitet_aus_gespraech: true` → sichtbarer Marker „vom Agent interpretiert" – genau die
  Punkte, die der AD bestätigt.
- `bearbeiter_aufloesung` (`eindeutig|mehrdeutig|nicht_gefunden|rolle`) → Badge; alles außer
  `eindeutig` erfordert eine explizite AD-Aktion (Person wählen / Rolle bestätigen).
- `temp_id` vs. `gguid` → neu vs. bestehend.

**Freigabe-Gate (Screen D):** `approveReport()` ist erst aktiv, wenn kein Bearbeiter mehr
`mehrdeutig`/`nicht_gefunden` ist. Freigabe setzt `status: freigegeben` + `freigabe.{durch,am}`.

**UI-Abstraktion:** eine gemeinsame `ReviewItem`-Zeile für alle fünf Abschnitte; das Mapping
`report.json → [ReviewItem]` lebt im `DataStore` (echte JSON-Keys bleiben Quelle der Wahrheit):
```swift
struct ReviewItem {
  let id: String                 // gguid ?? temp_id
  let kind: ReviewKind           // updatedTask/newTask/updatedOpp/newOpp/nextStep/openItem
  let title: String              // betreff / bezeichnung / beschreibung
  let actionBadge: String?       // status_update | update | anlegen | neu | strategisch
  let assignee: Assignee?        // bearbeiter_name + bearbeiter_aufloesung
  let dueDate: String?           // faellig_am
  let isAgentDerived: Bool       // abgeleitet_aus_gespraech
  let needsResolution: Bool      // bearbeiter_aufloesung ∈ {mehrdeutig, nicht_gefunden}
  let link: String?              // verknuepft_mit (gguid | temp_id)
}
```

---

## 7. Projekt-/Ordnerstruktur (SwiftPM)

```
VoiceAgentApp/
├─ App/                      # Composition Root; hier werden Mock/JSON bzw. später Live/Supabase verdrahtet
├─ Packages/
│  ├─ DesignSystem/          # Tokens + Liquid-Glass-Basiskomponenten (+ CLAUDE.md)
│  ├─ VoiceAgentKit/         # VoiceAgentSession-Protokoll + Mock + Voice-Models (+ CLAUDE.md)
│  ├─ DataStore/             # Auth/Visit-Repository-Protokolle + JSONFileStore + Daten-Models (+ CLAUDE.md)
│  └─ Features/
│     ├─ AuthFeature/        # Login
│     ├─ VisitListFeature/   # Screen A
│     ├─ ConversationFeature/# Screen B (Herzstück)
│     └─ ReportFeature/      # Screen C + D
└─ CLAUDE.md                 # dieses Dokument
```
Features hängen nur an `DesignSystem`, `VoiceAgentKit`, `DataStore`. LiveKit/Supabase nur in `App/`.

---

## 8. Umsetzungs-Reihenfolge (Plan zur Freigabe)

Jeder Schritt endet in einem **Hold Point**.

1. **DesignSystem-Package:** Tokens + 3–4 Glas-Basiskomponenten, alle mit Previews. → **Hold**
2. **DataStore-Package:** Models (`User`, `Visit`, `PrepReport`, `Transcript`, `VisitReport`) +
   `AuthRepository`/`VisitRepository` + `JSONFileStore` mit Seed-JSONs. → **Hold**
3. **VoiceAgentKit:** `VoiceAgentSession` + Models + `MockVoiceAgentSession`. → **Hold**
4. **AuthFeature + VisitListFeature** gegen `JSONFileStore`: Login → userID → zugeordnete Besuche. → **Hold**
5. **ConversationFeature (B)** gegen Mock: Status, Waveform, Transkript, Interrupt.
   **Hier die 2–3 Layout-Varianten** zur Auswahl. → **Hold**
6. **ReportFeature (C + D)** gegen JSON: Bericht in fünf Abschnitten (Abschnitt 6d), Review-Signale
   (`abgeleitet_aus_gespraech`, `bearbeiter_aufloesung`), Freigabe-Gate → lokal speichern. → **Hold**
7. **Live einstöpseln:** `LiveKitVoiceAgentSession` (Voice) – JSON-Datenschicht bleibt im PoC. → **Hold**

---

## 9. Kickoff-Prompt (copy-paste für Claude Code)

```
Lies die CLAUDE.md im Package-Root vollständig, plus die von mir gepasteten JSON-Schemas und
Stammdaten-Strukturen (users.json, visits.json, PrepReport, Transkript, VisitReport). Halte dich
strikt an Abschnitt 2 (Arbeitsweise): Plan-first, Hold Points, 2–3 Layout-Varianten zur Auswahl.

PoC-Kontext: alle strukturierten Daten liegen in lokalen JSON-Dateien (kein Supabase).
Auth: E-Mail/Passwort, userID == CRM-/JSON-ID, Besuchszuordnung über visits[].ownerUserID.

Wir starten mit Schritt 1 aus Abschnitt 8 (DesignSystem-Package).
Lege zuerst einen kurzen Plan vor und warte auf meine Freigabe. Noch kein Code.
```

---

## Anhang · Quellen
- Native Glas-Referenz: `conorluddy/LiquidGlassReference`
- Beispiele: `GetStream/awesome-liquid-glass` · Cheatsheet: `GonzaloFuentes28/LiquidGlassCheatsheet`
- LiveKit iOS: `agent-starter-swift` (Swift SDK)
- Design-Tokens aus Figma als Swift-Package (optional): `figmatoswift.com`
