kanban-task-threads
A Hermes plugin that gives every kanban task a Discord forum thread of its own: the first post is a live status card rewritten in place, and the replies are the append-only log. The post body answers "what is happening now?"; the thread answers "how did we get here?".

Webhook replies can also carry a Phosphor avatar selected by message type:
![]()
The card re-renders from board state on every change (status, block reason,
assignee, prerequisites, elapsed time via Discord's live <t:…:R> timestamps).
Each reply is signed with the profile that caused it. The card itself is
signed with the webhook's own name — deliberately: a webhook message's
username is fixed at creation, so signing the card with an assignee would
freeze the first one forever (see docs/transport.md).
Requirements
- Hermes 0.20+ with the kanban board in use.
- A Discord forum channel and a webhook bound to it. The webhook is write-only and bound to one channel: a leak costs only the ability to post there.
- Optionally, the selected publisher profile's existing Discord bot token.
Do not create a dedicated bot for this plugin. Its integration-managed role
needs
View Channel,MANAGE_THREADS(shown in Discord as Manage Threads and Posts),READ_MESSAGE_HISTORYfor archived listing, andMANAGE_CHANNELS(Manage Channels) to create missing status tags. Scope both management permissions to this forum, not the whole server. This unlocks title state, tags, archiving and metadata repair. The plugin works without a bot token and never requires one.

Discord creates and manages this integration role for the bot. Grant the
permission to that role; it is not a role you manually assign to people.
If you do not grant Manage Channels, create all managed tags manually before
the first publishing pass with the documented emojis; the plugin will reuse
them and will not need that permission while the full vocabulary remains
present and unchanged.
Install
The reviewed catalog entry is the normal install path:
hermes plugins install kanban-task-threads
hermes plugins enable kanban-task-threads # opt-in allow-list
A direct GitHub install remains available for unreleased revisions, but Hermes treats it as an unreviewed community source and scans the whole repository — including tests, CI and documentation — before installing it:
hermes plugins install diegomarino/kanban-task-threads
hermes plugins enable kanban-task-threads
If a future revision reports CAUTION, review every finding before repeating
the command with --force; that flag cannot override a DANGEROUS verdict.
Do not disable install-time scanning. Prefer the catalog name and its reviewed,
pinned commit for normal installations.
Set the secret in your profile's environment (through your secret manager —
e.g. a 1Password op:// reference — never a literal in config):
KANBAN_TASK_THREADS_WEBHOOK_URL=https://discord.com/api/webhooks/…/…
KANBAN_TASK_THREADS_BOT_TOKEN=… # optional
Then restart the gateway — a gateway holds its plugin set from startup.
Confirm with hermes plugins list that the source column reads user.
The forum channel is never configured: the plugin asks the webhook where it
posts (GET on the webhook returns its channel_id). One credential, one
source of truth.
Naming the forum is yours to do — the plugin discovers channels, it never
creates or names them. The binding between a board and a forum is the pair
(board setting, webhook secret), not any name. Suggested convention: name
the forum after the board's slug (#tasks-default, #tasks-web), so a human
reading the channel list can tell which board publishes where. One deployment
serves one board; point each board's deployment at its own forum's webhook.
Managed tags: with a bot token, the first pass holding the board lease reuses or creates the exact forum tags
🔎 triage,📋 todo,📅 scheduled,🟢 ready,🏃 running,⛔ blocked,🙋 needs-human,👀 review,✅ done,📦 archived, and❌ failed. It also repairs missing or changed emojis on those managed names. Existing unrelated tags are preserved. Discord permits at most 20 forum tags, so the pass fails closed rather than deleting anything if the combined set will not fit.Forums that require tags: the bot path automatically uses managed
triageas the creation tag when nodiscord_applied_tag_idsoverride is configured, then applies the task's real state. Webhook-only installations must still setdiscord_applied_tag_ids, because a webhook cannot discover or create forum tags.
Configuration
All optional, under plugins.entries.<id>.settings:
| Key | Default | Meaning |
|---|---|---|
board |
current board | Which board this instance serves |
publisher_profile |
empty | Exact ctx.profile_name allowed to publish. Empty preserves the public/single-profile default: every loaded profile is a lease candidate and the board lease elects the active publisher. A value pins publication and gives up cross-profile failover |
reply_on |
sensible set | Event kinds that earn a reply in the thread |
include_workspace_path |
false |
Publish the absolute workspace path on the card (see Privacy) |
discord_applied_tag_ids |
[] |
Optional creation-tag override. Required only for webhook-only operation in a forum that enforces a tag; bot mode otherwise bootstraps with managed triage |
avatars_enabled |
true |
Add message-state avatars; false preserves plain webhook identities |
avatar_base_url |
official catalog | Optional HTTPS directory containing 12 custom PNGs; blank uses the official versioned catalog |
avatar_theme |
duotone |
Official-catalog Phosphor weight: duotone, fill, or bold; ignored with a custom directory |
avatar_palette |
colored |
Official-catalog palette: colored, black, or white; ignored with a custom directory |
poll_seconds |
20 |
Seconds between passes when no hook kicks arrive |
stale_after_seconds |
600 |
Heartbeat age after which a running task renders as stale |
dashboard_url |
— | Task page URL; may contain {task_id} and {board}. Powers the card link and each reply's trailing [#] (without it, [#] jumps to the card in-app). Links are forever: every URL you publish is frozen in Discord permanently, so use a stable name that resolves from every device you click from — never an IP (the plugin warns on literal IPs), never anything discovered at boot |
comment_excerpt_chars |
180 |
Comment text quoted in replies; 0 = author only |
card_template, reply_templates |
built-ins | Cosmetic templates (docs/configuration.md) |
Templates are rendered through a restricted formatter (flat fields only —
attribute/index traversal is rejected) and fall back to the built-ins on any
error. What is not configurable, by design: which events publish, when the
card re-renders, idempotency, retry policy, truncation, and
allowed_mentions: {"parse": []} on every call.
Message avatars
Avatars are author identity, not message media: the plugin sets Discord's
avatar_url when creating the starter or a reply. It does not add attachment
images, embed thumbnails, or image embeds. The starter always uses default;
replies cover commented, blocked, unblocked, review_requested,
changes_requested, completed, gave_up, crashed, timed_out,
reclaimed, and archived. Unknown future kinds fall back to default.
Discord may visually group consecutive webhook messages that have the same
username even when their avatar_url differs. With avatars enabled, reply
identities therefore include the type: {profile_id} · {message_type} (for
example, profile_wester · blocked). Events without an attributable actor use
system · {message_type}. This keeps every type's avatar visible while
preserving the real profile id whenever it fits. Discord caps webhook names at
80 characters, so an unusually long profile id is marked with … and shortened
just enough to retain the complete message type. When avatars are disabled,
usernames remain unchanged and system observations continue using the webhook's
own identity.
With no avatar settings, the plugin uses the official duotone/colored
catalog. The repository includes 96 px PNGs for all three themes and palettes
under pages/v1/, plus their Phosphor 2.1.1 SVG sources, render manifest, and
license under assets/avatars/. colored is the approved semantic palette;
black uses a black glyph on a white circle and white uses a white glyph on
a black circle. Phosphor's native 20% duotone layer produces the secondary grey
without inventing a second icon color.
The checked-in workflow publishes pages/ with GitHub's official Pages actions
when that directory changes on main. The built-in URL contract is:
https://diegomarino.github.io/kanban-task-threads/
v1/{theme}/{palette}/96px/{message_type}.png
The bundled manifest selects v1; it is not a user setting. A significant
visual redesign moves the plugin to v2, while v1 remains published for old
installations and messages.
For custom artwork, avatar_base_url is the final directory containing
default.png, commented.png, blocked.png, unblocked.png,
review_requested.png, changes_requested.png, completed.png, gave_up.png,
crashed.png, timed_out.png, reclaimed.png, and archived.png. The plugin
appends only the filename—never its own version, theme, palette, or size:
avatar_base_url: https://assets.example.com/hermes-avatars
https://assets.example.com/hermes-avatars/blocked.png
The custom directory must be public HTTPS without authentication. It needs no
manifest. Theme and palette settings are ignored in custom mode; choose a
different collection by changing the directory URL. Invalid cosmetic settings
fall back or disable only avatars, never task publication. Set
avatars_enabled: false to opt out explicitly. Full operational details are in
Avatar customization and self-hosting.
The renderer for the official catalog requires rsvg-convert and performs no
network access:
python3 scripts/render_avatars.py
The runtime never hotlinks Phosphor, uploads assets, probes an origin, or creates hidden Discord messages to host files. See ADR-0015.
Privacy and egress — read before enabling
Everything this plugin publishes leaves your machine permanently and is visible to everyone who can read the channel:
- Task titles, block reasons, completion summaries and failure text are arbitrary agent output and may echo secrets or stack traces. They are published because they are the feature — length-capped, mentions neutralized — but they are an egress path. Point the webhook at a channel with an audience you trust.
workspace_pathcontains your OS username and project layout. Off by default; opt in withinclude_workspace_path.- Comment text is excerpted into replies (length-capped, ADR-0011);
set
comment_excerpt_chars: 0if comment bodies must never leave the board. - When a prerequisite thread opens, its dependency announcement includes the first 140 characters of that task's body. This path currently has no opt-out; avoid sensitive task bodies when dependency announcements are enabled.
Failure behavior
Discord being down never loses an event: the plugin consumes the board's own event log past a durable cursor, so it resumes where it left off after any crash or restart. Unreachable configurations degrade to a no-op with one log line instead of queuing work that can never be sent. A deleted post is respected (the plugin stops publishing for that task rather than re-creating it), and re-pointing the webhook freezes old posts instead of corrupting them. Details: docs/failure-policy.md.
Diagnostics go through the standard Python logger (kanban-task-threads
namespace): warning for transient retries, error for anything needing an
operator.
Limitations, honestly
- Comment text is excerpted, not full — replies quote up to
comment_excerpt_chars(default 180) with a link to the full text; set it to0to publish only who commented (ADR-0011). - Latency is seconds to
poll_seconds, not instant: hooks are best-effort kicks; correctness rides the durable cursor. - The thread name is frozen for webhook-only deployments (it carries the task id on purpose); a bot token unlocks renames.
- The status tag owns the thread's tags when the bot path is active: a manually applied tag will not survive the next automated retag (docs/transport.md documents the tradeoff).
- Metadata audit is deliberately bounded to all active guild threads plus the forum's latest 25 public archived threads. Older archived posts are not scanned until they re-enter that window.
- Replies are at-least-once across a crash between send and cursor write;
a rare duplicate reply is possible. Post creation is strictly guarded
instead — ambiguous outcomes wait for an operator (
reconcileverbs). - One deployment serves one board and one forum. Multi-forum routing is deliberately not built (ADR-0010).
This project is not affiliated with or endorsed by Discord Inc. or Nous Research.
Uninstall
hermes plugins disable kanban-task-threads
hermes plugins remove kanban-task-threads
remove deletes the directory but does not touch plugins.enabled — run
disable too. The task→post mapping is left behind deliberately (it lives
with the board, not the plugin); posts are never deleted.
Development
./scripts/sandbox test # unit suite (needs uv; pytest runs only through it)
./scripts/sandbox up # disposable Hermes home — never touches a real one
./scripts/sandbox task # walk a task through a realistic lifecycle
./scripts/sandbox consume # run the consumer against it, printing, no network
./scripts/sandbox doctor # load the plugin through the real runtime, offline
Design rationale lives in the ADRs; the map from design to code in ARCHITECTURE.md; topic deep-dives in docs/; releases in CHANGELOG.md (process: RELEASING.md); vulnerability reports: SECURITY.md.
A second transport?
Discord is the first transport, not the only conceivable one. The transport seam is five operations plus a capability declaration, and a Telegram mapping exists on paper — along with what it would lose (live timestamps, per-message identity, the webhook credential model). Read docs/transport.md before proposing one.

