Skip to content

feat(gastown): Slack integration — talk to the Mayor from Slack #1940

@jrf0110

Description

@jrf0110

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:

  1. User clicks "Install Mayor into Slack"
  2. If the Kilo Slack App is already installed in their workspace → skip to step 4
  3. If not installed → OAuth flow (existing @slack/oauth infrastructure)
  4. 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
  5. Custom avatar (optional): upload an image for this town's Mayor's Slack avatar. Default: Kilo logo or a generated avatar.
  6. Select a channel: channel picker — this channel is mapped to this town
  7. 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)

  1. User navigates to town settings → "Integrations" section
  2. Clicks "Connect Slack"
  3. If the Kilo Slack App is already installed in their workspace → show a channel picker (using existing @slack/web-api conversations.list)
  4. If not installed → redirect to existing OAuth flow, then show channel picker
  5. User selects a channel → creates the slack_gastown_channels mapping
  6. 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

  • Channel → town mapping stored and queryable
  • Messages in mapped channels routed to Mayor via sendMayorMessage
  • Mayor responses posted as Slack thread replies in mrkdwn format
  • Long responses split across messages (4000 char limit)
  • Reaction indicators (hourglass while processing, checkmark on complete)
  • "Install Mayor into Slack" button in town settings
  • Mayor display name configurable per town (default: "Mayor ({town-name})")
  • Custom avatar upload for Mayor (optional)
  • chat:write.customize scope added to OAuth scope list
  • Messages posted with per-town username and icon_url overrides
  • Channel picker in town settings (reuses existing Slack OAuth if installed)
  • DM routing: auto-select town (single town) or prompt (multiple towns)
  • Introductory message posted on connect
  • Disconnect option in town settings
  • Messages in non-mapped channels → default Kilo bot behavior (no regression)
  • Request logging in slack_bot_requests table
  • Works for both personal and org-owned towns
  • Settings-level changes blocked from Slack (Mayor can sling work but not modify town config)

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Post-launchenhancementNew feature or requestgt:coreReconciler, state machine, bead lifecycle, convoy flowgt:mayorMayor agent, chat interface, delegation tools

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions