Servicedesk AI-agent

Dit document legt uit hoe de AI-agent binnenkomende servicedeskmeldingen behandelt: hoe een mail bij de agent terechtkomt, welke filters ervoor zorgen dat hij niet op zichzelf of op systeemmails reageert, wat hij zelf mag doen en waar je moet kijken als er niets gebeurt.

De agent leeft in apps/api/src/modules/freescout-agent/, de trigger in apps/api/src/api/freescout-webhook/ en de uitvoering in de BullMQ-processor freescout-ai-agent.

FreeScout in onze stack

FreeScout is onze helpdesk, bereikbaar op help.tapster.nl. Drie dingen daaruit raken de agent.

Mailboxen. Elke productlijn heeft een eigen mailbox: 1 Tapster (info@tapster.nl), 4 Monitor Cliëntmedezeggenschap en 5 Koppel. De agent werkt alleen in de mailboxen die in FREESCOUT_AI_MAILBOX_IDS staan, en mag alleen zelf mailen in de mailboxen uit FREESCOUT_AI_AUTO_SEND_MAILBOX_IDS.

De API. Alles wat de agent in FreeScout doet loopt via freescoutClient, met een sleutel uit de module API & Webhooks in de header X-FreeScout-API-Key. Diezelfde client bedient ook de FreeScout-tools van de MCP-server, dus als de MCP-tools 401 geven, doet de agent dat ook.

Gebruik voor calls vanuit het cluster de externe URL (FREESCOUT_API_BASE_URL=https://help.tapster.nl). FreeScout is een Laravel-applicatie die op de interne servicenaam als Host-header een 500 teruggeeft, ook met een geldige sleutel.

De kennisbank. De agent zoekt zijn antwoorden in de FreeScout-KB en kan er concepten aan toevoegen. Die KB wordt ook gevuld door de userdocs-sync; zie Eindgebruiker-documentatie (userdocs).

De agent handelt onder het FreeScout-account “Tim van Tapster” (noreply@tapster.nl). Zijn gebruikers-id staat in FREESCOUT_CLAUDE_USER_ID en is nodig om onderscheid te maken tussen “de agent zelf” en “een collega”. Op de default 0 herkent de agent zijn eigen notities niet en slaat hij conversaties over die aan hemzelf zijn toegewezen.

De keten van mail tot antwoord

Klant mailt info@tapster.nl
    │
    ▼
FreeScout maakt een conversatie en vuurt een webhook
    │  POST /webhooks/freescout/convo-created
    │  X-FreeScout-Signature: HMAC-SHA1 over de rauwe body
    ▼
freescoutWebhookRouter  (gemount vóór bodyParser en vóór requestLogger)
    │  1. handtekening controleren, anders 401
    │  2. job op de queue met een deterministisch jobId
    ▼
BullMQ-queue freescout-ai-agent
    │
    ▼
processor
    │  1. ANTHROPIC_API_KEY aanwezig?
    │  2. shouldHandle: mailbox, spam, blacklist, toewijzing, notitie-regels
    │  3. automatisch bericht? tag + sluiten, zonder LLM
    │  4. auto-send bepalen op basis van de mailbox
    ▼
runServicedeskAgent  →  Anthropic Tool Runner (max 15 iteraties)
    │
    ▼
antwoord, concept, notitie of escalatie in FreeScout

Drie events geven een trigger: een nieuwe conversatie (convo.created), een klantreactie op een bestaande conversatie (convo.customer.reply.created) en een interne notitie (convo.note.created). Op agent-replies en agent-notities abonneren we bewust niet: dat is de loop.

De webhook is een stille faalmodus

De router hangt in server.ts vóór de globale bodyParser, want de HMAC gaat over de rauwe body. Daardoor hangt hij ook vóór requestLogger. Gevolg: een geweigerde webhook laat geen enkel spoor na. Geen HTTP-regel, geen foutmelding, geen job.

En weigeren doet hij snel:

if (!env.FREESCOUT_WEBHOOK_SECRET || !signature) return false;

Staat de secret leeg, dan krijgt elke webhook een 401. Van buitenaf lijkt dat identiek aan “er kwam geen mail binnen”. Zie je een conversatie in FreeScout zonder bijbehorende job in Bull Board, controleer dan eerst deze variabele.

Het jobId is deterministisch: freescout-ai-agent-<event>-<conversatieId>-<threadId>. FreeScout retryt tot tien keer binnen twee uur, en BullMQ negeert dat duplicaat zolang de job in de removeOnComplete-historie staat (zes uur). De thread-id in het jobId is bewust de nieuwste relevante thread, want anders zou een tweede klantreactie binnen dat venster als duplicaat worden weggegooid.

Faalt het enqueuen zelf, bijvoorbeeld doordat Redis even weg is, dan antwoordt de router met 503 zodat FreeScout het event opnieuw aanbiedt.

Filters staan in code, niet in de prompt

Loop-preventie hoort niet af te hangen van of het model zijn werkwijze onthoudt. shouldHandle in de processor beslist, in deze volgorde:

Controle Overslaan wanneer
Mailbox de mailbox staat niet in FREESCOUT_AI_MAILBOX_IDS
Spam de conversatie of state is spam
Notitie-regels alleen bij convo.note.created, zie hieronder
Afzender-domein het domein staat in FREESCOUT_AI_SENDER_DOMAIN_BLACKLIST
Toewijzing de conversatie staat op een andere medewerker dan de agent
Uitgaand een medewerker maakte de conversatie zelf aan, niet per mail

Voor notitie-events gelden strengere regels. De notitie moet van een mens zijn (niet van FREESCOUT_CLAUDE_USER_ID), en de conversatie moet de tag ai-twijfel of ai-escalatie dragen. De notitie-flow is er namelijk voor één scenario: de agent stelde een vraag, een collega antwoordt. Een notitie op een verder ongetagde conversatie doet dus niets, ook niet als er “graag beantwoorden” in staat.

Het toewijzingsfilter geldt bewust niet voor notitie-events: een escalatie staat per definitie op naam van een collega, en juist daar moet de agent kunnen terugkomen.

Tags haalt de processor bij notitie-events vers op via de API. De webhook-payload is daarvoor onbetrouwbaar: FreeScout levert tags alleen mee onder _embedded.tags bij embed=tags.

Automatische berichten en doorgestuurde mail

Systeemmails, out-of-office-antwoorden en bounces herkent de processor aan het onderwerp en aan het localpart van de afzender (noreply, mailer-daemon en soortgelijke). Tapster’s eigen platformmails herkent hij aan de vaste preamble “je ontvangt deze e-mail omdat het account”. Het onderwerp is daarvoor bewust geen signaal, want een klantvraag over de dagelijkse tijdlijn moet wél behandeld worden.

Is een nieuwe conversatie zo’n bericht, dan krijgt hij de tag ai-automatisch en gaat hij dicht, zonder LLM-run. Komt het automatische bericht binnen in een lopend gesprek, dan slaat de agent alleen die trigger over: dat gesprek kan nog op een echt antwoord wachten.

Daarnaast is er een vangnet voor collega’s. Wie een klantmail doorstuurt zonder FreeScout’s @fwd-commando, komt zelf als afzender op de conversatie te staan en wordt dus door de domein-blacklist geweigerd. In plaats van een stille skip krijgt die collega een instructie-reply met de juiste werkwijze, plus de tag doorgestuurd-zonder-fwd, waarna de conversatie sluit.

De agent-run

De systemprompt komt uit het promptbeheer onder de sleutel servicedesk.agent, via resolvePrompt met de env-pin van de omgeving. Bijsturen kan dus via de admin-UI of de MCP prompt-tools, zonder deploy. Is de prompt niet geseed of niet gepind, dan faalt de job met een duidelijke fout.

Het model komt niet uit de promptversie maar uit env.AGENT_MODEL. Het provider.model-veld in een promptversie is voor deze flow een dood veld.

De code bepaalt wat het model niet kan weten en zet dat in het openingsbericht: de aanhef op basis van het tijdstip, of auto-send aanstaat, en of dit een notitie-run is.

De tool-loop draait op de Anthropic Tool Runner met maximaal 15 iteraties. Omdat elke iteratie de volledige geschiedenis opnieuw als input stuurt, staat er een vast cache-breakpoint op de systemprompt en schuift er per iteratie een tweede breakpoint mee naar het laatste cachebare blok. Let op de SDK-les die daar onder zit: zodra je setMessagesParams gebruikt, is de historie extern beheerd en moet de aanroeper zelf de tool-executie, het opbouwen van de historie én de terminatie regelen.

Wat de agent mag

De toollijst is de vangrail: wat er niet in staat, kan de agent niet.

Groep Tools
Conversatie en kennisbank ophalen, KB zoeken en lezen, concept-KB-artikel aanmaken, notitie plaatsen, tags, status, toewijzen, medewerkers opzoeken
Platform list_tenants, find_user, get_user_access, digest_status, get_logs, check_endpoint_access, monitor_get_location_survey_links, mark_user_for_anonymization
Antwoorden altijd freescout_create_draft_reply, en alleen bij auto-send ook freescout_reply

De platformtools zijn vrijwel allemaal read-only. De enige die schrijft is mark_user_for_anonymization, en die markeert omkeerbaar: de definitieve anonimisering volgt pas na een wachttijd van veertien dagen.

Drie vangrails zitten in code en niet in de prompt, omdat ze niet mogen afhangen van promptdiscipline:

  • Maximaal één verstuurde reply per run.
  • Een concept-reply zet altijd ai-concept en ai-twijfel.
  • Toewijzen aan een mens zet altijd ai-escalatie.

Die laatste twee zijn precies de tags die de notitie-flow later weer vrijgeven.

Tags als statusmodel

Tag Betekenis
ai-automatisch herkend als systeemmail, gesloten zonder LLM-run
ai-zeker + ai-beantwoord de agent heeft de klant zelf gemaild
ai-concept + ai-twijfel er staat een concept klaar, een mens moet kijken
ai-escalatie toegewezen aan een collega
doorgestuurd-zonder-fwd vangnet-reply naar een collega

Waar je moet kijken

Dit is de belangrijkste valkuil bij het onderzoeken van “de agent doet niets”: skip-redenen komen niet in de prod-logs terecht. Ze gaan via job.log() en zijn alleen zichtbaar bij de job zelf in Bull Board, op /bullboard/queue/freescout-ai-agent.

In de logbuffer (zie Logging vs reporting) verschijnen alleen twee regels: FreeScout-agent gestart voor conversatie <id> bij een echte run, en een waarschuwing als ANTHROPIC_API_KEY ontbreekt. Geen logregels betekent dus niet dat er geen job was.

Elke run legt daarnaast een record vast in de collectie freescout_agent_runs op de admin-database, met model, promptversie, tokens, duur, stop-reden en eventuele fout. Die records vervallen automatisch na negentig dagen.

Kort stappenplan als er niets gebeurt:

  1. Staat er een job in Bull Board? Zo nee, dan komt de webhook niet binnen of wordt hij op de handtekening geweigerd.
  2. Staat de job er met een skip-reden? Dan ligt het aan de filters hierboven.
  3. Staat er wel een run maar geen resultaat? Kijk in freescout_agent_runs naar de stop-reden en de fout.

Configuratie

Variabele Zonder waarde
FREESCOUT_API_KEY elke call geeft 401, elke run loopt stuk op stap 1
FREESCOUT_WEBHOOK_SECRET elke webhook wordt geweigerd, zonder logregel
ANTHROPIC_API_KEY elke melding wordt overgeslagen met een waarschuwing
FREESCOUT_CLAUDE_USER_ID de agent herkent zijn eigen notities niet
FREESCOUT_AI_MAILBOX_IDS leeg betekent uit
FREESCOUT_AI_AUTO_SEND_MAILBOX_IDS leeg betekent alleen concepten
FREESCOUT_AI_SENDER_DOMAIN_BLACKLIST eigen systeemmails worden behandeld
AGENT_MODEL valt terug op de default

Deze horen bij elkaar. Ze staan in de ConfigMap app-config, die door kustomize wordt opgebouwd uit de env-bestanden van de overlay. Wat niet in die bestanden staat, bestaat na een apply niet meer, ook niet als het eerder met de hand in het cluster was gezet.

Zie ook