A live on-site list in Slack
Keep one Slack message per site that always shows who is checked in, updated by Agoo webhooks.
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
| Need | Details |
|---|---|
| Plan | Pro or Enterprise for live use. Build and test on any plan in test mode. |
| API key scopes | visits:read, people:read |
| Webhook endpoint | Subscribed to visit.checked_in and visit.checked_out |
| Slack | A Slack app with the chat:write bot scope, installed in your workspace |
| Runtime | Node.js 20 or later |
How it works
- When a visitor checks in or out, Agoo sends a webhook with the visit, including its
site_id. - Your service waits 3 seconds, so a burst of arrivals becomes one refresh.
- It reads everyone currently
checked_inat that site from the API and rewrites the site's Slack message withchat.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
- At api.slack.com/apps, select Create New App → From scratch, and name it, for example,
On site. - Under OAuth & Permissions → Bot Token Scopes, add
chat:write. - Select Install to Workspace, then copy the Bot User OAuth Token (it starts
xoxb-). - In each channel that should show a list, type
/invite @On site.
Set up the project
npm i @ardent-africa/agoo expressnpm i -D typescript tsx @types/express @types/nodeThe scripts use top-level await, so make the project an ES module:
npm pkg set type=moduleAGOO_API_KEY=agoo_sk_test_…
AGOO_WEBHOOK_SECRET=whsec_…
SLACK_BOT_TOKEN=xoxb-…
# Filled in at step 5
SLACK_MESSAGES='{}'Call Slack and Agoo
import { Agoo } from "@ardent-africa/agoo"
export const agoo = new Agoo({ apiKey: process.env.AGOO_API_KEY })/** 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
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, "&").replace(/</g, "<").replace(/>/g, ">")
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:
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 }))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):
SLACK_MESSAGES='{"site_01kjpt3yw0fz0v414608h9x65s":{"name":"Ridge HQ","channel":"C0123ABCDEF","ts":"1760432400.000100"}}'Refresh on every check-in and check-out
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
-
Use a test key and a test channel in Slack.
-
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 -
Start the service:
Terminal 2 npx tsx --env-file=.env src/server.ts -
Make someone arrive.
agoo triggercreates a fictional visitor and checks them in at your first site (add--site site_…to choose another):Terminal 3 agoo trigger visit.checked_inWithin a few seconds, "Test Visitor" appears in the Slack message.
-
Check them out with
agoo api POST /visits/{visit_id}/check-out --data '{}', using the visit ID thatagoo triggerprinted. They disappear from the list.
Production checklist
- Use a live secret key with only
visits:readandpeople:read. - Create a live webhook endpoint subscribed to
visit.checked_inandvisit.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_KEYandAGOO_WEBHOOK_SECRETin 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.
Related
Pre-register visitors from your CRM
When a client meeting is booked in your CRM, create an Agoo visit and send the client a QR pass, keep it in step with changes, and tell the CRM when they arrive.
Attendance to payroll
A monthly job that reads every clock-in and clock-out from Agoo and writes a payroll-ready CSV with hours worked, lateness and flags per person per day.