AgentDesk
Ein Full-Stack-KI-Assistent, der per LLM-Tool-Calling echte Backend-Endpunkte aufruft. Er zeigt jeden Schritt live in der Oberfläche und fragt nach, bevor er Daten ändert.
- Art
- Nebenprojekt
- Tech-Stack
- NestJS
- React
- PostgreSQL
- OpenAI
- Gemini
- SSE
Warum ich es gebaut habe
Die meisten KI-Chat-Demos beantworten nur Fragen. In einem echten Business-Tool liegt der Nutzen aber im Handeln: eine Bestellung nachschlagen, ein Ticket anlegen, eine Zahl abfragen. Ich wollte herausfinden, was nötig ist, damit ein Modell echte Endpunkte aufrufen kann, ohne Daten zu ändern, die es nicht ändern soll.
So funktioniert es
Du fragst in normaler Sprache, und der Agent wählt die Tools, die er braucht. Bei mehrstufigen Aufgaben ruft er mehrere nacheinander auf:
„Welche Bestellungen aus der letzten Woche sind noch unbezahlt? Leg ein Support-Ticket für die größte an.“
Hier ruft er search_orders und danach create_ticket auf und antwortet mit dem Ergebnis. Jeder Tool-Aufruf erscheint mit Ein- und Ausgabe im Chat, sodass man genau nachvollziehen kann, was passiert ist.
- React-UISSE-Stream
- NestJS-Agent-ServiceAgent-Loop
- LLMOpenAI / Gemini
- Tool-RegistryZod-Validierung
- PostgreSQLPrisma
Als Modell dient OpenAI oder Google Gemini, umschaltbar über eine einzige Umgebungsvariable. Tokens und Tool-Schritte werden per Server-Sent Events in die Oberfläche gestreamt, der Gesprächsverlauf liegt pro Nutzer in PostgreSQL. Der Login kommt aus meinem NestJS Production Starter.
Die Demo läuft mit Beispieldaten eines fiktiven Shops und Tools wie search_orders, get_order, get_customer, create_ticket, update_ticket und get_stats. Jedes Tool wird einmal mit einem Zod-Schema definiert. Dieses eine Schema prüft die Argumente des Modells zur Laufzeit und erzeugt zugleich das JSON-Schema, das das Modell sieht. Beides kann so nicht auseinanderlaufen. Ein neues Tool braucht etwa 20 Zeilen:
export const getCustomer = defineTool({
name: "get_customer",
description: "Look up a customer by email or ID",
schema: z.object({
email: z.string().email().optional(),
id: z.string().optional(),
}),
handler: async (args, ctx) => ctx.customers.find(args),
});
Grenzen setzen
Tools, die Daten ändern, etwa create_ticket oder refund_order, warten auf eine Bestätigung. Ein Modell kann eine Absicht falsch verstehen, und Nachfragen ist günstiger als eine Rückerstattung rückgängig zu machen. Dazu kommen eine Obergrenze für Tool-Schritte pro Durchlauf, Eingabevalidierung, Rate-Limits pro Nutzer und Berechtigungen pro Tool. Jeder Durchlauf protokolliert Prompts, Tool-Aufrufe, Latenz, Token-Verbrauch und Kosten.
Ein Agent-Framework habe ich bewusst nicht genutzt. Der Loop besteht aus rund 150 Zeilen TypeScript und bleibt so leicht zu debuggen und zu erklären. Getestet wird er mit einem Fake-LLM, das vorgegebene Tool-Aufrufe liefert. Die Tests sind dadurch reproduzierbar und kostenlos.
Was mich überrascht hat
Der Loop selbst war der kleinste Teil. Die meiste Arbeit steckte in Validierung, Fehlerbehandlung und Grenzen. Außerdem haben präzise Tool-Namen und Schema-Felder die Tool-Auswahl des Modells stärker verbessert als längere Systemanweisungen.