Desktop-App & fortgeschrittene Agenten

Architektur der Owlat-Desktop-Hülle, des Visualisierungsagenten, des adaptiven Dashboards, der Agenten-Gesundheit, der abgestuften Autonomie und der Coding-Agenten.

Desktop-App & fortgeschrittene Agenten

Dieser Teil der Roadmap bringt Owlat als native Hülle auf den Desktop, ergänzt spezialisierte Agenten für Visualisierung und Programmierung und führt abgestufte Autonomie ein, bei der das Deployment fein einstellt, wie viel Entscheidungsspielraum es an die KI delegiert.

Das meiste davon ist bereits gebaut, und vieles, was früher nur im Schema existierte, ist heute durchgängig angetrieben. Die Desktop-App wird als Tauri-Hülle ausgeliefert (apps/desktop); der Visualisierungsagent (apps/api/convex/visualizationAgent.ts) liest inzwischen echte Daten für eine erlaubte Menge von Datensätzen; das adaptive Dashboard (apps/api/convex/analytics/adaptiveDashboard.ts) erzwingt Rollenbedingungen; das Monitoring der Agenten-Gesundheit (apps/api/convex/agentHealth.ts) befüllt sechs von sieben Metriken und wertet alle drei Circuit Breaker aus; die Regeln für abgestufte Autonomie (apps/api/convex/autonomy.ts) steuern den aktiven Routing-Schritt mit einer funktionierenden Feedback-Schleife; und der Sidecar für den Coding-Agenten (apps/code-worker) wird inzwischen von einem eingehenden Auto-Trigger und einem GitHub-Merge-Webhook gespeist. Die folgenden Abschnitte beschreiben, was heute angebunden ist, und benennen in Callouts die Teile, die noch auf dem Weg sind.

Ein Deployment, eine Organisation

Owlat betreibt inzwischen genau eine Organisation pro Deployment, daher tragen die untenstehenden Tabellen keine organizationId. Einstellungen wie die Agentenkonfiguration und die Autonomieregeln sind deploymentweite Singletons.

Desktop-App

Warum Tauri

Die Desktop-App ist mit Tauri v2 gebaut:

Tauri v2Electron
Binärgröße~5–10 MB~150–200 MB
SpeicherverbrauchNative WebviewGebündeltes Chromium
LizenzMITMIT
BackendRustNode.js
Auto-UpdateEingebauter Updaterelectron-updater
Dock-/Taskleisten-BadgeNative APINative API

Tauri nutzt die native Webview des Betriebssystems (WebKit unter macOS, WebView2 unter Windows, WebKitGTK unter Linux) — kein gebündeltes Chromium. Die App-Hülle dupliziert keinerlei UI: Sie lädt dieselbe SPA aus apps/web (gebaut mit bun run generate:desktop, ohne einbackene Convex-URL) und richtet sie zur Laufzeit auf die URLs des aktiven Workspace.

Anbindung des Auto-Updates

Das Updater-Plugin ist angebunden (src/updater.ts), und der Release-Workflow signiert die Bundles. plugins.updater.pubkey in tauri.conf.json ist inzwischen mit einem öffentlichen minisign-Schlüssel befüllt, und der Endpunkt zeigt auf den GitHub-Releases-Host (https://github.com/wolvesdotink/owlat/releases/latest/download/latest.json). Der eingecheckte Schlüssel kann ein Beispiel- bzw. Nicht-Produktionsschlüssel sein — Betreiber, die eigene Builds veröffentlichen, sollten ein eigenes Signaturschlüsselpaar erzeugen und ihn ersetzen. Siehe apps/desktop/README.md für die Schritte zu Signierung und Release.

Architektur

apps/desktop/
  src-tauri/                 # Rust backend
    src/
      main.rs                # Tauri app setup, plugin wiring
      menu.rs                # Application menu
      notifications.rs       # Native OS notifications + dock/taskbar badge
      shortcuts.rs           # Global keyboard shortcuts
      window.rs              # Window create/show/focus helpers
      secrets.rs             # OS-keychain commands (set/get/delete)
    Cargo.toml
    tauri.conf.json          # Window config, CSP, deep-link schemes, updater
  src/                       # TypeScript bridge (runs in the webview)
    deeplink.ts              # Cold-start + live deep-link plumbing
    workspace.ts             # Workspace list/active-id persistence
    keychain.ts              # Keychain bridge for session tokens
    compose.ts               # Compose-window opener (mailto: handling)
    notifications.ts         # Notification bridge
    shortcuts.ts             # Shortcut registration
    menu.ts                  # Menu wiring
    window.ts                # Window helpers
    shell.ts                 # System-browser open
    dialog.ts                # Native dialog bridge
    ssh.ts                   # SSH transport for guided VPS setup
    autostart.ts             # Launch-at-login toggle
    updater.ts               # Tauri updater plugin wrapper
  package.json

Es gibt keine Rust-Datei updater.rs — das Auto-Update übernimmt das Tauri-Updater-Plugin, gekapselt aus src/updater.ts. Session-Geheimnisse sind das Einzige, was die Rust-Seite direkt besitzt: secrets.rs stellt die Befehle secret_set/secret_get/secret_delete bereit, gestützt auf die Crate keyring (Service com.owlat.desktop).

Zentrale Funktionen

Native Benachrichtigungen — betritt ein neuer Eintrag die Prüf-Warteschlange oder schickt ein Kollege eine Chat-Nachricht, wird über das Tauri-Notification-Plugin (src/notifications.ts) das Benachrichtigungssystem des Betriebssystems ausgelöst. Das Dock-/Taskleisten-Badge für Ungelesenes (src-tauri/src/notifications.rs) wird von den reaktiven Convex-Queries gespeist, die die Webview ohnehin abonniert. Das Schließen des Hauptfensters beendet die App — es gibt kein Menüleisten-Tray.

Deep Links — registriert für die Schemata owlat und mailto (tauri.conf.jsonplugins.deep-link). Es gibt keinen einzelnen Brücken-Einstiegspunkt; die Web-App importiert die themenspezifischen Module direkt, und das Bootstrapping der Deep Links liegt in apps/desktop/src/deeplink.ts, angetrieben vom Desktop-Plugin der Web-App. Der Handler in apps/web/app/lib/desktop/deepLink.client.ts leitet weiter:

Deep LinkÖffnet
owlat://thread/{id}/dashboard/inbox/{id}
owlat://chat/{id}/dashboard/chat/{id}
owlat://knowledge/{id}/dashboard/knowledge/{id}
owlat://file/{id}/dashboard/files/{id}
owlat://contact/{id}/dashboard/audience/contacts/{id}
owlat://auth?ott=…&state=…löst den Workspace-Anmelde-Handshake ein
mailto:…öffnet ein Verfassen-Fenster, vorbefüllt mit to/subject

Jeder nicht zugeordnete owlat://-Pfad fällt auf /dashboard/{path} zurück.

Owlat als Kanal — der interne Chat läuft über dieselben Nachrichtentabellen und dieselbe Agenten-Strecke wie E-Mail. Die Zustellung erfolgt über reaktive Convex-Subscriptions — kein WebSocket-Server über das hinaus, was Convex ohnehin bereitstellt. Siehe Kommunikationskanäle für die aktiven Kanäle (E-Mail und Chat) gegenüber den rein eingehenden (SMS/WhatsApp).

Multi-Workspace & Authentifizierung

Die Desktop-App verbindet sich mit einer oder mehreren entfernten Owlat-Instanzen. Das Modell ist in apps/desktop/README.md dokumentiert und über apps/desktop/src/workspace.ts, apps/web/app/composables/useDesktopWorkspaces.ts und apps/web/app/lib/desktop/ implementiert:

Einen Workspace hinzufügen

„Workspace hinzufügen“ öffnet die Seite /desktop/connect der Instanz im Systembrowser, nicht in der eingebetteten Webview. Die Authentifizierung erfolgt gegen die echte Instanz mit deren vollständigem serverseitigem Ablauf.

Ein Einmal-Token einlösen

Bei Erfolg gibt der Browser die Kontrolle über den Deep Link owlat://auth?ott=…&state=… zurück. Die App löst dieses Einmal-Token gegen eine header-basierte, cookielose domainübergreifende Session ein (completeConnection in useDesktopWorkspaces.ts).

Die Session im Schlüsselbund des Betriebssystems ablegen

Der Session-Blob wird über die Rust-Befehle aus secrets.rs in den nativen Geheimnisspeicher geschrieben, pro Workspace geschlüsselt. Die nicht geheime Workspace-Liste und die ID des aktiven Workspace liegen in workspaces.json (tauri-plugin-store) und referenzieren das Geheimnis nur über tokenRef.

Workspaces wechseln

Die Auswahl eines anderen Workspace lädt die Webview neu, damit sich die Singletons für Auth und Convex-Client aus den URLs des neu aktiven Workspace neu initialisieren.

Interner Chat

Teamkommunikation innerhalb der Desktop-App:

  • Direktnachrichten — Eins-zu-eins-Konversationen zwischen Mitgliedern der Organisation
  • Kanäle — themenbasierte Gruppenkonversationen (ähnlich Slack-Kanälen)
  • Thread-verknüpft — jede Konversation kann auf einen conversationThread verweisen und so interne Diskussion mit der Kundenkommunikation verbinden

Interne Chat-Nachrichten sind Zeilen in unifiedMessages (apps/api/convex/schema/messaging.ts) mit channel: 'chat'. Das Schema führt sowohl eine optionale memberId (der interne Absender) als auch eine optionale contactId; der eine ausgelieferte Chat-Writer, sendChatMessage (apps/api/convex/unifiedMessages.ts), hält die contactId des Threads fest, in dem er postet (die Mitgliedsidentität ist der authentifizierte Aufrufer). Die Vision ist, dass der Wissensgraph Fakten aus internen Konversationen genauso extrahiert wie aus Kunden-E-Mails. Die Live-Extraktion pro Nachricht ist inzwischen für die eingehende Strecke angebunden (E-Mail und eingehende Kanalnachrichten), aber interner Chat zwischen Mitgliedern löst noch keine Extraktion aus — unifiedMessages.sendChatMessage liegt nicht auf dem Extraktionspfad.

Schnellabfragen

Teammitglieder können dem System direkt aus der Desktop-App Fragen stellen:

  • „Wie viele Kontakte sind diesen Monat dem Newsletter-Thema beigetreten?“ → fragt Audience-Daten ab
  • „Wann haben wir zuletzt mit Acme Corp gesprochen?“ → fragt Konversations-Threads ab
  • „Zeig mir den Vertrag, den wir mit ihnen abgeschlossen haben“ → semantische Dateisuche

Schnellabfragen holen Kontext aus dem Wissensgraphen und dem Dateisystem, erzeugen eine Antwort mit Quellenangaben und rendern sie inline.

Ausgeliefert: Ask-Anything ist live

Die Ask-Anything-Oberfläche ist durchgängig angebunden. Das Backend ist quickQuery.tsask, eine authedAction, die sowohl durch das Feature-Flag ai.knowledge als auch durch die Berechtigung knowledge:read abgesichert ist (in dieser Reihenfolge über die interne Query quickQueryGate geprüft, da eine 'use node'-Action nicht direkt auf die Datenbank zugreifen kann). Sie fächert über beide Retrieval-Nähte auf — hybride Vektor- und Volltextsuche über knowledgeEntries (knowledge/retrieval.semanticSearch) und den semantischen Dateispeicher (semanticFileProcessing.semanticSearch, vector_files) — und ruft dann das LLM auf (lib/llm/dispatch.runLlmText), um eine belegte Antwort zu synthetisieren, die jede abgerufene Quelle nummeriert zitiert. Die zurückgegebenen sources umfassen sowohl Wissenseinträge als auch Dateien; abgerufene Titel/Texte werden auf Prompt-Injection bereinigt und als nicht vertrauenswürdige Daten eingezäunt, bevor das Modell sie zu sehen bekommt. Das ist eine vom Klassifikator für eingehende Mail getrennte Leseoberfläche — sie braucht keine Klassifikationskategorie „Anfrage“.

Visualisierungsagent

Ein spezialisierter Agent (apps/api/convex/visualizationAgent.ts), der eine natürlichsprachige Eingabe entgegennimmt und über den LLM-Anbieter eine in sich geschlossene HTML/CSS/JS-Visualisierung erzeugt. Die admin-geschützte Mutation createFromPrompt fügt eine Platzhalterzeile ein und plant die interne Action generate ein; Ausgaben können in Konversationen gerendert oder ans Dashboard angeheftet werden.

Echte Daten für erlaubte Datensätze, sonst illustrativ

generate liest inzwischen echte Kontozahlen — aber nur über eine feste Erlaubnisliste von vier benannten Datensätzen: email_delivery_30d, agent_health, contact_growth und campaign_performance. Jeder ist auf eine handgeschriebene, nur lesende interne Query abgebildet (dataEmailDelivery30d, dataAgentHealth, …); es gibt keinen Kanal für freie Abfragen. Echte Daten werden nur verwendet, wenn der Aufrufer ein explizites, erlaubtes Argument dataset übergibt — aus freien Prompts wird nie ein Datensatz erschlossen. Wird ein Datensatz übergeben, kippt der System-Prompt von „nur illustrativ“ auf „verwende genau diese Zahlen“, und der gewählte Datensatz-Schlüssel wird in dataQuery persistiert, damit eine spätere Aktualisierung denselben erlaubten Ausschnitt erneut abrufen kann. Ohne expliziten Datensatz — oder wenn ein Abruf fehlschlägt — fällt es auf klar gekennzeichnete illustrative Beispieldaten zurück (die sichtbare Bildunterschrift „Illustrative example — not your account data“). Die Visualisierungsseite bietet eine Auswahl von Datensätzen (eine Grafik auf einen Live-Datensatz umstellen) und pro Grafik eine Aktualisieren-Aktion, die den Abruf auf Wunsch erneut ausführt — beide lesen die Spalte dataQuery (den persistierten erlaubten Schlüssel).

So funktioniert es (Vision)

User: "Show me our email delivery rates for the last 30 days"
  → createFromPrompt (admin) inserts a placeholder + schedules generate()
  → generate() uses the explicitly-selected allowlisted dataset, if any
    (email_delivery_30d) and fetches the REAL numbers via a read-only
    internal query (no dataset → clearly-labeled illustrative sample data)
  → generate() asks the LLM provider for self-contained HTML/CSS/JS
    built from those exact numbers
  → Frontend renders the html in a sandboxed iframe
  → User can interact: hover for details, filter, change time range

Der Visualisierungsagent erzeugt rohes HTML, CSS und JavaScript — das gibt ihm volle gestalterische Freiheit für jede Art von visueller Ausgabe. Anders als bei eingeschränkten Charting-Bibliotheken kann der Agent so genau das bauen, was die Daten brauchen: Diagramme, Dashboards, Datentabellen, animierte Fortschrittsanzeigen, interaktive Karten oder vollständig maßgeschneiderte Visualisierungen.

  • Volle Flexibilität — der Agent schreibt HTML/CSS/JS direkt und ist nicht auf das Vokabular einer Charting-Bibliothek beschränkt
  • Interaktiv — JavaScript ermöglicht Hover-Tooltips, Filtern per Klick, animierte Übergänge, Echtzeit-Aktualisierungen
  • Portabel — Visualisierungen sind in sich geschlossene HTML-Bundles, die gespeichert, geteilt, in Berichte eingebettet oder an Dashboards angeheftet werden können
  • Isoliert — gerendert in einem sandboxed <iframe> mit sandbox="allow-scripts" — kein Zugriff auf die übergeordnete Seite, den Convex-Client, Cookies oder die Navigation. Das iframe kommuniziert ausschließlich per postMessage für Resize-Events
Sandboxing ist entscheidend

Agentengenerierter Code läuft in einem sandboxed iframe ohne Zugriff auf die Host-Anwendung. Das Attribut sandbox blockiert Top-Level-Navigation, Formularübermittlung, Popups und Same-Origin-Zugriff. Nur allow-scripts ist aktiviert, damit das eigene JavaScript der Visualisierung ausgeführt werden kann. Das verhindert, dass eingeschleuster Code auf Nutzersitzungen, Convex-Daten oder das DOM der übergeordneten Anwendung zugreift.

Schema

// apps/api/convex/schema/dashboard.ts
visualizations: defineTable({
  title: v.string(),
  description: v.optional(v.string()),
  html: v.string(),                // Self-contained HTML document (HTML + CSS + JS)
  dataQuery: v.optional(v.string()), // Allowlisted dataset key the viz was built from (for refresh); unset when illustrative
  pinned: v.boolean(),             // Pinned to dashboard
  createdBy: v.string(),           // User or agent ID
  threadId: v.optional(v.id('conversationThreads')),
  createdAt: v.number(),
  updatedAt: v.number(),
})
  .index('by_pinned', ['pinned'])
  .index('by_created_at', ['createdAt'])

Die Agenten-Action liegt in apps/api/convex/visualizationAgent.ts: die Queries list/get/listPinned, die admin-geschützte Action generate (die einen erlaubten Datensatz abruft, sofern er zugeordnet ist, bevor sie den LLM-Anbieter für das in sich geschlossene HTML aufruft), die nur lesenden Datensatz-Abrufer sowie die Mutationen zum An- und Abheften.

Rendering

Das Frontend rendert Visualisierungen über ein sandboxed iframe:

<iframe
  :srcdoc="visualization.html"
  sandbox="allow-scripts"
  referrerpolicy="no-referrer"
  style="width: 100%; border: none;"
/>

Das iframe nutzt srcdoc (kein Netzwerkabruf) und sandbox="allow-scripts" (JS wird ausgeführt, aber kein DOM-Zugriff auf das übergeordnete Dokument). Ein postMessage-Listener verarbeitet Resize-Events, damit sich die iframe-Höhe an den Inhalt anpasst.

Adaptives Dashboard

Das Dashboard ist kein statisches Raster aus Widgets. Es passt sich daran an, was die Nutzerin oder der Nutzer gerade jetzt braucht — morgens anders als abends, montags anders als freitags, für eine Support-Leitung anders als für eine Marketing-Managerin.

Kontextsignale

Das Dashboard setzt sich aus Kontextsignalen zusammen:

SignalWas es uns sagtBeispielwirkung
TageszeitMorgens = planen, abends = auswertenMorgens: die heute geplanten Kampagnen, die über Nacht eingegangene Warteschlange. Abends: die heutige Leistungsübersicht, offene Punkte für morgen
WochentagMontag = aufholen, Freitag = abschließenMontag: der Eingangsrückstand vom Wochenende, der Kampagnenplan der Woche. Freitag: Wochenkennzahlen, ungelöste Threads
RolleWofür die Person verantwortlich istEine Support-Leitung sieht Warteschlangentiefe und SLA-Status. Eine Marketing-Managerin sieht Kampagnenleistung und Audience-Wachstum
Jüngste AktivitätWoran die Person gearbeitet hatHaben Sie die letzte Stunde an einer Kampagne gearbeitet, hebt das Dashboard deren Echtzeit-Zustellstatistiken hervor
Offene PunkteWas Aufmerksamkeit brauchtEinträge in der Verifizierungs-Warteschlange, Kampagnen, die auf Freigabe warten, Ihnen zugewiesene Threads
AnomalienWas gerade ungewöhnlich istBounce-Rate-Spitze, ungewöhnliches Eingangsvolumen, Zustellprobleme bei einem bestimmten ISP

So funktioniert es

Das Dashboard besteht aus Karten — jede Karte ist eine in sich geschlossene Einheit, die ihre eigenen Daten über reaktive Convex-Queries lädt. Die Layout-Engine des Dashboards entscheidet anhand der obigen Kontextsignale, welche Karten in welcher Reihenfolge angezeigt werden.

// apps/api/convex/schema/dashboard.ts
dashboardLayouts: defineTable({
  userId: v.string(),             // Per-user layout (BetterAuth user id)
  // Context-driven layout rules
  rules: v.array(v.object({
    condition: v.object({
      timeRange: v.optional(v.object({    // e.g., { start: '06:00', end: '12:00' }
        start: v.string(),
        end: v.string(),
      })),
      dayOfWeek: v.optional(v.array(v.number())),  // 0=Sun, 1=Mon, etc.
      role: v.optional(v.string()),
    }),
    cards: v.array(v.object({
      type: v.string(),           // 'verification_queue', 'campaign_performance', 'inbound_summary', etc.
      size: v.union(v.literal('small'), v.literal('medium'), v.literal('large')),
    })),
    priority: v.number(),         // Higher priority rules override lower ones
  })),
  // Pinned cards always show regardless of context
  pinnedCards: v.optional(v.array(v.object({
    type: v.string(),
    size: v.union(v.literal('small'), v.literal('medium'), v.literal('large')),
  }))),
  updatedAt: v.number(),
})
  .index('by_user', ['userId'])

Die Layout-Engine in apps/api/convex/analytics/adaptiveDashboard.ts löst das aktive Layout auf: getLayout ermittelt die Rolle des Mitglieds (über getBetterAuthSessionWithRole) und wertet die Regeln nach Priorität gegen die aktuelle Tageszeit, den Wochentag und die Rolle aus, mischt angeheftete Karten hinein und fällt auf einen sinnvollen Standard zurück, wenn keine Regel passt. saveLayout lässt jedes Mitglied nur das eigene Layout verwalten (geschlüsselt by_user auf die User-ID der Session) — es persistiert den vollständigen bearbeiteten Kartensatz als pinnedCards und optional die adaptiven rules. Die Query getAvailableCards legt die registrierten Kartentypen offen, und getRawLayout liefert für den Editor das gespeicherte Layout (inklusive Regeln).

Rollenbedingungen werden erzwungen

Regelbedingungen unterstützen ein Feld role, und getLayout beachtet es inzwischen: Es ermittelt die Rolle des Mitglieds, und matchesCondition trifft auf eine rollenbezogene Regel nur zu, wenn die Rolle passt (eine Regel ohne Rolle passt weiterhin auf alle). Die Rollenauflösung erfolgt nach bestem Bemühen — eine leere Rolle überspringt schlicht rollenbezogene Regeln, statt das Layout zu verweigern. Bedingungen für Tageszeit und Wochentag werden ebenso beachtet.

Kartentypen

Die registrierten Kartentypen stammen aus DEFAULT_CARDS in apps/api/convex/analytics/adaptiveDashboard.ts:

KarteWas sie zeigt
verification_queueOffene Agentenentwürfe, die eine Prüfung brauchen
campaign_performanceKennzahlen der letzten Kampagnen
channel_healthStatus aller Kommunikationskanäle
agent_healthKennzahlen der KI-Agenten-Strecke
recent_contactsNeu hinzugefügte oder aktive Kontakte
queue_depthVerarbeitungswarteschlange eingehender Nachrichten
delivery_ratesErfolgsquoten der E-Mail-Zustellung
pinned_visualizationsAngeheftete Datenvisualisierungen
knowledge_graphJüngste Wissenseinträge
upcoming_campaignsGeplante Kampagnen
cost_by_stepLLM-Tokenkosten pro Schritt der Agenten-Strecke
accuracy_trendTrend von automatischer Freigabe vs. Ablehnung über die Zeit

Die letzten beiden (cost_by_step, accuracy_trend) sind vollständig angebunden: Sie sind registrierte Kartentypen mit Renderern in apps/web (CostByStepCard.vue, das api.agentHealth.getCostByStep liest, und AccuracyTrendCard.vue, das getAccuracyTrend liest) — cost_by_step schlüsselt die LLM-Kosten pro Schritt der Strecke auf, und accuracy_trend stellt das Verhältnis automatischer Freigaben der Ablehnungsrate über die Zeit gegenüber.

Sinnvolle Standardwerte

Ein Mitglied, das kein Layout gespeichert hat, erhält den eingebauten Standard (getDefaultLayout): Prüf-Warteschlange, Kampagnenleistung, Kanalzustand, Agenten-Gesundheit, Zustellraten, jüngste Kontakte. Von dort aus speichert es Regeln, heftet Karten an und stimmt sein eigenes Layout ab.

Vision: Layout-Vorschläge

Das längerfristige Ziel ist ein Agent, der Layout-Änderungen vorschlägt („Sie schauen jeden Morgen auf die Kampagnenleistung, aber sie steht ganz unten — soll ich sie nach oben verschieben?“) und aus Nutzungsmustern lernt. Schema und Resolver unterstützen heute handgeschriebene Regeln; nutzungsgetriebenes Lernen und Agentenvorschläge sind noch nicht implementiert.

Agentengenerierte Dashboard-Karten

Der Visualisierungsagent kann eine Karte pinned_visualizations erzeugen. Heftet ein Mitglied eine Visualisierung an, erscheint sie auf dem Dashboard und rendert ihr gespeichertes html erneut in einem sandboxed iframe. (Die Spalte dataQuery hält inzwischen fest, aus welchem erlaubten Datensatz die Visualisierung gebaut wurde, und eine Aktion für bedarfsgesteuerte Live-Daten-Aktualisierung führt den Abruf von der Grafikkarte aus erneut aus — siehe die Anmerkung unter Visualisierungsagent.)

Agenten-Gesundheit & Monitoring

Während die Agenten-Strecke eingehende Nachrichten verarbeitet, braucht das Deployment zentrales Monitoring — nicht nur zur Fehlersuche, sondern für die Sicherheit. Ein sich fehlverhaltender LLM-Anbieter oder ein Anstieg an Fehlschlägen ist ein Signal, das eine automatisierte Reaktion erfordert. Das ist in apps/api/convex/agentHealth.ts live: Eine Action rollupMetrics läuft per Cron alle fünf Minuten, schreibt Datenpunkte in agentMetrics und wertet die Circuit Breaker aus; die Monitoring-UI liest das aggregierte Dashboard und die Metrikhistorie, während die Strecke den Breaker-Zustand intern liest.

Metriken

agentMetrics definiert sieben Metriktypen. Der Fünf-Minuten-Rollup erfasst inzwischen alle sieben.

Metrik (metricType)Was sie misstHeute per Rollup befüllt?
queue_depthUnverarbeitete eingehende Nachrichten (received)Ja
processing_latencyDurchschnittliche Dauer abgeschlossener Aktionen im ZeitfensterJa
error_rateFehlgeschlagene Aktionen / Gesamtzahl der Aktionen im ZeitfensterJa
auto_approve_ratioAutomatisch freigegebene vs. von Menschen geprüfte Routen im ZeitfensterJa
rejection_rateAblehnungen / (Freigaben + Ablehnungen) über die letzten 24 hJa
llm_costGesamter LLM-Tokenverbrauch über die Aktionen des ZeitfenstersJa
classification_accuracyModellqualität über die ZeitJa (Konfidenz-Näherung — mittlere selbstberichtete classify-Konfidenz, keine menschliche Ground Truth)
Sieben von sieben Metriken werden jetzt befüllt

Der eingeplante Rollup leitet inzwischen zusätzlich zu queue_depth, processing_latency und error_rate auch auto_approve_ratio (aus den route-Schritt-Entscheidungen des Zeitfensters), rejection_rate (aus dem Feedback der menschlichen Verifizierungs-Warteschlange über die letzten 24 h, Ablehnungen gegen Freigaben+Ablehnungen gezählt) und llm_cost (summierte tokenUsage über die Aktionen des Zeitfensters) ab. classification_accuracy wird nun als Konfidenz-Näherung erfasst — die mittlere selbstberichtete Konfidenz der classify-Aktionen des Zeitfensters —, da es kein menschliches Ground-Truth-Signal gibt, gegen das bewertet werden könnte.

Agentenkonfiguration pro Deployment

Die operative Feinabstimmung der Strecke liegt in einer einzigen agentConfig-Zeile (apps/api/convex/schema/inbox.ts). Sie ist ein deploymentweiter Singleton; der Hauptschalter für den gesamten Agenten ist das Feature-Flag ai.agent, keine Spalte hier:

agentConfig: defineTable({
  isAutoReplyEnabled: v.boolean(),
  confidenceThreshold: v.number(),       // minimum confidence for auto-approval
  toneDescription: v.optional(v.string()),
  signatureTemplate: v.optional(v.string()),
  // Auto-reply rate limiting
  maxDailyAutoReplies: v.optional(v.number()),
  dailyAutoReplyCount: v.optional(v.number()),
  dailyAutoReplyResetAt: v.optional(v.number()),
  createdAt: v.number(),
  updatedAt: v.number(),
})

Die einzige eingebaute Kostenbremse ist maxDailyAutoReplies — eine Obergrenze für automatische Antworten, nachgehalten über dailyAutoReplyCount und zurückgesetzt über dailyAutoReplyResetAt. Sie begrenzt automatische Sendungen, nicht rohe LLM-Aufrufe. Siehe KI-Agent & Autonomie für die nutzerseitigen Steuerelemente.

Circuit Breaker

Die Tabelle agentCircuitBreakers definiert drei Breaker-Typen, jeder mit einem Standardschwellwert aus getDefaultThreshold in apps/api/convex/agentHealth.ts:

Breaker (breakerType)StandardschwelleStatus
llm_failure0.20 (20 % Fehlerrate)Live — bei jedem Rollup ausgewertet
confidence_degradation0.30 (Anteil an Klassifikationen mit niedriger Konfidenz)Live — bei jedem Rollup ausgewertet
rejection_spike0.40 (menschliche Ablehnungsrate über 24 h)Live — bei jedem Rollup ausgewertet

evaluateCircuitBreakers läuft in jedem Fünf-Minuten-Rollup und wertet inzwischen alle drei Breaker anhand der Signale dieses Zeitfensters aus — die Fehlerrate im Fenster (llm_failure), den Anteil jüngster Klassifikationen mit einer Konfidenz unter 0,5 (confidence_degradation) und die menschliche Ablehnungsrate über 24 h (rejection_spike). Jeder folgt derselben Hysterese-Zustandsmaschine: Übersteigt sein Wert die Schwelle des Breakers, löst er auf open aus; erholt sich der Wert, geht er über aufeinanderfolgende Rollups open → half_open → closed, bestätigt also die Erholung, bevor die automatische Freigabe wieder anläuft. Zustandsübergänge schreiben trippedAt/recoveredAt. Der Schritt route verweigert die automatische Freigabe, solange irgendein Breaker offen ist.

Alle drei Breaker werden ausgewertet

confidence_degradation und rejection_spike existieren nicht länger nur im Schema — beide sind inzwischen neben llm_failure in den Evaluator des Rollups eingebunden. (Davon getrennt reagiert auch der wöchentliche Job zur Anpassung der Autonomieschwellen weiter unten auf eine Ablehnungsrate von 40 % pro Kategorie; das ist jedoch der Autonomie-Cron, der Schwellen nachjustiert, und etwas anderes als das Auslösen des Breakers rejection_spike.)

Es gibt kein Objekt für ein Rate-Limit auf LLM-Aufrufe in der Agentenkonfiguration. Das einzige Rate-Limiting des agentConfig-Singletons ist die Obergrenze für automatische Antworten (maxDailyAutoReplies), behandelt unter Agentenkonfiguration pro Deployment; Tageslimits pro Kategorie liegen auf autonomyRules. Eine Budgetierung von LLM-Aufrufen bzw. Nebenläufigkeit ist nicht implementiert.

Dashboard-Integration

Die Agenten-Gesundheit erscheint auf dem adaptiven Dashboard als registrierte Karte agent_health (Kennzahlen der Strecke), gestützt auf getDashboardMetrics, neben der Karte queue_depth. Die detaillierteren Aufschlüsselungskarten cost_by_step (LLM-Kosten pro Schritt der Strecke, gestützt auf getCostByStep) und accuracy_trend (Verhältnis automatischer Freigaben vs. Ablehnungsrate über die Zeit, gestützt auf getAccuracyTrend) sind registrierte Kartentypen mit fertigen Renderern in apps/web.

Abgestufte Autonomie

Die Vision: Das Deployment steuert pro Kategorie, wie viel Entscheidungsspielraum es an den Agenten delegiert, und das System verdient sich Vertrauen schrittweise. Das Datenmodell und die Entscheidungsfunktionen existieren (apps/api/convex/autonomy.ts, abgesichert durch das Feature-Flag ai.autonomy) und steuern inzwischen den aktiven Routing-Schritt.

In den Routing-Schritt eingebunden

Der Schritt route (apps/api/convex/agent/steps/route/index.ts) entscheidet nun in drei Stufen, sicherste zuerst: (1) jeder offene Circuit Breaker → menschliche Prüfung (eine beeinträchtigte Strecke sendet nie automatisch); (2) ist das Flag ai.autonomy aktiv, wendet internal.autonomy.checkPermissionInternal die Regel der jeweiligen Kategorie an (Schwelle + Tageslimit + Prüfung auf offene Breaker) — eine Kategorie ohne Regel wird nie automatisch freigegeben, und eine automatische Freigabe belastet das Tageslimit der Kategorie über incrementDailyCount; (3) andernfalls der bisherige globale Schalter für automatische Antworten aus agentConfig + Konfidenzschwelle + Tageslimit. Autonomie pro Kategorie ist damit nicht länger reine Speicherung — sie steuert die automatische Freigabe durchgängig, wenn das Flag aktiv ist.

Regeln pro Kategorie

// apps/api/convex/schema/inbox.ts
autonomyRules: defineTable({
  category: v.string(),               // "support", "sales", "billing", etc.
  autoApproveThreshold: v.number(),   // Confidence threshold (0-1)
  maxDailyAutoActions: v.number(),    // Safety cap
  currentDailyCount: v.optional(v.number()),
  dailyCountResetAt: v.optional(v.number()),
  requiresHumanAbove: v.optional(v.number()), // e.g., dollar amount triggers human review
  isEnabled: v.boolean(),
  createdAt: v.number(),
  updatedAt: v.number(),
})
  .index('by_category', ['category'])

Regeln werden pro Kategorie über upsertRule (nur Owner/Admin) eingefügt oder aktualisiert und von checkPermissionInternal zurückgelesen (der Route-Schritt ruft es als internal.autonomy.checkPermissionInternal auf), das { mode, allowed, reason } zurückgibt, nachdem es die Schwelle, das Tageslimit der Kategorie (currentDailyCount gegen maxDailyAutoActions, zurückgesetzt in einem rollierenden 24-h-Fenster) und geprüft hat, ob irgendein Circuit Breaker offen ist.

Beispielkonfiguration

KategorieSchwelleTageslimitAnmerkungen
Einfache Empfangsbestätigungen0,9550„Danke, wir schauen uns das an“
Support-FAQ0,9030Standardantworten mit Datenabfrage
Abrechnungsfragen0,8520Kontospezifische Antworten
VertriebsanfragenKeine Regel = immer menschliche Prüfung
BeschwerdenKeine Regel = immer menschliche Prüfung

Eine Kategorie ohne Regel (oder mit isEnabled: false) wird nie automatisch freigegeben.

Feedback-Schleife

Menschliches Feedback wird in autonomyFeedback erfasst (recordFeedback: freigegeben/abgelehnt/bearbeitet plus die Konfidenz des Agenten). Ein wöchentlicher Cron (adjustThresholds) kalibriert daraufhin jede aktivierte Regel neu:

  • Übersteigt die Ablehnungsrate einer Kategorie über die letzte Woche 40 % (bei mindestens 5 Datenpunkten), zieht er die Schwelle um +0,10 an (maximal 0,99).
  • Liegt die Ablehnungsrate bei mindestens 20 Datenpunkten unter 10 %, lockert er die Schwelle um -0,05 (minimal 0,50).
Die Schleife hat aktive Eingaben

Die Verifizierungs-Warteschlange emittiert inzwischen bei jeder Entscheidung recordFeedback: approveDraft, rejectDraft und editDraft in apps/api/convex/inbox/mutations.ts hängen jeweils eine Zeile an autonomyFeedback an (eine Nachricht ohne Klassifikation wird unter other erfasst, damit das Signal nicht verloren geht). Der wöchentliche Cron adjustThresholds verarbeitet diese Zeilen, um die Schwellen pro Kategorie neu zu kalibrieren, und der Rollup zur Agenten-Gesundheit nutzt dasselbe Feedback für den Breaker rejection_spike. Eine „Konfidenzkalibrierung“ pro Nachricht über dieses Nachjustieren der Schwellen auf Kategorieebene hinaus gibt es weiterhin nicht.

Coding-Agenten

Der experimentellste Teil: ein Agent, der eine Aufgabenbeschreibung entgegennimmt und einen Pull Request erzeugt. Er läuft als Docker-Sidecar (apps/code-worker) — nicht innerhalb von Convex —, weil er ein Dateisystem und git braucht.

Aufgabenmodell

Aufgaben werden in einer eigenen Tabelle nachgehalten, nicht in agentActions:

// apps/api/convex/schema/codeWork.ts
codeWorkTasks: defineTable({
  description: v.string(),
  inboundMessageId: v.optional(v.id('inboundMessages')), // source context
  branch: v.optional(v.string()),
  prUrl: v.optional(v.string()),
  status: v.union(
    v.literal('queued'),
    v.literal('running'),
    v.literal('testing'),
    v.literal('review'),
    v.literal('merged'),
    v.literal('failed'),
  ),
  testResults: v.optional(v.string()),
  errorMessage: v.optional(v.string()),
  llmCost: v.optional(v.number()),
  createdAt: v.number(),
  updatedAt: v.number(),
})
  .index('by_status', ['status'])
  .index('by_created_at', ['createdAt'])

Worker-Ablauf

Der Worker fragt Convex nach der nächsten Aufgabe im Status queued und führt sie durchgängig aus (apps/code-worker/src/taskRunner.ts):

getNextQueued()  →  claim()        status: queued → running
  setupWorkspace()                 git clone --depth 1, checkout code-worker/{taskId}
  runCodingAgent()                 spawn the OpenCode CLI (OPENCODE_BIN) on the workspace
  (no diff?)        → markFailed    status: failed
  git commit
  markTesting()                    status: running → testing
  runTests()                       npx vitest run
  git push origin {branch}
  createPullRequest() (if GITHUB_* set, via @octokit/rest)
  completeWithPR()                 status: testing → review, stores prUrl + testResults

Ein Fehlschlag an irgendeiner Stelle ruft markFailed mit dem Fehler auf. Das Ergebnis landet in review mit einem PR-Link, den ein Mensch prüfen und mergen kann — und wird dieser PR auf GitHub gemergt, setzt ein Webhook die Aufgabe automatisch auf merged (siehe unten).

Auto-Trigger und Merge-Webhook sind angebunden

Eingehende Feature-Wünsche werden inzwischen automatisch zu Code-Aufgaben: Klassifiziert eine Nachricht als feature_request, plant der Posteingangs-Lebenszyklus (apps/api/convex/inbox/processingLifecycle.ts) internal.codeWorkTasks.createFromInbound ein, abgesichert durch das Flag inbox.codeTasks und idempotent über inboundMessageId. (Das manuelle Anlegen über Code-Aufgaben funktioniert weiterhin.) Auch der Status merged ist angebunden: Ein GitHub-Webhook (apps/api/convex/webhooks/githubHttp.ts, Route POST /webhooks/github, HMAC-verifiziert gegen GITHUB_WEBHOOK_SECRET) verarbeitet pull_request-Events vom Typ closed+merged und gleicht die html_url des PR über markMergedByPrUrl gegen codeWorkTasks.prUrl ab — ein Mensch mergt also weiterhin in GitHub, aber die Aufgabe wechselt selbst den Status, statt bei review hängen zu bleiben.

Zum Ausführen des Agenten braucht es weiterhin den Sidecar

Auto-Trigger und Merge-Webhook sind angebunden, aber eine Aufgabe tatsächlich auszuführen — klonen, den Coding-Agenten aufrufen, den PR eröffnen — erfordert weiterhin den optionalen Docker-Sidecar code-worker (unten), mit OpenCode im Image und konfigurierten GITHUB_*/GIT_*. Ohne den Sidecar reihen sich Aufgaben ein, aber nichts nimmt sie auf.

Compose-Service

Der Worker ist ein optionaler Sidecar, abgesichert durch das Feature-Flag inbox.codeTasks (Profil inbox-codetasks, auch unter dev aktiviert):

# infra/templates/docker-compose.vps.yml
code-worker:
  image: ghcr.io/wolvesdotink/code-worker:${OWLAT_VERSION}
  volumes:
    - code-workspace:/workspace
  environment:
    CONVEX_URL: http://convex:3210
    LLM_PROVIDER: ${LLM_PROVIDER:-openai}
    LLM_BASE_URL: ${LLM_BASE_URL:-}
    LLM_API_KEY: ${LLM_API_KEY:-}
    LLM_MODEL: ${LLM_MODEL:-gpt-4o}
    GITHUB_TOKEN: ${GITHUB_TOKEN:-}
    GITHUB_OWNER: ${GITHUB_OWNER:-}
    GITHUB_REPO: ${GITHUB_REPO:-}
    GIT_REPO_URL: ${GIT_REPO_URL:-}
    GIT_BASE_BRANCH: ${GIT_BASE_BRANCH:-main}
  profiles:
    - inbox-codetasks
    - dev
Experimentell

Coding-Agenten sind der am weitesten in der Zukunft liegende Teil der Vision. Die Klempnerei — Aufgabentabelle, Worker, PR-Erstellung — ist vorhanden, aber standardmäßig abgeschaltet und wird sich mit den Fähigkeiten der KI-Codegenerierung weiterentwickeln.