Channels
A channel is where a message comes from. Wick agents are reachable from four channels at once:
| Channel | Connection | Session key | Source |
|---|---|---|---|
| Slack | Socket Mode (default) or HTTP Event API | thread_ts | channels/slack/slack.go |
| Telegram | Long polling | tg-<chatID> | channels/telegram/telegram.go |
| Web UI | Direct HTTP + SSE | UUID minted by wick | internal/tools/agents/ |
| REST (OpenAI-compatible) | HTTP request/response, OpenAI SDK | rest-<conversation> (or fresh UUID) | channels/rest/rest.go |
All three implement the same Channel interface (channel.go:59) — the pool sees them uniformly via a SendFunc. Wiring is handled by *Registry (not server.go directly); channels/setup/ composers do the one-call boot assembly.
┌──────────┐ ┌────────────┐ ┌────────┐
│ Slack │ │ Telegram │ │ Web UI │
└────┬─────┘ └─────┬──────┘ └───┬────┘
│ │ │
└──────────────┼─────────────┘
▼
Registry.Add (auto-wires deps via setter interfaces)
│
▼
SendFunc (pool.Send)
│
▼
┌──────────┐
│ Pool │ — slot allocation, queue
└──────────┘
│
▼
Provider subprocess
│
▼
AgentEvent ─────► Registry.DispatchAgentEvent ─► channelsSource
Channel interface + types: channels/channel.go. Registry + fan-out: channels/registry.go. Setup composers: channels/setup/setup.go. DB-backed config store: channels/store.go. Web UI handler: internal/tools/agents/handler.go.
Common shape
Every channel:
- Listens for inbound messages.
- Runs access control (channel-specific).
- Intercepts meta-commands (see below) before they reach the agent.
- Calls
sendFn(ctx, sessionID, agentName, source, role, text)to dispatch into the pool. - Receives agent events via
OnAgentEventto stream the reply back. - Receives gate approval requests via
OnApprovalRequestfor interactive Bash approval inside the channel.
The Channel interface itself (channel.go:59) requires only Name() string, Start(ctx) error, Stop(), IsConfigured() bool. Everything else is opt-in via setter and receiver interfaces that Registry.Add wires automatically via type assertion:
| Interface | What it gives the channel |
|---|---|
SendFuncSetter | Pool dispatch closure |
SessionCheckerSetter | Probe whether a session already exists (used for first-turn context injection) |
SessionStartHookSetter | Callback fired once on brand-new session |
ApproveFnSetter | Gate approval resolver (channel name pre-bound by registry) |
PublicURLSetter | Base URL for /dashboard meta-command replies |
AgentEventReceiver | OnAgentEvent — stream agent output back to the user |
ApprovalReceiver | OnApprovalRequest / OnApprovalResolved — render gate modal in channel |
HTTPHandlerProvider | Expose a webhook path (Slack HTTP mode) |
LookupProvider | Back picker config fields with a live search against the upstream (Slack users, channels, …) |
HealthChecker | Power the Test Integration button on the channel config page — return per-probe pass/fail rows |
Channels declare exactly the interfaces they need; unused ones are simply not implemented.
Slack
📸 Screenshot needed:
agents-slack-config.png— capture/tools/agents/channels/slackshowing the form (Mode, Bot Token, App Token, Access Mode, Project dropdown). Save todocs/public/screenshots/agents-slack-config.png.
📸 Screenshot needed:
agents-slack-thread.png— capture a Slack thread mid-conversation: user message with ⏳/⚙️/✅ reaction lifecycle visible, bot reply chunked. Save todocs/public/screenshots/agents-slack-thread.png.
📸 Screenshot needed:
agents-slack-approval.png— capture a Slack thread where an approval prompt was posted (Approve / Block / Always buttons). Save todocs/public/screenshots/agents-slack-approval.png.
Connection modes
| Mode | When | Config |
|---|---|---|
socket | Default. No public URL needed. Wick opens a Socket Mode connection to Slack and receives events over a websocket. | BotToken (xoxb-) + AppToken (xapp-) |
http | Webhook style. Slack POSTs events to your public URL; you sign with the signing secret. | BotToken + SigningSecret + a publicly reachable wick |
Both are implemented in slack/slack.go; pick via the Mode config dropdown.
Session binding
Slack threads = wick sessions. The first message in a thread auto-creates a session keyed by thread_ts. Replies to the same thread reuse it. New top-level message in a channel = new thread = new session.
Reaction lifecycle
The agent's progress is mirrored on the user's message (slack.go:34-39):
| Reaction | Stage |
|---|---|
⏳ hourglass_flowing_sand | Queued (no slot yet) — only added when the pool hasn't dispatched within 3 seconds, so fast-path turns never flash it |
| (cleared) | Accepted by the pool — queue emoji removed; the assistant banner takes over |
🚫 no_entry_sign | Blocked — gate or access control rejected |
❌ x | Error — exception during the turn |
The bot uses reactions only for states the operator can't see anywhere else. Queue state lives only on the message until the pool takes it; once accepted, the queue reaction is cleared and the assistant banner (is thinking…) carries progress. On a successful done the banner is cleared too — the reply itself is the signal. Blocked / error remain as reactions so the post-mortem state is visible at a glance.
Progress banner (assistant threads)
When the workspace has Slack AI features enabled and the bot holds the chat:write scope, wick also calls assistant.threads.setStatus to render an "is thinking…" banner above the input. The banner is cleared on done / blocked / error. Because Slack auto-clears the status after ~2 minutes of inactivity, wick re-asserts it every 45 seconds during long tool-use turns so the banner stays visible throughout multi-step runs. Workspaces without AI features get a one-line debug log and rely on the reaction emoji alone.
Chunked reply
Slack hard-limits messages to 4000 chars. Wick chunks at 3800 to leave 200 chars headroom for continuation markers (slack.go:32). Each chunk is a separate threaded reply.
Approval prompt cleanup
Gate approval prompts in Slack are interactive button messages. When the prompt is resolved — decision clicked, request expired, or revoked from elsewhere — wick deletes the prompt message entirely instead of leaving an "Approved" / "Blocked" residue (slack.go OnApprovalResolved). The thread stays clean; the decision is observable through reaction state + downstream agent output.
Access control
Three independent per-resource whitelists, each with its own *_mode dropdown (all / whitelist):
| Field pair | What it gates |
|---|---|
UsersMode + AllowedUsers | Who (Slack user IDs) may trigger the agent |
GroupsMode + AllowedGroups | Which user groups |
ChannelsMode + AllowedChannels | Which channels / DMs the bot accepts messages from |
Semantics (slack.go allowedCfg):
- If both
UsersModeandGroupsMode=whitelist→ OR (pass when either matches). - If only one is
whitelist→ that list gates alone. - If both are
all→ identity check is skipped. ChannelsModeis always AND on top (different dimension: scope of where).
The allow-list fields use the picker widget — searchable typeahead backed by Slack's API (see pickers below) — so the operator picks chips by name instead of pasting raw IDs. The list field is hidden whenever its mode is all to keep the form compact.
Approval gates have their own approver block:
GateApprovers | Who may resolve approval buttons |
|---|---|
trigger_users (default) | Anyone who passes the access whitelists. |
admins | Workspace admins / owners (probed via users.info). |
custom | Explicit GateApproverUsers + GateApproverGroups pickers. |
Unauthorized clicks get an ephemeral "Not authorized" reply and the gate stays open. Checked per-click. No restart needed — see hot-reload.
Pickers
The picker widget is a generic typeahead bound to a channel-specific lookup source. Slack registers three sources:
| Source key | Backed by | Fallback |
|---|---|---|
slack.users | assistant.search.context (messages → de-dupe by author) | users.list |
slack.usergroups | usergroups.list | — |
slack.channels | assistant.search.context (channels → parse permalink for ID) | conversations.list |
The picker stores the chips as JSON [{id,name},...], identical in shape to the kvlist widget, so the same access-control parser reads either. Lookups are cached 60s per (source, query) to avoid hammering Slack's rate limits while the operator types.
Integration health check
The Slack config page has a Test Integration button at the top. Clicking it runs the API calls the channel depends on (in parallel, ~5s budget) and reports only the ones that failed. Each failed row shows the scope hint so the operator can fix the Slack app manifest without guessing.
Probes:
auth.testteam.info(scope:team:read)users.list(scope:users:read)usergroups.list(scope:usergroups:read)conversations.list(scopes:channels:read,groups:read)chat.postMessage(dry-run against an invalid channel ID — distinguishesmissing_scopefromchannel_not_found)reactions.add(dry-run against an invalid timestamp)assistant.search.context(scope:assistant:write— optional, falls back to list APIs)
When all probes pass the panel shows a single "✓ All checks passed" line.
Hot-reload
Hot-reload runs through Registry.WatchConfigs (30-second poll). Each channel registers a ConfigSource — a (Hash, Reload) pair — when it is added to the registry. For Slack the source lives in slack/source.go; the fingerprint covers the credentials (Mode, BotToken, AppToken, SigningSecret, pubURL) plus every access-control field (UsersMode, AllowedUsers, GroupsMode, AllowedGroups, ChannelsMode, AllowedChannels) and the approver block (GateApprovers, GateApproverUsers, GateApproverGroups). When the hash changes the registry calls Reload, which triggers a graceful stop + restart of the Socket Mode connection. Config save → 30s tail → Slack picks up the new tokens. No server restart.
Each per-user Slack instance gets its own ConfigSource scoped to that user's row (NewConfigSourceKeyed). Hot-reload monitors all instances independently; a credential change by one user does not affect other users' running instances.
Project selection
When only one project exists, Slack uses it without asking — the operator doesn't need to set ProjectID. With multiple projects, the ProjectID config field picks one.
App manifest
A ready-made Slack app manifest is shipped at docs/slack-app-manifest.json. Drop it into the Slack app create flow and you get the right scopes (app_mentions:read, chat:write, reactions:write, etc.) without hand-toggling.
Telegram
📸 Screenshot needed:
agents-telegram-config.png— capture/tools/agents/channels/telegramshowing the form (Bot Token, Allowed IDs, Project). Save todocs/public/screenshots/agents-telegram-config.png.
📸 Screenshot needed:
agents-telegram-chat.png— capture a Telegram chat with the bot: user message → bot reply, plus an inline-keyboard approval message (Approve / Block buttons). Save todocs/public/screenshots/agents-telegram-chat.png.
Setup
- Create a bot via @BotFather → grab the token (
123456:ABC-...). - Paste the token into
/tools/agents/channels/telegram→BotToken. - Optional: list allowed chat IDs in
AllowedIDs(kvlist). Empty = open to all chats the bot is added to.
The token is validated at config-save time. Invalid token → channel stays in dormant mode (no listener, no error log spam) and re-validates on the next save (telegram.go:99-117).
Session binding
One Telegram chat = one wick session, keyed tg-<chatID> (telegram.go:242). The session lives across messages in that chat.
Default project fallback
When the Telegram config has no ProjectID set, it falls back to the literal "main" (telegram.go:262-265), not the built-in default project. So if you set up Telegram on a fresh install with the default project only, the agent will fail to spawn until you either (a) create a project named main, or (b) set ProjectID to default in the channel config.
Connection: long polling
Telegram doesn't support Socket Mode like Slack. Wick uses long polling with a 60-second timeout (telegram.go:158-175). No public URL needed. Hot-reload works the same way as Slack — telegram/source.go fingerprints BotToken + AllowedIDs + ProjectID; Registry.WatchConfigs calls Reload on change.
Approvals via inline keyboard
Gate approval requests appear as an inline-keyboard message in the chat. Buttons: Approve once, Allow this session, Always, Block. Telegram limits callback_data to 64 bytes, so wick stores the full gate fields server-side and sends only a short token in the button (telegram.go:55-59).
When you tap a button, the original approval message is edited in place to show the outcome — no spam in the chat history.
Per-user instances
Telegram follows the same per-user model as Slack. Each user who saves a Telegram config (bot token + settings) gets their own channel instance keyed by their user ID. The App Owner's row uses user_id = NULL. On boot, wick starts one Telegram long-polling instance per configured owner; a new user saving their config triggers a hot-add without a server restart.
Each instance polls its own bot token independently. A credential change by one user does not affect other users' running bots.
Chunked reply
Telegram caps messages at 4096 chars. Wick buffers all output and posts the full reply chunked on Done (telegram.go:64-67). Streaming text deltas don't post intermediate updates (Telegram has no equivalent of Slack's reaction lifecycle).
Web UI
📸 Screenshot needed:
agents-web-session.png— capture/tools/agents/sessions/<id>with the conversation visible, composer at the bottom, and the running-agent indicator. Save todocs/public/screenshots/agents-web-session.png.
📸 Screenshot needed:
agents-web-approval.png— capture a session detail with the gate approval modal open (4 buttons + countdown timer + cmd shown). Save todocs/public/screenshots/agents-web-approval.png.
📸 Screenshot needed:
agents-web-askuser.png— capture a session with the AskUser inline card visible (question + option buttons + optional freeform input). Save todocs/public/screenshots/agents-web-askuser.png.
The web UI is the always-on third channel. No config — it's just /tools/agents plus per-session pages.
| Concern | How |
|---|---|
| Session create | First POST to /tools/agents/sessions/{id}/send with a fresh UUID auto-creates the session. The UI mints the UUID. |
| Streaming | SSE at GET /tools/agents/stream broadcasts agent_event, approval_request, approval_resolved, ask_user, ask_user_resolved. The page subscribes via EventSource. |
| Approval modal | When approval_request fires for the visible session, JS opens a modal with 4 buttons + 25s countdown. Click → POST /sessions/{id}/approve with {id, decision}. |
| AskUser card | When ask_user fires, JS renders an inline card in the composer area with the question, option buttons, and (if allow_freeform=true) a text input. Submit → POST /sessions/{id}/answer. |
| Approved-commands panel | Lists every approve_always rule for the current session, with a Revoke button. |
SSE event vocabulary
The web UI listens to one stream (GET /tools/agents/stream?session=<id>) and dispatches every event by type. Other channels reuse the same broadcaster (stream.go) so adding a transport doesn't change the event shape — only how the transport delivers it.
type | Producer | Payload | FE handler |
|---|---|---|---|
lifecycle | State machine via pool OnLifecycle | lifecycle: spawning|working|idle|killed, pid, at (LastActive ms) | Update header badge, idle countdown, typing indicator |
session_start | Parser SessionStart event | — | Show typing indicator, append assistant bubble shell |
text_delta | Parser per chunk | data: <chunk> | Append to current assistant bubble |
thinking | Parser Thinking event | data: <text> | Append thinking card to turn trace |
tool_use | Parser ToolUse event | tool_name, tool_input, tool_use_id, at | Render tool card with running spinner |
tool_result | Parser ToolResult event | tool_use_id, data, is_error, at | Attach result to matching tool card |
done | Parser Done event | — | Finalize turn bubble |
error | Parser Error event | data: <error msg> | Render error bubble, finalize turn |
unknown | Parser fallback | — | Ignored (debug-only) |
approval_request | Gate sidecar via daemon socket | data: JSON ApprovalRequest | Open approval modal |
approval_resolved | Approval write-back | data: JSON {id, decision} | Close modal, refresh approved panel |
ask_user | AskUser MCP tool | data: JSON {question, options, allow_freeform} | Render inline askUser card |
ask_user_resolved | Answer submit | data: JSON {id, value} | Hide askUser card |
system_turn | Provider switcher | data: JSON {text, steps} | Render system bubble |
The snapshot endpoint (GET /stream/snapshot?session=<id>) replays the current in-flight state as the same events so a page refresh mid-turn paints the partial bubble + trace cards without waiting for the next live event. The snapshot reads from pool first (live entry.PartialText + entry.InFlightEvents), then falls back to inflight.jsonl on disk for crashed-but-not-flushed turns.
AskUser MCP tool
This is the agent-initiated counterpart to the gate. The agent calls the ask_user MCP tool. Two call styles are supported:
Single question (original form):
{
"session_id": "9b7e-...",
"question": "Which environment?",
"options": [
{"label": "Production", "value": "prod"},
{"label": "Staging", "value": "stg"}
],
"allow_freeform": false
}Response: {"value": "prod", "text": "Production"}
Multi-question wizard (questions[]):
{
"session_id": "9b7e-...",
"questions": [
{
"key": "env",
"question": "Which environment?",
"type": "choice",
"options": [
{"label": "Production", "value": "prod", "description": "Live traffic"},
{"label": "Staging", "value": "stg"}
],
"required": true
},
{
"key": "reason",
"question": "Reason for change",
"type": "text",
"placeholder": "Brief description",
"required": true
}
]
}Response: {"values": {"env": "prod", "reason": "routine deploy"}}
Question types: choice (single-select; auto-advances on pick), multi (multi-select checkbox), rank (drag-to-rank), dropdown (select), text (free text), secret (masked input). A missing type defaults to choice when options are present, otherwise text.
The UI renders a step-by-step modal with Back / Skip / Next navigation and required-field validation. Single-select options auto-advance to the next question; Enter also advances.
The handler registers a pending question, broadcasts SSE, blocks the MCP call until the user answers (4-minute timeout), then returns the answer to the agent. Unlike the gate, this is voluntary — the agent decides when to ask, and a forgetful agent can skip it.
The reason wick ships its own AskUser MCP tool instead of relying on Claude Code's AskUserQuestion harness tool: the harness tool isn't available when Claude runs in pipe mode (-p), only inside the Claude Code TUI. An MCP tool works in every mode.
Per-channel ask_user control
By default ask_user is enabled for web UI and interactive clients, and disabled for Slack, Telegram, and REST sessions (those channels cannot currently render the modal). Operators can flip this per channel:
- Slack / Telegram / REST channel config: add
ask_user_enabled = trueto opt in. - Global fallback (web / stdio / external MCP): controlled by
AskUserModein Configs →agentsgroup (on/off).
See AskUser policy for the full resolution table.
Attention notifications
When the session tab is in the background and an ask_user or approval_request SSE event arrives, wick plays a short two-tone chime and (if the browser has been granted notification permission) fires a browser Notification. This covers the "tab open but not visible" case; the PWA web-push path covers "web UI fully closed."
The audio context and notification permission are both unlocked on the first click or keypress inside the page (browser autoplay policy requirement). No extra configuration is needed.
REST (OpenAI-compatible)
📸 Screenshot needed:
agents-rest-config.png— capture/tools/agents/channels/restshowing the form (Enabled toggle, Project) and the docs panel with three tabs (Chat Completions / Responses / Models). Save todocs/public/screenshots/agents-rest-config.png.
OpenAI Chat Completions, Responses, and Models APIs exposed at /integrations/rest/api/v1/openai/*. Any OpenAI SDK (openai-python, openai-node, LangChain, LiteLLM, …) works by pointing base_url at that path and using a wick Personal Access Token as the API key. Source: channels/rest/.
Endpoints
| Method | Path | Purpose |
|---|---|---|
POST | /integrations/rest/api/v1/openai/chat/completions | Chat Completions — most clients. |
POST | /integrations/rest/api/v1/openai/responses | OpenAI Responses API. Supports previous_response_id chaining. |
GET | /integrations/rest/api/v1/openai/models | Lists every enabled wick provider as an OpenAI model object. |
Auth
Every request must carry a wick Personal Access Token as Authorization: Bearer wick_pat_…. Mint tokens at /profile/tokens. There is no shared bot token — auth is per-request, so a request with no Bearer returns 401, an unknown token returns 401. Channel-level enable is just an on/off in the config form; the token does the real auth.
Session binding via conversation
Sessions are keyed by the OpenAI Responses API standard conversation field (an extension on Chat Completions; native on Responses). Three modes:
| Mode | Trigger | Behaviour |
|---|---|---|
| Stateless | Omit conversation and previous_response_id | Each request spawns a fresh wick session UUID. Client owns history — re-send the full messages / input each turn (OpenAI parity). |
| Conversation | "conversation": "<id>" | All requests with the same id reuse one wick session (rest-<id>). Only the new turn is sent; wick keeps history. Works on both endpoints. Also accepted via metadata.conversation for clients that expose only standard OpenAI fields. |
| Responses chaining | "previous_response_id": "resp_<id>" (Responses only) | The id returned by a prior /responses call. Wick reuses the underlying session — equivalent to passing the same conversation. |
One vocabulary
Wick deliberately uses conversation everywhere instead of a custom session_id field. That keeps clients aligned with the OpenAI Responses API spec and means a single Python snippet (just extra_body={"conversation": "..."}) drives both endpoints.
Model validation
The model field on chat / responses must match one of the ids returned by GET /models. The catalogue is built live from provider.Load() (models.go): each enabled provider instance shows up once — default-seeded entries surface as the bare type (claude, codex, gemini), named instances as type/name (claude/work). Disabled instances are skipped. Unknown ids return 404 model_not_found with the OpenAI-shaped error body so SDK typed-exception handling works. Empty model is allowed and lets wick pick.
Streaming
Not supported. "stream": true returns 400 immediately — clients should leave it at the default false. The handler waits for the agent's Done event before responding, so a turn takes as long as the underlying provider does.
Approvals
Gate prompts are auto-blocked (rest.go OnApprovalRequest). REST clients cannot deliver an interactive decision, so any approval request resolves to block and the resulting error surfaces as a 403. Use the web UI to approve sensitive commands.
Concurrency
Two safeguards:
- Per-session REST lock: a second request on the same
conversationwhile the first is still in flight gets409 session busy. Prevents two REST clients racing the same wick session. - Pool queue: dispatch always goes through
sendFn → pool.Send, which FIFO-queues when slots are full and preempts idle slots when configured. REST has no direct spawn path. See Pool & Sessions.
Configured response shape (chat completions)
{
"id": "wick-rest-…",
"object": "chat.completion",
"created": 1715000000,
"model": "claude",
"choices": [
{
"index": 0,
"message": { "role": "assistant", "content": "…" },
"finish_reason": "stop"
}
]
}Configured response shape (responses)
{
"id": "resp_…",
"object": "response",
"status": "completed",
"model": "claude",
"output": [
{
"type": "message",
"role": "assistant",
"content": [{ "type": "output_text", "text": "…", "annotations": [] }]
}
],
"output_text": "…",
"previous_response_id": null
}id is resp_<conversation> so a client can reuse it either as previous_response_id or pass the same conversation back — both land in the same wick session.
Per-user instances
REST follows the same per-user model as Slack. Each user who saves a REST config gets their own channel instance keyed by their user ID. The App Owner's row uses user_id = NULL. On boot, wick starts one REST instance per configured owner; a new user saving their config triggers a hot-add without a server restart.
All per-user REST instances share the same HTTP endpoint (/integrations/rest/api/v1/openai/*). Auth is always per-request via Bearer token, so the right user's config row is selected based on the PAT owner. Removing a user's bot_token-equivalent (disabling the channel) stops only that user's instance.
Hot-reload
Same pattern as the other channels: rest/source.go fingerprints Enabled + ProjectID. Registry.WatchConfigs calls Reload on change. Toggle the channel from /tools/agents/channels/rest and it activates within 30 seconds without a server restart.
Meta-commands
Channels intercept these before they reach the agent (metacmd.go:31-66). All are case-insensitive and accept / or ! prefix.
| Command | Action |
|---|---|
/agent <name> | Switch the active named agent in this session. |
/reset | Clear the session context. The next message starts a fresh subprocess (no --resume). |
/status | Reply with the current session + agent state. |
/dashboard (or /link) | Reply with the dashboard URL for this session — built from PublicURL config + session ID. |
/log [N] | Reply with the last N command-gate log lines. |
Meta-commands aren't forwarded to the agent subprocess. They run inside wick.
Why the ! prefix
Some Slack workspaces strip leading / characters from messages routed through certain integrations. The ! prefix is a fallback that survives that path.
Channel config in DB
Channel configs live in agent_channels (store.go), one row per channel type per user:
| Column | Holds |
|---|---|
type | slack / telegram / rest |
name | Display name (currently always default) |
user_id | Owner of this row. NULL = App Owner row (the oldest promoted user). |
enabled | Mirrors whether bot_token is non-empty |
config | JSON map: per-field settings (one per wick:"key=..." field) |
config is a flat JSON map, not a typed struct on disk. The typed struct is rebuilt at load time. Reasoning: keeps channel-specific schema migrations cheap — add a new field to SlackChannelConfig, add the form field, no DB migration.
Per-user vs App Owner rows
Every non-owner user who saves a Slack config gets their own agent_channels row (user_id = <their id>). The App Owner's row uses user_id = NULL. When a user saves their config for the first time, a new Slack channel instance is started immediately (hot-add) without a server restart; removing the bot_token removes that instance.
The Channels menu is visible to all logged-in users, not admins only. Each user sees and edits only their own row.
On existing installs where is_owner was not set, the migration promotes the oldest user to App Owner automatically.
Adding a new channel
The recipe for a hypothetical Discord channel. server.go never changes after the setup hook is in place.
- Config struct in
internal/agents/config/discord.gowithwick:"..."tags. - Channel subpackage
internal/agents/channels/discord/— implementChannel+ opt-in interfaces (AgentEventReceiver,ApprovalReceiver, …). Mirrorslack/ortelegram/for theReload+ConfigSourcepattern. - DB store in
channels/store.go: addLoadDiscordtoDBStore, extend theTelegramConfigStore-style interface inchannel.go. - Setup composer in
channels/setup/setup.go: addDiscord(reg, store, sendFn)function + extendAll()with one line. - UI handler in
internal/tools/agents/channels_handler.go— form save/load.
The Channel interface itself doesn't change. The hard parts are the platform-specific bits: how messages stream back, how access control works, how approvals are rendered. The Registry wires everything else automatically.
Workflow integration
Channels participate in workflows two ways:
- Inbound events — a
channeltrigger fires a workflow when an event matchessource+match.event(Slackapp_mention,message,block_action,view_submission, …). The full payload lands in.Event.Payload. - Outbound actions — a
channelnode calls registered actions (Slacksend_message,add_reaction,open_dm,open_modal,push_modal,update_modal,send_ephemeral,publish_home,respond_url,update_message, …) without spawning an agent turn.
See Workflows ▶ channel node for the full surface.
See also
- Pool & Sessions — how
SendFuncactually does the dispatch. - Projects — per-channel
ProjectIDconfig field. - Workflows — typed channel events + outbound actions inside a workflow DAG.
- Command Gate — the approval modal in the web UI is the same approval Slack/Telegram render.