slaude Docs

Field notes

2026-05-08 — MVP bootstrap

  • Repo init. Research dispatched on hermes-agent + folk.
  • Folk research: AgentManager already transport-agnostic. Could fork+strip Electron, but greenfield smaller surface.
  • Hermes research: clean SOUL/SKILL/memory pattern. Slack via slack-bolt Socket Mode. ~/.hermes/ home dir.
  • Decision: greenfield Bun+TS. Mirror hermes layout, steal folk AgentManager.
  • Shipped MVP: config, db, soul, AgentManager, slack adapter (Socket Mode), skill loader, sqlite memory provider, manifest CLI, README. Typecheck green. Not yet exercised end-to-end against a real Slack workspace.
  • Mistake: first stab at slack adapter used import bolt from "@slack/bolt"; const { App } = bolt; — fails ESM types. Direct named import works: import { App, LogLevel } from "@slack/bolt".
  • Mistake: pre-tool-use hook flagged db schema file falsely (no exec call); re-saved verbatim, accepted.
  • Note: claude-agent-sdk v0.1.77 installed; v0.2.x available. Defer upgrade until MVP is proven; check breaking changes in Options.systemPrompt and query() signature first.
  • Owner direction (Telegram): (1) provider-agnostic via Anthropic-compatible API, no Claude Code OAuth path; (2) deploy model = one container = one persona = one SOUL.md. Locked both decisions; added Dockerfile + docker-compose + k8s manifest stub. Replicas pinned to 1 because Slack Socket Mode is single-leader.
  • Owner direction (Telegram): want Hermes-parity Slack UX. Shipped: (1) ReactionTracker — 👀/⚙️/✅/❌ status reactions per inbound message; (2) Presence — users.profile.set while busy, ref-counted across sessions; (3) Streamer — one Slack reply per turn that grows via chat.update with a ▍ cursor and rolls over past 36k soft cap.
  • Mistake (caught before deploy): manager only emitted done when the SDK iterable ended, but the persistent prompt iterable never ends → done never fired → streamer never flushed. Fixed by emitting done/error from SDK result messages (per-turn) instead. Filed under "subtle bug to remember on every transport that buffers per-turn".
  • Tool approval shipped: AgentManager.setPermissionResolver() installs an SDK canUseTool callback. Slack adapter's PermissionGate posts a Block Kit Allow / Always / Deny prompt to the active thread. SLAUDE_AUTO_ALLOW_TOOLS env pre-approves safe ops. "Always allow" returns the SDK's suggestions as updatedPermissions so the user isn't asked again that session.
  • Permission modes shipped: per-session permission_mode column + slash commands /mode <ask|accept-edits|bypass|plan|dont-ask>, /abort, /help. Mode is persisted and also flipped on a live Query via setPermissionMode() for in-flight turns. SLAUDE_DEFAULT_MODE env sets the default for new sessions.
  • Channel-style Slack output: dropped auto-streaming of assistant text. Slack output now flows exclusively through an SDK MCP server (slaude_slack) bound per-session, exposing reply/edit/react/unreact. Pattern stolen from claude-code Channels. Trade: cleaner UX, no thinking/tool-call noise; risk: agent may forget to call reply (mitigated via SOUL.md mandate + adapter posts a "no reply emitted" notice on done if nothing surfaced). Inbound user messages are wrapped in <channel source="slack" channel_id=… thread_ts=… inbound_ts=… user=…> envelope with explicit reply-via-tool directive.
  • Per-session SlackContext is mutated across turns (channel/threadTs/inboundTs) so the closure-bound MCP tools keep targeting the right thread for the session lifetime. AgentManager exposes setMcpResolver mirroring setPermissionResolver. mcp__slaude_slack__* tools are auto-allowed in the permission gate — they're the only path to user output, so gating them would deadlock.
  • Removed src/gateway/slack/streamer.ts. Live-edit streaming retired; agents call mcp__slaude_slack__edit to revise a prior reply.
  • Provider-swap landmine: when ANTHROPIC_BASE_URL is repointed at a non-Anthropic gateway (DeepSeek, OpenRouter, …), claude-cli's 1P event export still tries to phone home and the child process exits with code 1. Fixed by injecting CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 + DISABLE_TELEMETRY/AUTOUPDATER/BUG_COMMAND/ERROR_REPORTING=1 into the SDK child env.
  • Stale resume id across providers: marking claude_started=1 after a turn on provider A means the SDK passes resume: <session-id> to provider B which has no record → No conversation found with session ID → exit 1. Manager now buffers child stderr, detects that string on query() throw, calls Sessions.clearStarted(id), and re-boots #startSession with the same firstText (and a retried guard so the finally block doesn't tear down the freshly-rebooted session). User sees no error.
  • Slack event delivery quirk in private channels: message.groups event subscription must be enabled in the app config (separate from groups:history scope) for in-thread replies w/o @mention to reach the bot. If it's not, only app_mention events arrive — users have to re-mention every reply. Logged via the app.use event firehose so this is visible at startup.
  • Health endpoints: Bun-native HTTP server on SLAUDE_HEALTH_PORT (default 8080). /healthz returns 200 always w/ uptime+live-session count (liveness); /readyz runs SELECT 1 on the sqlite db and returns 503 on failure (readiness). K8s manifest wires both probes. Set port to 0 to disable.
  • Slack metadata + attachments: inbound envelope now includes user_id + user_name (resolved via cached users.info). Files attached to the Slack message are downloaded with Authorization: Bearer <bot-token> to <working_dir>/attachments/<inbound_ts>/<filename> and surfaced as <attachment name=… mimetype=… size=… path=… /> blocks inside the <channel> envelope so the agent can Read them directly. Empty-text messages are accepted when files are present (file-only DMs).
  • Idle TTL shipped (folk-style): SLAUDE_IDLE_MINUTES (default 15). AgentManager.#armIdle clears+sets a timer on every user msg and turn-end (result); on expiry it flushes turn buffer and closes the prompt iterable. The SDK for await unwinds, #live entry deleted. Next inbound msg in the same thread → #startSession re-boots query() w/ resume: row.id (already set whenever claude_started=1). Slack routes map (SlackContext) is kept across idle-close so the per-session MCP server still has a live ref when resumed.