← Blog

AI agents

CRM-agents inkaderen: scoped tools, dry-run, killswitch

Een ops-lead stelt de juiste vraag voordat een agent live gaat: wat houdt 'm tegen om om 2 uur 's nachts de verkeerde klant te mailen? Drie patronen geven het antwoord.

Jacob Molkenboer· Oprichter · A Brand New Company· 11 apr 2024· 8 min
Messing relais op gevouwen ivoren formulier, limegroene memo met potloodvinkje, rode lakzegel, op warm bureaublad.

Het is 16:42 op een dinsdag. De operations-lead bij een Nederlands logistiek bedrijf van veertig man staart naar een dashboard dat we voor haar bouwden, drie dagen voordat we een sales-triage-agent live zetten op haar HubSpot. De agent gaat inbound replies lezen, deal-stages bijwerken, follow-ups concipiëren en de saaie automatisch versturen. Ze heeft ons niet gevraagd hoe slim hij is. Ze heeft, tot twee keer toe, gevraagd wat 'm tegenhoudt om om twee uur 's nachts de verkeerde klant te mailen als er iets misgaat.

Dat is de juiste vraag. Snelheid is het makkelijke deel. Inkadering is wat een agent door de eerste boardreview en de productie in krijgt.

Anthropic, het team dat Claude traint, publiceerde een nuttige engineering-notitie over building effective agents die strookt met wat wij in het veld zien. De patronen vertalen vrijwel een-op-een naar een CRM-agent. Wij draaien er de afgelopen twee jaar drie op elke installatie: scoped tools, een dry-run-modus en een killswitch waar de ops-lead daadwerkelijk bij kan. Het vierde patroon, budgetlimieten, pakten we op nadat een onbewaakte loop in één middag een rekening van vier cijfers had gemaakt.

Scoped tools, geen volle API-toegang

De fout die iedere first-time agent-bouwer maakt: de hele CRM-SDK eromheen wikkelen en het model het laten uitzoeken. Dat zoekt het uit. Op een rustige zondag zoekt het ook uit hoe het twee contactrecords merget, omdat een voorbeeld in de prompt net iets te ruim geformuleerd was.

De oplossing is een klein oppervlak. Schrijf de kleinste tool die het kleinste nuttige ding doet. Vijf werkwoorden die elk één taak hebben winnen het van één tool die elf dingen kan.

Een triage-agent die we in maart bij een SaaS-klant opleverden heeft vier tools. Dat is de hele gereedschapskist:

const tools = [
  {
    name: "get_deal",
    description: "Read one deal by id. No writes.",
    input_schema: {
      type: "object",
      properties: { deal_id: { type: "string" } },
      required: ["deal_id"]
    }
  },
  {
    name: "append_note",
    description: "Append a timestamped note. Cannot edit or delete existing notes.",
    input_schema: { /* ... */ }
  },
  {
    name: "set_stage",
    description: "Move a deal to a stage in the predefined pipeline. Refuses unknown stages.",
    input_schema: { /* ... */ }
  },
  {
    name: "schedule_follow_up",
    description: "Create a follow-up task for the deal owner. Does not send email.",
    input_schema: { /* ... */ }
  }
]

Geen delete. Geen merge. Geen execute_query. Als de agent iets wil doen waar we niet op gerekend hadden, post hij in een Slack-kanaal en voert een mens de actie handmatig uit. In vijf maanden hebben we één tool toegevoegd. De ops-lead leest de volledige lijst in twintig seconden en kan je precies vertellen wat de agent wel en niet kan. Die helderheid is het hele punt.

De tool-use-documentatie van Anthropic zelf hamert op hetzelfde. Elke tool is een gat in de muur, dus snijd zo min mogelijk gaten en vorm elk gat naar precies één werkwoord.

Dry-run als default, niet als test

Elke state-veranderende tool die wij schrijven retourneert twee verschillende dingen, afhankelijk van een flag.

def set_stage(deal_id: str, stage: str, dry_run: bool = True) -> dict:
    deal = crm.get_deal(deal_id)
    if dry_run:
        return {
            "would_have": f"set deal {deal_id} ({deal['name']}) "
                          f"from '{deal['stage']}' to '{stage}'",
            "wrote": False,
        }
    crm.update_deal(deal_id, stage=stage)
    audit.write(actor="agent", verb="set_stage",
                deal_id=deal_id, before=deal['stage'], after=stage)
    return {"wrote": True, "stage": stage}

Op dag één van elke installatie staat dry_run op orchestrator-niveau hard op True. De agent draait op echte productiedata, leest echte deals, genereert echte plannen en schrijft niets. De ops-lead leest drie tot vijf dagen een would-have-done-log. Wij vangen drift. We trekken de prompt aan. We voegen een voorbeeld toe aan de few-shot-bank. Daarna zetten we de flag om, tool voor tool.

De dry-run-periode is het waardevolste testvenster dat we hebben. Hij brengt de gevallen aan het licht die niemand bij de scoping noemde: de deal die al twee jaar in Negotiation staat omdat de klant hem daar leuk vindt, de prospect wiens voornaam alleen "Ahmed" is omdat de CRM slecht is geïmporteerd, het testrecord uit 2019 waar nog steeds het privé-mailadres van de CEO aan hangt. Geen enkele staging-omgeving die dat reproduceert.

Kerngedachte

Dry-run is geen testmodus. Het is de default. Je promoveert naar writes wanneer de would-have-log je niet meer verrast.

Een killswitch waar de ops-lead echt bij kan

Een killswitch in een configbestand waar je via SSH bij moet, is geen killswitch. Dat is een postmortem met extra stappen.

De echte is een knop. We zetten 'm in een klein dashboard dat de ops-lead op dag één bookmarkt. Eén klik zet een flag in Redis. De agent checkt die flag bovenaan elke loop-iteratie. Staat de flag aan, dan keert de agent direct terug, zonder tool-calls, zonder model-roundtrip.

def should_halt(scope: str = "global") -> bool:
    return redis.get(f"agent:halted:{scope}") == b"1"

def step(state):
    if should_halt() or should_halt(state.customer_id):
        return state.with_status("halted")
    # ... otherwise plan, call tool, loop

Waarom juist Redis? Omdat we niet willen dat de halt-check afhangt van hetzelfde codepad dat zich misdraagt. De check is drie regels in een eigen bestand. Staat de reasoning-loop van de agent in de fik, dan werkt dat bestand nog. Het dashboard dat de flag omzet, is één HTML-pagina met één knop. Hij laadt in 200ms op een telefoon, en dat is precies wat telt als de ops-lead in de trein zit.

De halt-één-klant-variant is de extra vijftien minuten bouwen meer dan waard. Zelfde patroon, smallere key: agent:halted:customer:1234. Handig wanneer één account vreemde output produceert en de rest van de portefeuille gewoon werkt. Zonder die variant heeft de ops-lead een binaire keuze tussen alles aan en alles uit, en dan kiest ze uit en belt ze jou.

Waarschuwing

Als de halt-flag-check in hetzelfde proces draait als de tool die volgens jou misgaat, heb je geen killswitch gebouwd. Zet 'm in een apart bestand, een aparte dependency, het liefst een aparte datastore. Onafhankelijkheid is het hele punt.

Budgetlimieten, want ook de kosten lopen weg

Inkadering gaat niet alleen over veiligheid. Het gaat ook over kosten. Een onbewaakte agent-loop, draaiend op een CRM met een paar duizend records, kan in één middag een inferencerekening van vier cijfers produceren als een webhook misvuurt en de loop dezelfde payload een paar duizend keer opnieuw verwerkt. We hebben het zien gebeuren op andermans stack. We zijn niet van plan het op die van onszelf te zien.

Drie limieten, allemaal afgedwongen voordat het model wordt aangeroepen:

LIMITS = {
    "tokens_per_run": 40_000,
    "tool_calls_per_run": 30,
    "usd_per_day": 25.00,
}

Slaat een limiet aan, dan stopt de agent op precies dezelfde manier als bij de handmatige killswitch. Zelfde bestand. Zelfde Redis-key. De ops-lead krijgt een Slack-ping met de limiet die getriggerd is en de laatste tool-call die liep. Geen verrassing op de factuur. Niemand die om 4 uur 's nachts een Grafana-paneel hoeft te bewaken.

Omkeerbaarheid, waar je het kunt regelen

Sommige tool-calls zijn van nature omkeerbaar. Een notitie toevoegen is omkeerbaar. Een deal-stage verplaatsen is omkeerbaar. Een mail versturen is dat niet. Een Stripe-afschrijving aftrappen evenmin. Een record verwijderen ook niet, niet binnen de tijdlijn waar de ops-lead om geeft.

Dus we trappen tools op basis van omkeerbaarheid, en de toestemming van de agent om ze aan te roepen volgt die trap. Omkeerbare tools roept de agent vrijuit aan. Semi-omkeerbare tools (state-wijzigingen, taken aanmaken) lopen door de dry-run-dan-omzetten-workflow van hierboven. Onomkeerbare tools roept hij helemaal niet aan, nooit. Hij concipieert de mail. Een mens stuurt 'm. Die regel heeft ons geen uur geautomatiseerd werk gekost en heeft ons meerdere lange gesprekken bespaard.

De audit log die niemand leest, totdat het wel zo is

Elke tool-call schrijft een regel. Actor, werkwoord, target-id, voor-state, na-state, prompt-hash, modelversie, timestamp. Gewone Postgres-tabel, geïndexeerd op actor en target-id. Niets bijzonders.

Zes maanden lang is de tabel een curiositeit. Daarna vraagt iemand op finance waarom een bepaalde deal in maart op "Closed Lost" is gezet, en het antwoord staat in twee seconden in de tabel, mét de exacte prompt die het veroorzaakte. De audit log is wat "de AI deed het" verandert in "hier staat precies wat er is gebeurd, wanneer en waarom."

Diezelfde log maakt een AVG-verzoek beantwoordbaar. De agent is een verwerker, geen verantwoordelijke. De audit log levert het bewijs. We hebben tot nu toe twee van zulke verzoeken gehad op data die de agent had aangeraakt; beide waren binnen een uur afgehandeld omdat de tabel er al stond.

Wat het je kost

Niets hiervan is moeilijk. Het is misschien twee dagen werk binnen een bouw van een week. De kosten zitten in terughoudendheid: je bouwt de kleinste tool, niet de meest flexibele. Je levert dry-run als eerste op en weerstaat de drang om voor de demo de writes aan te zetten. Je legt de killswitch onder de neus van de ops-lead voordat je de agent onder haar neus legt.

De winst is dat de agent live mag blijven. Vrijwel elke agent die we in productie hebben uitgezet, is uitgezet omdat inkadering een afterthought was en iemand zenuwachtig werd. Vrijwel niemand van de agents die we netjes hebben ingekaderd, is uitgezet.

Toen we dit voorjaar de HubSpot-triage-agent bouwden voor een SaaS-klant, liepen we tegen het volgende aan: hun pipeline-definities veranderden midden in het kwartaal, en onze set_stage-tool begon nieuwe, geldige stages te weigeren. Uiteindelijk hebben we de pipeline een hot-reloadbare config gemaakt die de ops-lead zelf bezit, geen onderdeel van onze deploy, zodat ze om 09:00 een stage kan toevoegen zonder ons te bellen. Dat soort kleine afstand-doen-van-controle blijven we tegenkomen in AI-agent-werk: de operator die met het systeem leeft, heeft meer hendels nodig dan je verwacht.

Heb je vandaag een agent live op een system of record, dan is het kleinste nuttige dat je deze week kunt doen: een dry-run-flag toevoegen aan één write-tool en die drie dagen schaduwdraaien. Je vindt minstens één verrassing. Daar begint het hele inkaderingsverhaal.

Kern

Lever elke CRM-agent op met scoped tools, dry-run als default en een Redis-killswitch die de ops-lead vanaf haar telefoon kan indrukken. De rest is detail.

FAQ

Waarom de agent niet gewoon admin-API-toegang geven en op de prompt vertrouwen?

Omdat de prompt het meest volatiele onderdeel van het systeem is. Een scoped toolset wordt afgedwongen door code, niet door taal. Code laat zich niet uit zijn regels praten.

Hoe lang moet de dry-run-periode duren?

Totdat de would-have-done-log je niet meer verrast. Voor de meeste CRM's is dat drie tot vijf dagen. Sla deze stap over en je bent later langer bezig met opruimen.

Waar leeft de killswitch, technisch gezien?

Een Redis-flag die bovenaan elke agent-loop-iteratie wordt gecheckt, met daarvoor een single-page dashboard dat de ops-lead bookmarkt. Bewust onafhankelijk van het hoofdcodepad van de agent.

Hoe zit het met onomkeerbare acties zoals mails sturen of betalingen incasseren?

Trap je tools op omkeerbaarheid. De agent concipieert onomkeerbare acties en een mens trapt ze af. Je verliest vrijwel geen automatisering en slaapt 's nachts door.

ai agentsautomationintegrationsarchitectureoperations

Iets bouwen?

Start een project