Mentions and resolvers
Resolve user, channel and usergroup ids to names, show unresolved mentions like Slack, and back it with the real Slack Web API.
Slack’s mrkdwn and rich text only ever carry ids (<@U0123ABC>, <#C0123ABC>, <!subteam^S0123>),
never names. @nkootstra/block-kit never fetches on your behalf; you supply resolvers and it renders
whatever they return, the same way Slack’s own client resolves ids against its cache.
The Resolvers shape
Pass resolvers to <BlockKitProvider>. Each function is synchronous: return a name if you have it,
or undefined if you don’t (yet):
import { BlockKitProvider, Message, type Resolvers } from "@nkootstra/block-kit";
const resolvers: Resolvers = {
user: (id) => ({ U0ADA: "Ada Lovelace" })[id],
channel: (id) => ({ C0RELEASES: "releases" })[id],
usergroup: (id) => ({ S0ENG: "engineering" })[id],
userProfile: (id) =>
id === "U0ADA"
? {
name: "Ada Lovelace",
title: "Staff Engineer",
pronouns: "she/her",
avatarUrl: "https://api.slack.com/img/blocks/bkb_template_images/beagle.png",
status: { emoji: "palm_tree", text: "On vacation" },
timeZone: "Europe/Amsterdam",
}
: undefined,
};
<BlockKitProvider resolvers={resolvers}>
<Message blocks={[{ type: "section", text: { type: "mrkdwn", text: "<@U0ADA>" } }]} />
</BlockKitProvider>;
user?(id: string) => string | undefined
Display name for a user mention, e.g. <@U0ADA> → "Ada Lovelace".
(id: string) => string | undefineduserProfile?(id: string) => UserProfile | undefined
Details for the profile card that opens when a user mention is clicked. Falls back to the name from user when omitted.
(id: string) => UserProfile | undefinedchannel?(id: string) => string | undefined
Name for a channel mention, e.g. <#C0RELEASES> → "releases".
(id: string) => string | undefinedusergroup?(id: string) => string | undefined
Name for a usergroup (subteam) mention, e.g. <!subteam^S0ENG> → "engineering".
(id: string) => string | undefinedUserProfile fields: name (required), realName, title, pronouns, avatarUrl, status: { emoji?, text? }, and timeZone (an IANA zone, used for the card’s “local time” row).
What unresolved ids look like
An id a resolver returns undefined for renders exactly like Slack’s client does when it hasn’t
cached that id yet: not an error, not the raw id:
- A user mention in mrkdwn or a Home tab/modal’s plain mrkdwn text (
<@U0123>with no resolver hit) falls back to plain@U0123text. Slack doesn’t show a loading pill for it either. - A user or usergroup mention in a
rich_textblock shows an empty loading pill (a small pulsing bar), matching Slack’s Builder while it’s still resolving a mention it hasn’t seen before. - A channel mention with no
channelresolver hit and no inline label (<#C0123>without|name) renders as a locked “Private channel” pill, since Slack can’t show a name for a channel the viewer isn’t in either. - A usergroup mention always shows the loading state until resolved. There’s no unresolved fallback text for subteams, matching Slack.
The profile card
Clicking a resolved user mention opens a popover (Slack’s p-member_profile_card) built from
resolvers.userProfile. It shows the avatar (or an initial), name and pronouns, real name and title if
they differ from the display name, a custom status with its emoji, and local time when timeZone is
set. Without a userProfile resolver it still opens, showing just the name from user.
createWebApiResolvers
@nkootstra/block-kit/web-api builds Resolvers backed by a real @slack/web-api WebClient (or an
emulator that implements the same methods): users.info and conversations.info per id, and
usergroups.list once for every usergroup id (the Web API has no per-id usergroup lookup).
import { WebClient } from "@slack/web-api";
import { BlockKitProvider, Message } from "@nkootstra/block-kit";
import { createWebApiResolvers } from "@nkootstra/block-kit/web-api";
const client = new WebClient(process.env.SLACK_BOT_TOKEN);
const resolvers = createWebApiResolvers(client, { batchWindowMs: 20 });
<BlockKitProvider resolvers={resolvers}>
<Message blocks={[{ type: "section", text: { type: "mrkdwn", text: "<@U0ADA>" } }]} />
</BlockKitProvider>;
Because the resolver functions must stay synchronous, a lookup that hasn’t finished yet returns
undefined immediately and schedules a background fetch. Calls for the same kind (user or
channel) made within batchWindowMs (default 20ms), for example every mention in a long message, are
coalesced into one batch of concurrent requests rather than firing one at a time.
useWebApiResolvers
In a component, useWebApiResolvers wraps createWebApiResolvers and re-renders you automatically
whenever a lookup completes, so a mention that started unresolved shows its name once it arrives:
import { WebClient } from "@slack/web-api";
import { BlockKitProvider, Message } from "@nkootstra/block-kit";
import { useWebApiResolvers } from "@nkootstra/block-kit/web-api";
const client = new WebClient(process.env.SLACK_BOT_TOKEN);
export function Channel({ blocks }: { blocks: AnyBlock[] }) {
const resolvers = useWebApiResolvers(client);
return (
<BlockKitProvider resolvers={resolvers}>
<Message blocks={blocks} />
</BlockKitProvider>
);
}
client is captured once (like createWebApiResolvers, opts is only read on first render for that
client): construct the WebClient outside the component, or memoize it, so it has a stable identity
across renders.