Skip to content

Channels ​

A channel is where a message comes from. Wick agents are reachable from four channels at once:

ChannelConnectionSession keySource
SlackSocket Mode (default) or HTTP Event APIthread_tschannels/slack/slack.go
TelegramLong pollingtg-<chatID>channels/telegram/telegram.go
Web UIDirect HTTP + SSEUUID minted by wickinternal/tools/agents/
REST (OpenAI-compatible)HTTP request/response, OpenAI SDKrest-<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 ─► channels

Source

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:

  1. Listens for inbound messages.
  2. Runs access control (channel-specific).
  3. Intercepts meta-commands (see below) before they reach the agent.
  4. Calls sendFn(ctx, sessionID, agentName, source, role, text) to dispatch into the pool.
  5. Receives agent events via OnAgentEvent to stream the reply back.
  6. Receives gate approval requests via OnApprovalRequest for 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:

InterfaceWhat it gives the channel
SendFuncSetterPool dispatch closure
SessionCheckerSetterProbe whether a session already exists (used for first-turn context injection)
SessionStartHookSetterCallback fired once on brand-new session
ApproveFnSetterGate approval resolver (channel name pre-bound by registry)
PublicURLSetterBase URL for /dashboard meta-command replies
AgentEventReceiverOnAgentEvent — stream agent output back to the user
ApprovalReceiverOnApprovalRequest / OnApprovalResolved — render gate modal in channel
HTTPHandlerProviderExpose a webhook path (Slack HTTP mode)
LookupProviderBack picker config fields with a live search against the upstream (Slack users, channels, …)
HealthCheckerPower 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/slack showing the form (Mode, Bot Token, App Token, Access Mode, Project dropdown). Save to docs/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 to docs/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 to docs/public/screenshots/agents-slack-approval.png.

Connection modes ​

ModeWhenConfig
socketDefault. No public URL needed. Wick opens a Socket Mode connection to Slack and receives events over a websocket.BotToken (xoxb-) + AppToken (xapp-)
httpWebhook 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.

Session ownership mapping ​

A Slack thread becomes a session owned by the wick user behind the sender — the same identity they would get by opening that session in the web UI. This matters beyond attribution: the session owner decides which MCP credential the spawned agent carries, so it is what scopes the connectors the agent can reach.

The join key is the sender's email, because it is the only field Slack and wick both understand. Slack user IDs are workspace-local and mean nothing to wick.

Requires the users:read.email scope on the Slack app. Without it users.info returns a blank email with no error, so senders cannot be matched and every message is refused with email is required.

SenderResult
Email matches an approved user with Agents accessSession owned by that user; agent runs with their connector access
Email matches a user pending approvalRefused — told to wait for an admin to approve them
Email matches an approved user without Agents accessRefused — told to ask an admin for the grant
Email has no wick user, auto-register offRefused — told to ask an admin for an invite
Email has no wick user, auto-register onAccount created, then refused as pending approval
No readable email (missing scope, or a bot/app sender)Refused with email is required
Slack guest (single-channel, multi-channel, Slack Connect)Always refused
Sender cannot be resolved at all, and the channel instance has no owner to fall back on (the App Owner's own row)Refused: "I could not work out which wick account to act as..."

The check runs before the agent spawns. Refusing afterwards would already have run a turn under the wrong identity, which is the thing this prevents.

The owner is stamped on the session before the first sendFn call, not after. A spawn mints its MCP credential from the session's owner at the moment it starts, so stamping later would race the first spawn of a new thread: if the spawn won that race, it fell back to the shared internal token — a synthetic admin with no tag filter — and kept that identity in its process argv for the life of the thread. Fixing the owner on disk afterwards couldn't undo it. This is also why a channel dispatch carries its resolved caller through context.Context (WithCallerUserID/CallerUserID) instead of leaving it to be read off disk later: the pool needs it at dispatch time to decide whether a running subprocess can be reused, since reusing a process spawned for a different sender would keep serving that sender's credential.

Sender identity ​

Every channel (Slack, Telegram, REST, and the web composer) resolves who sent a message from its own transport envelope — never from the message text, which is why the identity can't be forged by writing "I am someone else" — and carries it as a structured sender field on the turn, alongside text. The stored text is exactly what the person typed; nothing is prepended to it on disk.

What the agent receives is a separate, deliberately smaller thing: a single leading [from: Name] line prepended only to the model's copy of the message, controlled by Agents settings → Session Identity → sender_visibility:

LevelLine the agent seesDefault
offnothing — the agent cannot tell participants apart
name[from: Real Name]✅
name_id[from: Real Name (U0123ABC)] — adds the platform user ID, for agents that need to @-mention or DM a specific person
full[from: Real Name (U0123ABC) @handle via slack] — adds the handle and channel too

This governs the model's copy only. The dashboard always shows the full sender regardless of this setting — a person reading a shared thread needs to tell participants apart whatever the model is told. In the web UI, a user turn from someone other than the person reading gets a name/channel chip, a colour-stable avatar initial, and a neutral bubble instead of the "you" green.

Permission is the same as the dashboard ​

Slack is a second door onto the Agents tool, not a way around its permissions. Once the sender is identified, wick asks the same question the dashboard asks — CanAccessTool against /tools/agents — so a user who cannot open Agents in the web UI cannot get one by messaging the bot.

Two gates, in this order:

  1. Approval. An unapproved account is refused, whatever else it carries.
  2. Agents access. An approved account still needs the tool to be enabled for it (and to carry a required filter tag, if the Agents tool has any).

The order is what the sender is told about, and it matters: "pending approval" and "no access to Agents" need different fixes, and a sender told the wrong one chases the wrong admin request.

Registering an identity is not granting access. With auto-register on, an unknown sender gets a wick account so an admin has a row to approve — but that account is pending, so the very next thing they see is the pending-approval refusal.

Grant checklist

Approve the user under Admin → Users, then confirm they can reach the Agents tool. If the Agents tool carries no filter tags, approval alone is enough — every approved user can reach it. Add a filter tag to the tool if you need Agents restricted to a subset of approved users.

Why this is not a per-channel setting

Channel config rows are per-owner — any user who can add their own Slack bot owns that row. If auto-register lived there, that user could switch it on for their own bot and mint pending wick accounts. It is an install-level switch in the Agents settings so only an admin can allow it.

Auto-register ​

Agents settings → Session Identity → channel_auto_register (default off) creates a wick account for a channel sender whose email has none. The account arrives inert:

  • Unapproved. Slack vouches that the address is on the workspace; it does not prove this sender controls it. An admin approving the row under Admin → Users is what turns one claim into the other.
  • Never admin, even when the email appears in the admin list. Admin has to come from a path that proves control of the address.
  • No password, so it cannot be signed into directly — the admin's invite flow issues credentials.

Approve the user, then grant connector access with tags as usual. Until then the sender is refused with a message naming the exact step that is missing.

Guests are refused even on workspaces that have none today: guest access is a workspace-level setting an admin can enable later without touching wick, and at that point an outside party could DM the bot.

Reaction lifecycle ​

The agent's progress is mirrored on the user's message (slack.go:34-39):

ReactionStage
⏳ hourglass_flowing_sandQueued (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_signBlocked — gate or access control rejected
❌ xError — 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.

Reaction auto-reply ​

React 🤖 (robot_face) on a thread's top (parent) message to make that thread auto-reply: every new reply in the thread is dispatched to the agent without an @mention, for as long as the 🤖 stays on the parent. Remove the 🤖 → auto-reply off (a run already in flight finishes; only the next reply is dropped). It is one switch on the parent — not a reaction per bubble. Threads are still started by @mention only; the switch never creates a new session, so it only acts on threads that already have one.

Enable it on the Slack channel config page: toggle reaction_trigger_enabled, then pick reaction_channels_mode — all (any channel the bot is in honours 🤖) or whitelist (only the channels listed in reaction_channels, the default). The reaction channel list is independent of the access whitelist; reactors still have to pass the same access control as a normal message.

For Slack to deliver the events this relies on, the app must subscribe to the right events + scopes (the shipped slack-app-manifest.json already includes them):

PurposeEvent subscriptionBot scope
Turn the 🤖 switch onreaction_addedreactions:read
Turn the 🤖 switch offreaction_removedreactions:read
Pick up a new channel reply while the switch is onmessage.channelschannels:history
Resolve the reacted message's parent thread + read its text(Web API call)channels:history

Without reactions:read + the two reaction events, the switch silently never arms. Without message.channels, replies in a channel thread are never seen even with the switch on. The Test Integration button surfaces missing scopes per-probe.

Progress banner (assistant threads) ​

When the workspace has Slack AI features enabled and the bot holds the chat:write scope, wick calls assistant.threads.setStatus to show a live progress banner above the composer:

  • Footer state — coarse phase label ("Thinking", "Working", or "Idle") with an animated … suffix that cycles dots while the turn runs.
  • Activity bubble (loading_messages) — a rotating list of the last ~5 activity lines (e.g. "Thinking", "Running: npm test", "Reading slack.go") so the user can see what step the agent is on. The bubble re-asserts itself on every heartbeat tick to override Slack's default "is thinking…" copy and to survive Slack's ~2-minute status timeout.

The banner is cleared on done / blocked / error. Workspaces without AI features get a one-line debug log and rely on the reaction emoji alone.

Sub-agent progress shows up on the same banner. A delegated child runs under its own session with no thread of its own, so its progress used to go nowhere and the banner sat frozen on "Delegating: …" for the whole child run. The registry now relays a child's ToolUse / ToolResult / Thinking events onto the nearest ancestor session a channel is actually rendering, labelled with the child's role: researcher → Reading store.go. The child's final reply and any error still arrive only through the delegation result, not through this relay, so nothing is shown twice. Channels opt in by implementing HasLiveTurn; Telegram doesn't, so it has no equivalent banner and sees no change.

HasLiveTurn tests an explicit running flag on the turn, not just presence in the turns map — a finished turn's entry sticks around afterwards (its threadTS/msgTS are still needed for reactions and approvals), so presence alone would relay a child's progress onto a thread whose conversation already ended. The footer label parser also splits a relayed label on its → separator to recover the child's actual activity, so a relayed Thinking renders as "Thinking" rather than falling through to "Working". If nothing refreshes a label for 90s (a long model call, a wedged tool, a child that dies without a Done), the banner ages it out to a neutral working state — the sub-agent's name stays, the specific activity doesn't.

Killing a leader deliberately spares its detached async sub-agents, but the thread would otherwise just go quiet while they keep running. Slack posts one notice naming the survivors and edits that same message in place as each finishes, rather than posting a new message per change. This is Slack-only — Telegram has no banner and doesn't implement the receiver interface.

Streaming reply ​

Rather than waiting until the full response is ready, wick posts an empty placeholder as soon as the first text token arrives and then edits it in place via chat.update as the reply streams in (~1.5 s flush interval). The final edit lands on done. This means users see the reply grow word-by-word in Slack, matching the streaming feel of the web UI. Chunking still applies (see below) — long replies are split across multiple threaded messages, each of which streams independently.

Chunked reply ​

Slack hard-limits messages to 4000 chars. Wick chunks at 3800 (slack.go:32). Each chunk is a separate threaded reply, posted plain with no continuation marker.

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 pairWhat it gates
UsersMode + AllowedUsersWho (Slack user IDs) may trigger the agent
GroupsMode + AllowedGroupsWhich user groups
ChannelsMode + AllowedChannelsWhich channels / DMs the bot accepts messages from

Semantics (slack.go allowedCfg):

  • If both UsersMode and GroupsMode = whitelist → OR (pass when either matches).
  • If only one is whitelist → that list gates alone.
  • If both are all → identity check is skipped.
  • ChannelsMode is 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:

GateApproversWho may resolve approval buttons
trigger_users (default)Anyone who passes the access whitelists.
adminsWorkspace admins / owners (probed via users.info).
customExplicit 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 keyBacked byFallback
slack.usersassistant.search.context (messages → de-dupe by author)users.list
slack.usergroupsusergroups.list—
slack.channelsusers.conversations (channels the bot is a MEMBER of)—

slack.channels deliberately does not use conversations.list or assistant.search.context: both return every channel in the workspace, including the ones the bot was never invited to. Picking one of those yields a trigger that can never fire and a send that fails with not_in_channel, so the source is scoped to actual membership.

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 (instance, source, query) — the instance is part of the key because each per-user bot has its own token and its own memberships.

In the workflow editor the lookup fans out over every registered Slack instance and merges the results de-duped by ID; when more than one bot is connected each entry is suffixed with the bot that can reach it (#support — @ygsw-bot). A bot whose call fails (missing scope, not yet authed) is skipped rather than blanking the dropdown. The channel config page stays scoped to the signed-in user's own bot.

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.test
  • team.info (scope: team:read)
  • users.list (scope: users:read)
  • users.info (email) (scope: users:read.email) — checked separately because its failure is silent: without the scope users.info still succeeds and simply returns a blank email, so users.list passing says nothing about it. Every sender would then be refused with email is required. Fails only when no member has an email; some members lacking one is normal and reported as a note instead.
  • usergroups.list (scope: usergroups:read)
  • conversations.list (scopes: channels:read, groups:read)
  • chat.postMessage (dry-run against an invalid channel ID — distinguishes missing_scope from channel_not_found)
  • reactions.add (dry-run against an invalid timestamp)
  • assistant.search.context (scope: assistant:write — optional, used by the slack.users picker only)

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. Because each per-user Slack instance shares the same dispatch closure with every other instance of the type, each instance stamps its own ProjectID onto the session it creates — two Slack bots on different projects don't collide. See Projects ▶ Slack / Telegram / REST default project.

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, reactions:read, channels:history, etc.) and event subscriptions (app_mention, message.im, message.mpim, message.channels, reaction_added, reaction_removed) without hand-toggling — including everything the reaction auto-reply switch needs.

Sender context and file attachments ​

Each inbound user turn is enriched before it reaches the agent:

Sender identity. Resolved once per user ID via users.info (name, handle) and cached for the session, then carried as the structured sender field described in Sender identity above — not prepended into the message text. If the users.info lookup fails, the turn still carries the bare user ID so the agent can tell speakers apart even when it can't name them.

File / attachment metadata. When a user posts a message with attached files (images, PDFs, documents, file_share events), the attachment manifest is appended to the user turn:

[Attached files — fetch via the slack connector (files.info / the link) if you need the contents]
- filename.pdf (PDF) · 42.3kB · https://…/permalink

Each entry carries the file title or name, pretty type (e.g. "PDF", "PNG"), human-readable size, and the best available link (Slack permalink, with url_private as a fallback). The file bytes are not downloaded into the turn — the agent can fetch them on demand via the slack connector (files.info, get_permalink, or the link directly) if it needs the content.

file_share message subtypes (attachment-only posts with no body text) are now passed through instead of being silently dropped.

Telegram ​

📸 Screenshot needed: agents-telegram-config.png — capture /tools/agents/channels/telegram showing the form (Bot Token, Allowed IDs, Project). Save to docs/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 to docs/public/screenshots/agents-telegram-chat.png.

Setup ​

  1. Create a bot via @BotFather → grab the token (123456:ABC-...).
  2. Paste the token into /tools/agents/channels/telegram → BotToken.
  3. 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).

Sender identity mapping ​

Telegram maps senders to wick accounts the same way Slack does — same channel_auto_register switch, same approval gate, same ResolveWickUser resolution order — with one difference: the Telegram Bot API reports no email at any scope. There is no field to join on.

Instead, the sender's numeric Telegram ID becomes a reserved-domain stand-in email, e.g. 8812@telegram.local — a lookup key only, never a real address, never shown to the sender, and never delivered to. An admin can later merge that placeholder account into the person's real one in Admin → Users; the channel-identity link is keyed on the numeric ID, not the synthetic address, so the merge doesn't break it.

SenderResult
Known, approved wick account with Agents accessSession owned by that user; agent runs with their connector access
Known account pending approvalRefused — told to wait for an admin to approve them
Known, approved account without Agents accessRefused — told to ask an admin for the grant
Unknown sender, auto-register offRefused — told to ask an admin for an invite (or to enable auto-register)
Unknown sender, auto-register onAccount created (unapproved, never admin, no password), then refused as pending approval
Bot senderRefused — there is no person to map to a wick account

Behaviour change for existing Telegram installs

Before this, every Telegram message ran as the channel owner, with no identity check at all. From this version, identity mapping is always wired in: an unknown, pending, or unapproved sender gets a reply telling them what to do instead of silently running as the channel owner. If your bot is used by people who don't (and shouldn't) have wick accounts, either keep channel_auto_register off so unknown senders are refused with an "ask an admin" message, or restrict who can reach the bot via AllowedIDs.

The identity check runs before the agent spawns, same reasoning as Slack: refusing after a spawn would already have run a turn under the wrong identity.

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 to docs/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 to docs/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 to docs/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.

ConcernHow
Session createFirst POST to /tools/agents/sessions/{id}/send with a fresh UUID auto-creates the session. The UI mints the UUID.
StreamingSSE at GET /tools/agents/stream broadcasts agent_event, approval_request, approval_resolved, ask_user, ask_user_resolved. The page subscribes via EventSource.
Approval modalWhen 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 cardWhen 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 panelLists 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.

typeProducerPayloadFE handler
lifecycleState machine via pool OnLifecyclelifecycle: spawning|working|idle|killed, pid, at (LastActive ms)Update header badge, idle countdown, typing indicator
user_messagePool OnUserMessage, fired for a user-role turn injected from a non-web source (a channel or the schedule runner)session_id, agent_name, source, textAppend the user bubble live, badged by source (⏰ "Scheduled" for source=schedule, "via Slack" / "via Telegram" for channels)
session_startParser SessionStart event—Show typing indicator, append assistant bubble shell
text_deltaParser per chunkdata: <chunk>Append to current assistant bubble
thinkingParser Thinking eventdata: <text>Append thinking card to turn trace
tool_useParser ToolUse eventtool_name, tool_input, tool_use_id, atRender tool card with running spinner
tool_resultParser ToolResult eventtool_use_id, data, is_error, atAttach result to matching tool card
doneParser Done event—Finalize turn bubble
errorParser Error eventdata: <error msg>Render error bubble, finalize turn
unknownParser fallback—Ignored (debug-only)
approval_requestGate sidecar via daemon socketdata: JSON ApprovalRequestOpen approval modal
approval_resolvedApproval write-backdata: JSON {id, decision}Close modal, refresh approved panel
ask_userAskUser MCP tooldata: JSON {question, options, allow_freeform}Render inline askUser card
ask_user_resolvedAnswer submitdata: JSON {id, value}Hide askUser card
system_turnProvider switcherdata: 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.

user_message exists to close a gap: a message arriving from Slack, Telegram, or a fired schedule used to only show up in an already-open web session after a manual refresh, because the composer's own optimistic render only covers turns typed in that same tab. The pool skips the event for source="ui" — the composer already renders those — so it fires only for turns the viewer couldn't otherwise have seen coming.

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):

json
{
  "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[]):

json
{
  "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 = true to opt in.
  • Global fallback (web / stdio / external MCP): controlled by AskUserMode in Configs → agents group (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/rest showing the form (Enabled toggle, Project) and the docs panel with three tabs (Chat Completions / Responses / Models). Save to docs/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 ​

MethodPathPurpose
POST/integrations/rest/api/v1/openai/chat/completionsChat Completions — most clients.
POST/integrations/rest/api/v1/openai/responsesOpenAI Responses API. Supports previous_response_id chaining.
GET/integrations/rest/api/v1/openai/modelsLists 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:

ModeTriggerBehaviour
StatelessOmit conversation and previous_response_idEach 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.

Background mode ​

Add "background": true (or metadata.background: "true" for SDKs that only expose the standard OpenAI metadata map) to either endpoint to return immediately with "status": "queued" instead of waiting for the agent:

json
{ "id": "wick-…", "object": "chat.completion", "status": "queued", "choices": [...] }

The message is queued on the session exactly like a chat message — the reply is not returned in that response, it lands in the session history. Pair background with conversation and read the result back with a normal follow-up request on the same conversation; without conversation the request is fire-and-forget and the reply is unreachable. Useful behind aggressive HTTP timeouts (proxies, serverless gateways) where the caller can't wait out a long-running turn.

Concurrency ​

  1. Per-session turn queue: concurrent requests on the same conversation no longer conflict — a request that arrives while an earlier one is still in flight queues FIFO behind it, same as chat channel messages, and each request gets the reply to its own message (never mixed up, even if an earlier caller gave up and disconnected).
  2. 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) ​

json
{
  "id": "wick-rest-…",
  "object": "chat.completion",
  "created": 1715000000,
  "model": "claude",
  "choices": [
    {
      "index": 0,
      "message": { "role": "assistant", "content": "…" },
      "finish_reason": "stop"
    }
  ]
}

Configured response shape (responses) ​

json
{
  "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.

CommandAction
/agent <name>Switch the active named agent in this session.
/resetClear the session context. The next message starts a fresh subprocess (no --resume).
/statusReply 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:

ColumnHolds
typeslack / telegram / rest
nameDisplay name (currently always default)
user_idOwner of this row. NULL = App Owner row (the oldest promoted user).
enabledMirrors whether bot_token is non-empty
configJSON 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.

  1. Config struct in internal/agents/config/discord.go with wick:"..." tags.
  2. Channel subpackage internal/agents/channels/discord/ — implement Channel + opt-in interfaces (AgentEventReceiver, ApprovalReceiver, …). Mirror slack/ or telegram/ for the Reload + ConfigSource pattern.
  3. DB store in channels/store.go: add LoadDiscord to DBStore, extend the TelegramConfigStore-style interface in channel.go.
  4. Setup composer in channels/setup/setup.go: add Discord(reg, store, sendFn) function + extend All() with one line.
  5. 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 channel trigger fires a workflow when an event matches source + match.event (Slack app_mention, message, block_action, view_submission, …). The full payload lands in .Event.Payload.
  • Outbound actions — a channel node calls registered actions (Slack send_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 SendFunc actually does the dispatch.
  • Projects — per-channel ProjectID config 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.
Built with ❤️ by a developer, for developers.