Docs
Recipes

A live on-site list in Slack

Keep one Slack message per site that always shows who is checked in, updated by Agoo webhooks.

Planned· P9For developers

This is designed and scheduled but not built yet. We document it now so you can plan your integration.

Goal

Coastline Consult's office manager wants to glance at Slack and see who is in the building, without opening the console. You'll build a small service that keeps one pinned message per site, such as "On site now · Ridge HQ (9)", up to date: each check-in and check-out triggers a refresh.

If you only need hosts to hear about their own visitors, the planned Slack and Teams integration does that without code. This recipe is for a shared, always-current list.

What you need

NeedDetails
PlanPro or Enterprise for live use. Build and test on any plan in test mode.
API key scopesvisits:read, people:read
Webhook endpointSubscribed to visit.checked_in and visit.checked_out
SlackA Slack app with the chat:write bot scope, installed in your workspace
RuntimeNode.js 20 or later

How it works

  1. When a visitor checks in or out, Agoo sends a webhook with the visit, including its site_id.
  2. Your service waits 3 seconds, so a burst of arrivals becomes one refresh.
  3. It reads everyone currently checked_in at that site from the API and rewrites the site's Slack message with chat.update.

Because each refresh rebuilds the list from Agoo, it doesn't matter if webhooks arrive twice or out of order: the message always ends up matching Agoo. A refresh every 5 minutes also catches visitors checked out automatically at the end of the day.

Steps

Create the Slack app

  1. At api.slack.com/apps, select Create New App → From scratch, and name it, for example, On site.
  2. Under OAuth & Permissions → Bot Token Scopes, add chat:write.
  3. Select Install to Workspace, then copy the Bot User OAuth Token (it starts xoxb-).
  4. In each channel that should show a list, type /invite @On site.

Set up the project

npm i @ardent-africa/agoo express
npm i -D typescript tsx @types/express @types/node

The scripts use top-level await, so make the project an ES module:

Terminal
npm pkg set type=module
.env
AGOO_API_KEY=agoo_sk_test_…
AGOO_WEBHOOK_SECRET=whsec_…
SLACK_BOT_TOKEN=xoxb-…
# Filled in at step 5
SLACK_MESSAGES='{}'

Call Slack and Agoo

src/agoo.ts
import { Agoo } from "@ardent-africa/agoo"

export const agoo = new Agoo({ apiKey: process.env.AGOO_API_KEY })
src/slack.ts
/** Calls a Slack Web API method and throws if Slack reports an error. */
export async function slack<T extends object = object>(method: string, body: Record<string, unknown>): Promise<T> {
  const res = await fetch(`https://slack.com/api/${method}`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.SLACK_BOT_TOKEN}`,
      "Content-Type": "application/json; charset=utf-8",
    },
    body: JSON.stringify(body),
  })
  const json = (await res.json()) as { ok: boolean; error?: string } & T
  if (!json.ok) throw new Error(`Slack ${method} failed: ${json.error}`)
  return json
}

Build the message from Agoo

src/onsite.ts
import { agoo } from "./agoo"

const hostNames = new Map<string, string>()
const clock = new Intl.DateTimeFormat("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: "Africa/Accra" })

/** Slack mrkdwn needs &, < and > escaped. */
const esc = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")

async function hostName(personId: string | null | undefined): Promise<string | undefined> {
  if (!personId) return undefined
  if (!hostNames.has(personId)) {
    const person = await agoo.people.retrieve(personId)
    hostNames.set(personId, person.name)
  }
  return hostNames.get(personId)
}

export async function onSiteMessage(siteId: string, siteName: string) {
  const lines: string[] = []

  for await (const visit of agoo.visits.list({ site_id: siteId, status: "checked_in", limit: 100 }).autoPaginate()) {
    const company = visit.visitor.company ? ` (${esc(visit.visitor.company)})` : ""
    const host = await hostName(visit.host_id)
    const visiting = host ? `, visiting ${esc(host)}` : ""
    const since = visit.checked_in_at ? `, since ${clock.format(new Date(visit.checked_in_at))}` : ""
    lines.push(`• *${esc(visit.visitor.name)}*${company}${visiting}${since}`)
  }

  // Slack allows 3,000 characters per section and 50 blocks per message.
  const sections: string[] = []
  for (const line of lines) {
    const last = sections.at(-1)
    if (last !== undefined && last.length + line.length + 1 <= 2900) sections[sections.length - 1] = `${last}\n${line}`
    else sections.push(line)
  }
  const shown = sections.slice(0, 45)

  const blocks: Record<string, unknown>[] = [
    { type: "header", text: { type: "plain_text", text: `On site now · ${siteName} (${lines.length})` } },
    ...(lines.length === 0
      ? [{ type: "section", text: { type: "mrkdwn", text: "Nobody is checked in." } }]
      : shown.map((text) => ({ type: "section", text: { type: "mrkdwn", text } }))),
    {
      type: "context",
      elements: [
        {
          type: "mrkdwn",
          text: `Updated ${clock.format(new Date())} from Agoo${sections.length > shown.length ? " · list shortened, see the console for everyone" : ""}`,
        },
      ],
    },
  ]

  return { text: `On site now at ${siteName}: ${lines.length}`, blocks }
}

The visitor's name and company come from what they typed at the kiosk. Escaping them stops anyone from injecting Slack formatting or mentions such as <!channel>.

Create one message per site

Run this once for each site, with the channel's ID (in Slack, open the channel's details; the ID starts with C) and the site's name:

scripts/create-message.ts
import { slack } from "../src/slack"

const [channel, siteName] = process.argv.slice(2)
if (!channel || !siteName) {
  console.error("Usage: npx tsx --env-file=.env scripts/create-message.ts <channel-id> <site name>")
  process.exit(2)
}

const posted = await slack<{ channel: string; ts: string }>("chat.postMessage", {
  channel,
  text: `On site now · ${siteName}`,
})

console.log(JSON.stringify({ name: siteName, channel: posted.channel, ts: posted.ts }))
Terminal
npx tsx --env-file=.env scripts/create-message.ts C0123ABCDEF "Ridge HQ"

Pin the new message in Slack, then put the printed values in SLACK_MESSAGES, keyed by the site's ID (agoo api GET /sites lists them):

.env
SLACK_MESSAGES='{"site_01kjpt3yw0fz0v414608h9x65s":{"name":"Ridge HQ","channel":"C0123ABCDEF","ts":"1760432400.000100"}}'

Refresh on every check-in and check-out

src/server.ts
import express from "express"
import { agoo } from "./agoo"
import { onSiteMessage } from "./onsite"
import { slack } from "./slack"

type Target = { name: string; channel: string; ts: string }
const TARGETS: Record<string, Target> = JSON.parse(process.env.SLACK_MESSAGES ?? "{}")

const pending = new Map<string, ReturnType<typeof setTimeout>>()

/** Refreshes a site's message in 3 seconds, folding any events in between into one refresh. */
function refreshSoon(siteId: string) {
  const target = TARGETS[siteId]
  if (!target || pending.has(siteId)) return

  pending.set(
    siteId,
    setTimeout(async () => {
      pending.delete(siteId)
      try {
        const message = await onSiteMessage(siteId, target.name)
        await slack("chat.update", { channel: target.channel, ts: target.ts, ...message })
      } catch (err) {
        console.error(`Refreshing ${target.name} failed`, err)
      }
    }, 3000),
  )
}

const app = express()

app.post("/webhooks/agoo", express.raw({ type: "application/json" }), async (req, res) => {
  let event
  try {
    event = await agoo.webhooks.verify(req.body, req.headers, process.env.AGOO_WEBHOOK_SECRET!)
  } catch {
    res.status(400).send("Invalid signature")
    return
  }

  if (event.type === "visit.checked_in" || event.type === "visit.checked_out") {
    refreshSoon(event.data.object.site_id)
  }
  res.sendStatus(204)
})

app.listen(3000, () => {
  console.log("Listening on http://localhost:3000")
  // Refresh every list now, then every 5 minutes to catch automatic check-outs.
  const refreshAll = () => Object.keys(TARGETS).forEach(refreshSoon)
  refreshAll()
  setInterval(refreshAll, 5 * 60_000)
})

The webhook handler responds at once and does the work afterwards, well inside Agoo's 15-second limit.

Try it in test mode

  1. Use a test key and a test channel in Slack.

  2. Forward test events and copy the printed secret into AGOO_WEBHOOK_SECRET:

    Terminal 1
    agoo listen --forward-to http://localhost:3000/webhooks/agoo --events visit.checked_in,visit.checked_out
  3. Start the service:

    Terminal 2
    npx tsx --env-file=.env src/server.ts
  4. Make someone arrive. agoo trigger creates a fictional visitor and checks them in at your first site (add --site site_… to choose another):

    Terminal 3
    agoo trigger visit.checked_in

    Within a few seconds, "Test Visitor" appears in the Slack message.

  5. Check them out with agoo api POST /visits/{visit_id}/check-out --data '{}', using the visit ID that agoo trigger printed. They disappear from the list.

Production checklist

  • Use a live secret key with only visits:read and people:read.
  • Create a live webhook endpoint subscribed to visit.checked_in and visit.checked_out, and set its secret.
  • Use live site IDs in SLACK_MESSAGES: test-mode IDs are different.
  • Post only to private channels whose members are allowed to see who is visiting. Visitor names are personal data.
  • Store SLACK_BOT_TOKEN, AGOO_API_KEY and AGOO_WEBHOOK_SECRET in your platform's secret store.
  • Run one instance (or share the debounce through a store such as Redis), so two instances don't update the same message at once.
  • Watch the logs for Refreshing … failed, which usually means the Slack token was revoked or the bot was removed from the channel.

On this page