The ranked CSV from guide 01 landed well at Fernway. Too well. Now Maya in marketing messages you twice a day: can you find trail runners in Colorado, anyone who does gear reviews under 50K followers, someone like the creator we signed last month. Every question goes through you, and every answer takes you away from the work you were hired to do.
So give Maya the tool. In this guide you build /creators, a Slack command that takes a brief in plain English and posts a ranked shortlist into the channel, with the reason each creator fits and a button to find more like them. Anyone on the team can use it, and you never see the request.
Maya 10:42
/creators trail runners who post race recaps and gear reviews, 10k-200k followers
Creator FinderApp10:42
Creators for “trail runners who post race recaps and gear reviews”
- Similar
Ty M. @switchback.ty
18.3K followers · 5.2% engagement · fit 0.78
Shares ultrarace accounts and discusses trail gear, though the gear post is a positive account rather than a clearly evaluative review.
- Similar
Erik L. @fjell.racer
35.0K followers · 4.7% engagement · fit 0.75
An accomplished trail runner who posts about a race win, but the supplied sources do not show gear reviews.
- Similar
Agi C. @kowhai.trails
29.3K followers · 2.6% engagement · fit 0.70
Shares trail-running and training content plus a personal marathon recap, but the supplied sources do not establish gear reviews.
- Similar
Kim R. @milesandmud
11.3K followers · 0.9% engagement · fit 0.70
Provides a detailed 50-mile race recap with trail sections, but no gear review appears in the supplied evidence.
“Going into JFK, I hadn’t done any training runs on the AT and didn’t get a course preview in.”
Showing 1–5 of 20 · search cost 65 credits · asked by @maya
Next 5Read the reasons closely. Each one says what matches and what is missing: Kim writes detailed race recaps, but there is no gear review in her posts. That is what lets Maya trust the list. She can see which part of the brief each creator covers before she sends a single DM, and the quote shows her the post it came from.
How it works
The bot is about 400 lines of TypeScript across three files, comments included. A brief costs one paid search that returns 20 ranked creators. Influship stores the search, so paging through it is free, and lookalikes cost a few cents more when someone asks for them:
- 1MayaTypes
/creators trail runners who review shoes - 2BotAcknowledges within Slack’s 3-second window: "Searching…"free
- 3InflushipRanks 20 creators against the brief and stores the search65 cr
- 4BotPosts the first 5 into the channel with reasons and quotesfree
- 5MayaClicks Next 5: the bot reads the stored searchfree
- 6MayaClicks Similar on a creator: 5 lookalikes and why each matches7.5 cr
At those prices a team running 20 searches a day spends about $13. A daily credit cap per workspace, built in step 6, makes sure an enthusiastic Friday afternoon cannot turn into a surprise bill.
Your first call
Everything the bot does starts with one request. Try it from a script before touching Slack. New Influship accounts get 500 starter credits with no card; this call costs 35 (25 per search plus 2 per creator returned).
import Influship from 'influship';
const client = new Influship(); // reads INFLUSHIP_API_KEY
const search = await client.search.create({
query: 'trail runners who post race recaps and gear reviews',
filters: { followers: { max: 200_000 } },
limit: 5,
});
for (const { primary_profile, match } of search.data) {
console.log(primary_profile?.username, match.score, match.reasons[0]);
}$ npx tsx first-call.tsswitchback.ty 0.78 Shares ultrarace accounts and discusses trail gear, though the gear…fjell.racer 0.75 Accomplished trail runner who posts about a race win, but the supplied…kowhai.trails 0.7 Shares trail-running and training content plus a personal marathon…milesandmud 0.7 Provides a detailed 50-mile race recap with trail sections, but no gear…hochtal.runs 0.67 A trail runner who shares race accounts, but no gear reviews are…
The bot is this call, plus the plumbing to put its answer where your team already works.
Create the Slack app from a manifest
A manifest describes the whole app in one file: the slash command, the button interactivity, and Socket Mode. Socket Mode opens an outbound websocket from your bot to Slack, so you can run it on your laptop without a public URL or a tunnel.
display_information:
name: Creator Finder
description: Find Instagram creators from a brief, powered by the Influship API
features:
bot_user:
display_name: Creator Finder
always_online: true
slash_commands:
- command: /creators
description: Find creators that fit a brief
usage_hint: trail runners who review shoes, 10k-100k followers
should_escape: false
oauth_config:
scopes:
bot:
- commands
settings:
interactivity:
is_enabled: true
socket_mode_enabled: true
org_deploy_enabled: false
token_rotation_enabled: false- Go to api.slack.com/apps, choose Create New App, then From a manifest, pick your workspace and paste the file above.
- Under Basic Information, create an App-Level Token with the
connections:writescope. It starts withxapp-. - Under Install App, install it to your workspace and copy the Bot User OAuth Token. It starts with
xoxb-.
The only bot scope is commands. The bot never reads messages, and it replies through the response URL Slack attaches to each command and button click, so nobody has to invite it to a channel.
Set up the project
mkdir creator-finder && cd creator-finder
pnpm init
pnpm add @slack/bolt influship
pnpm add -D tsx typescript @types/nodeCopy env.example to .env and fill in the three tokens:
# Influship: developers.influship.com > API Keys
INFLUSHIP_API_KEY=
# Slack: OAuth & Permissions > Bot User OAuth Token (starts with xoxb-)
SLACK_BOT_TOKEN=
# Slack: Basic Information > App-Level Tokens, scope connections:write (starts with xapp-)
SLACK_APP_TOKEN=
# Credits the bot may spend per workspace per UTC day (1 credit = $0.01)
DAILY_CREDIT_LIMIT=500Add "type": "module" and the scripts from the sample package.json. The project ends up with three source files and one test:
Search once, page for free
src/influship.ts owns every call to the API. It starts with the client, the page size and the published prices it uses to check the budget:
import Influship from 'influship';
import type { CreatorLookalikeResponse } from 'influship/resources/creators';
import type { SearchRetrieveResponse } from 'influship/resources/search';
// Reads INFLUSHIP_API_KEY. A ranked search can take a while, so allow up to 90 seconds.
const client = new Influship({ timeout: 90_000 });
export const PAGE_SIZE = 5;
const SEARCH_LIMIT = 20;
// Published prices in credits (1 credit = $0.01). Used to check the budget before a call.
export const SEARCH_COST = 25 + 2 * SEARCH_LIMIT;
export const SIMILAR_COST = 1.5 * PAGE_SIZE;
export type Creator = SearchRetrieveResponse;
export type Lookalike = CreatorLookalikeResponse;
export type ResultsPage = {
searchId: string;
query: string;
total: number;
offset: number;
creators: Creator[];
nextCursor: string | null;
credits: number;
unranked: boolean;
};People type follower ranges into briefs: "10k-100k followers". Search is good at topics, audiences and content styles, but a size limit is a hard rule, so the bot pulls the range out and sends it as a filter:
const FOLLOWER_RANGE = /(\d+(?:\.\d+)?)\s*([km])?\s*(?:-|to)\s*(\d+(?:\.\d+)?)\s*([km])?\s+followers/i;
function toNumber(value: string, unit?: string): number {
const multiplier = unit?.toLowerCase() === 'm' ? 1_000_000 : unit?.toLowerCase() === 'k' ? 1_000 : 1;
return Math.round(Number(value) * multiplier);
}
/**
* "trail runners who review shoes, 10k-100k followers" becomes a query plus a follower
* filter. Ranges are applied as hard filters; everything else is left to the search.
*/
export function parseCommand(text: string) {
const range = text.match(FOLLOWER_RANGE);
const query = text.replace(FOLLOWER_RANGE, '').replace(/[,\s]+$/, '').trim().slice(0, 300);
if (!range) return { query };
const min = toNumber(range[1], range[2] ?? range[4]);
const max = toNumber(range[3], range[4]);
return { query, followers: { min: Math.min(min, max), max: Math.max(min, max) } };
}Then the search itself. It asks for 20 creators in one paid call, and reads the first page of 5 back from the stored search:
export async function searchCreators(text: string): Promise<ResultsPage> {
const { query, followers } = parseCommand(text);
const { data: search, response } = await client.search
.create({
query,
limit: SEARCH_LIMIT,
platforms: ['instagram'],
...(followers && { filters: { followers } }),
})
.withResponse();
// The search is stored. Reading it back in pages costs nothing.
const first = await readPage(search.search_id);
return {
...first,
query,
offset: 0,
total: search.total,
credits: Number(response.headers.get('x-credits-charged') ?? 0),
unranked: search.quality?.mode === 'retrieval_fallback',
};
}
export async function readPage(searchId: string, cursor?: string) {
const page = await client.search.retrieve(searchId, {
limit: PAGE_SIZE,
...(cursor && { cursor }),
});
return {
searchId,
creators: page.data,
nextCursor: page.has_more ? page.next_cursor : null,
};
}Why 20 up front? Search costs 25 credits per call plus 2 per creator. One search for 20 costs 65 credits. Four searches for 5 would cost 140 and could return different rankings each time. GET /v1/search/{id} re-reads a stored search for free, so "Next 5" costs nothing.
Turn results into Slack messages
Slack messages are JSON in a format called Block Kit. Building them in their own file, as plain functions with no Slack client, means you can test every message without a workspace. Each creator becomes one section block, with a Similar button that carries the creator's ID:
function creatorBlock(item: Creator): Block {
const profile = item.relevant_profile ?? item.primary_profile;
const stats = [
`${compact(profile?.followers)} followers`,
profile?.engagement_rate != null ? `${profile.engagement_rate.toFixed(1)}% engagement` : null,
`fit ${item.match.score.toFixed(2)}`,
].filter(Boolean);
// The first reason is the summary sentence. Back it with a verbatim quote from a post
// when the evidence has one, so the channel can see why without opening the profile.
const reason = item.match.reasons[0];
const quote = item.match.evidence.find(
(e) => e.provenance === 'post_evidence' && e.evidence_quote,
)?.evidence_quote;
const lines = [
profileLine(item.creator.name, profile),
stats.join(' · '),
reason ? `>${escape(truncate(reason, 400))}` : null,
quote ? `>_“${escape(truncate(quote, 200))}”_` : null,
].filter(Boolean);
return {
type: 'section',
block_id: `creator_${item.creator.id}`,
text: { type: 'mrkdwn', text: lines.join('\n') },
accessory: {
type: 'button',
action_id: 'find_similar',
text: { type: 'plain_text', text: 'Similar' },
value: JSON.stringify({ id: item.creator.id, name: truncate(item.creator.name, 80) }),
},
};
}
export function resultsMessage(page: ResultsPage, requestedBy?: string): Message {
const from = page.offset + 1;
const to = page.offset + page.creators.length;
const notes = [
`Showing ${from}–${to} of ${page.total}`,
page.credits ? `search cost ${page.credits} credits` : 'paging is free',
requestedBy ? `asked by <@${requestedBy}>` : null,
].filter(Boolean);
const blocks: Block[] = [
{
type: 'header',
text: { type: 'plain_text', text: truncate(`Creators for “${page.query}”`, 150) },
},
];
if (page.unranked) {
blocks.push({
type: 'context',
elements: [
{
type: 'mrkdwn',
text: ':warning: These results are matched but not AI-ranked this time. Try again for a ranked list.',
},
],
});
}
if (page.creators.length === 0) {
blocks.push({
type: 'section',
text: { type: 'mrkdwn', text: 'No creators matched. Try a broader brief or remove the follower range.' },
});
}
for (const item of page.creators) {
blocks.push(creatorBlock(item), { type: 'divider' });
}
blocks.push({ type: 'context', elements: [{ type: 'mrkdwn', text: notes.join(' · ') }] });
if (page.nextCursor) {
const state: PageState = {
searchId: page.searchId,
query: page.query,
total: page.total,
offset: to,
cursor: page.nextCursor,
};
blocks.push({
type: 'actions',
elements: [
{
type: 'button',
action_id: 'next_page',
text: { type: 'plain_text', text: 'Next 5' },
value: JSON.stringify(state),
},
],
});
}
return { text: `Creators for "${page.query}"`, blocks };
}Four Slack rules shape this code, and each one fails quietly if you miss it:
- Escape creator text. Names, bios and quotes are written by creators, and Slack reads
&,<and>as formatting. Theescape()helper runs on everything that came from the API. - Respect the limits. 50 blocks per message, 3,000 characters per section, 150 per header and 2,000 per button value. Reasons and quotes are truncated, and 5 creators per page keeps the message well inside the block limit.
- Keep state in the button. The bot stores nothing between clicks. The Next 5 button carries the search ID and cursor in its value, so any instance of the bot can serve the click.
- Skip profile pictures. Slack fetches image URLs when a message is posted, and social platform image links expire. One dead avatar can get the whole message rejected, so the bot links to the profile instead.
The formatting helpers and the lookalike message follow the same pattern:
// Slack treats &, < and > as control characters in mrkdwn.
function escape(text: string): string {
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
}
function truncate(text: string, max: number): string {
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
}
function compact(count: number | null | undefined): string {
if (count == null) return 'unknown';
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`;
return String(count);
}
function profileLine(name: string, profile: Creator['primary_profile']): string {
if (!profile) return `*${escape(name)}*`;
return `*<${profile.url}|${escape(name)}>* @${escape(profile.username)}`;
}Wire up the slash command
src/app.ts connects Slack to everything above. The command handler has one hard constraint: Slack shows the user an error if the app does not acknowledge within 3 seconds, and a ranked search takes closer to a minute. So the handler acknowledges first, with a "Searching…" note only the user sees, then searches and replies through the response URL:
app.command('/creators', async ({ command, ack, respond }) => {
const text = command.text.trim();
if (!text) {
await ack(helpMessage);
return;
}
if (!canSpend(command.team_id, SEARCH_COST)) {
await ack(OVER_BUDGET);
return;
}
// Slack needs an answer within 3 seconds. Acknowledge now, search after.
await ack({ response_type: 'ephemeral', text: `Searching for creators: “${text}”…` });
try {
const page = await searchCreators(text);
recordSpend(command.team_id, page.credits);
await respond({ response_type: 'in_channel', ...resultsMessage(page, command.user_id) });
} catch (error) {
await respond({ response_type: 'ephemeral', ...errorMessage(error) });
}
});response_type: 'in_channel' posts the shortlist where everyone can see it, which is the point: the next person with the same question finds the answer in the channel history.
Add the buttons and guard the spend
Next 5 reads the following page of the stored search and replaces the message in place, so the channel does not fill up with pages:
app.action<BlockAction<ButtonAction>>('next_page', async ({ ack, action, respond }) => {
await ack();
const state = JSON.parse(action.value ?? '{}') as PageState;
try {
const page = await readPage(state.searchId, state.cursor);
await respond({
replace_original: true,
...resultsMessage({ ...state, ...page, credits: 0, unranked: false }),
});
} catch (error) {
await respond({ response_type: 'ephemeral', replace_original: false, ...errorMessage(error) });
}
});Similar runs a lookalike search seeded with that creator and posts the results as a new message. Each lookalike comes with a line from its own content that explains the match, so Maya can see why it was suggested:
app.action<BlockAction<ButtonAction>>('find_similar', async ({ ack, action, body, respond }) => {
await ack();
const seed = JSON.parse(action.value ?? '{}') as { id: string; name: string };
const teamId = body.team?.id ?? 'unknown';
if (!canSpend(teamId, SIMILAR_COST)) {
await respond({ response_type: 'ephemeral', replace_original: false, text: OVER_BUDGET });
return;
}
try {
const { creators, credits } = await findSimilar(seed.id);
recordSpend(teamId, credits);
await respond({
response_type: 'in_channel',
replace_original: false,
...similarMessage(seed.name, creators, credits),
});
} catch (error) {
await respond({ response_type: 'ephemeral', replace_original: false, ...errorMessage(error) });
}
});
await app.start();
console.log('Creator Finder is running. Try /creators in Slack.');export async function findSimilar(creatorId: string) {
const { data: page, response } = await client.creators
.lookalike({ seeds: [{ creator_id: creatorId }], limit: PAGE_SIZE })
.withResponse();
return {
creators: page.data,
credits: Number(response.headers.get('x-credits-charged') ?? 0),
};
}Both paid paths check a daily credit cap per workspace before calling the API, then record the real charge from the X-Credits-Charged header afterwards. The default is 500 credits ($5) a day, about seven searches, set with DAILY_CREDIT_LIMIT. Raise it to fit how much your team searches:
const DAILY_LIMIT = Number(process.env.DAILY_CREDIT_LIMIT ?? 500);
const spend = new Map<string, { day: string; credits: number }>();
function today(teamId: string) {
const day = new Date().toISOString().slice(0, 10);
const entry = spend.get(teamId);
return entry?.day === day ? entry : { day, credits: 0 };
}
export function canSpend(teamId: string, estimate: number): boolean {
return today(teamId).credits + estimate <= DAILY_LIMIT;
}
export function recordSpend(teamId: string, credits: number) {
const entry = today(teamId);
spend.set(teamId, { day: entry.day, credits: entry.credits + credits });
}Test it without Slack
The test file starts a tiny local server that stands in for the Influship API and points the SDK at it with INFLUSHIP_BASE_URL. It runs a search, pages to the end, asks for lookalikes, and checks every message against Slack's limits. No tokens, no credits:
$ pnpm test✔ parses a follower range out of the brief✔ search shows the first page and records the real cost✔ Next 5 reads the stored search and stops on the last page✔ Similar posts lookalikes with shared traits✔ the daily cap blocks spend past the limitℹ pass 5
Read the full test file for the mock server; it is a good template for testing any bot that calls the API.
Run it
$ pnpm startCreator Finder is running. Try /creators in Slack.
In any channel, type /creators with a brief. An empty /creators shows examples. A few briefs that work well:
/creators trail runners who review shoes, 10k-100k followers/creators UK home cooks who post quick weeknight recipes/creators dermatologists who explain skincare ingredients on camera
Specific briefs win. "Fitness" returns a generic list. "Women over 40 posting strength training at home" returns creators Maya can use.
Deploy it
Socket Mode needs a process that stays running, not a serverless function. Any host that runs Node.js works: Railway, Render, Fly.io or a small VM. Set the same four variables from .env, use pnpm start as the start command, and run one instance so the in-memory cap stays accurate.
Socket Mode is ideal for a bot that only your workspace uses. Apps listed in the Slack Marketplace have to receive events over HTTP instead; Bolt supports both, and switching is a change to the App options.
Troubleshooting
- /creators failed with the error "dispatch_failed"
- The bot is not running or not connected. Start it with pnpm start and check the SLACK_APP_TOKEN.
- invalid_auth when the bot starts
- The
SLACK_BOT_TOKENis wrong or from another workspace. Copy it again from Install App after reinstalling. - The command is missing from Slack
- Reinstall the app after changing the manifest so Slack picks up the command.
- Out of credits / Influship API key is missing
- Check
INFLUSHIP_API_KEYin.envand the balance at developers.influship.com. - Today's credit limit message
- The workspace hit DAILY_CREDIT_LIMIT. Raise it, or wait for midnight UTC.
Where to take it next
- Reply in threads. Add the
chat:writescope and post lookalikes withthread_tsso each conversation stays together. The bot then needs to be invited to the channels it posts in. - Save a shortlist. Add a button that writes the creators to an Influship shortlist with
POST /v1/shortlists, so a search in Slack becomes a list the team can reopen later. - Score against a saved brief. Store Fernway's campaign brief once and add a Check fit button that calls the match endpoint from guide 01 for a good, neutral or avoid decision.
- Send a weekly digest. Run a scheduled search every Monday and post new creators to
#creator-scouting.
The complete code
All three files in full, plus the test, manifest, package.json and tsconfig.json.
import { App, type BlockAction, type ButtonAction } from '@slack/bolt';
import {
errorMessage,
helpMessage,
type PageState,
resultsMessage,
similarMessage,
} from './blocks.js';
import {
canSpend,
findSimilar,
readPage,
recordSpend,
SEARCH_COST,
SIMILAR_COST,
searchCreators,
} from './influship.js';
// Socket Mode opens an outbound websocket to Slack, so the bot runs anywhere, including
// your laptop, without a public URL.
const app = new App({
token: process.env.SLACK_BOT_TOKEN,
appToken: process.env.SLACK_APP_TOKEN,
socketMode: true,
});
const OVER_BUDGET = "This workspace has used today's Influship credit limit. It resets at midnight UTC.";
// ---------- /creators <brief> ----------
app.command('/creators', async ({ command, ack, respond }) => {
const text = command.text.trim();
if (!text) {
await ack(helpMessage);
return;
}
if (!canSpend(command.team_id, SEARCH_COST)) {
await ack(OVER_BUDGET);
return;
}
// Slack needs an answer within 3 seconds. Acknowledge now, search after.
await ack({ response_type: 'ephemeral', text: `Searching for creators: “${text}”…` });
try {
const page = await searchCreators(text);
recordSpend(command.team_id, page.credits);
await respond({ response_type: 'in_channel', ...resultsMessage(page, command.user_id) });
} catch (error) {
await respond({ response_type: 'ephemeral', ...errorMessage(error) });
}
});
// ---------- Next 5: free, reads the stored search ----------
app.action<BlockAction<ButtonAction>>('next_page', async ({ ack, action, respond }) => {
await ack();
const state = JSON.parse(action.value ?? '{}') as PageState;
try {
const page = await readPage(state.searchId, state.cursor);
await respond({
replace_original: true,
...resultsMessage({ ...state, ...page, credits: 0, unranked: false }),
});
} catch (error) {
await respond({ response_type: 'ephemeral', replace_original: false, ...errorMessage(error) });
}
});
// ---------- Similar: lookalikes for one creator ----------
app.action<BlockAction<ButtonAction>>('find_similar', async ({ ack, action, body, respond }) => {
await ack();
const seed = JSON.parse(action.value ?? '{}') as { id: string; name: string };
const teamId = body.team?.id ?? 'unknown';
if (!canSpend(teamId, SIMILAR_COST)) {
await respond({ response_type: 'ephemeral', replace_original: false, text: OVER_BUDGET });
return;
}
try {
const { creators, credits } = await findSimilar(seed.id);
recordSpend(teamId, credits);
await respond({
response_type: 'in_channel',
replace_original: false,
...similarMessage(seed.name, creators, credits),
});
} catch (error) {
await respond({ response_type: 'ephemeral', replace_original: false, ...errorMessage(error) });
}
});
await app.start();
console.log('Creator Finder is running. Try /creators in Slack.');import Influship from 'influship';
import type { CreatorLookalikeResponse } from 'influship/resources/creators';
import type { SearchRetrieveResponse } from 'influship/resources/search';
// Reads INFLUSHIP_API_KEY. A ranked search can take a while, so allow up to 90 seconds.
const client = new Influship({ timeout: 90_000 });
export const PAGE_SIZE = 5;
const SEARCH_LIMIT = 20;
// Published prices in credits (1 credit = $0.01). Used to check the budget before a call.
export const SEARCH_COST = 25 + 2 * SEARCH_LIMIT;
export const SIMILAR_COST = 1.5 * PAGE_SIZE;
export type Creator = SearchRetrieveResponse;
export type Lookalike = CreatorLookalikeResponse;
export type ResultsPage = {
searchId: string;
query: string;
total: number;
offset: number;
creators: Creator[];
nextCursor: string | null;
credits: number;
unranked: boolean;
};
// ---------- parse the slash command ----------
const FOLLOWER_RANGE = /(\d+(?:\.\d+)?)\s*([km])?\s*(?:-|to)\s*(\d+(?:\.\d+)?)\s*([km])?\s+followers/i;
function toNumber(value: string, unit?: string): number {
const multiplier = unit?.toLowerCase() === 'm' ? 1_000_000 : unit?.toLowerCase() === 'k' ? 1_000 : 1;
return Math.round(Number(value) * multiplier);
}
/**
* "trail runners who review shoes, 10k-100k followers" becomes a query plus a follower
* filter. Ranges are applied as hard filters; everything else is left to the search.
*/
export function parseCommand(text: string) {
const range = text.match(FOLLOWER_RANGE);
const query = text.replace(FOLLOWER_RANGE, '').replace(/[,\s]+$/, '').trim().slice(0, 300);
if (!range) return { query };
const min = toNumber(range[1], range[2] ?? range[4]);
const max = toNumber(range[3], range[4]);
return { query, followers: { min: Math.min(min, max), max: Math.max(min, max) } };
}
// ---------- search once, page for free ----------
export async function searchCreators(text: string): Promise<ResultsPage> {
const { query, followers } = parseCommand(text);
const { data: search, response } = await client.search
.create({
query,
limit: SEARCH_LIMIT,
platforms: ['instagram'],
...(followers && { filters: { followers } }),
})
.withResponse();
// The search is stored. Reading it back in pages costs nothing.
const first = await readPage(search.search_id);
return {
...first,
query,
offset: 0,
total: search.total,
credits: Number(response.headers.get('x-credits-charged') ?? 0),
unranked: search.quality?.mode === 'retrieval_fallback',
};
}
export async function readPage(searchId: string, cursor?: string) {
const page = await client.search.retrieve(searchId, {
limit: PAGE_SIZE,
...(cursor && { cursor }),
});
return {
searchId,
creators: page.data,
nextCursor: page.has_more ? page.next_cursor : null,
};
}
// ---------- creators like this one ----------
export async function findSimilar(creatorId: string) {
const { data: page, response } = await client.creators
.lookalike({ seeds: [{ creator_id: creatorId }], limit: PAGE_SIZE })
.withResponse();
return {
creators: page.data,
credits: Number(response.headers.get('x-credits-charged') ?? 0),
};
}
// ---------- a daily credit cap per workspace ----------
const DAILY_LIMIT = Number(process.env.DAILY_CREDIT_LIMIT ?? 500);
const spend = new Map<string, { day: string; credits: number }>();
function today(teamId: string) {
const day = new Date().toISOString().slice(0, 10);
const entry = spend.get(teamId);
return entry?.day === day ? entry : { day, credits: 0 };
}
export function canSpend(teamId: string, estimate: number): boolean {
return today(teamId).credits + estimate <= DAILY_LIMIT;
}
export function recordSpend(teamId: string, credits: number) {
const entry = today(teamId);
spend.set(teamId, { day: entry.day, credits: entry.credits + credits });
}import type { types } from '@slack/bolt';
import { APIError } from 'influship';
import type { Creator, Lookalike, ResultsPage } from './influship.js';
type Block = types.KnownBlock;
export type Message = { text: string; blocks: Block[] };
/** What a "Next 5" button needs to fetch the following page. Stored in the button value. */
export type PageState = {
searchId: string;
query: string;
total: number;
offset: number;
cursor: string;
};
// ---------- formatting helpers ----------
// Slack treats &, < and > as control characters in mrkdwn.
function escape(text: string): string {
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
}
function truncate(text: string, max: number): string {
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
}
function compact(count: number | null | undefined): string {
if (count == null) return 'unknown';
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`;
return String(count);
}
function profileLine(name: string, profile: Creator['primary_profile']): string {
if (!profile) return `*${escape(name)}*`;
return `*<${profile.url}|${escape(name)}>* @${escape(profile.username)}`;
}
// ---------- the search results message ----------
function creatorBlock(item: Creator): Block {
const profile = item.relevant_profile ?? item.primary_profile;
const stats = [
`${compact(profile?.followers)} followers`,
profile?.engagement_rate != null ? `${profile.engagement_rate.toFixed(1)}% engagement` : null,
`fit ${item.match.score.toFixed(2)}`,
].filter(Boolean);
// The first reason is the summary sentence. Back it with a verbatim quote from a post
// when the evidence has one, so the channel can see why without opening the profile.
const reason = item.match.reasons[0];
const quote = item.match.evidence.find(
(e) => e.provenance === 'post_evidence' && e.evidence_quote,
)?.evidence_quote;
const lines = [
profileLine(item.creator.name, profile),
stats.join(' · '),
reason ? `>${escape(truncate(reason, 400))}` : null,
quote ? `>_“${escape(truncate(quote, 200))}”_` : null,
].filter(Boolean);
return {
type: 'section',
block_id: `creator_${item.creator.id}`,
text: { type: 'mrkdwn', text: lines.join('\n') },
accessory: {
type: 'button',
action_id: 'find_similar',
text: { type: 'plain_text', text: 'Similar' },
value: JSON.stringify({ id: item.creator.id, name: truncate(item.creator.name, 80) }),
},
};
}
export function resultsMessage(page: ResultsPage, requestedBy?: string): Message {
const from = page.offset + 1;
const to = page.offset + page.creators.length;
const notes = [
`Showing ${from}–${to} of ${page.total}`,
page.credits ? `search cost ${page.credits} credits` : 'paging is free',
requestedBy ? `asked by <@${requestedBy}>` : null,
].filter(Boolean);
const blocks: Block[] = [
{
type: 'header',
text: { type: 'plain_text', text: truncate(`Creators for “${page.query}”`, 150) },
},
];
if (page.unranked) {
blocks.push({
type: 'context',
elements: [
{
type: 'mrkdwn',
text: ':warning: These results are matched but not AI-ranked this time. Try again for a ranked list.',
},
],
});
}
if (page.creators.length === 0) {
blocks.push({
type: 'section',
text: { type: 'mrkdwn', text: 'No creators matched. Try a broader brief or remove the follower range.' },
});
}
for (const item of page.creators) {
blocks.push(creatorBlock(item), { type: 'divider' });
}
blocks.push({ type: 'context', elements: [{ type: 'mrkdwn', text: notes.join(' · ') }] });
if (page.nextCursor) {
const state: PageState = {
searchId: page.searchId,
query: page.query,
total: page.total,
offset: to,
cursor: page.nextCursor,
};
blocks.push({
type: 'actions',
elements: [
{
type: 'button',
action_id: 'next_page',
text: { type: 'plain_text', text: 'Next 5' },
value: JSON.stringify(state),
},
],
});
}
return { text: `Creators for "${page.query}"`, blocks };
}
// ---------- the lookalike message ----------
export function similarMessage(seedName: string, creators: Lookalike[], credits: number): Message {
const blocks: Block[] = [
{
type: 'section',
text: { type: 'mrkdwn', text: `*Creators like ${escape(seedName)}*` },
},
];
for (const item of creators) {
// shared_traits are full sentences from the creator's content; one is enough here.
const trait = item.similarity.shared_traits[0];
blocks.push({
type: 'section',
text: {
type: 'mrkdwn',
text: [
profileLine(item.creator.name, item.primary_profile),
`${compact(item.primary_profile?.followers)} followers · similarity ${item.similarity.score.toFixed(2)}`,
trait ? `>${escape(truncate(trait, 200))}` : null,
]
.filter(Boolean)
.join('\n'),
},
});
}
blocks.push({
type: 'context',
elements: [{ type: 'mrkdwn', text: `${creators.length} lookalikes · cost ${credits} credits` }],
});
return { text: `Creators like ${seedName}`, blocks };
}
// ---------- help and errors ----------
export const helpMessage: Message = {
text: 'Describe the creators you want after /creators.',
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: [
'*Describe the creators you want.* For example:',
'`/creators trail runners who review shoes`',
'`/creators UK home cooks who post quick weeknight recipes, 10k-100k followers`',
].join('\n'),
},
},
],
};
export function errorMessage(error: unknown): Message {
let text = 'Something went wrong talking to Influship. Try again in a minute.';
if (error instanceof APIError) {
if (error.status === 402) text = 'The Influship account is out of credits. Ask an admin to add billing.';
else if (error.status === 429) text = 'Influship is rate limiting this workspace. Try again in a minute.';
else if (error.status === 401) text = 'The Influship API key is missing or invalid.';
const requestId = error.headers?.get('x-request-id');
if (requestId) text += ` (request ${requestId})`;
}
return { text, blocks: [{ type: 'section', text: { type: 'mrkdwn', text } }] };
}