Field notes
2026-05-09 / 2026-05-10 — Slack UX, engagement, approval, CI
- Slack Agents API status indicator shipped:
assistant.threads.setStatus-backedStatushelper drives the animated "thinking…" / "running…" text next to the bot name in threads. Pattern stolen from hermes ( gateway/platforms/slack.py:send_typing). Auto-disables onmissing_scope/not_in_assistant_thread. Manifest now declaresassistant_view+assistant:writescope so fresh installs unlock it. Status text is humanized in the adapter (humanizeToolStatus) — Bash → "running<cmd>", Read → "reading", Grep/Glob/Edit/Write/TodoWrite/WebFetch all mapped, mcp__slaude_slack__* mapped to "replying"/"editing reply"/"uploading "/"reacting :name:"/"requesting approval". - Presence + reactions degrade gracefully on missing scope/wrong-token-type (auto-disabled after first failure with a single log line) so an under-provisioned install boots without log spam. Presence requires
xoxp(user token) — disabled by default; opt-in viaSLACK_USER_TOKEN.Reactionsprintedneeded/providedscopes on failure for fast diagnosis. - Diagnostic firehose:
app.usemiddleware logs every Bolt event ([slack-evt] type/subtype ch=… ts=…);auth.testruns at startup and prints granted scopes next to the manifest's declared scopes — caught more than one mismatch during testing. SDK child stderr piped to console as[claude-cli] …. - Provider-swap landmine fix (already noted) + transparent
resumeretry: afterSessions.clearStarted, manager re-boots the same prompt withresume=falseso the user doesn't see the failure. Aretriedguard prevents the outerfinallyfrom tearing down the fresh session. - Engagement model (channels/groups): per-thread
engagedset.@mention slaudeengages → handle this msg + plain follow-ups.@mention someone elsedisengages → drop. Plain msg in disengaged thread → drop. DMs always engaged. Replaces the prior "always require @mention in channels" rule, which lost flow once a conversation was going. The earlier "auto-handle every in-thread reply once a session exists" was wrong (intrusive — grabbed messages clearly aimed at human colleagues). - Markdown → Slack mrkdwn converter (
format.ts:mdToMrkdwn) applied inmcp__slaude_slack__reply/edit. Carves out fenced/inline code via control-char sentinels first, then transforms the rest:**X**/__X__→*X*; single*X*/_X_→_X_;~~X~~→~X~;[t](u)→<u|t>;# heading→*heading*;- / *→•. Italic pass runs FIRST (while bold markers are still**) to avoid eating bold. Tables (| … | --- | …) render as either a padded monospace block (total width ≤ 60) or a bold-keyed definition list (wider — Slack thread panel wraps long rows otherwise). Legacy operator-style trap: model wraps reply infenceand then bold/italic inside shows literal — soul mandate now bans whole-reply fences. - Files attachment from agent:
mcp__slaude_slack__uploadwrapsWebClient.files.uploadV2to post a local file (image/PDF/log) to the active thread. Optionalinitial_commentruns throughmdToMrkdwn. Auto-allowed via themcp__slaude_slack__*permission-gate prefix; underlying API call needsfiles:writescope. - Approval gate (manager-style, agent-driven): new
mcp__slaude_slack__request_approval(summary, tools?, files?, risks?, category?)pairs with running the session inbypass/YOLO mode. Posts Block Kit Approve/Deny; resolves via the click'sresponse_urlso buttons clear instantly (PermissionGate updated to do the same). Returns{approved, by, note?}. The agent self-organizes the high-level checkpoint per soul mandate; per-tool gating disabled. - Approver allowlist sources, in priority order: persona scope-described entries → legacy persona "category: ids" / fenced JSON → env
SLAUDE_APPROVERS→ envSLACK_ALLOWED_USERS→ anyone. Modern format under## Approversis<id-or-mention>: <scope description>per line; runtime tokenizes both scope and the agent's plan summary (lowercase, simple stem, stopword-stripped) and selects approvers whose tokens overlap. Catchall keywords (anything/any/all/default/*/catchall/everything) make an entry always eligible. The agent does NOT pass user IDs — security boundary: parsing happens server-side at click time, so a buggy/jailbroken model can't redirect approval to a friendlier user. - Permission gate fixes: (1) "Always allow" without SDK suggestions used to do nothing; now falls back to a session-scoped
addRules:[{toolName}]PermissionUpdate so Bash etc. stop prompting after the first approval; (2)chat.updatelagged behindack()so users double-clicked; switched torespond({replace_original: true})which fires against the click's response_url and is much faster. - Adapter
route.spokenow flips onreply/edit/upload(any user-visible tool), not justreply— turns that upload a file no longer trigger the "(no reply emitted)" fallback. - Soul split:
RUNTIME_BASELINE(immutable, in code) defines slack output discipline, formatting, approval discipline, engagement;<persona>(operator's~/.slaude/SOUL.md) defines identity (name, role, voice, manager, audience, mandate).STARTER_PERSONAis now a scaffold operators must fill — no behavioral defaults baked in. Baseline is intentionally identity-neutral ("you operate as a Claude Code agent reachable through Slack") so it doesn't fight a persona that names the agent something else. - Slack workspace pivot: company workspace had restricted scope and admin-gated re-install; moved to a personal
personal-workspaceworkspace where full scopes (chat:write, files:write, reactions:write, message.* histories, etc.) install cleanly.assistant:writeandusers.profile:writestill missing on that install (status falls back to disabled gracefully). - CI / Docker / release shipped:
bun test --coverageruns 137 tests acrosstests/*.test.tscovering every pure module + Slack helper (format, commands, soul/loader, skills, db/sessions, memory, attachments, status, reactions, presence, users, approval-gate, permission-gate, health, env, home).bunfig.tomlenforcescoverageThreshold = 0.97; current run hits 99.55% lines / 98.29% funcs. Untested integration glue (server, adapter, manager, mcp-tools, manifest CLI) is excluded by virtue of not being imported from any test — Bun coverage only counts touched modules..github/workflows/ci.ymlruns typecheck + coverage on push/PR..github/workflows/docker.ymlbuilds linux/amd64+arm64 and pushes to GHCR (ghcr.io/<owner>/slaude) on main +v*.*.*tag with semver/sha/latest tag matrix; PRs build but don't push..github/workflows/release.ymlfires onv*.*.*tag, runs the test suite, generates a changelog fromgit log <prev>..<tag>, and creates a GitHub release (auto-prerelease when tag contains-). Cutting a release:git tag v0.1.0 && git push --tags. - Test isolation pattern:
tests/setup.tsis preloaded viabunfig.tomland creates a fresh$SLAUDE_HOMEperbun testrun viamkdtempSync, sodb/schemabootstrap,soul/loaderwrites, and the~/.slaude/.envdotenv loader all hit a tmp dir instead of the operator's real home. Same setup seeds a.envwith quoted/single/plain entries so the dotenv branches get covered on first import. - AbortSignal abort tests need a microtask between
gate.request()andcontroller.abort()— the asyncawait postMessagein the gate hasn't reached theaddEventListenerline yet at the moment we synchronously call abort. Without theawait new Promise(setTimeout, 5)interleave, the abort fires with no listener registered, and the request hangs forever (caught: 5s timeout in CI).