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-conceptenai-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:
- Staat er een job in Bull Board? Zo nee, dan komt de webhook niet binnen of wordt hij op de handtekening geweigerd.
- Staat de job er met een skip-reden? Dan ligt het aan de filters hierboven.
- Staat er wel een run maar geen resultaat? Kijk in
freescout_agent_runsnaar 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
- MCP-server voor de FreeScout-tools die dezelfde client gebruiken.
- Eindgebruiker-documentatie (userdocs) voor hoe de kennisbank gevuld wordt.
- Logging vs reporting voor het verschil tussen de logbuffer en de foutrapportage.