← Blog

Process automation

Slack timesheet agent: building the nudge nobody mutes

How we wired a Slack agent into a Utrecht agency's project tracker so the daily missing-timesheet nudge gets read instead of muted to oblivion.

Jacob Molkenboer· Founder · A Brand New Company· 5 Sept 2024· 9 min
Brass bell, cream index card with green ribbon, leather planner with red bookmark on ivory desk.

Friday, 17:42. The operations lead at a 45-person Utrecht marketing agency has a spreadsheet open with 41 rows. Thirteen are red. Those thirteen people did not log their hours this week, and the agency bills by the hour. She knows the names by heart now. The first three are always the same.

She has tried everything. The weekly all-hands reminder. The please log your time Slack channel that everyone muted in March. The cute GIF. The threatening GIF. The personal DM, which works for a week, then stops working. Last quarter the studio underbilled €18,400 because three retainer clients got fewer hours invoiced than the team actually worked. Not because the work didn't happen. Because the work was never logged.

This is the most boring problem in agency operations. It is also one of the most expensive. We built a Slack-resident agent to fix it for one of our clients earlier this year, and the missing-timesheet rate dropped from 31% on Friday afternoon to 4% by Monday morning, sustained over the following twelve weeks. The interesting part is not the agent. It is what we had to stop the agent from doing.

What a nudge nobody mutes actually means

Most timesheet reminders fail for the same reason every notification fails. They are aimed at a group, sent at the wrong moment, and have nothing inside them that lets you act in the place you are reading them. A channel post in #ops at 4pm on Friday is a tax. A personal DM at 9:18am on Monday that says Hey Liesbeth, you have 6.5 hours unlogged from last Tuesday and Thursday. Here is a button to log the default 4h to Project Aurora and 2.5h to Project Boorne. Edit before sending if needed. is a tool.

The difference is not tone. The difference is whether the message contains the action.

We had three constraints from the client. Nudges must be private DMs, never channel posts. Nudges must reference the specific days and the specific projects the person worked on, pulled from the project tracker, not asked of the user. And nudges must let the person finish the task inside Slack without opening a browser tab. If any of those three slipped, the agent would join the graveyard of muted bots that every agency already owns.

The stack we used

The agency runs Harvest as its time tracker and Slack as its operating system. We wired the agent with Slack Bolt for JavaScript, a small Postgres table for state, and a 30-line scheduler that runs at 09:15 Amsterdam time on every weekday. The whole thing fits in a single Node process behind a Cloudflare tunnel. The full code is under 500 lines.

The architecture is boring on purpose. There is no language model in the message loop. The agent uses a model for one thing only: parsing the rare free-text reply where someone says log 3h to whatever Stijn logged Tuesday, I was paired with him. Everything else is deterministic. We did this because a hallucinated timesheet entry is an audit problem, not a UX problem, and audit problems compound.

Step 1: write down what missing means before you write code

This is the step every team skips. You cannot send a nudge for a missing timesheet until you know what complete looks like, and complete is a political question, not a technical one.

We sat in a room with the ops lead, the CFO, and three senior delivery leads for forty minutes. The rules they agreed on:

  • A workday is complete when total logged hours equal contractual hours for that person, plus or minus 0.5h.
  • Sickness, holiday, and study leave count as logged hours. The team uses Harvest's internal categories for those.
  • Friday's timesheet is due by Monday 09:00. Earlier days are due by end of day plus one.
  • Part-timers have a per-weekday contract. Pulling those from the HR system was step zero and took three hours.
  • Freelancers are excluded. The agent only nudges payrolled staff.

Write this down. Get it signed by the person whose name appears on the invoice. Otherwise the agent will get blamed the first time someone's pre-approved holiday hours never made it into the system, and the political damage to the bot is permanent.

Step 2: wire the project tracker, not the user

The instinct is to ask each user what did you work on yesterday. The instinct is wrong. The project tracker already knows, because the rest of the team logged their time and project assignments are visible. The agent reads three Harvest endpoints at boot.

// 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
}

The third endpoint is the one that earns its keep. The project_assignments resource tells you which projects each person is currently allowed to log against. When the nudge offers a one-click log 4h to Project Aurora button, Aurora has to be in their assignment list or Harvest rejects the entry with a 422. The agent reads assignments daily, caches them, and uses them to build buttons that cannot bounce. The exact response shapes are in the Harvest API v2 documentation.

Step 3: write a message that contains the next action

Here is the template we landed on after three iterations. Bolt's Block Kit makes the buttons cheap.

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
}

Three things matter about this layout. The hours are pre-filled, so the one-click path takes one click. The Custom button opens a modal with the same fields, so power users are not punished. The I was off button does not yell at the user. It writes a sick or leave entry quietly and apologises for the noise.

We built the suggestion logic from a heuristic, not a model. If the person is assigned to one active project, suggest that one. If they are assigned to several, suggest the project the rest of their team logged the most hours against on that day. If they are unassigned, send a one-line DM to the project manager instead of pestering the user. That last branch saved a senior strategist from being asked four mornings in a row to log time to projects she had finished in May.

The action handler that catches the button press is forty lines of Bolt and worth reading the Bolt for JavaScript actions reference before you start. It writes back to Harvest immediately, replaces the original message with a logged 4h to Project Aurora line, and stores the entry id in our state table so an undo reply rolls it back inside the next ten minutes.

Step 4: escalate the system, not the human

The first version of this agent shamed people. After three missed nudges, it copied their manager. After five, it posted in #ops. Both behaviours were removed in week two, because both produced the wrong feedback loop. People muted the bot harder and then complained about it in retro. The bot's social capital does not recover from a public callout.

The version we ship now escalates the system, not the human. After three missed days for the same person, the agent does three quiet things. It books a 15-minute Calendar block titled catch up on Harvest on Friday at 16:00. It marks the person as needs sync in its internal state. On Monday it asks the ops lead, once, whether anything systemic is going on. No DM to the manager. No public post. The escalation finds friction in the workflow, not in the person.

Warning

Do not give your operations agent write access to anything it cannot reverse on its own. If it logs a wrong time entry, it should know how to delete that entry without a human in the loop. If it cannot, the audit trail will eat you alive the first time someone disputes their payroll.

The Meta news this week is a reminder of why scope control matters in the other direction too. Reports that thousands of Instagram accounts were taken over by abusing Meta's own AI chatbot are a clean case study in what happens when an agent has more authority than its prompt boundary can defend. An ops agent inside a 45-person studio is not at that scale, but the principle is the same. Scope its powers to the smallest set that does the job, and log every action somewhere a human can read.

Step 5: measure the right thing

The metric we report to the client is not nudges sent. It is uninvoiced billable hours surfaced. Every week the agent runs a Sunday-night report that compares logged hours against project assignments against client retainers, and tells the ops lead how many billable hours would have been missed without the nudge ladder. In the first quarter of running, that number was €23,100. In the second quarter, it dropped to €4,200, because the agent had trained the team into a new default.

That is the right direction. A successful nudge agent should make itself less useful over time, and you should be measuring whether it does.

We also track the mute rate. If more than 5% of recipients have muted the bot's DMs after a month, the message is wrong. Ours sits at 1.8%.

What to do tomorrow morning

When we built this for the Utrecht agency, the hard part was not the Slack code. It was getting the rules of missing agreed in writing before any button shipped. We ended up running a 40-minute decision meeting with ops, finance, and delivery, and the resulting one-page memo did more for adoption than the bot did. The same pattern applies to most of the AI agents we build: the agent is the easy half.

So tomorrow morning, before any code: open your project tracker. Pull a CSV of last week's time entries. Sort by employee and sum hours per day. Eyeball the gaps. If you find three or more people with more than five missing hours each, the spreadsheet you are looking at is a draft of the nudge your agent will eventually send. Build the spreadsheet view first. The bot is the second draft.

Key takeaway

Build the spreadsheet view of what 'missing' means before you build the bot. The bot is the easy half; the agreement is the work.

FAQ

Why send DMs instead of a single daily channel post?

A channel post is a tax everyone mutes within a week. A private DM names the specific days and projects you missed and gives you a button to fix it in one click. Acted on, not ignored.

Do you use an LLM to write the nudge text?

No. The text is templated and the suggestions are deterministic from the project tracker. A model only parses rare free-text replies like 'log 3h to whatever Stijn worked on Tuesday'. Hallucinated time entries are an audit problem.

What happens when someone replies that they were sick or on holiday?

The 'I was off' button writes the correct leave or sickness category in Harvest quietly and stops nudging for that day. No public correction, no apology theatre. The user clicks once and the agent shuts up.

How long did this take to build end to end?

Under 500 lines of Node and roughly two weeks of work. Half of that was getting the rules of 'missing' agreed in writing with ops, finance, and delivery leads before any code shipped.

Will the same approach work with Float, Productive, or Teamleader instead of Harvest?

Yes. The pattern needs three endpoints: list users, list time entries by date range, and list each user's allowed projects. Any tracker that exposes those over an API can be wired the same way.

ai agentsprocess automationintegrationsworkflowoperationscase study

Building something?

Start a project