Slack Gateway Module
September 20, 2026 · View on GitHub
Overview
The Slack integration (kiro_crew/slack/) connects KiroCrew to Slack via Socket Mode. DMs are routed through ACP to kiro-cli with real-time streaming and interactive tool approval.
Independently scheduled agent runs admit their exact execution key as durable work before provider allocation, publishing its privacy mode in the canonical session execution record. Single and sequential-agent paths share that admission, so first-turn child creation does not require a dashboard slot or a previous transcript. A damaged committed mode refuses allocation; a key prefix alone never grants a mode. Origin-chat injection keeps the chat's own policy. Cron execution binding is published off the event loop before mode admission and provider allocation, using the run's already captured execution context.
Startup wires memory objects behind one gateway-lifetime in-process barrier.
Both dashboard and API-only servers receive the orchestrator's existing context
builder. Post-bind workflow initialization uses that same object for essentials
and store-bound context; it does not construct a second memory stack or add
pre-bind memory reads.
After the dashboard binds, one tracked worker activates pending V1 and V2 restores before opening any memory database or
markdown/FTS store. It clears a previous gateway's cached handles, initializes the
already-wired Global store and rebuilds FTS before releasing memory access.
The gateway publishes that task to dashboard state and emits KIROCREW_READY
without yielding to it, then awaits it before arming cron, heartbeat, automatic
memory work or channel transports. Persisted Crew work and restored legacy
channel agents resume after the same wait. Agent-backed dashboard turns shield-wait on
the same task at their central admission seam before identity, provider or
metadata work. A cancelled turn therefore cannot cancel preparation or record
the transient fence as a failed turn. The bound dashboard remains available for
status and recovery while preparation runs; its memory content operations
refuse access until the pass settles. A journal or
activation failure is recorded against that canonical store, and the worker
continues restoring later stores. Once the pass completes, healthy Global,
named V1 and private V2 stores become usable independently. A Global restore or
initialization failure fences only Global and skips its migration; private
repair and automatic backups of healthy member V2 stores still run. Structural configuration or worker
initialization failure can keep the whole preparing fence closed.
Failed-store context, HTTP, direct/cached store handles and backups refuse with
a named reason; HTTP returns 503 and code: store_unavailable.
Store status, backup listing and cancellation remain available for owner recovery.
Failure preserves the journal and prior data. Owner backup and cancellation
responses report activation_failed, restore_error and restart_required
for the affected store, even when its journal parses or has been cancelled.
Cancellation does not unlock that store in this gateway; a subsequent restart
retries recovery before access. Once the preparing pass completes, an owner can
stage a known-good backup for a failed store, including when its current database
is unreadable. Staging validates ownership and the backup without opening live
memory, and retains the existing pending-journal lock. It does not clear the
failure fence or activate that copy until the next restart. Preparing, stopped
and structurally failed gateways still refuse new staging.
The failure map is process-local and lasts only
for that gateway. V2 product store users hold a shared POSIX admission lock outside the replaceable directory; restore activation requires the exclusive lock. Windows relies on native open-handle replacement refusal. This does not claim coordination with arbitrary external writers that bypass the product protocol. A stopped worker
closes any late handle before its barrier is released and cannot release a
successor gateway's barrier.
After successful memory readiness, one gateway-owned repair loop visits the active Global store and cached named V1/V2 stores in round-robin order every 30 seconds on the embedding executor. Each visit revalidates readiness and the named store's declaration and ownership, uses only an already-ready backend and repairs at most 16 missing vectors per memory kind using existing bulk pacing. Bounded cursor pages move past failed rows and wrap for retries. Later seeds, queued writes and model reconciliation therefore receive repair without a restart. Successful pages append to the resident native index instead of rebuilding and writing the entire index on every page. Shutdown stops new visits and fences late embedding commits. The loop waits for Global's boot migration and full repair sweep before visiting that store, never opens a store and adds no per-member task or model load. V1 retrieval, admission, decay, consolidation and capacity behavior remain unchanged.
The first heartbeat after memory becomes ready schedules a tracked background backup pass for every active memory store: the default store first, then declared named V1 stores and active member V2 stores. Existing per-store backup freshness prevents duplicate copies across restarts; later checks retain the daily cadence at tick 30 modulo 1440. A large store does not delay subsequent heartbeat ticks or idle-session checks. Only one backup pass belongs to a heartbeat service at a time. Shutdown signals its worker to finish at most the current atomic copy, then skip pruning and all remaining stores. Stopping the async waiter never resets that worker's stop flag. Automatic backup enumeration excludes archived, unbound private stores. Manual all-store backups visit the same set. Archived files and backup listings remain available for owner inspection; restore requires an active exclusive binding and there is no archive reattachment UI.
Explicit member deletion and committed package-agent pruning release that store's
SQLite handle, FAISS/scoring arrays and markdown/lesson caches off the event loop.
An in-flight construction cannot republish a handle across the cache's eviction
generation. Existing files and rollback copies remain intact; recreating a member
receives a fresh store identity. Superseded restore trees remain outside automatic
backup_keep retention and require explicit owner cleanup. Their UUID names and
file timestamps do not establish completed recovery or safe deletion order.
During operation, member cron jobs, linked DMs, nudges and completion injections validate their own recorded memory identity before acquiring a provider. Completion injections use the parent conversation's memory; delegates keep their target's member-scoped memory for the delegated run and retries.
Memory-operation refusals retain their named recovery reason in channel replies, but pass through the shared credential/exfiltration and local-path redactors before truncation. Both native Slack and its transport dispatcher apply the same protection as Discord and Telegram. Native Slack sanitizes the accumulated reply before final rendering and conversation persistence; an operating-system error must not expose its data-home path to channel readers.
Native and transport Slack dispatch resolve persisted agent/project overrides off-loop. Only the event loop updates the live override maps, retaining a newer command or completed hydration that arrived during the read. Both dispatch paths recheck thread ownership after hydration and store admission before provider allocation. Unlinking returns to the canonical Slack conversation; pinned answers retain their asker. Transport also retains its privacy-boundary owner check. Cached overrides keep the existing synchronous no-I/O fast path.
Architecture
Channel startup diagnostics receive setting names and boolean presence checks, never credential values. Each channel keeps its existing enablement predicate; missing settings are named once, and configured or disabled channels stay silent.
Slack Socket Mode → events.py (dispatch) → handler.py → SessionManager → AcpClient → kiro-cli
↘ interactive payloads → interactions.py (dispatch) → approve/reject/ack
↘ member_joined_channel → allowlist.py (prompt_allowlist) → owner DM
Files
| File | Purpose |
|---|---|
slack/__init__.py | Package (no eager imports to avoid aiohttp at import time) |
slack/client.py | SlackClientOps ABC + RealSlackClient (slack-sdk wrapper) |
slack/files.py | Slack adapter over shared attachment ingestion — authenticated downloads, inlineable images/text/documents, and byte-identical opaque files with local path + metadata; caller-owned cleanup and SEL audit |
slack/format.py | Markdown → Slack mrkdwn conversion (headings, links, strike, tables, mermaid, ANSI strip, truncation) |
slack/handler.py | handle_message() — streams ACP response, handle_interaction() — button clicks (with None provider guard) |
slack/gateway.py | GatewayOrchestrator — service lifecycle, cron/heartbeat/subagent/task callbacks, shutdown, auto-update. Entry point: run_gateway() |
slack/events.py | Socket Mode event routing — dedup (SeenCache), slash commands, member_joined_channel tracking, message dispatch |
slack/interactions.py | Block Kit button routing — tool approval, OPTIONS choices, cron/subagent ack, allowlist approve/deny, track channel approve/deny |
slack/blocks.py | Reusable Block Kit dict builders for slash command UIs (session list, send-to-slack). Action IDs: mc_<command>_<action>[_<id>] |
slack/allowlist.py | Tracking-channel allowlist prompts (prompt_allowlist, prompt_track_channel) + config persistence (persist_allowed_user, persist_tracking_channel) |
slack/scope_probe.py | Tracked-channel history-readability probe (warn_unreadable_tracked_channels) — warns when the installed token cannot read a tracked channel (e.g. a private channel on an install predating groups:history) |
slack/enterprise.py | Enterprise Grid workspace validation — validate_enterprise() (startup auth.test + cache) + check_message_origin() (per-message team_id check). SEL audit on all outcomes. See V2160269460 |
slack/channel_resolver.py | Channel ID → human-readable name resolution (in-memory + on-disk cache), because ChannelConfig stores no name field |
slack/outbound.py | Lifecycle of a posted OPTIONS control. Holds no rendering of its own — slack/format.py owns that, so the redaction pipeline exists once |
slack/retry.py | open_dm_with_retry — one bounded DM-open retry with a single retryability classification and backoff. Reached through GatewayOrchestrator._open_dm_with_retry; other DM-open sites still call SlackClientOps.open_dm directly, so coverage is the orchestrator paths, not every sender. post_message stays single-shot per call site |
slack/renderer.py | SlackRenderer — maps the neutral messaging.TurnDriver OutputEvent stream onto Slack streaming + Block Kit |
slack/transport.py | SlackTransport — Slack as a concrete MessagingTransport with a deny-by-default authorize. No live path constructs it; only channel_type is read, by handlers_system |
slack/transport_dispatch.py | The new-path dispatch events.py routes to when messaging.use_transport is on: handle_message_transport builds a TurnDriver and SlackRenderer over the existing Slack client. It does not go through SlackTransport.receive or authorize |
slack/sessions_view.py | Slack half of the recent-sessions list shared by the slash command, the DM keyword and the App Home tab; collection lives in messaging/sessions_view.py |
APIs
Slack App OAuth Contract
The bundled slack-manifest.yaml is the setup source of truth. Its bot scopes
are app_mentions:read, channels:history, channels:read, chat:write,
commands, files:read, files:write, groups:history, groups:read,
im:history, im:read, im:write, reactions:write, and users:read.
message.groups is subscribed alongside message.channels so private-channel
turns and thread continuation are delivered.
The manifest also requests user scopes channels:history, channels:read,
groups:history, groups:read, im:history, im:read, mpim:history,
mpim:read, search:read, and users:read. These scopes apply only to a
separately configured Slack MCP/search integration's xoxp-... token. The
gateway constructs every Slack client with SLACK_BOT_TOKEN; it does not read
or store the user token.
run_gateway(cfg: KiroCrewConfig, *, no_dashboard=False, no_crons=False) -> None
Starts the Socket Mode listener. Blocks until SIGINT/SIGTERM. When no_crons=True, the CronService is instantiated but not started — cron jobs are visible in the dashboard but not executed. Use for multi-instance setups where a single primary instance handles cron execution. On shutdown, calls dashboard_state.close_all_ws() before AppRunner.cleanup() to prevent 30s hang from blocked WebSocket async for msg loops.
Restart after update
Automatic-update restarts select and validate the composed gateway launcher before saving state or draining callbacks/sessions. Without a launcher they retain the core-managed interpreter resolver loaded before apply. Launcher selection and the companion integration contract are defined in platform-context; the callback fence and final yield-free drain-to-exec handoff apply to both launch paths.
Shutdown Sequence
- First Ctrl+C sets
shutdown_event→ graceful shutdown begins (10s deadline) - Second Ctrl+C calls
os._exit(0)immediately (force exit) _shutdown()first disarms the loop-stall watchdog (dashboard_state._loop_watchdog.stop()+ cancels_loop_heartbeat), then saves active chat slots, cancels handler tasks, stops cron/heartbeat, closes sessions. The watchdog MUST be disarmed beforeclose_all()/cancel_all()because that teardown deliberately kills every kiro-cli child — the sameos.waitpidreaping burst the watchdog guards against — and a slow teardown would otherwise let the armedfaulthandler.dump_traceback_later(exit=True)timer_exit(1)the process mid-shutdown (a clean quit would look like a crash). The watchdog's ownon_cleanuphook fires too late (insideAppRunner.cleanup(), gathered concurrently with the reaping).- The gateway clears its port-keyed run marker in both dashboard and API-only
modes, then
cleanup_orphaned_sessions()kills any kiro-cli PIDs tracked in the PID file beforeos._exit(0).
Self-initiated exits carry a non-zero status. _shutdown_and_exit composes
shutdown_exit_code(watchdog) or listener_guard_exit_code(...), so an operator
stop still exits 0 while a shutdown the gateway asked for itself does not —
a restart-on-failure supervisor never relaunches an exit 0:
| Status | Source | Meaning |
|---|---|---|
| 0 | operator (SIGTERM, systemctl stop, Ctrl+C) | stay down as asked |
75 (EX_TEMPFAIL) | stale-asset watchdog | the served assets vanished |
69 (EX_UNAVAILABLE) | listener guard (dashboard/listener_guard.py) | the TCP listener could not be restored, so the process was alive but unreachable |
The listener-guard path is Windows-only in practice: CPython's proactor loop
closes the LISTEN socket after one failed accept() and never re-arms it. The
guard rebinds first and only sets this status when rebinding keeps failing, or
when the rebind binds yet the loopback /api/live probe still gets no answer —
a state no rebind can fix.
Event-loop stall watchdog & blocking-work executors
The gateway runs a single asyncio loop, so any blocking call on the loop thread freezes the whole backend. App Home skill loader construction and listing run together in a worker: listing can initialize/read the persistent SQLite metadata index. Two mechanisms contain this (see dashboard/loop_watchdog.py, executors.py):
LoopStallWatchdog— armed only whenfaulthandler.is_enabled()(the realgatewayentrypoint; notchat/tui). The async heartbeat (dashboard/server.py, 5s interval)beat()s it each tick, re-arming a C-leveldump_traceback_later(exit=True)timer that dumps all thread stacks and_exit()s if the loop goes silent. Desktop/foreground launches automatically use 25s; managed systemd/launchd gateways automatically use 90s because they have no Electron probe and WSL, VM, or heavy disk pressure can suspend scheduling long enough to make 25s a false death. The config value is nullable/automatic so an unrelated full config save cannot pin either launch-class default; any explicitdashboard.loop_stall_exit_after_secsvalue, including 25, overrides both. Older full-config saves may have materialized the former 25-second default; Kiro Crew reports that through the read-only superseded-default warning anddoctorrather than guessing whether the value was deliberate. The managed path emits a non-fatal all-thread dump to stderr atstall_after=30s, never to the fatal crash-sentinel file, then exits at its service budget if the loop has not recovered. If the hard timer is disabled or fails to arm, that soft-only fallback is written to the dedicated dump file as well as stderr so it remains discoverable.KIROCREW_SERVICE_MANAGED=1in the generated systemd unit or launchd plist is the sole managed-launch authority; inherited systemd metadata is deliberately ignored because descendants receive it too.kirocrew doctordetects an installed definition without the marker and tells the operator to runkirocrew service installonce to regenerate it and adopt the managed-service default.- Bounded executors — blocking maintenance work is offloaded off the default executor (which the loop uses for DNS) into two separate bounded pools:
maintenance_executor()(mc-maint, fast orphan-reaping sweeps + agent-overlay rewrites) andcron_executor()(mc-cron, long/concurrent cron command & script jobs). Kept separate so a burst of cron jobs cannot starve the orphan sweeps. MCPprobe_all()fan-out is bounded byasyncio.Semaphore(5). init_socket_modeis a coroutine awaited ON the loop, never offloaded whole —WSSocketModeClient.__init__ends inasyncio.ensure_future, which requires a current event loop in the constructing thread, so running the function in ato_threadworker crashes every Slack-enabled boot withRuntimeError: There is no current event loop(the #7518 regression; under systemd the unit crash-loops intoStartLimitBurstand staysfailed). Its two blocking calls — the YOLO grant's profiles-dir walk (set_yolo_mode→grant_declared_yolo) and the enterpriseauth.testnetwork call (validate_enterprise) — are offloaded individually inside the coroutine, which keeps the security-relevant early-return ordering (owner check → YOLO grant → enterprise validation) intact. Pinned bytest_slack_events_coverage.py::TestInitSocketMode— including a test that constructs the realWSSocketModeClient(a mocked constructor is how the regression slipped past CI) and a source-level pin refusingto_thread(init_socket_mode, ...)at the gateway call site.
handle_message(slack, sessions, channel, text, thread_ts, msg_ts, user_id, approval_mode, ..., subagent_manager) -> None
Processes a single incoming message with streaming:
Session key discipline: the handler derives two values at entry —
reply_ts = thread_ts or msg_ts (the bare Slack thread timestamp, used for
posting replies and as the key of thread-indexed maps: SessionMap's
thread→session index and the dashboard _slack_to_slot map) and
session_key = canonical_key(reply_ts) (the namespaced slack:<ts> form,
used for everything session-scoped: SessionManager registry, conversation
log, per-thread override maps, trust set). The canonical form is stable
across all messages of a thread; the legacy bare form is folded onto the same
live session by SessionManager._fold_key (see session.md).
- Check hooks for auto-reply
- Check
statuskeyword — reply with stats summary - Check owner-only
!commands (!yolo,!agent,!ta,!allowlist,!dashboard) - Check spawn/bg commands (subagent manager)
- Check cron keyword commands (
cron list,cron remove,cron pause,cron resume) - Check task runner commands (
task run <path>,run status) - Initialize
StatusReactionController→ set phase "queued" (👀) - Post "Thinking…" message
- Acquire per-session semaphore (via
get_or_create) to serialize concurrent messages - Create
Taskfor lifecycle tracking - Stream events from provider
- Progressive message edits (~1/sec) with cursor indicator (▍)
- On
text_chunkevent: accumulate response text, set phase "thinking" (🤔) - On
thinking_chunkevent: accumulate thinking separately, set phase "thinking" (🤔) - On
tool_callevent: set phase based on tool type — coding (👨💻), browsing (🌐), or generic tool (🔧) - On
permission_requestevent: pause stall watchdog, auto-approve or post Block Kit buttons, resume watchdog - On
complete: record success, check context usage - On error: record failure (circuit breaker trips at 5 consecutive)
- Finalize status reactions in
finallyblock → done (🦞) or error (😱); release semaphore - Strip inline
<thinking>tags from accumulated text - Final update with mrkdwn-converted response (split into multiple messages if over 3900 chars)
- Post thinking content as 💭 thread reply (if any, and
slack.show_thinkingis true)
StatusReactionController
Phase-aware Slack reaction manager with stall detection. Manages emoji lifecycle per message:
- Phases: queued (👀) → thinking (🤔) → coding (👨💻) / browsing (🌐) / tool (🔧) → done (🦞) / error (😱). All phase emojis are configurable via
slack.reactionsinconfig.json. - Debouncing: Intermediate phase transitions debounced at 700ms to prevent flickering from rapid tool calls. Terminal states fire immediately.
- Stall detection: Soft stall (🥱) at 15s, hard stall (😨) at 45s of no progress. Resets on any ACP event. Paused during tool approval waits.
- Tool mapping:
_tool_to_phase(tool_name, tool_kind)maps tools to phases — preferstool_kindfrom ACP, falls back to tool name with MCP__separator handling.
LLM-Initiated Commands
The LLM executes cron and spawn operations via bash using the kirocrew CLI:
kirocrew cron add "name" "message" --every 300— writes to crons.json, gateway auto-detects via mtime synckirocrew spawn "task"— POSTs to dashboard API at localhost:5476, gateway spawns subagent
handle_interaction(channel, msg_ts, action_id) -> None
Routes Block Kit button clicks to pending tool approvals:
approve_toolaction →AcpClient.approve_tool(), resumes streamingreject_toolaction →AcpClient.reject_tool(), stops streaming
SlackClientOps (ABC)
Testable interface for Slack Web API:
post_message(channel, text, thread_ts) -> strpost_blocks(channel, blocks, text, thread_ts) -> strupdate_message(channel, ts, text)delete_message(channel, ts)add_reaction(channel, ts, emoji)remove_reaction(channel, ts, emoji)
Per-Channel Activation Modes
Each channel can have its own activation mode controlling when the bot responds:
| Mode | Behavior |
|---|---|
always | Process every message from allowed users |
mention | Only respond when @mentioned; continue in thread replies if bot has active session |
observe | Passively record all messages with deep history buffer; respond only when @mentioned (like mention but with richer context) |
off | Ignore all messages completely — no history recorded |
Defaults: DMs (D-prefix) default to always. Group channels (C/G-prefix) default to mention.
Config (config.json):
{
"slack": {
"channels": {
"C0123ONCALL": { "activation": "always", "agent": "ops" },
"C0456REVIEWS": { "activation": "mention", "agent": "reviewer" },
"C0789GENERAL": { "activation": "off" }
},
"dm_activation": "always"
}
}
Per-channel agent override: Each channel can specify an agent that overrides the global default. The agent is passed to SessionManager.get_or_create().
Thread reply behavior (mention mode): When the bot is @mentioned in a group channel, it responds in a thread. Subsequent replies in that thread are processed without needing @mention, as long as the bot has an active session for that thread (SessionManager.has_session(thread_ts)). Replies in threads where the bot was never mentioned are ignored.
Owner commands (!channel):
!channel— show current channel activation mode and agent!channel always|mention|observe|off— set activation mode, persisted toconfig.json!channel agent <name>— set per-channel agent override!channel agent off— remove per-channel agent override
Implementation: events.py:_route_message() checks orch._cfg.channel_config(channel) before dispatching. The @mention prefix is stripped from text before sending to the LLM. _persist_channel_config() in handler.py writes to config.json atomically via tmp+rename.
Tracking Channel Monitoring
Slack Commands
Slash Command (events.py)
Command name configurable via slack.command in config (default: kirocrew).
| Command | Handler | Purpose |
|---|---|---|
/<command> @user | _handle_slash | Allowlist prompt (Allow/Deny) to owner |
/<command> #channel | _handle_slash | Tracking-channel prompt (Track/Ignore) to owner |
/<command> sessions | _handle_slash | List active sessions with Slack link status (Block Kit) |
/<command> sessions resume <key> | _handle_slash | Resume a session in the current Slack thread |
/<command> dashboard | _handle_slash | Generate presigned dashboard link (DM'd to user) |
/<command> restart | _handle_restart | Restart the gateway (owner-only; requires an INVOCATION_ID / systemd supervisor, else refuses). SEL-audited (approved/denied). Best-effort save_all_slots_to_history + close_all + sel.flush (each bounded by wait_for), then os._exit(1) so the supervisor respawns |
Owner-Only ! Commands (handler.py)
Restricted to KIROCREW_OWNER_ID. Processed before keyword commands.
| Command | Purpose |
|---|---|
!yolo on/off/status | Toggle global auto-approve for all tool calls |
!agent <name> / !agent off | Switch kiro-cli agent globally (all new sessions) |
!ta <name> / !ta off | Switch agent for current thread only |
!allowlist @user | Grant/revoke user access |
!allowlist #channel | Add/remove tracking channel |
!restart | Restart the gateway. Bang alias intercepted in events.py before the LLM session; delegates to /kirocrew restart (_handle_restart) so owner-check + supervisor guard stay a single source of truth (handler.py:_BANG_TO_SLASH) |
Allowed-User ! Commands (handler.py)
Available to any user on the allowlist (not just owner).
| Command | Purpose |
|---|---|
!dashboard [duration] | Get a presigned dashboard link (DM'd to you) — deprecated, use /kirocrew dashboard |
!stop | Force-halt the active agent execution in the current thread. Sends cooperative session/cancel; falls back to hard kill if not acked within agent.soft_stop_budget_secs. Posts ephemeral Block Kit stopping message with Kill Now button. If no execution is running, replies "Nothing running." |
Keyword Commands (handler.py)
Available to all allowed users.
| Command | Handler | Purpose |
|---|---|---|
status | handle_message | Runtime stats summary |
spawn <task> / bg <task> | _handle_spawn | Run subagent (blocking / async) |
spawn list / spawn status | _handle_spawn | List active subagents |
cron list | _handle_cron | List cron jobs |
cron remove <id> | _handle_cron | Remove a cron job |
cron pause <id> | _handle_cron | Pause a cron job |
cron resume <id> | _handle_cron | Resume a paused cron job |
task run <path> | _handle_task_run | Start autonomous task runner |
run status | _handle_task_run | Check task runner status |
Channel Monitoring
- Config:
config.json → slack.tracking_channels— list of channel IDs to watch - Event:
member_joined_channel— fires when a user joins a channel the bot is in - Requires
channels:readscope (for public channels) andgroups:read(for private) - When a user joins a monitored channel,
prompt_allowlist()sends Allow/Deny to the owner - Users already on the allowlist are silently skipped
- If
tracking_channelsis empty, no monitoring occurs - Tracked channels are capability-probed (
slack/scope_probe.py, oneconversations.historycall withlimit=1) after the socket connects at startup and whenever a channel is added to tracking. Amissing_scope/channel_not_foundresult logs a warning and pushes a dashboard notification — a private channel tracked under an install predatinggroups:historywould otherwise fail silently. Deferred (asyncio.create_task), best-effort: transient network errors report nothing /<command> @userstill works as a manual trigger (command name configurable viaslack.commandin config, default:kirocrew)/<command> #channeladds a tracking channel via owner approval
File Attachment Processing
Slack file_share messages are processed in _route_message() after dedup + auth. Three categories handled in order:
Voice / Audio (kiro_crew/transcribe.py)
- Mimetypes:
audio/*,video/webm - Flow: Download via
SlackClientOps.download_file()→transcribe.transcribe_audio()→ transcription text prepended as[Voice memo transcription]...[End of transcription] - Config: Enabled by default (
stt.enabled = true).stt.providerdecides where recognition runs, and the defaultlocalruns it in this process on a resident whisper.cpp model, so a memo costs one model download (stt.model,baseby default) and nothing after that. A stored retired provider degrades tolocal; there is no binary to put onPATH. Availability per provider comes fromtranscribe.availability_detail(), which distinguishes a missingvoiceextra from a platform with no prebuilt recognizer and from a macOS too old for theappleprovider, because those need different fixes. The pinnedimageio-ffmpegwheel decodes the memo's ogg/Opus or webm internally and is bundled in desktop releases; users do not install system FFmpeg. Setup: configuration § Speech-to-text. - Provider-independent guards:
transcribe_audiorefuses a sensitiveaudio_pathand redacts every provider's output before returning, both before/after dispatch rather than inside a branch, so a provider cannot be added that skips either. See stt-streaming. - Security: Transcription output run through
redact_credentials()+redact_exfiltration_urls()before injection. Audio file suffix sanitized to alphanumeric only._transcribe_audio_filesrecords aslack.download_fileand a transcription SEL entry per memo.
Images (files.py)
- Mimetypes:
image/png,image/jpeg,image/gif,image/webp,image/bmp(aligned withAcpClient._send_prompt()regex) - Size limit: 10 MB (checked from Slack metadata before download and actual bytes after download)
- Flow: Download to temp file → inject local path into message text →
_send_prompt()detects path, base64-encodes, sends as{"type": "image"}content block to kiro-cli - Temp lifecycle: Caller (
_route_message) owns cleanup. Done callback onhandle_messagetask cleans up after_send_prompt()reads the file. Early-return paths andcreate_taskfailures also clean up. Queued messages carry their paths in the entry'simage_temp_pathskwargs;_dispatch_queuedunlinks after the turn consumes them, and the queue-discard paths —cancel_queued,clear_queue,dequeue's cancelled-skip, and the_pending_queuedrops in_handle_message_deletedand the!stophandler — unlink viasession.unlink_queued_temp_paths()so entries that never dispatch don't leak files. Known gap: session-teardown paths (restart/remove/destroy/idle sweep) dropsession.queuewithout unlinking. - Non-inlineable images (
image/svg+xml,image/tiff, etc.) use the opaque-file path below; they are never injected as ACP image blocks
Text / Code Files (files.py)
- Mimetypes:
text/*,application/json,application/xml,application/javascript - Size limit: 512 KB download cap, 50 KB injection cap (truncated with
[… truncated]marker) - Flow: Download to temp → read with
errors="replace"→ redact credentials/URLs → inject as[File: name]\ncontent\n[End of file] - Temp lifecycle: Always cleaned in
finallyblock (text content is read into memory, file not needed after)
Opaque Files
- Mimetypes:
video/*and every format not handled as inlineable image, text/code, document, or audio; this includes ZIP, binary payloads, SVG, and TIFF - Size limit: 50 MB per file, checked against Slack metadata before download and authoritative bytes after download
- Flow: Stream authenticated bytes to a randomized
tempfile.mkstemp()path → inject the bare local path plus[Attached file: name]metadata (original mimetype and actual byte count) → expose the complete file to agent tools - Integrity and lifecycle: Bytes are not transformed. The current or queued turn owns the path and unlinks it after the agent turn completes, or when a queued entry is discarded; early-return and task-creation failures also clean it up
- Passive by default: Opaque content is never automatically parsed, extracted, or executed. An inlineable image suffix (
.png,.jpg,.jpeg,.gif,.webp,.bmp) is stripped from the temporary path, because the ACP encoder types a path by suffix alone — otherwise a file namedphoto.pngbut declaredapplication/octet-streamwould be inlined as an image without passing content-signature validation. Agent tool access remains subject to normal permissions and hooks - SEL audit logs successful downloads, pre/post-limit skips, and failures
Safety Controls
- Type-specific size limits are checked from Slack metadata before download and against actual bytes after download
- Filetype suffix sanitized to alphanumeric only (prevents path traversal)
tempfile.mkstemp()for all downloads — never uses original Slack filenameredact_credentials()+redact_exfiltration_urls()on all text content- SEL audit on every download, skip, and error
Streaming UX
- Response streams in real-time via progressive Slack message edits
- Edit throttled to ~1/sec to avoid Slack rate limits (Tier 3: ~50 req/min)
- Cursor indicator (▍) shown during streaming, removed on completion
- Tool calls shown inline as 🔧 tool name
- Thinking/reasoning content filtered from the main response — accumulated separately and posted as a 💭 thread reply after the main message. Inline
<thinking>/</thinking>tags are also stripped as a safety net. The thread reply is suppressed whenslack.show_thinkingisfalse(defaulttrue). - Final message split into multiple posts if over 3900 chars (via
split_message()) - Redaction notice — when the delivered text (answer or thinking) still carries a
security.CREDENTIAL_REDACTION_TAGSplaceholder or asecurity.EXFILTRATION_REDACTION_TAG_PREFIX(suspicious-URL) placeholder, onemessaging.renderer.redaction_noticemessage is posted in the thread after the answer is committed, so the reader knows a command or link they copy will not run as pasted. Worded by kind (credential → re-enter the secret; URL → re-check the link), and byte-identical to the priorcredential_redaction_noticesentence when only credentials were rewritten. Redaction is NOT relaxed — Slack is an egress path. Counted from the tag in the sent text rather than the redactor's warnings list, which is empty on the streaming path because each chunk was already redacted upstream. One notice per turn: answer and thinking share a single tally. Approving a review-mode draft (interactions.py) posts the same notice for the same reason, since that publishes to the whole channel. Both posts are best-effort — a failed notice must never turn a delivered answer into a failed turn
Message Queue (session.py + events.py)
When a message arrives while a session is actively processing, it's queued instead of spawning a competing session:
- Session-level queue:
enqueue()/dequeue()onSessionManagerusing a per-sessiondeque+ cancelled set - Orchestrator-level queue:
_pending_queuedict for the startup race (task running but session object not yet created) - ⏳ reaction: added to queued messages so the user sees visual feedback
- FIFO drain:
_on_donecallback drains both queue levels after each handler completes - Cancellation:
message_deletedevent removes queued messages or marks in-flight messages as cancelled; first!stoppress clears the queue (viastop_turnwhich callsclear_queueunconditionally) is_cancelled()check: handler checks before responding and before the LLM call to suppress responses for deleted messages
Linked Thread Sync (handler.py + interactions.py)
Bidirectional message mirroring between dashboard chat sessions and Slack threads:
- Slack → Dashboard:
handle_message()checks_slack_to_slotreverse lookup; if linked, routes message to dashboard slot's_run_chat()queue - Dashboard → Slack:
_run_chat()mirrors user messages and agent responses to the linked thread viastart_stream()/append_task()/stop_stream() - Link to Dashboard button:
LINK_DASHBOARD_ACTIONin timing footer imports thread history into a new dashboard slot !link-to-dashboardcommand: same as button but triggered via bang command inside a thread- Session resume: shows Thread/DM choice buttons;
_handle_resume_choice()with per-session lock for idempotency - Fresh-anchor title (
dashboard/chat_slack.pyslack-link endpoint): the new-thread anchor message title uses the fallback chain slot.title → first-prompt snippet (60 chars, whitespace-collapsed) →"New session"— the raw slot key is never user-visible (untitled slots default their title to the key, so the endpoint gates ondisplay_title != NEW_SESSION_TITLE)
Sessions View (sessions_view.py)
Shared data-collection and Block Kit rendering for recent sessions, used by three surfaces:
/<command> sessionsslash command —_handle_sessionsinevents.pysessionskeyword in DMs —_handle_sessions_commandinhandler.py- App Home Tab — 🧵 Sessions section in
_publish_home_tab(split into "Main chat" and "Autopilot / task runner" sub-lists)
The collector and renderer live in kiro_crew/slack/sessions_view.py so both events.py and handler.py can import them at module top-level without forming a circular import. sessions_view.py depends only on kiro_crew.slack.blocks and kiro_crew.security — it knows nothing about events or handler, which is what keeps the import graph acyclic.
All three surfaces call await _collect_recent_sessions_off_loop(sessions, *, limit, kind, include_ended=False) — the required entry point for async callers, which runs the synchronous collector _collect_recent_sessions in a worker thread via asyncio.to_thread — to read JSONL files under ~/.kiro/crew/sessions/, classify them as dashboard (main chat slots), taskrunner (autopilot/task runner steps), or other, and _build_sessions_blocks(rows, *, for_home_tab=False) to render them. The sync collector does unbounded-size transcript reads and is worker-thread-only: never call it directly from an async def. It pre-scans the directory (kind from the filename stem, mtime from stat) and reads limit matching transcripts plus one per skipped candidate met on the way down the mtime order, so the read count does not grow with the directory. include_ended and the third skip reason are covered under "Ended rows leave the list" below.
The slash command and keyword (which post via chat.postMessage) use the shared blocks.session_task_card builder. The Home Tab calls with for_home_tab=True and uses section blocks instead — Slack's views.publish API rejects task_card with unsupported type: task_card. Both paths keep the canonical mc_session_resume_{key} action ID handled by interactions.py:_handle_session_resume.
The Home Tab requests up to _HOME_TAB_SESSIONS_PER_KIND = 5 rows per kind so both surfaces stay well under Slack's 100-block view limit. The slash command and keyword each request _SESSIONS_DEFAULT_LIMIT = 10 rows.
At most _HOME_TAB_COLLECT_CONCURRENCY Home Tab collections run at once. Every app_home_opened from an allowed user schedules its own publish with no dedupe, and each collection reads up to limit transcripts on the process-wide default executor — shared with history appends, cron store writes and session storage. Ungated, a burst of tab opens fills that executor with multi-MB reads and unrelated asyncio.to_thread callers queue behind them. The gate wraps only the collection; the Slack API calls around it stay unserialized. It is created lazily rather than at import, because a module-level asyncio.Semaphore binds to whichever loop is current when the module loads and the gateway's loop does not exist yet.
Each surface emits a SEL audit event for the data-access via sel.log_api_access:
- Slash command:
slack.sessions_slash_data_access(caller = Slack user id) - Keyword:
slack.sessions_data_access(caller = session key) - Home Tab:
slack.home_tab_sessions_data_access(caller = Slack user id)
Sharing the builder also means the sessions keyword now displays the same 🟢 active / ⚫ inactive marker as the slash command. Previously the keyword path rendered every card as inactive regardless of session state.
Ended rows leave the list
⏹️ End (mc_session_end_{key}, handled by interactions.py:_handle_session_end) records a dismissal on the row's transcript: closed: True plus a closed_at epoch on the metadata line, written through ConversationLog.update_metadata_if and therefore mtime-preserving. messaging/sessions_view._row_is_ended reads that flag back and the collector leaves such rows out unless the caller passes include_ended=True.
Three details are load-bearing:
- The record is written whether or not a session is live. The soft remove above it only kills a process, and a cluttered list is mostly idle rows — for those the removal branch resolves no key and does nothing, which is why End used to have no observable effect at all.
- The skipped row frees its slot. Dismissed rows are skipped inside the read loop the same way empty and unreadable files are, so the list still fills to
limitwith live sessions instead of shrinking. The cost is one read per skipped row: with the n newest rows dismissed, n transcripts are read and discarded before the first kept row. Unlike the corrupt-file skips this is an ordinary state, so it is reachable in normal use; it is bounded by the directory, andwith_messages=Falsereduces each such read to line 0. closed_atis stamped after the teardown, because consolidation and skill extraction write the transcript on the way out of an End. Nothing in this list compares it (see below); it is written because the dashboard's reader does, and a flag with no instant makes every close there permanent.
A live session outranks the flag, so a resumed conversation is listed immediately. ▶️ Resume also clears the flag outright (ConversationLog.clear_closed), so the row stays listed once that process exits.
This is deliberately not the rule dashboard/channel_slots._close_stands applies to the same field. That one asks whether a channel conversation outran a closed tab and compares the close against the channel's last write. This one asks whether the user still wants the row, and background housekeeping — consolidation, skill extraction, an auto-title — writes the file without the user doing anything, so any write-based rule would put a dismissed row straight back at the top.
The opt-in is sessions all / sessions ended (DM keyword) and /<command> sessions all (slash). sessions_view.SESSIONS_INCLUDE_ENDED_ARGS is the one vocabulary, read both by sessions_view.sessions_include_ended and by handler._is_sessions_keyword — the matcher has to admit the argument or the message is never routed to the sessions handler at all. Opted-in rows render 🛑 in both the task card and the Home Tab layout so they are distinguishable from merely idle ones. The Home Tab has no argument surface and always uses the default.
!compact Command (handler.py)
Triggers in-place ACP /compact on the current thread's session:
- Adds ♻️ reaction, posts "Compacting context…"
- Streams
/compactcommand, waits forcompaction_statusevent - Falls back to
wait_for_compaction()(sharedCOMPACT_WAIT_TIMEOUT_SECSbudget) if no inline status - Posts result (✅/❌) + timing footer
- On failure:
sessions.discard_conversation(session_key)— kills the session and drops only the resume sid, so the next message cold-starts. The session-map ENTRY survives, keeping the thread↔session linkageget_session_for_threadroutes later replies through;destroyhere would fork the thread into a fresh session with none of its context. Housekeeping never removes a channel identity (see session)
Wedged-Session Recovery (AcpPromptBusy)
When kiro-cli reports a prompt is still in flight ("already in progress" — a tool stall, timeout, or message race), AcpClient raises AcpPromptBusy (acp/client.py) with a friendly "I'm still processing a previous request… it clears on its own once the stale turn expires" message. handle_message catches it and auto-resets the wedged session via sessions.reset(session_key) so the next message cold-starts cleanly, then records the failure (the reset itself is best-effort — a reset failure is logged, not raised). The message deliberately names no command: the auto-reset above is what recovers the session, so the text has nothing to ask the user for (it used to say !restart, which is Slack-only, owner-gated, and restarts the gateway rather than the session -- see common/error-handling.md).
OPTIONS Buttons (format.py)
LLM responses ending with [OPTIONS: choice1 | choice2 | choice3] are rendered as interactive Block Kit checkboxes with a Send button:
extract_options()parses the[OPTIONS: ...]tag from the response text- Tag is stripped from the displayed message
build_options_blocks()creates Block Kit checkboxes (max 10) + primary Send button- Checkboxes posted as a follow-up message in the thread
- Send click →
_handle_options_submit()→ reads checkbox state → posts styled selection → routes combined selection to handler - Legacy single-choice buttons still supported via
OPTIONS_ACTION_PREFIX
Action IDs: options_checkboxes (toggle), options_submit (send). Checkbox value contains the choice text.
Beyond the reply-finalization path in handler.py, two other Slack delivery paths also render [OPTIONS: ...] as buttons: the dashboard send_message MCP tool (api_send_message in dashboard/handlers/messaging.py) and cron subagent delivery (_deliver_cron_response in gateway.py). Both call extract_options() / build_options_blocks(), skip the tag parse when the caller supplies explicit blocks (those own their own layout), and wrap the follow-up options post in try/except so a failed options post never fails the primary message.
Inline action values (action::)
action:: is an inline-action value protocol inside legacy OPTIONS controls, not a general Block Kit routing protocol. slack.interactions.dispatch calls _handle_options only for action IDs carrying OPTIONS_ACTION_PREFIX, which slack.format defines for OPTIONS choices; every other action ID reaches the tool-approval fallback when the interaction supplies a channel and message. test_unknown_action_id_falls_through_to_tool_approval locks that fallback.
Two gates run before any handler: is_allowed_user(user_id) on the dispatcher, and channel_inbound_permitted("slack") for OPTIONS interactions. Both are load-bearing because the action value becomes agent-visible context and a routed turn.
An OPTIONS choice whose value starts with action:: enters the action branch of _handle_options. The remainder of value is an opaque payload — the handler neither parses nor requires JSON — and the visible label comes from action["text"]["text"], falling back to the selected overflow option's text. _route_action_to_session then performs the shared delivery:
- Redact exfiltration URLs and credentials from the label, then attempt to replace matching elements in the source message with a context label.
- Post the redacted label as a visible reply in the source thread. A failed post aborts routing, so an agent turn never runs without its visible Slack message;
test_post_message_failure_abortslocks that ordering. - Redact and bound the payload per
_ACTION_PAYLOAD_CAP, record the Slack access event, and build anAction button clickedcontext entry. - Call
slack.handler.handle_messagewith the source message'sthread_ts, the new reply timestamp, the visible label, andaction_context.
ContextBuilder.build_message appends a non-empty action_context ahead of the message text, so the payload arrives as context rather than displayed verbatim in the thread (test_redaction_applied_to_payload). The source-message update is best-effort: _route_action_to_session logs and continues when update_message fails, so a successful route does not guarantee the original button was visually replaced.
_mark_button_clicked walks every actions block; for each block containing the supplied action ID it removes every matching element, inserts a context block holding ✓ {label} immediately before that actions block, and omits the actions block once no elements remain. Blocks without a matching element survive untouched. The identifier match is the load-bearing link between Slack's interaction payload and the rendered message, so an action ID reused across separate actions blocks produces one context label per matching block. TestMarkButtonClicked covers replacement, no-match input, and empty-block removal.
_handle_options also carries a direct-handler branch for an action_id beginning with action::: it parses the suffix as a JSON object, obtains a selection through _extract_selected_value (which handles selected_option, date, time and datetime fields), adds selected_value, derives a label from placeholder.text plus the selected display text, and routes through _route_action_to_session. Malformed JSON or a non-object payload stops the branch without routing. That branch is not reachable through the Slack dispatcher — dispatch forwards only OPTIONS_ACTION_PREFIX action IDs, so an action:: action ID falls through to _handle_tool_approval; test_extended_element_happy_path, test_malformed_json_in_action_id_no_crash and test_non_dict_json_in_action_id_no_crash exercise _handle_options directly. An element with an OPTIONS_ACTION_PREFIX action ID whose selected value starts with action:: enters the value branch instead, where that value is the opaque payload and no base JSON object is merged with selected_value. Agents must not treat action:: in an extended element's action_id as an available Slack protocol.
test/test_action_interactions.py covers the direct action-handler path, payload redaction, audit logging and the block-transforming helpers; test/test_slack_interactions_coverage.py::TestDispatchPayloadParsing::test_unknown_action_id_falls_through_to_tool_approval covers the dispatch boundary that excludes arbitrary action IDs.
Messaging Transport (messaging.use_transport)
A channel-neutral dispatch path that replaces the native handle_message stream loop with a shared SlackTransport → TurnDriver → SlackRenderer pipeline. Gated by messaging.use_transport (MessagingConfig, default True in KiroCrew — the transport abstraction is the canonical path; set false to fall back to the legacy native handler — config/loader.py). When the flag is on, events.py:_route_message routes the message to handle_message_transport; when off, nothing in the live gateway path imports the transport (it is purely additive).
SlackTransport(slack/transport.py): wrapsSlackClientOpsin the neutralMessagingTransportcontract (dependency directionslack → messaging; themessagingpackage never imports Slack).authorize()is owner-only, deny-by-default — an empty allow-list authorizes nobody, and it SEL-audits every rejection (operation="slack_transport.authorize",outcome="denied"), including empty/missinguser_id, so the deny-by-default control is observable.TurnDriver(messaging/driver.py): channel-neutral turn loop converting providerAcpEvents into abstractOutputEvents. Approval ladder mirrors the nativeAPPROVAL_*contract —APPROVAL_AUTO/APPROVAL_TRUST(approve all),APPROVAL_TRUST_READS(approvetool_kind == "read"),APPROVAL_INTERACTIVE(deny-by-default unless the injected decider approves). Two injected predicates keep the driver channel-neutral:auto_approve_tool(thespawn_run/auto_approve_subagent_spawnhook predicate) andauto_approve_session(per-session Trust). Interactive buttons are rendered only when a decider is present — without one,_approve()denies by default so posting buttons would leave dead controls.SlackRenderer+SlackApprovalDecider(slack/renderer.py): renders abstract output onto a Slack thread and holds the underlyingSlackClientOpsso the dashboard→Slack mirror keeps working. Approval buttons usemc_tool_approve_/mc_tool_trust_(per-session Trust) /mc_tool_deny_action prefixes.SlackApprovalDecidermaintains a process-global_REGISTRYkeyed by request id so the module-level interaction handler canresolve_global()a click without a direct reference to the per-turn decider;session_for()maps a click back to its session for per-session Trust. The decider is deny-by-default — itwait_fors the button future and returnsFalseon timeout.handle_message_transport(slack/transport_dispatch.py): agent resolution order is thread override (!agent) → per-channel override (slack.channels.<id>.agent) → configured default → canonical"kirocrew"(_DEFAULT_KIROCREW_AGENT). The final fallback matters: without it an emptyagent.default_agentmakes kiro-cli launch its bare built-in default with nokirocrew-coreserver, sospawn_runwould be missing. Fires the ack reaction + working status before the (cold-start) session acquisition, matching native ordering._resolve_approval_mode(orch)(events.py): the single per-message chokepoint that folds runtime YOLO (owner-toggled/kirocrew yolo, TTL-cappedsafety_override) intoAPPROVAL_AUTO, evaluated fresh each message. The transportTurnDriveronly sees this resolved mode, so both the native and transport paths honor the runtime toggle consistently rather than an unconditional auto-approve. Deny-by-default unless auto-approve is explicitly active.
Tool Approval Flow
- ACP sends
permission_requestevent during streaming events.py:_resolve_approval_mode()evaluates runtime YOLO, then the CLI--approvaloverride, thenagent.approval_mode; only an explicit auto policy yieldsAPPROVAL_AUTO, otherwise it yieldsAPPROVAL_INTERACTIVE. Native and transport dispatch both use this chokepoint, preventing an operator policy from being silently bypassed.- Handler posts Block Kit message with ✅ Approve / 🤝 Trust / 🚀 YOLO / 🚫 Reject buttons
events.pyroutesinteractiveSocket Mode event tointeractions.dispatch()- Approval/rejection sent to ACP, streaming resumes or stops
- Approval button message replaced with outcome text
- 120s timeout — auto-rejects if no click
Session Management
See session.py module spec. Each Slack thread_ts maps to a separate AcpClient instance with idle timeout cleanup.
Message Queue
Messages arriving while a session is busy are queued with ⏳ reaction and drained FIFO after each handler completes. See Message Queue above.
Startup
start_pool() creates the background session for cron/heartbeat. Chat sessions cold-start on first message — no warm pool, no MCP reset hack.
Live configuration
GatewayOrchestrator is the process's channel host, so it owns two config
appliers, registered in _register_config_appliers on the shared ConfigWatch
(config/live.py). The Subscription objects are kept on self._config_subs
because the watcher holds a bound method WEAKLY — an orchestrator a test builds and
discards must not pin itself into the registry. See
messaging § Live configuration for the shape every channel shares.
The hoist is one function per channel
Boot reads each channel's enable flag, credentials and options out of the config
and onto the orchestrator (_wecom_enabled, _telegram_bot_token, and so on)
before _start_channel_transports runs. That work is one
_hoist_<channel>(cfg, creds) per channel — _hoist_wecom, _hoist_telegram,
_hoist_weixin, _hoist_whatsapp, _hoist_feishu, _hoist_discord,
_hoist_webex, _hoist_imessage, _hoist_teams — called from __init__ in
roster order. One function per channel is what makes a reconnect possible at all:
restart_channel re-runs exactly one of them against a fresh config instead of
re-deriving every channel's state, so restarting Telegram cannot disturb Discord.
restart_channel(channel_type, *, cfg=None)
The in-process equivalent of a gateway restart for ONE channel, in boot's order:
bounded close of the old handle (registry.shutdown_tasks), drop the handle and
its legacy _<channel>_client mirror, re-run that channel's hoist against cfg
plus a fresh credential read off the loop, re-evaluate the channels governance
gate and the readiness badge, then desc.start(orch) and store the new handle. A
channel whose new config disables it, leaves it uncredentialed, or is denied by
policy ends CLOSED with its badge explaining why — exactly as it would after a
real restart.
The channel's section on self._cfg is replaced with cfg's, because the
maybe_start_* factories and the dispatchers they build read their allow-lists
and options from orch._cfg.<channel>; without that the restarted transport would
authorize against the boot-time roster. The close, the hoist and the publish of
the new handle run under _channel_restart_lock; the connect between them does
not, so a disable's inline close is never queued behind a slow connect, and the
per-channel restart generation (bumped by every close) decides whether the
connected client is published or torn down as superseded. A superseded start
also takes back what its factory already published -- the transport
registration on DashboardState.channel_transports and the legacy
_<channel>_client mirror -- by identity only (_forget_superseded_start),
so a closed transport never keeps answering get_channel_transport while a
newer start's registration is left alone.
_on_channel_config_change decides when to call it: a channel restarts only when
a changed path names one of its descriptor's boot_keys
(registry.changed_boot_keys, messaging/registry.py). Live fields of the same
section — allow-lists, thresholds, render toggles — are applied by that channel's
own applier without a reconnect, so a change touching only them leaves the socket
alone. Before _channel_transports_started the applier raises ConfigDeferred
instead of restarting, because the boot loop starts every channel from the hoist
and a restart there would race it; the watcher keeps the paths stale and re-runs
the applier every tick against its CURRENT snapshot, so the first tick after
start_channels flips the flag performs the restart the edit asked for. The boot
loop itself never calls the applier: a replay outside ConfigWatch._apply_one
would skip the degraded check, and a document with a discarded channel section
retained during the window would then raise straight out of boot instead of
being deferred. That deferral
covers boot keys only, so live fields
edited in the same window — an allow-list revocation between the watcher arming
at dashboard init and the transports starting — are covered differently: the boot
loop re-hoists every bootable channel from the watcher's CURRENT snapshot
(_adopt_channel_sections_from_watcher) before the enabled census, so a channel
switched on in the window is started at all, and then re-hoists EACH channel
again (_adopt_channel_section_from_watcher, the before_start hook of
registry.start_channels) synchronously, immediately before that channel's
factory. The second pass exists because channels start one after another and a
connect can take seconds: a revocation that lands while an earlier channel is
connecting has no applier yet for a channel that is not constructed, and a single
read at the top would have left the later channel building from a document the
earlier connects had let go stale. The hook is synchronous and every
maybe_start_<channel> constructs its dispatcher — which subscribes to the
watcher — before its first await, so nothing can be dispatched between that read
and the channel's own subscription. A snapshot whose
channel section is degraded leaves the boot copy alone — fail-closed, like every
applier. The whole-config marker alone does not: the snapshot never carries a
torn document's defaults (the watcher keeps the previous values while the file
does not parse), so on a snapshot * is the loader's process-long memory of a
repaired tear, and refusing on it would freeze the roster until a restart.
The Slack applier
Slack is deliberately NOT in the restart loop. Its socket client is owned by
_connect_slack under the channels governance gate (a deny must DROP the
client), and its tokens live in the credential store rather than config.json, so
no slack.* write can change the connection. _on_slack_config_change
(subscribed on slack + messaging) reconciles everything else in place:
slack.tracking_channels/slack.open_channels→ the orchestrator's sets AND thehandlermodule globals, mutated IN PLACE so the Slack-native modal, which edits those same set objects, and a CLI write converge on one set rather than two that disagree.slack.channels/slack.dm_activation/messaging.*/trusted_bot_*/home_tab_sessions_per_kind/forward_to_agent_callback→ the shared config object every Slack read reaches throughhandler.slack_cfg(), updated section-by-section in place soorch._cfgandhandler._orch_cfgcannot diverge.slack.reactions→handler.refresh_phase_emojis, which rebuilds_PHASE_EMOJISin place; the four read sites callphase_emojis()rather than the module global, so a reaction rename lands on the next status update.slack.observe_*→ the liveChannelHistorycaps, and observe-mode registration follows the new channel activations.slack.allowed_enterprise_ids→enterprise.reload_allowed_team_idsoff the loop, which re-runs the VALIDATED_load_allowed_team_idsrather than a raw read, fails closed on a degraded file, and SEL-audits the change. It runs whether or not the workspace has been validated yet: before validation the module is default-open, so a reload that skipped that state would leave a freshly written allowlist unapplied and every workspace admitted; the validated read adds the validated team id only once there is one, andvalidate_enterprise()re-runs it when the workspace is known. Never widening is the point: this list is what keeps another Grid workspace out.
Fail closed as a whole: when the loader DISCARDED the slack section
(degraded_sections) nothing under it is applied, the previous sets stay in force,
and the change is logged by PATH only — a slack section contains tokens, so no
applier logs a value. A change to slack.trusted_bot_ids, open_channels or
tracking_channels is SEL-audited as its own event, because those sets widen who
may drive a turn; the per-message admission decision is still audited where it is
made.
slack.command is the one Slack field marked restart=True in
config/sections.py: the slash command is registered with Slack's app manifest, so
no in-process apply can change it. No channel CONNECTION field is marked, because
restart_channel applies those without a process restart.
Subagent & Cron Acknowledgment
Subagent completion and cron execution results post to both dashboard (WebSocket) and Slack (DM with ack button). Shared ack_button() helper in interactions.py handles button replacement:
- Try
response_urlfirst (instant, works for 30 min) - Fallback:
chat.updatevia Slack API (works indefinitely) - Section text truncated to 2990 chars (Slack's 3000 char limit)
Bidirectional sync: Slack ack → resolves dashboard approval future + broadcasts notification_ack WS event. Dashboard ack → resolves Slack pending future.
Subagent Slack Replies
When a subagent with a Slack parent session completes, the synthesized LLM response is posted to the owner's DM thread. Long replies are split into multiple messages using _split_message() from handler.py (3900 chars per chunk, split on newline boundaries), matching the behavior of final chat messages.
A parent session born on any other channel (Telegram, Discord, unified: DM buckets, …) delivers the same synthesized reply through the governed cross-surface transport ladder instead (_deliver_channel_reply in gateway.py): the conversation is resolved via origin link (recorded by Discord's inbound dispatch) → non-Slack mirror link (e.g. a Telegram /link binding) → for direct (1:1) sessions only, the stored "{namespace}:{user_id}" channel value resolved through transport.resolve_configured_target; the target is vetted by _resolve_channel_target (SEL-audited, fail-closed, capability-gated on supports_proactive_send), then redacted and chunked to the transport's max_message_chars. Delivery is best-effort and fail-closed on ambiguity — group/forum sessions without an origin or mirror link, dispatchers that record neither, and denied egress all degrade to the dashboard notification (never a cross-conversation send), and the injected ACP turn still keeps the parent session aware of the result.
Tool Approval via Slack
Structured monitor completion adapters
The AutoNudge router keeps its historical on_fire -> bool, cycle_count,
fired, and rearm contracts. A separate runtime-only hook is supplied only when
a structured monitor already has an actionable fingerprint marked in-flight;
legacy loops and ordinary channel messages receive none. MonitorController
runs the typed GitHub probe off the event loop, persists the decision and
in-flight claim, and calls the Slack/Discord or dashboard adapter only for
WAKE_ACTIONABLE. The adapter receives the already formatted envelope and does
not add the legacy cycle tag. Every non-actionable, retry, and terminal decision
dispatches zero turns.
A Slack message routed into a linked dashboard slot retains channel provenance on
the immediate turn, queue entries, and recovery turns. A monitor directive produced
there persists channel as its creation surface even though its storage binding is
the linked chat key, so the link cannot confer dashboard owner credentials on its
provider probes.
Terminal observer notifications are deduplicated for structured monitors within
one gateway process. The retained monitor record also stores whether the dashboard
durably appended its terminal notice. Startup schedules every terminal notice without
that delivery marker as a supervised background task, so notification persistence
cannot delay gateway readiness. The task persists the marker only after the captured
notification append future succeeds, giving the persist-then-notify boundary at-least-once crash
semantics: a crash or append failure can repeat a notice, but cannot suppress the
only notice permanently. A failed notification creation or append releases the
process-local deduplication claim, allowing a later observer event to retry without
requiring a gateway restart. Gated
legacy loops use only their existing expired notification; the following fired
event must not deliver the same terminal notification again.
Terminal notices identify the watched pull request by its stored target URL,
including channel-bound watches with no dashboard jump link. The completed body,
including the retained target, passes through shared URL and credential redaction
before dashboard notification persistence. The stored stop
reason distinguishes a merged pull request from one ready for review: only a
merge says no action is needed. A pull_request_closed blocker states that the
pull request was closed unmerged and offers reopen-or-abandon recovery. Other
known blockers name the credentials, permission, setup, approval, completion,
conversation, or saved-record problem; unknown reasons point to retained details
without guessing that the pull request closed. An unavailable-session notice
directs the operator to start a new watch from an active conversation.
Slack's structured inline nudge runs through TurnDriver with the shared,
session-bound directive consumer. Genuine core-MCP monitor_update,
monitor_stop, and structured autonudge_stop tool results therefore mutate
the authoritative Slack monitor before any later raw completion; forged or
sub-agent results retain the driver's fail-closed behavior. Legacy nudges keep
their collector path. Both paths consume provider_last_turn_usage(client)
exactly once. That one TurnUsage object is fanned out to the existing usage-row
writer and, when the stream observed safe completion evidence, the monitor hook.
ACP-synthesized terminals are excluded. Because stale-stream synthesis reuses
end_turn, that reason remains uncharged until ACP events expose provenance;
other safe reasons determine cancellation or failure.
Stream exhaustion and timeout before that event still write the existing usage
row but do not report monitor completion or charge the monitor budget. Callback
or usage-row persistence failure does not change the Slack delivery result. A
structured stream that started reports DISPATCHED even if it exhausts or raises
before EVENT_COMPLETE; the controller's persisted evidence deadline resolves
the missing callback. Legacy callers retain their historical boolean result.
Discord synthetic nudge injection passes the same hook through
DiscordDispatcher to TurnDriver. Only a safe EVENT_COMPLETE reason reports
completion; a command return, dispatch exception, or renderer
close() is not completion evidence. Thus dashboard, Slack, and Discord all
reach the same typed controller callback even though their transport lifecycles
remain different. A queued dashboard turn revalidates its claim after background
admission and before entering _run_chat, so a stopped monitor cannot run
prompt-submit hooks; _run_chat revalidates again immediately before provider
entry to cover revocation during turn setup. Their pre-completion delivery
contract is also shared:
DISPATCHED, BUSY, or UNAVAILABLE; BUSY is an ordinary durable retry of the
same claimed wake, while only UNAVAILABLE terminates the monitor.
Background task approvals (subagent, cron, task runner, and AutoNudge) post approval buttons to Slack DM via _interactive_approval(), racing with dashboard approval:
- Posts ✅ Approve / 🚫 Reject buttons to owner DM
- Creates
_PendingApprovalentry for interactive handler - Dashboard callback resolves Slack future on dashboard approve
- Slack button click resolves dashboard future
handle_interaction()guards against None provider and double-set on futures
Background Deny-Fast (Unattended Sources)
_interactive_approval(source) is used by both interactive UI/slack and
unattended background sources. For background sources there is no human
responder, so waiting the interactive approval window on every approval would
stall cron, heartbeat, task-runner, or AutoNudge turns.
_BACKGROUND_APPROVAL_SOURCES = {"cron", "heartbeat", "taskrunner", "autonudge", ""}(module constant ingateway.py).is_background = source in _BACKGROUND_APPROVAL_SOURCES.subagentis NOT background: subagent approvals route to the dashboard where the spawning human is present (via the parent slot), so they keep the long interactive window.- When
is_background, both the Slackwait_for(pending.future, ...)andDashboardState.request_approval(..., is_background=True)useDashboardState._BACKGROUND_APPROVAL_TIMEOUT_SECSand then deny on expiry — letting the turn proceed/fail rather than hang.test/test_dashboard_approval.py::TestBackgroundApprovalDenyFastpins the bounded background window and the unchanged interactive window. - The Slack and dashboard windows reference
DashboardState._BACKGROUND_APPROVAL_TIMEOUT_SECS/DashboardState._APPROVAL_TIMEOUTas the single source of truth.
Heartbeat Tool Allowlist (HEARTBEAT_SAFE_TOOLS)
Heartbeat sessions run unattended and cannot prompt a human for tool approval. _is_heartbeat_safe_tool(event_title) checks whether a tool is safe to auto-approve using a strict exact-match against the HEARTBEAT_SAFE_TOOLS frozenset — no verb/heuristic fallback (deny-by-default, per security-controls).
Title normalization (applied before the set lookup):
- Strip leading status prefix (
Running:) via_HEARTBEAT_STATUS_PREFIXES. - Strip ACP
mcp__<server>__<Tool>prefix. - Strip runtime
@<server>/<Tool>prefix (kiro-cli titles arrive asRunning: @internal-mcp/ReadInternalWebsites).
Only the bare tool name (e.g. ReadInternalWebsites) is tested against the frozenset. Unknown tools are denied and a SEL audit event (outcome: denied, reason: not_in_heartbeat_safe_tools) is emitted so operators can tune the list. SEL failure on the approve path fails closed (denies the tool).
Dashboard Token Authentication
!dashboard [duration] Command (deprecated → /kirocrew dashboard)
Owner command in handler.py that generates a time-limited token URL for dashboard access:
- Parses optional duration argument via
parse_duration()— accepts<N>hor<N>mformat (default:1h) - On invalid duration, replies with usage message
- Calls
generate_token(user_id, ttl)to create an HMAC-SHA256 signed token - Constructs URL using configured host from
dashboard.url, or machine hostname for remote access, orlocalhostfor local-only - Logs via SEL with
operation='slack.dashboard_token' - Posts the URL as an ephemeral-style message in the Slack thread
Token Auth Middleware
token_auth_middleware(local_only) in token_auth.py — aiohttp middleware in the explicit middleware chain:
- Auth required: on every gated request, loopback included — local-only mode no longer trusts loopback (local port forwarders make remote traffic appear as 127.0.0.1)
- Bypassed for: static assets (
/assets/,/static/,/logo.png,/manifest.json,/sw.js,/icon-*.png) - Token sources:
?token=query param (first use) ormc_token_{port}cookie (subsequent requests) - First query-param use: binds token to client IP, marks consumed, sets
HttpOnly; SameSite=Strict; Path=/cookie - Cookie use: validates token + IP binding, allows repeated access
- Rejection: returns 403 HTML page with instructions to run
/kirocrew dashboardin Slack; API paths get JSON error
Token format: base64url(payload).base64url(HMAC-SHA256-signature) with per-process secret (os.urandom(32)).
Dashboard URL Config
Single dashboard.url field on KiroCrewConfig (default: ""), loaded from config.json → dashboard.url.
is_local_only(dashboard_host, slack_connected) determines the mode:
- No Slack → local-only (no auth layer)
- Loopback host → local-only
- Non-loopback host → all interfaces, token auth required
{
"dashboard": {
"url": "http://my-host.example.com:8080"
}
}
"auto"+ Slack + remote host →"0.0.0.0""auto"+ Slack + localhost →"127.0.0.1"
Tunnel URL in Slack Links (slack.use_tunnel_url)
SlackConfig.use_tunnel_url (bool, default False) gates whether the AEA
tunnel URL is used when building dashboard links posted to Slack:
false(default) —send_dashboard_link()ignores any active tunnel and builds links fromdashboard.url(if set) or the resolved host:port. Disabled by default until the tunnel mechanism is scaled for general use.true—send_dashboard_link()prefersget_tunnel_url()when a tunnel is active, falling back todashboard.url/host:port when the tunnel is down.
The setting is independent of tunnel.enabled (which controls whether the
tunnel itself runs). A user may run a tunnel for direct browser access while
keeping Slack links pointed at the local origin.
--no-tunnel overrides it. When use_tunnel_url is on, the box is
localhost-only and no tunnel is live, send_dashboard_link() offers a composed
edition an on-demand provisioning seam (current_context().tunnel .ensure_available()) — a second door out that bypasses setup_tunnel entirely,
provisioning straight on the provider without ever constructing a
TunnelManager. On a process booted with --no-tunnel that seam is not reached
at all (tunnel.publish_disabled()), the refusal is SEL-audited as
tunnel.provision_denied / no_tunnel_boot_flag — the same control as the boot
refusal, so neither door's denials are missing from the trail — and the link is
composed from the local origin instead. The DM also carries a line naming
--no-tunnel and the ssh -L form: every other route to a local link can still
become reachable (the edition seam re-issues once its tunnel connects), but this
one never will, so without it the requester taps a link that times out every time
with the explanation only in the log. Without that check the flag would be a
promise the product does not keep: an instance that refused to publish at boot
would publish the first time anyone asked for a dashboard link.
Slack connect is non-fatal (GatewayOrchestrator._connect_slack): the
initial socket-mode connect() is wrapped so a network/proxy/timeout failure
(e.g. a stale HTTPS_PROXY in the launching shell — slack_sdk's aiohttp client
honours proxy env vars via trust_env) logs a warning and the gateway
continues in dashboard-only mode instead of crashing the whole process.
Only ordinary Exceptions are swallowed; CancelledError (BaseException)
still propagates so real task cancellation is not masked. There is no
background retry of the initial connect — Slack DM stays disabled until the
next gateway restart. The "connected to Slack" banner prints only after a
confirmed connect.
Config example (remote access via URL):
{
"dashboard": {
"url": "http://my-host.example.com:8080"
}
}
Security
- Owner-locked via
KIROCREW_OWNER_IDin.env(supports W/U prefix cross-matching) - Enterprise Grid validation (
slack/enterprise.py): Two-layer defence against data exfiltration to personal/external Slack workspaces:- Startup gate:
validate_enterprise()callsauth.testwith the bot token, verifiesenterprise_idmatches the configured production (E0123ABC456) or sandbox (E0456DEF789) grid. Cachesteam_idandenterprise_idin memory. Clears cache before each validation attempt so re-validation failures are fail-closed. Gateway refuses to connect if validation fails. - Per-message gate:
check_message_origin()compares each incoming event'steamfield against the cachedteam_id. Catches.envhot-swap while running. Zero-cost in-memory string comparison, no API call. Deny-by-default: emptyteamfield is rejected.
- Configurable extra IDs via
slack.allowed_enterprise_idsin config.json (for additional subsidiary grids) - One list, two id spaces — Enterprise Grid needs BOTH kinds in it.
auth.testreturns an org-levelenterprise_id(E…) and the install workspace'steam_id(T…), while each inbound event carries the child workspaceteam_idit was sent in. The startup gate checksenterprise_id or team_id, so on Grid the org id must be listed or validation refuses and Slack is disabled; the per-message gate only ever compares the event's workspace id, which anE…entry can never equal, so every child workspace id must be listed or its messages are denied. Supplying either kind alone fails, and the two failures look nothing alike: workspace-ids-only refuses loudly at boot, while org-id-only passes validation (Enterprise validation OK) and then denies every DM — armed, because any entry leaves default-open, with nothing inbound able to match._diagnose_allowlist_id_spaces()warns at load time for the org-id-only case (SELerror=allowlist_admits_no_inbound_workspace), and the startup refusal names the missing org id for the other, so neither state is silent or points at the wrong remedy. Both are DIAGNOSTIC: admission is unchanged, because treating anE…entry as org-wide admission would widen the allowlist this gate exists to keep narrow. - Corrupt-config fail-closed:
KiroCrewConfig.load()degrades a torn/corruptconfig.json(orconfig.local.jsonoverlay) to a defaults object rather than raising, soslack.allowed_enterprise_idswould come back empty._load_allowed_team_ids()positively detects that degraded read (a config file that exists on disk but does not parse) and fails CLOSED -- the allowlist stays enforced and admits NO origin (not even the just-validated workspace, which would answer the allowlist's own question) so startup is refused, and the degradation is SEL-audited (operation=slack.allowed_team_ids_load,error=config_load_degraded_fail_closed) -- instead of silently reverting to default-open. A genuinely unconfigured allowlist (no config file, or a clean file listing none) stays default-open. - One reader owns the allowlist: the admitted set comes only from that validated read of
slack.allowed_enterprise_ids. Caller-suppliedextra_ids-- the caller's own earlierKiroCrewConfig.load()snapshot of the same key -- does not contribute to it. The validated read is never older than the snapshot, so ids the snapshot holds and the read does not are ids the operator REMOVED, and unioning them would undo the removal. Consequence in both directions: removing one id takes effect at validation, and emptying the list returns to default-open, matching what a restart does.extra_idsdoes not contribute on theauth.test-failure path either, so the validated read is the sole source on every path: that path decides fail-open vs fail-closed by asking whether a restriction is configured, and counting an older snapshot there would manufacture a restriction the file does not list. An UNREADABLE config still refuses there -- a config that cannot be honoured is not one that honestly lists no restriction -- and a configured allowlist still fails closed. - All validation outcomes logged to SEL (
operation=slack.enterprise_validation) kirocrew doctorincludes workspace validation check
- Startup gate:
- Deny-by-default: if
KIROCREW_OWNER_IDis unset or empty, Slack is disabled entirely at startup (init_socket_moderefuses to connect). The access check in_route_messagealso rejects all messages when owner ID is missing, as a secondary guard. - Interactive payload access check:
interactions.dispatch()uses deny-by-default — rejects unless the clicking user is positively confirmed as allowed. Non-allowed users receive an ephemeral message ("⛔ You are not authorized to use these buttons.") and the original buttons remain intact for the owner to click later. - Dedup cache (
SeenCache) prevents processing duplicate Slack events - Bot self-message filtering via
bot_idcheck - Trusted bot IDs (
slack.trusted_bot_idsin config): allows specific bot IDs to bypass the blanketbot_idfilter, enabling multi-node mesh communication. Empty list = all bot messages dropped (default), and a bot id NOT in the list is denied exactly as with no list (fail-closed,error=untrusted_bot). Admission requires a positivebot_idmatch against the allowlist; the match setsfrom_trusted_bot, which lets thebot_idstand in assender_idand grants access equivalent to an allowed user — authorization is explicit via thetrusted_bot_idsconfig allowlist, not theslack.allowed_userslist. All trusted-bot permission decisions emit SEL audit events (allowed decisions carryresources="trusted_bot"so the decision basis is traceable). Echo protection: error replies to trusted-bot messages are suppressed on both dispatch routes — the native path (from_trusted_botinhandle_message) and the default transport path (from_trusted_botinhandle_message_transport, threaded through the immediate call, both session queues, and_dispatch_queued; the error message is suppressed but the thread status is still cleared). Successful-reply loops are bounded by the per-thread turn cap (slack.trusted_bot_turn_limit, default 5, minimum 1): a thread that has run that many consecutive trusted-bot turns admits no more (error=trusted_bot_turn_limit_reached) until an allowed human posts in it, which resets the count — without the cap, two mutually trusted gateways would admit each other's replies as fresh turns indefinitely. Only a message that actually dispatches a turn moves the count (Slack retries, message/app_mention duplicate pairs, and activation-dropped messages do not). Review-mode channels deny trusted bots outright (error=trusted_bot_denied_in_review_channel): the review draft flow delivers via an ephemeral to the sender, which requires a human user id. The gateway's own bot id (cached from the startupauth.testthat enterprise validation already performs) is never trusted even when listed (error=own_bot_id_never_trusted) — otherwise every reply would re-enter the handler as fresh input, a self-reply loop; whenauth.testwas unavailable the self identity is unverified and the admission FAILS CLOSED, trusting nobody (error=trusted_bot_requires_verified_self_id) — the same posture enterprise validation takes for a configured allowlist with unverifiable workspace identity. The (unwired)SlackTransport.receiveinbound path and this gate call ONE owner of the admission rule,slack.enterprise.trusted_bot_admission— positive allow-list match, own-bot exclusion, fail-closed unverified self id, audited decisions, trust before the subtype filter — so the two Slack inbound paths cannot drift about which peer bots are admissible. What each site still owns is the READ TIMING of the allow-list it passes in: this gate passes the live config, so an operator's edit takes effect on the next event, while the transport freezes a constructor snapshot to match itsallowed_userspattern. - Socket Mode — no public URL exposed
- Credentials stored in
~/.kiro/crew/.envwithchmod 600
Dependencies
| Package | Version | Purpose |
|---|---|---|
slack_sdk | >= 3.0 | Socket Mode + Web API |
aiohttp | — | Dashboard HTTP server |
websockets | — | Socket Mode transport |
croniter | — | Cron expression matching |
snowballstemmer | — | Snowball stemming for semantic KV keyword scoring |
pysqlite3-binary | — | FTS5/UPSERT compat on AL2 (Linux only) |