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.
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 v2 | Electron | |
|---|---|---|
| Binärgröße | ~5–10 MB | ~150–200 MB |
| Speicherverbrauch | Native Webview | Gebündeltes Chromium |
| Lizenz | MIT | MIT |
| Backend | Rust | Node.js |
| Auto-Update | Eingebauter Updater | electron-updater |
| Dock-/Taskleisten-Badge | Native API | Native 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.
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.json → plugins.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
conversationThreadverweisen 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.
Die Ask-Anything-Oberfläche ist durchgängig angebunden. Das Backend ist quickQuery.ts → ask, 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.
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>mitsandbox="allow-scripts"— kein Zugriff auf die übergeordnete Seite, den Convex-Client, Cookies oder die Navigation. Das iframe kommuniziert ausschließlich perpostMessagefür Resize-Events
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:
| Signal | Was es uns sagt | Beispielwirkung |
|---|---|---|
| Tageszeit | Morgens = planen, abends = auswerten | Morgens: die heute geplanten Kampagnen, die über Nacht eingegangene Warteschlange. Abends: die heutige Leistungsübersicht, offene Punkte für morgen |
| Wochentag | Montag = aufholen, Freitag = abschließen | Montag: der Eingangsrückstand vom Wochenende, der Kampagnenplan der Woche. Freitag: Wochenkennzahlen, ungelöste Threads |
| Rolle | Wofür die Person verantwortlich ist | Eine Support-Leitung sieht Warteschlangentiefe und SLA-Status. Eine Marketing-Managerin sieht Kampagnenleistung und Audience-Wachstum |
| Jüngste Aktivität | Woran die Person gearbeitet hat | Haben Sie die letzte Stunde an einer Kampagne gearbeitet, hebt das Dashboard deren Echtzeit-Zustellstatistiken hervor |
| Offene Punkte | Was Aufmerksamkeit braucht | Einträge in der Verifizierungs-Warteschlange, Kampagnen, die auf Freigabe warten, Ihnen zugewiesene Threads |
| Anomalien | Was gerade ungewöhnlich ist | Bounce-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).
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:
| Karte | Was sie zeigt |
|---|---|
verification_queue | Offene Agentenentwürfe, die eine Prüfung brauchen |
campaign_performance | Kennzahlen der letzten Kampagnen |
channel_health | Status aller Kommunikationskanäle |
agent_health | Kennzahlen der KI-Agenten-Strecke |
recent_contacts | Neu hinzugefügte oder aktive Kontakte |
queue_depth | Verarbeitungswarteschlange eingehender Nachrichten |
delivery_rates | Erfolgsquoten der E-Mail-Zustellung |
pinned_visualizations | Angeheftete Datenvisualisierungen |
knowledge_graph | Jüngste Wissenseinträge |
upcoming_campaigns | Geplante Kampagnen |
cost_by_step | LLM-Tokenkosten pro Schritt der Agenten-Strecke |
accuracy_trend | Trend 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.
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 misst | Heute per Rollup befüllt? |
|---|---|---|
queue_depth | Unverarbeitete eingehende Nachrichten (received) | Ja |
processing_latency | Durchschnittliche Dauer abgeschlossener Aktionen im Zeitfenster | Ja |
error_rate | Fehlgeschlagene Aktionen / Gesamtzahl der Aktionen im Zeitfenster | Ja |
auto_approve_ratio | Automatisch freigegebene vs. von Menschen geprüfte Routen im Zeitfenster | Ja |
rejection_rate | Ablehnungen / (Freigaben + Ablehnungen) über die letzten 24 h | Ja |
llm_cost | Gesamter LLM-Tokenverbrauch über die Aktionen des Zeitfensters | Ja |
classification_accuracy | Modellqualität über die Zeit | Ja (Konfidenz-Näherung — mittlere selbstberichtete classify-Konfidenz, keine menschliche Ground Truth) |
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) | Standardschwelle | Status |
|---|---|---|
llm_failure | 0.20 (20 % Fehlerrate) | Live — bei jedem Rollup ausgewertet |
confidence_degradation | 0.30 (Anteil an Klassifikationen mit niedriger Konfidenz) | Live — bei jedem Rollup ausgewertet |
rejection_spike | 0.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.
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.
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
| Kategorie | Schwelle | Tageslimit | Anmerkungen |
|---|---|---|---|
| Einfache Empfangsbestätigungen | 0,95 | 50 | „Danke, wir schauen uns das an“ |
| Support-FAQ | 0,90 | 30 | Standardantworten mit Datenabfrage |
| Abrechnungsfragen | 0,85 | 20 | Kontospezifische Antworten |
| Vertriebsanfragen | — | — | Keine Regel = immer menschliche Prüfung |
| Beschwerden | — | — | Keine 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 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).
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.
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
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.