← Blog

Process automation

Slack-urenagent: een dagelijkse nudge die niemand muteert

Hoe we een Slack-agent koppelden aan de projecttracker van een Utrechts bureau, zodat de dagelijkse herinnering voor ontbrekende uren gelezen wordt in plaats van weggemute.

Jacob Molkenboer· Oprichter · A Brand New Company· 5 sep 2024· 9 min
Koperen bel, crème indexkaart met groen lint, leren agenda met rode bladwijzer op ivoren bureau.

Vrijdag, 17:42. De operations lead van een Utrechts marketingbureau van 45 mensen heeft een spreadsheet open met 41 regels. Dertien zijn rood. Die dertien mensen hebben deze week hun uren niet ingeklopt, en het bureau factureert per uur. Ze kent de namen inmiddels uit haar hoofd. De eerste drie zijn altijd dezelfde.

Ze heeft alles geprobeerd. De wekelijkse reminder in de all-hands. Het Slack-kanaal log alsjeblieft je uren dat in maart door iedereen op mute is gezet. De grappige GIF. De dreigende GIF. De persoonlijke DM, die een week werkt en daarna stopt met werken. Vorig kwartaal heeft de studio €18.400 te weinig gefactureerd omdat drie retainerklanten minder uren op de factuur kregen dan het team daadwerkelijk had gewerkt. Niet omdat het werk niet gebeurd was. Omdat het werk nooit was ingeklopt.

Dit is het saaiste probleem in bureau-operations. Het is ook een van de duurste. We hebben eerder dit jaar voor een klant een Slack-agent gebouwd die dit oplost, en het percentage ontbrekende uren zakte van 31% op vrijdagmiddag naar 4% op maandagochtend, twaalf weken lang stabiel. Het interessante is niet de agent. Het interessante is wat we de agent juist niet hebben laten doen.

Wat een nudge die niemand muteert eigenlijk betekent

De meeste herinneringen voor urenregistratie falen om dezelfde reden waarom elke notificatie faalt. Ze zijn gericht aan een groep, verstuurd op het verkeerde moment, en bevatten niets waarmee je ter plekke kunt handelen. Een kanaalpost in #ops om 16:00 op vrijdag is belasting. Een persoonlijke DM om 09:18 op maandagochtend die zegt Hoi Liesbeth, je hebt 6,5 uur niet geboekt van afgelopen dinsdag en donderdag. Hier is een knop om standaard 4u te boeken op Project Aurora en 2,5u op Project Boorne. Pas het aan voor je verstuurt als dat nodig is. is gereedschap.

Het verschil zit niet in de toon. Het verschil zit erin of het bericht zelf de actie bevat.

We kregen drie randvoorwaarden van de klant. Nudges moeten privé-DM's zijn, nooit kanaalposts. Nudges moeten verwijzen naar de specifieke dagen en de specifieke projecten waar diegene aan heeft gewerkt, opgehaald uit de projecttracker, niet uitgevraagd bij de gebruiker. En nudges moeten de persoon de taak binnen Slack laten afronden zonder een browsertab te openen. Als één van die drie wegviel, zou de agent zich aansluiten bij het kerkhof van gemute bots dat elk bureau al bezit.

De stack die we gebruikten

Het bureau draait Harvest als urenregistratie en Slack als operating system. We bouwden de agent met Slack Bolt voor JavaScript, een kleine Postgres-tabel voor state, en een scheduler van 30 regels die elke werkdag om 09:15 Amsterdamse tijd loopt. Het hele ding past in één Node-proces achter een Cloudflare-tunnel. De volledige code zit onder de 500 regels.

De architectuur is met opzet saai. Er zit geen taalmodel in de message loop. De agent gebruikt een model voor één ding: het parsen van de zeldzame vrije-tekstreply waarin iemand zegt boek 3u op wat Stijn dinsdag heeft geboekt, ik werkte met hem samen. De rest is deterministisch. Dat hebben we zo gedaan omdat een gehallucineerde uren-entry een audit-probleem is, geen UX-probleem, en audit-problemen stapelen op.

Stap 1: leg vast wat 'ontbrekend' betekent voordat je code schrijft

Dit is de stap die elk team overslaat. Je kunt geen nudge sturen voor een ontbrekende urenstaat tot je weet hoe 'compleet' eruitziet, en 'compleet' is een politieke vraag, geen technische.

We zaten veertig minuten in een kamer met de ops lead, de CFO en drie senior delivery leads. De regels waar zij het over eens werden:

  • Een werkdag is compleet als het totaal aan geboekte uren gelijk is aan de contractuele uren voor die persoon, plus of min 0,5u.
  • Ziekte, vakantie en studieverlof tellen als geboekte uren. Het team gebruikt daarvoor de interne categorieën van Harvest.
  • De urenstaat van vrijdag is uiterlijk maandag 09:00 binnen. Eerdere dagen zijn binnen op einde van de dag plus één.
  • Parttimers hebben een contract per weekdag. Die ophalen uit het HR-systeem was stap nul en kostte drie uur.
  • Freelancers vallen buiten scope. De agent stuurt alleen nudges naar mensen op de loonlijst.

Schrijf dit op. Laat het tekenen door degene wiens naam op de factuur staat. Anders krijgt de agent de schuld de eerste keer dat iemands vooraf goedgekeurde vakantie-uren niet in het systeem belanden, en de politieke schade aan de bot is permanent.

Stap 2: koppel de projecttracker, niet de gebruiker

De reflex is om elke gebruiker te vragen waar heb je gisteren aan gewerkt?. Die reflex klopt niet. De projecttracker weet het al, want de rest van het team heeft hun uren geboekt en projecttoewijzingen zijn zichtbaar. De agent leest bij het opstarten drie Harvest-endpoints.

// harvest.js — read once at 09:10, cache for the day
import fetch from 'node-fetch'

const HARVEST = 'https://api.harvestapp.com/v2'
const headers = {
  'Authorization': `Bearer ${process.env.HARVEST_TOKEN}`,
  'Harvest-Account-Id': process.env.HARVEST_ACCOUNT_ID,
  'User-Agent': 'abn-timesheet-agent (ops@agency.nl)',
}

export async function getUsers () {
  const r = await fetch(`${HARVEST}/users?is_active=true`, { headers })
  return (await r.json()).users
}

export async function getEntries (userId, fromISO, toISO) {
  const url = `${HARVEST}/time_entries?user_id=${userId}&from=${fromISO}&to=${toISO}`
  const r = await fetch(url, { headers })
  return (await r.json()).time_entries
}

export async function getAssignments (userId) {
  const url = `${HARVEST}/users/${userId}/project_assignments`
  const r = await fetch(url, { headers })
  return (await r.json()).project_assignments
}

Het derde endpoint is degene die zijn geld waard is. De project_assignments-resource vertelt je op welke projecten elke persoon op dit moment uren mag boeken. Wanneer de nudge een one-click knop aanbiedt voor boek 4u op Project Aurora, dan moet Aurora in hun assignment-lijst staan of Harvest weigert de entry met een 422. De agent leest assignments dagelijks, cachet ze, en gebruikt ze om knoppen te bouwen die niet kunnen bouncen. De exacte response-structuren staan in de Harvest API v2-documentatie.

Stap 3: schrijf een bericht dat de volgende actie bevat

Dit is de template waar we na drie iteraties op uitkwamen. Block Kit van Bolt maakt de knoppen goedkoop.

function buildNudge (user, missingDays, suggestions) {
  const blocks = [
    { type: 'section', text: { type: 'mrkdwn',
      text: `Morning ${user.first_name}. You have *${missingDays.length} unlogged day${missingDays.length > 1 ? 's' : ''}* from last week.` } },
    { type: 'divider' },
  ]
  for (const day of missingDays) {
    blocks.push({
      type: 'section',
      text: { type: 'mrkdwn',
        text: `*${day.label}* — ${day.gap}h missing. Likely projects, based on your assignments and what the rest of the team logged that day:` },
    })
    blocks.push({
      type: 'actions',
      elements: [
        ...suggestions[day.iso].map(s => ({
          type: 'button',
          text: { type: 'plain_text', text: `${s.hours}h · ${s.project_name}` },
          action_id: `log:${day.iso}:${s.project_id}:${s.task_id}:${s.hours}`,
        })),
        { type: 'button', text: { type: 'plain_text', text: 'Custom…' }, action_id: `custom:${day.iso}` },
        { type: 'button', text: { type: 'plain_text', text: 'I was off' }, action_id: `off:${day.iso}`, style: 'danger' },
      ],
    })
  }
  return blocks
}

Drie dingen zijn belangrijk aan deze layout. De uren zijn voorgevuld, dus de one-click route kost één klik. De Custom-knop opent een modal met dezelfde velden, zodat power users niet worden gestraft. De I was off-knop schreeuwt niet tegen de gebruiker. Hij schrijft stilletjes een ziek- of verlof-entry en biedt excuses aan voor de ruis.

We bouwden de suggestielogica op een heuristiek, niet op een model. Als iemand op één actief project zit, suggereer dat ene. Zit iemand op meerdere, dan suggereer het project waar de rest van het team die dag de meeste uren op boekte. Heeft iemand geen toewijzingen, stuur dan een DM van één regel naar de projectmanager in plaats van de gebruiker lastig te vallen. Die laatste tak heeft een senior strateeg ervoor behoed om vier ochtenden achter elkaar gevraagd te worden uren te boeken op projecten die ze in mei al had afgerond.

De action handler die de buttonpress opvangt is veertig regels Bolt, en het is de moeite waard om de Bolt for JavaScript actions reference te lezen voor je begint. Hij schrijft direct terug naar Harvest, vervangt het oorspronkelijke bericht met een regel 4u geboekt op Project Aurora, en bewaart het entry-id in onze state-tabel, zodat een undo-reply het binnen tien minuten kan terugdraaien.

Stap 4: escaleer het systeem, niet de mens

De eerste versie van deze agent zette mensen te kijk. Na drie gemiste nudges zette hij hun manager in cc. Na vijf postte hij in #ops. Allebei zijn ze in week twee verwijderd, want allebei produceerden ze de verkeerde feedback loop. Mensen zetten de bot harder op mute en klaagden er vervolgens over in de retro. Het sociale kapitaal van de bot herstelt zich niet van een publieke afrekening.

De versie die we nu shippen escaleert het systeem, niet de mens. Na drie gemiste dagen voor dezelfde persoon doet de agent drie stille dingen. Hij boekt een Calendar-blok van 15 minuten met als titel bijwerken in Harvest op vrijdag om 16:00. Hij markeert de persoon als needs sync in zijn interne state. Maandag vraagt hij de ops lead, één keer, of er iets structureels aan de hand is. Geen DM naar de manager. Geen publieke post. De escalatie zoekt frictie in de workflow, niet in de persoon.

Let op

Geef je operations-agent geen schrijfrechten op iets wat hij niet zelf kan terugdraaien. Als hij een verkeerde uren-entry boekt, moet hij die entry zelf kunnen verwijderen zonder dat er een mens aan te pas komt. Kan hij dat niet, dan vreet de audit trail je op de eerste keer dat iemand zijn loonstrook betwist.

Het Meta-nieuws van deze week is een herinnering aan waarom scope-control ook de andere kant op telt. Berichten dat duizenden Instagram-accounts zijn overgenomen door Meta's eigen AI-chatbot te misbruiken, zijn een schoolvoorbeeld van wat er gebeurt als een agent meer bevoegdheden krijgt dan zijn prompt-grens kan verdedigen. Een ops-agent binnen een studio van 45 mensen zit niet op die schaal, maar het principe is hetzelfde. Beperk zijn bevoegdheden tot de kleinste set die het werk doet, en log elke actie op een plek waar een mens het kan lezen.

Stap 5: meet het juiste ding

De metric die we aan de klant rapporteren is niet verstuurde nudges. Het is niet-gefactureerde billable uren die alsnog boven water komen. Elke week draait de agent een rapport op zondagavond dat geboekte uren afzet tegen projecttoewijzingen en klant-retainers, en vertelt de ops lead hoeveel facturabele uren gemist waren zonder de nudge-ladder. In het eerste kwartaal dat hij draaide was dat €23.100. In het tweede kwartaal zakte dat naar €4.200, omdat de agent het team in een nieuw standaardgedrag had getraind.

Dat is de juiste richting. Een succesvolle nudge-agent zou zichzelf in de loop van de tijd minder nuttig moeten maken, en je hoort te meten of dat ook gebeurt.

We meten ook de mute-rate. Als meer dan 5% van de ontvangers na een maand de DM's van de bot op mute heeft staan, klopt het bericht niet. De onze staat op 1,8%.

Wat je morgenochtend kunt doen

Toen we dit voor het Utrechtse bureau bouwden, was de Slack-code niet het lastige deel. Het lastige deel was de regels voor 'ontbrekend' op papier krijgen voordat één knop werd opgeleverd. We hebben uiteindelijk een beslismeeting van 40 minuten gehouden met ops, finance en delivery, en de memo van één pagina die daaruit kwam, deed meer voor adoptie dan de bot zelf. Dat patroon geldt voor de meeste AI-agents die we bouwen: de agent is de makkelijke helft.

Dus morgenochtend, voor er één regel code geschreven wordt: open je projecttracker. Trek een CSV van de tijdsregistraties van vorige week. Sorteer op werknemer en tel uren per dag op. Bekijk de gaten met je ogen. Vind je drie of meer mensen met meer dan vijf ontbrekende uren elk, dan is de spreadsheet waar je naar kijkt een eerste versie van de nudge die je agent uiteindelijk gaat sturen. Bouw eerst de spreadsheet-view. De bot is de tweede versie.

Kern

Bouw eerst de spreadsheet-view van wat 'ontbrekend' betekent, voordat je de bot bouwt. De bot is de makkelijke helft. Het akkoord is het werk.

FAQ

Waarom DM's sturen in plaats van één dagelijkse kanaalpost?

Een kanaalpost is belasting die iedereen binnen een week op mute zet. Een privé-DM noemt de specifieke dagen en projecten die je gemist hebt en geeft je een knop om het in één klik op te lossen. Wordt gebruikt, niet genegeerd.

Gebruiken jullie een LLM om de nudge-tekst te schrijven?

Nee. De tekst loopt via een template en de suggesties zijn deterministisch uit de projecttracker. Een model parset alleen zeldzame vrije-tekstreplies zoals 'boek 3u op waar Stijn dinsdag aan werkte'. Gehallucineerde uren-entries zijn een audit-probleem.

Wat gebeurt er als iemand antwoordt dat ze ziek waren of op vakantie?

De knop 'I was off' schrijft stilletjes de juiste verlof- of ziektecategorie in Harvest en stopt met nudgen voor die dag. Geen publieke correctie, geen excuses-theater. De gebruiker klikt één keer en de agent houdt zijn mond.

Hoe lang duurde het om dit end-to-end te bouwen?

Onder de 500 regels Node en ongeveer twee weken werk. De helft daarvan ging zitten in de regels voor 'ontbrekend' op papier krijgen met ops, finance en delivery leads, voordat er één regel code werd opgeleverd.

Werkt dezelfde aanpak met Float, Productive of Teamleader in plaats van Harvest?

Ja. Het patroon heeft drie endpoints nodig: lijst van gebruikers, lijst van tijdsregistraties per datumbereik, en lijst van toegestane projecten per gebruiker. Elke tracker die die via een API aanbiedt, kun je op dezelfde manier koppelen.

ai agentsprocess automationintegrationsworkflowoperationscase study

Iets bouwen?

Start een project