slaude Docs

Field notes

2026-05-09 / 2026-05-10 — Slack UX, engagement, approval, CI

  • Slack Agents API status indicator shipped: assistant.threads.setStatus-backed Status helper 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 on missing_scope/not_in_assistant_thread. Manifest now declares assistant_view + assistant:write scope 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 via SLACK_USER_TOKEN. Reactions printed needed/provided scopes on failure for fast diagnosis.
  • Diagnostic firehose: app.use middleware logs every Bolt event ([slack-evt] type/subtype ch=… ts=…); auth.test runs 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 resume retry: after Sessions.clearStarted, manager re-boots the same prompt with resume=false so the user doesn't see the failure. A retried guard prevents the outer finally from tearing down the fresh session.
  • Engagement model (channels/groups): per-thread engaged set. @mention slaude engages → handle this msg + plain follow-ups. @mention someone else disengages → 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 in mcp__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 in fence and then bold/italic inside shows literal — soul mandate now bans whole-reply fences.
  • Files attachment from agent: mcp__slaude_slack__upload wraps WebClient.files.uploadV2 to post a local file (image/PDF/log) to the active thread. Optional initial_comment runs through mdToMrkdwn. Auto-allowed via the mcp__slaude_slack__* permission-gate prefix; underlying API call needs files:write scope.
  • Approval gate (manager-style, agent-driven): new mcp__slaude_slack__request_approval(summary, tools?, files?, risks?, category?) pairs with running the session in bypass/YOLO mode. Posts Block Kit Approve/Deny; resolves via the click's response_url so 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 → env SLACK_ALLOWED_USERS → anyone. Modern format under ## Approvers is <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.update lagged behind ack() so users double-clicked; switched to respond({replace_original: true}) which fires against the click's response_url and is much faster.
  • Adapter route.spoke now flips on reply / edit / upload (any user-visible tool), not just reply — 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_PERSONA is 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-workspace workspace where full scopes (chat:write, files:write, reactions:write, message.* histories, etc.) install cleanly. assistant:write and users.profile:write still missing on that install (status falls back to disabled gracefully).
  • CI / Docker / release shipped: bun test --coverage runs 137 tests across tests/*.test.ts covering 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.toml enforces coverageThreshold = 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.yml runs typecheck + coverage on push/PR. .github/workflows/docker.yml builds 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.yml fires on v*.*.* tag, runs the test suite, generates a changelog from git 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.ts is preloaded via bunfig.toml and creates a fresh $SLAUDE_HOME per bun test run via mkdtempSync, so db/schema bootstrap, soul/loader writes, and the ~/.slaude/.env dotenv loader all hit a tmp dir instead of the operator's real home. Same setup seeds a .env with quoted/single/plain entries so the dotenv branches get covered on first import.
  • AbortSignal abort tests need a microtask between gate.request() and controller.abort() — the async await postMessage in the gate hasn't reached the addEventListener line yet at the moment we synchronously call abort. Without the await new Promise(setTimeout, 5) interleave, the abort fires with no listener registered, and the request hangs forever (caught: 5s timeout in CI).