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.
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.
| Sender | Result |
|---|---|
| Email matches an approved user with Agents access | Session owned by that user; agent runs with their connector access |
| Email matches a user pending approval | Refused — told to wait for an admin to approve them |
| Email matches an approved user without Agents access | Refused — told to ask an admin for the grant |
| Email has no wick user, auto-register off | Refused — told to ask an admin for an invite |
| Email has no wick user, auto-register on | Account 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:
| Level | Line the agent sees | Default |
|---|---|---|
off | nothing — 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:
- Approval. An unapproved account is refused, whatever else it carries.
- 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):
| 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.
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):
| Purpose | Event subscription | Bot scope |
|---|---|---|
| Turn the 🤖 switch on | reaction_added | reactions:read |
| Turn the 🤖 switch off | reaction_removed | reactions:read |
| Pick up a new channel reply while the switch is on | message.channels | channels: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 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 | users.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.testteam.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 scopeusers.infostill succeeds and simply returns a blank email, sousers.listpassing says nothing about it. Every sender would then be refused withemail 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 — distinguishesmissing_scopefromchannel_not_found)reactions.add(dry-run against an invalid timestamp)assistant.search.context(scope:assistant:write— optional, used by theslack.userspicker 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://…/permalinkEach 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/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).
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.
| Sender | Result |
|---|---|
| Known, approved wick account with Agents access | Session owned by that user; agent runs with their connector access |
| Known account pending approval | Refused — told to wait for an admin to approve them |
| Known, approved account without Agents access | Refused — told to ask an admin for the grant |
| Unknown sender, auto-register off | Refused — told to ask an admin for an invite (or to enable auto-register) |
| Unknown sender, auto-register on | Account created (unapproved, never admin, no password), then refused as pending approval |
| Bot sender | Refused — 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 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 |
user_message | Pool OnUserMessage, fired for a user-role turn injected from a non-web source (a channel or the schedule runner) | session_id, agent_name, source, text | Append the user bubble live, badged by source (⏰ "Scheduled" for source=schedule, "via Slack" / "via Telegram" for channels) |
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.
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):
{
"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.
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:
{ "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
- Per-session turn queue: concurrent requests on the same
conversationno 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). - 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.