Self-hosted Discord control plane for Flue coding-agent sessions
threadcord · Node 22+ · Postgres · Flue · discord.js
/task createopens a public thread on your reply, clones the requested GitHub repo into/workspaces, and runs a Flue agent turn. Postgres holds task state and durable turn rows; pg-boss delivers turns with per-task FIFO ordering, retries, and concurrency caps. After a restart, interrupted running turns are requeued automatically.
Threadcord is a Discord bot plus a small Hono server. Use /task create to pick a setup profile, model, and instruction in one modal. The bot opens a thread on the command reply, clones the repo, and dispatches work to a Flue coding agent. Thread messages handle follow-ups; thread commands handle status, cancel, abort, and done.
Configuration lives in .env.example. Zod validation is in src/config.ts.
- Getting started
- Configure model providers
- Message format
- Setup profiles
- MCP tool servers
- Why use Threadcord?
- Features
- How it works
- Data privacy
- Create a bot in the Discord developer portal.
- Turn on the Message Content intent.
- Grant View Channel, Send Messages, Create Public Threads, Send Messages in Threads, Read Message History. Optionally grant Manage Messages so the bot can pin task header messages — pinning is best-effort and task operation works without it.
- Copy the bot token into
.envasDISCORD_BOT_TOKEN. Invite the bot where you will use/taskand/setup.
cp .env.example .env
# Discord, GitHub, provider keys
docker compose build
docker compose upCompose sets THREADCORD_HTTP_BEARER to threadcord-dev-bearer when .env leaves it blank. Change it before any network-exposed deploy.
GET /healthreturns 200 when Postgres is up and the Discord client is ready.GET /health/livechecks Postgres only.- Default port is
3583. - Set
THREADCORD_HTTP_BEARERbefore exposing the service. Required whenNODE_ENV=production.
Health check:
curl http://localhost:3583/healthLiveness (Postgres only):
curl http://localhost:3583/health/livecp .env.example .env
npm install
npm run dev
npm run check
npm run test
npm run buildnpm run dev runs flue dev --target node. Flue generates the Node server (dist/server.mjs after build) and dispatch wiring. npm start runs the built server only and does not load .env — export variables or use Docker Compose.
Models are allowed when their provider is configured in .env. Discord tasks use model: <provider>/<model-id>.
| Provider | Env vars | Discord example |
|---|---|---|
| anthropic | ANTHROPIC_API_KEY, ANTHROPIC_MODELS |
model: anthropic/claude-sonnet-4-5 |
| openai | OPENAI_API_KEY, OPENAI_MODELS |
model: openai/gpt-5-codex |
Flue resolves these from its catalog. Set the API key and a comma-separated model list. No extra registration code is needed.
- Add the provider ID to
PROVIDERS(comma-separated for multiple). - Set
PROVIDER_<ID>_BASE_URL,PROVIDER_<ID>_API, andPROVIDER_<ID>_MODELS. OptionalPROVIDER_<ID>_API_KEYandPROVIDER_<ID>_HEADERS. - Normalise the ID for env var names:
my-gatewaybecomesPROVIDER_MY_GATEWAY_*. - Use
model: <id>/<model-id>in Discord, where<model-id>comes from_MODELS.
Example (local Ollama via OpenAI-compatible endpoint):
PROVIDERS=ollama
PROVIDER_OLLAMA_BASE_URL=http://localhost:11434/v1
PROVIDER_OLLAMA_API=openai-completions
PROVIDER_OLLAMA_MODELS=llama3.1:8bCommon api values: openai-completions, openai-responses, anthropic-messages. See the Flue Provider API for the full list.
To route a catalog provider through a proxy, list its ID in PROVIDERS and set PROVIDER_<ID>_BASE_URL (for example PROVIDERS=anthropic with PROVIDER_ANTHROPIC_BASE_URL=...). Flue layers your transport on the catalog.
Use PROVIDER_<ID>_HEADERS for providers that require custom request headers. The value must be a JSON object with string values:
PROVIDERS=agent-router
PROVIDER_AGENT_ROUTER_BASE_URL=https://router.example.com/v1
PROVIDER_AGENT_ROUTER_API=openai-completions
PROVIDER_AGENT_ROUTER_MODELS=gpt-5-codex
PROVIDER_AGENT_ROUTER_HEADERS={"User-Agent":"Threadcord"}Inside Docker Compose, localhost in a provider URL points at the container, not your host. Use host.docker.internal, a Compose service name, or host networking.
Allowed models are derived at startup from these provider blocks. The create modal requires a model; it is pre-filled with the default (first configured model).
When DISCORD_CHANNEL_ID is set, plain messages in that control channel can create tasks in addition to /task create. Put the instruction first, then keyed metadata lines at the end:
Fix the login bug
repo: owner/repo
branch: main
model: anthropic/claude-sonnet-4-5
push: threadcord/feat/login-fix
repo and branch are required. model and push are optional. Attachments can supply content when the instruction prose is empty. Omit DISCORD_CHANNEL_ID to allow only /task create.
Run /task create in any channel where the bot can post and create threads.
- A modal opens with a setup profile dropdown (
owner/repo @ branch, up to 25 ready profiles), a model field (defaults to the first allowed model at startup), and a task instruction field. - After you submit, the bot replies with a link to a new public thread. Progress and agent output stream there.
If no setup profile is ready, run /setup create first.
Coding agents normally create branches named threadcord/<type>/<meaningful-name> (for example threadcord/feat/add-auth). Push overrides are not exposed in the slash UI; use follow-up instructions in the thread if you need a specific push target. Only the task base branch and explicit threadcord/* branches are allowed as push targets.
Thread commands (in a Threadcord-created thread):
statusreplies with a jump link to the pinned task header (live status lives there).abortor/abortasks for confirmation, then stops the in-flight agent turn and cancels the task.cancelor/cancelasks for confirmation, then cancels the task without failing the current turn (no further dispatches).doneasks for confirmation, then marks awaitingorqueuedtask complete.
Threadcord stores durable setup profiles in its own Postgres database. A profile belongs to one normalized GitHub repository and one base branch. It contains an environment JSON recipe and Markdown memory for future coding agents.
Normal coding tasks require a ready setup profile. If a task targets a repository and branch without one, Threadcord rejects the task and tells the user to run setup first. Task workspaces can still expire. Setup profile data stays in Postgres.
Target repositories do not need Threadcord files. Setup does not require .cursor, .threadcord, THREADCORD_SETUP.md, AGENTS.md, or any committed compatibility file.
Setup environment JSON:
{
"install": "npm ci",
"start": "",
"checks": {
"build": "npm run build",
"test": "npm test",
"lint": "npm run lint",
"typecheck": "npm run check"
},
"requiredEnv": ["DATABASE_URL"],
"requiredServices": ["postgres"]
}install and checks are discovered and verified by the setup agent during first-time setup. Threadcord runs install with bash -c
on the initial task turn, so setup profiles can use project-specific bootstrap
commands and shell pipelines. Setup install uses the same non-login shell behavior
as agent commands, so workspace-local npm globals remain on PATH.
Promotion happens when the setup agent calls save_threadcord_setup_profile. That tool re-runs install, every stored checks command, and (when start is non-empty) a short smoke probe of start in the setup workspace. checks should be commands that passed in that workspace. If a useful command needs missing secrets or services, record the names in requiredEnv, requiredServices, and memory instead of saving a failing check unless you can make it pass during setup. start is optional; leave it empty if there is no long-running dev server to probe. Use /setup update (or /setup edit) when you want to override install or checks before re-running setup.
Threadcord scopes each setup and task workspace with its own HOME, npm global prefix, and cache directory. Commands such as npm install -g <tool> install into that workspace and put the workspace-local bin directory on PATH. Deleting the workspace deletes those globals.
Setup commands (Discord slash command /setup with subcommands; status, view, edit, export, and delete use a profile picker instead of repo/branch args; import still takes repo, branch, and attachments):
| Subcommand | Purpose |
|---|---|
create |
First-time setup when no profile exists, or when the profile is failed. Opens a modal (repo, branch, skills — no model field), then spawns a public thread on the slash command with a live agent log (same style as coding tasks). The setup agent discovers and verifies install and checks. |
update |
Re-run setup when the profile is ready or failed (not while running or updating). Opens a modal (repo, branch, skills, install, checks — no model field) so you can edit the command recipe before re-running, then spawns a setup thread with live log. |
status |
Pick a profile, then show profile status, revision, and last run state (ephemeral). Re-run anytime for a fresh snapshot; while setup is running, open the setup thread from your create/update command for the live agent log. |
view |
Pick a profile, then view the active profile environment and memory (ephemeral). |
edit |
Pick a profile, then open a private draft editor with buttons and modals. |
export |
Pick a profile, then export environment JSON and memory Markdown as ephemeral attachments. |
delete |
Pick a profile, confirm in a dialog, then remove the profile and its runs/drafts (not allowed while running or updating). |
import |
Import environment and/or memory attachments into a draft (repo and branch required). |
Repository names are normalized to lowercase owner/repo. Coding tasks require a profile in ready status.
Draft edits are isolated from the active profile. Applying a draft increments the profile revision only if the active profile still matches the draft base revision. If someone changed the profile first, Threadcord reports a conflict and leaves the active profile unchanged.
Setup profiles store required environment variable names only. They must not contain secret values. Threadcord validates setup JSON and memory before saving, importing, or applying a draft. Draft import and apply perform structural validation only. They do not re-run commands because the setup workspace is gone.
During coding turns, the agent can call append_threadcord_setup_memory to append Markdown to the active profile memory (gotchas, stable fixes, operator preferences). Each append increments the profile revision; new tasks use the latest revision on admission. Follow-up turns in an existing task reload memory on the next dispatch. Appends do not change install or checks.
Add external MCP (Model Context Protocol) tool servers at runtime via Discord. No .env changes or restarts needed.
| Command | What it does |
|---|---|
/mcp add |
Opens a modal to configure a server (id, URL, token, transport, headers). Validates the connection before saving. |
/mcp remove |
Pick a server from a dropdown, confirm removal. Closes the live connection and deletes from DB. |
/mcp list |
Lists configured servers (id, URL, transport), paginated when there are many. Tokens are never shown. |
MCP servers are global — every task gets tools from all connected servers. Servers persist in Postgres and reconnect on restart.
Each control-channel message gets its own public thread. Status updates and follow-ups stay in that thread.
MAX_CONCURRENT_TASKS caps parallel agent turns across the process (pg-boss localConcurrency). Extra tasks and follow-ups queue as durable task_turns rows delivered by pg-boss with per-task FIFO ordering (key_strict_fifo). Follow-up messages in a thread queue behind the current turn for that task. On each task’s initial turn only, Threadcord runs the profile’s install command in the task workspace before dispatching the coding agent (follow-up turns reuse the checkout without re-running install).
Postgres, workspace volumes, and API keys stay on your machine or VPS.
| Capability | Where | What happens |
|---|---|---|
| New task | Control channel | Thread created, repo cloned, first turn queued (requires ready setup profile) |
| Follow-up | Task thread | Turn row + pg-boss job queued; runs after prior turn for that task finishes |
status |
Task thread | Replies with a jump link to the pinned task header |
abort |
Task thread | Confirms, then stops in-flight agent work and cancels the task (/abort or abort) |
cancel |
Task thread | Confirms, then cancels task; current turn may finish; no further dispatches |
done |
Task thread | Confirms, then marks task completed from waiting or queued |
| Open PR | Agent tool | create_github_pull_request after push |
| Learn | Agent tool | append_threadcord_setup_memory after verified repo lessons |
| Setup | /setup slash |
Durable per-repo profiles; see Setup profiles |
| MCP tools | /mcp slash |
Add, remove, and list global MCP tool servers at runtime |
flowchart TD
discord["Discord messages"] --> gateway["gateway.ts"]
gateway --> orchestrator["TaskOrchestrator"]
orchestrator --> intake["task_turns row + pg-boss send"]
intake --> worker["pg-boss worker"]
worker --> executor["turn-executor.ts"]
executor --> bootstrap["git clone and checkout"]
executor --> flue["Flue coding agent"]
flue --> githubApi["GitHub API"]
flue --> observe["observe-bridge.ts"]
observe --> thread["Discord thread progress"]
gateway.tsroutes channel messages to task creation and thread messages to follow-ups.orchestrator.tsadmits tasks and follow-ups in a Postgres transaction: insert atask_turnsrow andboss.sendatomically.- pg-boss delivers
task-turnjobs toturn-executor.ts, which claims the turn, bootstraps the workspace, dispatches the Flue agent, and awaits an in-process completion bridge. store.tsandturn-store.tspersist task and turn state;boss.tsowns queue delivery, retries, and per-task FIFO.agents/coding.tsruns the Flue agent with git/bash tools and the GitHub PR tool.observe-bridge.tsstreams agent progress into the thread and resolves turn outcomes on agent end/failure.TaskOrchestratorrefreshes the pinned Components-v2 task header for live state.
Task rows and cloned repos live in your Postgres and workspace volume. No third-party control plane.
Agent turns send your instruction and repo context to whichever model you configured. Check that provider's data policy.
Instructions and status lines post to your server. redact.ts strips token-shaped strings before send.
Clone, push, and PR creation use your GITHUB_TOKEN. Repo access is bounded by that token's scope. Limit who can run /task create and post in task threads accordingly.
The bot needs the Manage Messages permission to pin the task header message. Without it, pinning fails silently (logged server-side) and the task continues normally. The header is still posted and editable — only the pin is missing.
After a restart, turns stuck in running are reset to queued, their pg-boss jobs are re-sent, and the task moves back to queued until the turn runs again. Orphaned queued turns without a pg-boss job are enqueued on boot. If a Discord thread is no longer accessible (archived, deleted, or permissions changed), the restart notification for that task is logged and skipped. Other tasks and queue slots are unaffected.
When the agent hits repeated tool validation errors (wrong argument schemas), the turn is aborted early to stop error spirals. Discord receives a generic failure message; detailed validation text stays in server logs. Adjust AGENT_MAX_VALIDATION_FAILURES in .env to tune the threshold.
MIT. See LICENSE.