Discord ops
Discord is Echo’s human-in-the-loop channel. When an item lands in Needs Human, Echo posts it to a project’s Discord channel; when an operator replies, Echo routes that reply back into the board. Both directions are deterministic scripts driven by the hub session’s Discord MCP tool — the scripts never touch the network themselves.
Outbound: escalation → Discord
Section titled “Outbound: escalation → Discord”An escalation is queued as a file, not posted directly. flip_to_needs_human.sh
(and finalize_watcher.sh on a failed deploy) writes a JSON payload
(issue, title, reason, project_item_id, queued_at) into the project’s
outbox_dir. Posting is a separate, later step so a transient Discord failure
never blocks the status flip.
-
Drain (
drain_outbox.sh --project <root>). For each queued file it does an atomicmvintonotifications_dir— that move is the per-file lock, so only one concurrent tick claims each file (this is the fix for the old double-post bug). It resolves the project’sdiscordChannelIdfrom the registry and emits one JSON line per claimed file: the payload pluschannel_idanddelivered_path. Mention triggers (@everyone,@here,<@id>) in the untrusted title/reason are defanged with a zero-width space in the emitted copy before it’s posted. -
Post. The hub session (see
/echo:tick) reads each emitted line and posts it to Discord via the MCPreplytool withchat_id = channel_id, as a compact one-liner:⚠️ Echo ·
project· #issue“title” needs a human —reason -
Record (
record_delivery.sh --delivered-path <p> --message-id <id>). On a successful post the hub stamps the returned Discordmessage_idinto the delivered record, so an inbound quote-reply can be matched back to the item. If the post fails, the file is moved back tooutbox_dirand the next tick retries.
Inbound: reply → board
Section titled “Inbound: reply → board”After draining, the hub calls the MCP fetch_messages tool
(limit = discord.replyLookback, default 50), writes the messages to a temp
file, and runs route_replies.sh --project <root> --messages <file>. For each
reply it finds the matching Needs-Human record and re-queues the item.
Matching is, in order:
- By reference — the reply’s
referenced_message_idequals a record’s stampeddiscord_message_id(fromrecord_delivery.sh). - By
#Nfallback — a leading#<digits>in the reply text, matched to the newest unresolved record for that issue.
A normal reply flips the item Needs Human → Ready via flip_to_ready.sh,
attaching the reply text as operator guidance on the issue. Each record is
marked resolved so a reply is acted on exactly once.
The approve → merge command
Section titled “The approve → merge command”If the reply text, trimmed and lowercased, exactly equals one of
discord.approveKeywords (default ["approve", "merge"]), and the held item has
an open worker PR, route_replies.sh treats it as the human merge gate: it
merges the open PR directly (gh pr merge --squash --delete-branch) instead of
re-queuing a worker that would just re-hit the merge gate.
The match is exact, not a prefix — so "approve after you fix the test" or
"merge conflict, please rebase" are read as guidance and re-queue the item,
not as an approval. If there’s no open PR, the reply falls through to the normal
re-queue.
The author allowlist — fail-closed
Section titled “The author allowlist — fail-closed”Reply routing is gated by discord.replyAuthors, a list of Discord user IDs.
The author check runs before any matching, so it authenticates both the
reference match and the #N fallback.
// config: enable inbound replies for two operators"discord": { "replyLookback": 50, "replyAuthors": ["427028945137827851", "1507389582902034462"], "approveKeywords": ["approve", "merge"]}A reply from an author not on the list is logged and skipped without touching any record.
See also
Section titled “See also”- The run lifecycle — where Needs Human fits in the flow.
- Merge gates — what an
approvereply merges. /echo:tick— the hub command that drains, posts, and routes.- Config reference — the
discordblock.