A resource for design system and DesignOps teams
ComponentusageSlack bot.
What gets celebrated gets repeated. Celebrate what you can prove.
Scan main. Diff last week. Post the milestones.
Make adoption visible where people already are.
The essay behind this guide describes a Slack bot that announced adoption milestones: a team's first component, then 10, 50 and 100. A milestone in the channel makes invisible work visible, to the team that did it and to everyone who has not started. In Field Guide 16's terms, it turns referral and revenue back into awareness.
Such a bot fails in two ways: it posts numbers nobody can check, or it posts something embarrassing, like a regression, a ranking of people or an invented hours-saved figure. This one counts JSX instances on the default branch, explains the count in every message, posts only good news about teams, and does nothing unless given --post and a webhook secret.
The kit: four zero-dependency scripts, two fixture repos a week apart, real react-scanner reports of the same code, and a workflow. No message was sent while writing this guide.
Practical guidance, not a standard. Dry runs only: the posting path was exercised against a stub, never a real webhook, and the workflow was parsed but not executed. Check Slack's and GitHub's current docs before production use. Prepared with AI assistance and edited by hand.
Start here
Scan, diff, message, post.
Four small steps, each its own script, so you can swap one without touching the others.
| Step | Script | In | Out |
|---|---|---|---|
| Scan | scan-usage.mjs | Repo on main, or react-scanner reports | Snapshot: instances per team and component |
| Diff | diff-usage.mjs | Last snapshot, this snapshot | Milestones, first uses, drops |
| Message | slack-message.mjs | Diff, config | Block Kit payload, validated |
| Post | bot.mjs | Payload, SLACK_WEBHOOK_URL | A dry-run print, or one POST |
| Schedule | ds-celebration.yml | Friday 09:00 UTC | Weekly run, snapshot kept as an artifact |
Trying it
Run the bot on the two fixture repos (Appendix A). Nothing leaves your machine.
Wiring it up
Sections 01 and 04, then the workflow in section 05. Record a baseline first.
You already run react-scanner
Feed its reports in with --react-scanner. Item B05: never mix sources in one history.
Deciding what to post
Section 02. Teams, milestones and firsts; no drops, no people, no hours saved.
| Label | Meaning |
|---|---|
DATA | A field in bot.config.json or the snapshot format. |
SCRIPT | Enforced or computed by a kit script. |
PROTOCOL | A rule about how the bot runs and handles state and secrets. |
PRIVACY | Protects people: the bot celebrates teams, not individuals. |
LOOP | Feeds adoption back into awareness, the growth loop the essay describes. |
PRACTICE | A working method with a review signal, not a hard gate. |
Section 01
Count what you can prove.
Every number the bot posts must survive someone opening the repo and counting. That means a static scan of the default branch.
Suggested owners: Design system engineering lead
Count JSX instances on main, per team
An instance is one rendered element from the system's package. Scan main, so a milestone means shipped. Teams map by path prefix; unmapped code lands in Unassigned, which is never celebrated.
Evidence: Week-39 fixture: 76 instances of 11 components across 5 teams; week 38 had 64.
teams[].pathsinbot.config.json.Kit:
scan-usage.mjs.Tests, stories and comments are not usage
react-scanner's default globs skip test and spec files [S12]; the kit also skips stories and commented-out code.
Evidence: The fixture's test file (2 Buttons), story and a comment mentioning
<Button>add 0.S12. Kit fixture.
Imports are resolved as written
Aliases count under the real name, namespace usages fold into the component, sub-components keep their path, type-only imports count nothing.
Evidence:
TextField as Fieldcounts 4 TextField;<UI.Text>counts as Text;<Tabs.List>as Tabs.List.Kit fixture.
The regex scanner is checked against an AST scanner
A regex scanner is fast and dependency-free, and wrong on exotic code. Check it against react-scanner on your repo before trusting it.
Evidence: react-scanner 1.2.0 on the week-39 fixture: the same team totals (52, 13, 3, 5, 3) and, with
namespaces: ["UI"], the same per-component counts.S12. Kit:
examples/react-scanner/.One source per history
The two scanners name some instances differently (
UI.TextagainstText), so diffing across them invents "firsts". Switching source means a new baseline.Evidence:
diffSnapshots()refuses snapshots from different sources.Kit.
If someone can count it by hand and get a different number, do not post it.
Section 02
Post good news about teams.
The essay's milestones are a team's first component, then 10, 50 and 100. The bot adds firsts, and leaves out anything that would turn the channel into a scoreboard.
Suggested owners: Design system lead + community lead
The first run records a baseline and posts nothing
With no previous snapshot, everything would be a milestone. Save the snapshot and wait a week.
Evidence: Week-38 run:
baseline: 64 instances of @acme/ui across 5 teams, no payload.Kit:
bot.mjs.Only the highest threshold crossed is posted
Thresholds 1, 10, 50, 100, 250, 500, 1000 on team instances. A team that jumps from 5 to 60 hears about 50, not 10 and 50.
Evidence: Week 39: Checkout 46 to 52 crosses 50; Search 9 to 13 crosses 10.
Essay thresholds, extended. Kit output.
A team's first component is its own message
The first migration is the hardest. It gets its own line, worded as a first, not as a threshold.
Evidence: Week 39: Accounts 0 to 3, posted as "shipped its first
@acme/uicomponents".Essay. Kit output.
Firsts are celebrated: in the organisation, and per team
The first team to ship a component de-risks it for everyone else. A team's first use of an existing component is quieter news, grouped in one block.
Evidence: Week 39: Billing ships the first DatePicker; Search uses Badge and Button for the first time.
Kit output.
No drops, no people, no invented hours
Drops go to the run log for a conversation, not the channel. The bot names teams, never individuals. It posts counts it can prove; an hours-saved figure is a model, not a count.
Evidence: Week-39 log:
not posted: Billing went from 6 to 5 instances. No@mentions or time-saved field in the payload builder.Field Guide 16, items F15 and F20.
Celebrate the team that moved. Talk to the one that slipped.
Section 03
Build a message Slack accepts.
Incoming webhooks take JSON with text and optional blocks. The limits below are from Slack's reference; the kit checks each before anything is sent.
Suggested owners: Design system engineering lead
The webhook owns the channel
An incoming webhook posts to the channel chosen at install; channel, username and icon cannot be overridden [S01]. A
channelfield does nothing, so the validator rejects it.Evidence:
validatePayload()flagschannel,username,icon_emojiandicon_url.S01.
The top-level text carries the whole news
With blocks,
textis the notification fallback [S03], and screen readers read it rather than the blocks [S02]. One sentence with every milestone in it.Evidence: Week-39 payload: a 258-character
textnaming all five pieces of news.S02, S03.
Block Kit limits are checked before sending
At most 50 blocks [S02]; header plain_text up to 150 characters [S05]; section text 1 to 3000, up to 10 fields [S04]; context up to 10 elements [S06]. Busy weeks post the top 20 highlights and "and N more".
Evidence: Week-39 payload: 8 blocks, no problems. A failing payload stops the run.
maxHighlights: 20.S02, S04, S05, S06.
Names are escaped
Slack's mrkdwn treats
&,<and>as control characters; send them as HTML entities [S07]. A team calledR&D <Labs>would otherwise break the message.Evidence:
escapeMrkdwn()on every team and component name; a unit test with that team name.S07.
Every message says where its numbers come from
A context line: the commit, the counting method, what is excluded, the org total and a dashboard link.
Evidence: Week 39: scan of main at
9b7e0a3, tests and stories excluded, 76 instances, up 12.Kit output.
A message that explains its own numbers does not need a follow-up thread.
Section 04
Send only when asked twice.
The bot is harmless until it has a secret and a flag. Keep it that way.
Suggested owners: Design system engineering lead
Dry run is the default
Without
--postthe bot prints the payload and sends nothing. Posting needs both the flag andSLACK_WEBHOOK_URL.Evidence: Every run for this guide was a dry run. With
--postand a non-Slack URL the bot exits 1 before any request.Kit:
bot.mjs.The webhook URL is a secret
Slack warns that the URL contains a secret and revokes leaked ones [S01]. Read it from the environment, never from arguments; never print it; store it as a repository secret.
Evidence:
postToSlack()accepts onlyhttps://hooks.slack.com/services/URLs and never logs them.S01.
Success is HTTP 200 with the body ok
Incoming webhooks answer 200 and
ok; errors such asinvalid_payloadorchannel_is_archivedcome back as text [S01]. Anything else fails the run.Evidence: Unit tests against a stub
fetch:okpasses, a 400invalid_payloadthrows. Never tested against Slack.S01.
The snapshot advances only after a successful post
If the post fails and the snapshot moves on, that week's news is lost. Save after success, and only from runs that actually post.
Evidence:
--write-stateruns after the post; the workflow uploads only when posting succeeded.Kit.
The safest bot is one that has to be asked twice before it speaks.
Section 05
Run it every Friday.
A weekly rhythm matches the essay's weekly tracking. The workflow's job is to keep the snapshot chain unbroken with the least access possible.
Suggested owners: Design system engineering lead + platform team
Schedule in UTC, and expect delays
Scheduled workflows use UTC cron on the latest commit of the default branch, can be delayed under load, and are disabled in public repos after 60 days without activity [S08].
Evidence:
cron: "0 9 * * 5", plusworkflow_dispatchfor a manual dry run.S08.
Last week's snapshot is an artifact, not a cache entry
Cache entries unused for a week are evicted [S09], which a weekly job can hit exactly. The workflow downloads the newest posting run's artifact with
gh run download[S10, S11].Evidence:
upload-artifactwith 30-day retention; the download loop picks the newest run that has one.S09, S10, S11.
The workflow gets the least it needs
contents: readto scan,actions: readto fetch the previous artifact, the webhook in secrets. No write access to the repo.Evidence:
permissionsblock inds-celebration.yml.Kit workflow.
Same day, same scan, same channel. Consistency is what makes a milestone mean something.
Appendix A
Week 39, in dry run.
Two fixture repos a week apart: week 38 records the baseline, week 39 produces the payload. Both runs print; neither posts.
terminal
$ node scripts/bot.mjs examples/acme-web-week-38 --state state.json --write-state state.json
baseline: 64 instances of @acme/ui across 5 teams
$ node scripts/bot.mjs examples/acme-web-week-39 --state state.json --commit 9b7e0a3 --date 2026-09-25
dry run: 76 instances of @acme/ui across 5 teams
not posted: Billing went from 6 to 5 instances
dry run: payload below, nothing sent. Add --post and SLACK_WEBHOOK_URL to send.examples/payload.week-39.json (excerpt)
{
"text": "Design system milestones: Checkout crossed 50 @acme/ui instances; Search crossed 10 @acme/ui instances; Accounts shipped its first @acme/ui components; Billing shipped the first DatePicker in the organisation; Search used Badge and Button for the first time.",
"blocks": [
{ "type": "header",
"text": { "type": "plain_text", "text": "Design system milestones, 2026-09-25", "emoji": true } },
{ "type": "section",
"text": { "type": "mrkdwn",
"text": ":tada: *Checkout* crossed *50* `@acme/ui` instances (now 52, up 6 this week)." } },
{ "type": "divider" },
{ "type": "section",
"text": { "type": "mrkdwn", "text": "New to a team this week\n*Search*: first `Badge`, `Button`" } },
{ "type": "context",
"elements": [
{ "type": "mrkdwn", "text": "Counts are JSX instances of `@acme/ui` components from a static scan of main at `9b7e0a3`; tests and stories excluded. Org total: 76 instances, up 12." },
{ "type": "mrkdwn", "text": "<https://design.acme.example/adoption|Adoption dashboard>" } ] }
]
}Three of the four milestone sections are left out here; the file has all 8 blocks.
Appendix B
The weekly workflow.
workflows/ds-celebration.yml, copied to .github/workflows/. Parsed as YAML by the kit's tests; not executed.
workflows/ds-celebration.yml (excerpt)
on:
schedule:
- cron: "0 9 * * 5" # Fridays 09:00 UTC
workflow_dispatch:
inputs:
post: { description: "Post to Slack (unticked = dry run)", type: boolean, default: false }
permissions:
contents: read
actions: read # to download the previous run's snapshot
jobs:
celebrate:
runs-on: ubuntu-latest
env:
POST: ${{ github.event_name == 'schedule' || inputs.post }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with: { node-version: 22 }
- name: Download last snapshot # newest posting run's artifact, via gh run download
- name: Scan, diff and post
env:
SLACK_WEBHOOK_URL: ${{ secrets.DS_CELEBRATION_WEBHOOK }}
run: |
node tools/ds-bot/scripts/bot.mjs . --config tools/ds-bot/bot.config.json \
--state .ds-usage/last.json --write-state .ds-usage/last.json \
--commit "${GITHUB_SHA::7}" --date "$(date -u +%F)" \
$([ "$POST" = "true" ] && echo --post)
- name: Keep snapshot for next week
if: success() && env.POST == 'true'
uses: actions/upload-artifact@v7
with: { name: ds-usage, path: .ds-usage/last.json, retention-days: 30 }Condensed. The file has the full download step and comments. Check action versions before use.
Keep with the bot
Leave a setup record.
One record when the bot goes live, updated whenever its rules change, so the channel knows what the numbers mean.
Sources / maintenance
Keep the guide current.
Sources checked 24 September 2026. Slack moved its developer docs to docs.slack.dev; the limits quoted are from those pages on that date. Action versions were the latest releases on GitHub that day.