# Discord Setup > How to wire `hanarang-rails` to a Discord guild for the real DiscordTransport. ## Overview Rails splits transport and observation: - **Transport (deterministic)**: Rails posts marker blocks with structured invoke data. Agent bots parse the markers directly (not through LLM). - **Observation (natural language)**: Agent bots keep posting free-form messages for human readers. Rails ignores the free-form text. ## Bot accounts Two kinds of discord bots are involved: 1. **Rails bot** — posts invoke markers, state transitions, escalations. 2. **Agent bots (one per role, optional)** — each agent/sister has its own bot persona that responds with result markers and natural-language commentary. If you don't need per-role personas, you can run a single bot for both rails and all agents. ## Rails bot setup 1. Go to https://discord.com/developers/applications 2. Create a new application → bot user 3. Enable privileged intents: **Message Content Intent** must be on. 4. OAuth2 URL generator → scopes: `bot`, permissions: `Send Messages`, `Read Message History`, `Create Public Threads`, `Manage Messages` (for marker cleanup, optional). 5. Invite the bot to your guild. 6. Copy the token. Set in `.env`: ``` RAILS_DISCORD_TOKEN= DISCORD_GUILD_ID= DISCORD_PIPELINE_CHANNEL_ID= ``` ## Agent bot integration Each agent host needs a minimal message handler that recognizes rails markers and routes them out of the LLM path: ```ts import { DiscordPoster } from "hanarang-rails"; client.on("messageCreate", async (msg) => { const invokeMarker = ""; if (msg.content.includes(invokeMarker)) { // Structured mode — do NOT send to the LLM const req = extractJsonBlock(msg.content, "rails:invoke"); const result = await runRailsTask(req); // your agent's task runner const resultMarker = "\n```json\n" + JSON.stringify(result) + "\n```\n"; await msg.channel.send( resultMarker + "\n\n(Agent natural-language commentary here, optional)" ); return; } // Otherwise: existing free-form conversation path await runFreeFormLlm(msg); }); ``` ### Marker format **Invoke** (rails → agent): ``` ```json { "pipelineId": "01HW0...", "contractId": "01HW1...", "stage": "implement", "role": "implement", "sprintId": "SPRINT-007", "task": { "title": "Add feature X", "description": "...", "workdir": "/path/to/workdir" }, "timeoutMs": 30000, "structuredOutput": true } ``` ``` **Result** (agent → rails): ``` ```json { "stage": "implement", "verdict": "IMPL_DONE", "payload": { "branch": "feature/sprint-007", "commits": ["abc1234"], "workdir": "...", "selfTestReport": {"typecheck": "pass"} }, "errorReason": "" } ``` 구현 완료했어요! 테스트 전부 통과했습니다 ❤️ ``` The natural-language tail after `/rails:result` is free-form — rails ignores it, but the human user sees it. ## DiscordPoster interface To wire rails to a real discord.js client, implement `DiscordPoster` and pass it when constructing `DiscordTransport`: ```ts import { Client, GatewayIntentBits, TextChannel } from "discord.js"; import { DiscordTransport, type DiscordPoster } from "hanarang-rails"; const client = new Client({ intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent, ], }); await client.login(process.env.RAILS_DISCORD_TOKEN); const poster: DiscordPoster = { async postMessage(channelId, content) { const channel = await client.channels.fetch(channelId); if (!channel?.isTextBased()) throw new Error("Not a text channel"); const msg = await (channel as TextChannel).send(content); return msg.id; }, async waitForResult({ channelId, pipelineId, stage, timeoutMs, signal }) { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error("timeout")), timeoutMs); signal?.addEventListener("abort", () => { clearTimeout(timer); reject(new Error("aborted")); }); const handler = (msg: any) => { if (msg.channel.id !== channelId) return; const body = msg.content as string; if (!body.includes("")) return; if (!body.includes(pipelineId)) return; if (!body.includes(`"stage":"${stage}"`)) return; clearTimeout(timer); client.off("messageCreate", handler); resolve(body); }; client.on("messageCreate", handler); }); }, async close() { await client.destroy(); }, }; const transport = new DiscordTransport({ token: process.env.RAILS_DISCORD_TOKEN!, guildId: process.env.DISCORD_GUILD_ID!, channelId: process.env.DISCORD_PIPELINE_CHANNEL_ID!, poster, }); ``` ## Per-pipeline threads For cleanness, create a forum thread per pipeline: ```ts // On state transition, create a thread under the pipeline channel const thread = await (channel as TextChannel).threads.create({ name: `[SPRINT-007] ${projectName}`, autoArchiveDuration: 1440, }); ``` Pass `thread.id` as `channelId` when invoking agents. Rails stores the pipeline → thread mapping in SQLite (`pipelines.contextJson`). ## Testing without a live bot For development, use `rails run --mock` — the `MockTransport` doesn't touch discord and returns deterministic success messages. All tests ship with a fake poster; no real tokens needed. ## Security - **Never commit tokens.** `.env` is gitignored. Use secrets manager for production. - **Validate HMAC** on any inbound webhooks (Gitea). See `GITEA_WEBHOOK_SECRET`. - **Rate limit guard**: rails retries on 429 with exponential backoff (Sprint 005). ## Related - `operations.md` — day-to-day ops - `migration-guide.md` — porting from legacy bridges - `.plans/design/transports.md` — transport abstraction design