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.systemPromptandquery()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.setwhile busy, ref-counted across sessions; (3) Streamer — one Slack reply per turn that grows viachat.updatewith a ▍ cursor and rolls over past 36k soft cap. - Mistake (caught before deploy): manager only emitted
donewhen the SDK iterable ended, but the persistent prompt iterable never ends →donenever fired → streamer never flushed. Fixed by emittingdone/errorfrom SDKresultmessages (per-turn) instead. Filed under "subtle bug to remember on every transport that buffers per-turn". - Tool approval shipped:
AgentManager.setPermissionResolver()installs an SDKcanUseToolcallback. Slack adapter'sPermissionGateposts a Block Kit Allow / Always / Deny prompt to the active thread.SLAUDE_AUTO_ALLOW_TOOLSenv pre-approves safe ops. "Always allow" returns the SDK'ssuggestionsasupdatedPermissionsso the user isn't asked again that session. - Permission modes shipped: per-session
permission_modecolumn + slash commands/mode <ask|accept-edits|bypass|plan|dont-ask>,/abort,/help. Mode is persisted and also flipped on a liveQueryviasetPermissionMode()for in-flight turns.SLAUDE_DEFAULT_MODEenv 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, exposingreply/edit/react/unreact. Pattern stolen from claude-code Channels. Trade: cleaner UX, no thinking/tool-call noise; risk: agent may forget to callreply(mitigated via SOUL.md mandate + adapter posts a "no reply emitted" notice ondoneif 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
SlackContextis mutated across turns (channel/threadTs/inboundTs) so the closure-bound MCP tools keep targeting the right thread for the session lifetime.AgentManagerexposessetMcpResolvermirroringsetPermissionResolver.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 callmcp__slaude_slack__editto revise a prior reply. - Provider-swap landmine: when
ANTHROPIC_BASE_URLis 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 injectingCLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1+DISABLE_TELEMETRY/AUTOUPDATER/BUG_COMMAND/ERROR_REPORTING=1into the SDK child env. - Stale
resumeid across providers: markingclaude_started=1after a turn on provider A means the SDK passesresume: <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, callsSessions.clearStarted(id), and re-boots#startSessionwith the samefirstText(and aretriedguard so thefinallyblock doesn't tear down the freshly-rebooted session). User sees no error. - Slack event delivery quirk in private channels:
message.groupsevent subscription must be enabled in the app config (separate fromgroups:historyscope) for in-thread replies w/o@mentionto reach the bot. If it's not, onlyapp_mentionevents arrive — users have to re-mention every reply. Logged via theapp.useevent firehose so this is visible at startup. - Health endpoints: Bun-native HTTP server on
SLAUDE_HEALTH_PORT(default 8080)./healthzreturns 200 always w/ uptime+live-session count (liveness);/readyzrunsSELECT 1on 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 cachedusers.info). Files attached to the Slack message are downloaded withAuthorization: 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 canReadthem directly. Empty-text messages are accepted when files are present (file-only DMs). - Idle TTL shipped (folk-style):
SLAUDE_IDLE_MINUTES(default 15).AgentManager.#armIdleclears+sets a timer on every user msg and turn-end (result); on expiry it flushes turn buffer and closes the prompt iterable. The SDKfor awaitunwinds,#liveentry deleted. Next inbound msg in the same thread →#startSessionre-bootsquery()w/resume: row.id(already set wheneverclaude_started=1). Slackroutesmap (SlackContext) is kept across idle-close so the per-session MCP server still has a live ref when resumed.