Summary
Allow users to interact with their town's Mayor via Slack. Users send messages in a Slack channel (or DM the bot), and the Mayor responds — same as chatting in the Gastown UI.
Approach: Extend the Existing Slack Bot
We already have a production Slack integration (src/lib/slack-bot.ts) with:
- OAuth install flow (user-owned and org-owned)
- Events API webhook handler (
src/app/slack/webhook/route.ts)
- Request verification, thread replies, markdown→mrkdwn conversion
- Context injection (channel history, repo context)
- Tool system (
spawn_cloud_agent)
- Request logging, admin UI, model selection
- Org-level access controls
We do NOT need a new Worker, a new Slack App, or new OAuth infrastructure. We extend the existing bot to route messages to the Gastown Mayor when a town is connected to a channel.
Design
Channel → Town Mapping
Add a mapping between Slack channels and Gastown towns. When the bot receives a message in a mapped channel, it routes to the Mayor instead of the default Kilo bot behavior.
Storage: extend the existing platform_integrations row for the Slack installation, or add a new slack_gastown_channels table:
type SlackGastownChannel = {
integration_id: string; // FK to platform_integrations
slack_channel_id: string;
town_id: string;
connected_by: string; // Kilo user ID
connected_at: string;
};
Message Routing
In src/lib/slack-bot.ts, before the existing runBot() call:
// Check if this channel is mapped to a Gastown town
const townMapping = await getGastownChannelMapping(teamId, channelId);
if (townMapping) {
// Route to Mayor instead of default bot
return await routeToMayor(townMapping.townId, message, slackContext);
}
// Default: existing Kilo bot behavior
return await runBot(/* ... */);
routeToMayor()
async function routeToMayor(townId: string, message: string, slackCtx: SlackContext) {
// 1. Resolve the Kilo user from the Slack user (existing identity linking)
const kiloUser = await resolveKiloUser(slackCtx.slackUserId);
// 2. Call sendMayorMessage on the Gastown worker
const response = await gastownClient.sendMayorMessage(townId, message, kiloUser);
// 3. Convert Mayor's response from Markdown to Slack mrkdwn
const slackMessage = markdownToSlackMrkdwn(response.message);
// 4. Post as a thread reply (existing pattern)
await postThreadReply(slackCtx, slackMessage);
}
This reuses the existing:
markdownToSlackMrkdwn() conversion (src/lib/slack/markdownToSlackMrkdwn.ts)
- Thread reply posting pattern
- Reaction status indicators (hourglass → checkmark)
- Request logging (
slack_bot_requests table)
Named Mayor Per Town
Each town's Mayor gets its own display name and avatar in Slack. This is achieved with a single Kilo Slack App using chat:write.customize scope — Slack's chat.postMessage API accepts username and icon_url overrides per message.
When the Mayor for "acme-town" responds:
Mayor (acme-town) [APP]
I've created a convoy to implement the auth module...
When the Mayor for "mega-todo-app" responds in a different channel:
Mayor (mega-todo-app) [APP]
The refinery just merged PR #47...
The user's experience is that each town has its own named Mayor in Slack — but it's one app behind the scenes.
Why not one Slack App per Mayor? The Slack API doesn't support creating bot users dynamically. Each bot requires a Slack App, and creating apps programmatically is gated behind Slack's app review process. The chat:write.customize approach achieves the same UX without this limitation.
Town Settings: "Install Mayor into Slack"
The town settings "Integrations" section shows an "Install Mayor into Slack" button:
- User clicks "Install Mayor into Slack"
- If the Kilo Slack App is already installed in their workspace → skip to step 4
- If not installed → OAuth flow (existing
@slack/oauth infrastructure)
- Name your Mayor: text input with default "Mayor ({town-name})" — this is the display name that appears on all Slack messages from this town's Mayor
- Custom avatar (optional): upload an image for this town's Mayor's Slack avatar. Default: Kilo logo or a generated avatar.
- Select a channel: channel picker — this channel is mapped to this town
- Bot posts an intro message with the custom name and avatar:
Mayor (acme-town) Hi! I'm the Mayor for acme-town. Send me a message here to delegate work, check status, or ask about the codebase.
The Mayor name and avatar are stored in the town config:
// townConfig.slack
slack?: {
workspace_id: string;
channel_id: string;
bot_token: string; // encrypted, from OAuth
mayor_display_name: string; // custom Mayor name for this town
mayor_icon_url?: string; // custom avatar URL
mayor_display_name: string; // e.g. "Mayor (acme-town)"
mayor_icon_url?: string; // custom avatar URL (stored in R2 or similar)
connected_at: string;
};
Channel Messages (custom identity per message)
When posting the Mayor's response, use Slack's identity override:
await slackClient.chat.postMessage({
channel: channelId,
text: slackMessage,
thread_ts: threadTs,
username: townConfig.slack.mayor_display_name, // "Mayor (acme-town)"
icon_url: townConfig.slack.mayor_icon_url, // custom avatar
});
This requires chat:write.customize scope (add to the existing OAuth scope list).
DM Behavior
A single bot can only have one display name in the DM sidebar. When a user DMs the Kilo bot:
- If the user has only one town → route to that town's Mayor automatically
- If the user has multiple towns → prompt on first message: "Which town? You have: acme-town, mega-todo-app"
- Remember the selection for the DM conversation (store in a thread-level metadata or user preference)
- The DM responses use the selected town's Mayor display name and avatar
Connection Flow (Town Settings)
- User navigates to town settings → "Integrations" section
- Clicks "Connect Slack"
- If the Kilo Slack App is already installed in their workspace → show a channel picker (using existing
@slack/web-api conversations.list)
- If not installed → redirect to existing OAuth flow, then show channel picker
- User selects a channel → creates the
slack_gastown_channels mapping
- Bot posts an introductory message in the channel: "Connected to Gastown town {name}. Send a message here to talk to the Mayor."
What the Mayor Can Do From Slack
Everything it can from the UI — the message goes through sendMayorMessage which is the same RPC:
- Sling beads and convoys
- Check status, bead progress, agent activity
- Delegate work
- Answer codebase questions
Notifications (Stretch)
Once a channel is connected, post proactive notifications:
- Convoy completed / landed
- Bead failed (with failure reason)
- Escalation created
- PR ready for review
- Agent needs help
These use the existing chat.postMessage pattern, posted as new messages (not thread replies).
Multi-Town Support
- Different channels → different towns
- Same Slack workspace can have multiple town connections
- DM to the bot → falls through to default Kilo bot behavior (or prompt user to pick a town)
Files to Modify
| File |
Change |
src/lib/slack-bot.ts |
Add getGastownChannelMapping() check before runBot(), add routeToMayor() |
src/lib/integrations/slack-service.ts |
Add connectGastownChannel(), disconnectGastownChannel(), listGastownChannels(), mayor identity config |
src/routers/slack-router.ts |
Add tRPC procedures for channel→town mapping CRUD |
| Town settings component |
Add Slack connection UI (channel picker, connect/disconnect) |
| DB migration |
Add slack_gastown_channels table (or extend platform_integrations metadata) |
Files NOT to Modify
src/app/slack/webhook/route.ts — existing webhook handler works as-is
src/lib/slack/verify-request.ts — existing verification works
src/lib/slack/markdownToSlackMrkdwn.ts — reuse directly
- OAuth flow — reuse existing install flow entirely
- No new Slack App registration needed — same bot, new capability
Acceptance Criteria
References
Summary
Allow users to interact with their town's Mayor via Slack. Users send messages in a Slack channel (or DM the bot), and the Mayor responds — same as chatting in the Gastown UI.
Approach: Extend the Existing Slack Bot
We already have a production Slack integration (
src/lib/slack-bot.ts) with:src/app/slack/webhook/route.ts)spawn_cloud_agent)We do NOT need a new Worker, a new Slack App, or new OAuth infrastructure. We extend the existing bot to route messages to the Gastown Mayor when a town is connected to a channel.
Design
Channel → Town Mapping
Add a mapping between Slack channels and Gastown towns. When the bot receives a message in a mapped channel, it routes to the Mayor instead of the default Kilo bot behavior.
Storage: extend the existing
platform_integrationsrow for the Slack installation, or add a newslack_gastown_channelstable:Message Routing
In
src/lib/slack-bot.ts, before the existingrunBot()call:routeToMayor()This reuses the existing:
markdownToSlackMrkdwn()conversion (src/lib/slack/markdownToSlackMrkdwn.ts)slack_bot_requeststable)Named Mayor Per Town
Each town's Mayor gets its own display name and avatar in Slack. This is achieved with a single Kilo Slack App using
chat:write.customizescope — Slack'schat.postMessageAPI acceptsusernameandicon_urloverrides per message.When the Mayor for "acme-town" responds:
When the Mayor for "mega-todo-app" responds in a different channel:
The user's experience is that each town has its own named Mayor in Slack — but it's one app behind the scenes.
Why not one Slack App per Mayor? The Slack API doesn't support creating bot users dynamically. Each bot requires a Slack App, and creating apps programmatically is gated behind Slack's app review process. The
chat:write.customizeapproach achieves the same UX without this limitation.Town Settings: "Install Mayor into Slack"
The town settings "Integrations" section shows an "Install Mayor into Slack" button:
@slack/oauthinfrastructure)The Mayor name and avatar are stored in the town config:
Channel Messages (custom identity per message)
When posting the Mayor's response, use Slack's identity override:
This requires
chat:write.customizescope (add to the existing OAuth scope list).DM Behavior
A single bot can only have one display name in the DM sidebar. When a user DMs the Kilo bot:
Connection Flow (Town Settings)
@slack/web-apiconversations.list)slack_gastown_channelsmappingWhat the Mayor Can Do From Slack
Everything it can from the UI — the message goes through
sendMayorMessagewhich is the same RPC:Notifications (Stretch)
Once a channel is connected, post proactive notifications:
These use the existing
chat.postMessagepattern, posted as new messages (not thread replies).Multi-Town Support
Files to Modify
src/lib/slack-bot.tsgetGastownChannelMapping()check beforerunBot(), addrouteToMayor()src/lib/integrations/slack-service.tsconnectGastownChannel(),disconnectGastownChannel(),listGastownChannels(), mayor identity configsrc/routers/slack-router.tsslack_gastown_channelstable (or extendplatform_integrationsmetadata)Files NOT to Modify
src/app/slack/webhook/route.ts— existing webhook handler works as-issrc/lib/slack/verify-request.ts— existing verification workssrc/lib/slack/markdownToSlackMrkdwn.ts— reuse directlyAcceptance Criteria
sendMayorMessagechat:write.customizescope added to OAuth scope listslack_bot_requeststableReferences
src/lib/slack-bot.tssrc/lib/integrations/slack-service.tssrc/lib/slack/markdownToSlackMrkdwn.ts