Skip to content
Habakkuk

Architecture

How Habakkuk is built: layers, durable jobs, storage, models, permissions, channels, HTTP, memory, skills and operations.

On this page

Habakkuk is a personal and team AI agent: one Go binary that talks to you over the CLI and messaging channels, uses tools, delegates work between agents, and runs scheduled jobs. It is built around one rule: every piece of work is a durable job.

Goals, measurable#

Goal Target
Small Core under 25k lines of Go; one static binary, no CGO, no Node or Python runtime
Fast Cold start under 100 ms; zero added latency on top of the model stream
Stable kill -9 at any point loses no committed work; a restarted process resumes interrupted turns
Bounded Retention on by default; tool outputs over the inline budget go to blob files; databases never grow without limit
Clean No global state; every call carries an explicit AgentContext; packages depend downward only
Scalable One process serves many agents; workers scale by goroutines; storage is per-concern SQLite (Postgres later behind the same interfaces)

Layers#

cmd/habakkuk            CLI entry: init, setup, chat, tui, talk, run, serve, channels, mcp, acp, send, pair, node, nodes, whatsapp, schedules, board, meetings, sessions, memory, skills, plugins, checkpoints, rollback, computer, doctor, backup, restore, update
internal/app          wires config, stores, providers, tools, channels, workers together
internal/work/schedule     scheduled prompts and scripts: cron/@every/one-off, at-most-once per slot
internal/work/board        task board shared by agents: cards, dependencies, dispatch, results back to the creator
internal/tools/media        attachment type detection, speech-to-text
internal/tools/mediatools   text-to-speech, image and video generation, view_image, read_document, web_search
internal/tools/voice        habakkuk talk: mic capture and playback through external programs, voice activity detection, wake phrases
internal/channel      gateway (routing, pairing, chat approvals, delivery), supervisor, formatting
internal/channel/telegram, slack, whatsapp, discord, signal, matrix   platform adapters
internal/channel/web  HTTP surface: chat page, JSON API with Server-Sent Events, webhooks, OpenAI-compatible API
internal/protocols/mcpserver    Habakkuk as an MCP server (stdio and /mcp)
internal/protocols/acp          Agent Client Protocol server for code editors
internal/tui          the full-screen terminal UI (`habakkuk tui`, and `habakkuk chat` on a terminal)
internal/channel/email, sms, msteams, googlechat, bluebubbles, line, mattermost, irc, ntfy   more platform adapters
internal/channel/whatsappcloud, messenger, feishu, dingtalk, wecom, zulip, rocketchat, nextcloudtalk, twitch   more adapters: zulip long-polls, rocketchat holds a realtime WebSocket, twitch an IRC connection, the rest take webhooks (metagraph: shared Graph API code; apptoken: cached app access tokens; upload: multipart forms)
internal/channel/jwt   RS256 token checks and signing for Bot Framework and Google
internal/agent        the turn loop and prompt assembly
internal/llm          provider-neutral types, error classification, router (retry, rotate, fallback)
internal/llm/anthropic, internal/llm/openai, internal/llm/bedrock   provider adapters (official SDKs)
internal/llm/gemini   Gemini adapter (REST, standard library)
internal/llm/auth         subscription logins: device flows, auth.json, token refresh under a file lock
internal/llm/usage        per-call token usage, pricing, per-session model choices (usage.db)
internal/tools        registry, toolsets, permission modes, built-in tools, background processes
internal/tools/sandbox      command backends: a per-agent Docker container, or a remote host over SSH
internal/core/checkpoint   workspace snapshots in a shadow git repository: rollback and /undo
internal/tools/review       LLM approval reviewer: one model call decides allow, deny or ask
internal/knowledge/memory       long-term memory: Markdown notes, FTS5 index, memory tools
internal/knowledge/skills       SKILL.md discovery, precedence, skill tools, explicit invocation, the curator
internal/knowledge/hub          installs skills and plugins: sources, safe unpacking, scan, tree hash, lock file, backups
internal/knowledge/plugins      habakkuk-plugin.yaml packages: install, describe, script commands
internal/core/frontmatter  YAML frontmatter for Markdown files (memory notes, SKILL.md)
internal/protocols/mcp          MCP client: server connections, tools from MCP servers as Habakkuk tools, OAuth for remote servers
internal/protocols/a2a          Agent2Agent protocol: Habakkuk's agents served at /a2a, the a2a toolset for other agents
internal/tools/homeassistant  homeassistant toolset (REST) and event triggers (WebSocket)
internal/tools/browser      browser toolset: a CDP client, one Chrome per agent, accessibility snapshots, URL guard
internal/tools/meeting      meeting attendance: join Meet, Zoom and Teams as a guest, read live captions, notes
internal/tools/computer     computer toolset: screenshots and mouse/keyboard through OS commands; X11, Wayland, macOS, per-agent Xvfb
internal/core/jobs         durable job queue: leases, heartbeats, serial keys, retries
internal/core/session      sessions and transcripts, crash repair, transcript search
internal/core/state        per-session overrides, goals, steering, task lists, progress drafts (state.db)
internal/ops/hooks        user commands run on events
internal/core/store        SQLite open/migrate, blob store
internal/config       strict YAML config, validation, defaults
internal/core/events       in-process event bus for streaming to clients
internal/protocols/nodes        device nodes: pairing, the /nodes connections, local capabilities, nodes_list and node_invoke
internal/ops/i18n         message catalogs (embedded YAML per locale) for Habakkuk's own chat and CLI text

Dependencies point downward: agent uses llm, tools, session; jobs knows nothing about agents; store knows nothing above it.

Durable work#

A user message does not run a model call directly. It appends the message to the session and enqueues a turn job with serial_key = session id. A worker claims the job with a lease, heartbeats while working, and runs the turn loop:

  1. Load the transcript and repair it (a tool call with no recorded result gets an "interrupted" error result, so the model re-checks state rather than blindly re-running a side effect).
  2. If the last message needs a model reply, call the model and append the assistant message.
  3. If the reply contains tool calls, run them (in parallel), append all results in one message, go to 2.
  4. When the assistant replies without tool calls, complete the job.

Every append happens only while the worker still owns its lease. If the process dies, the lease expires and any worker resumes from step 1. The serial key guarantees one active turn per session. Scheduled runs, delegations and board cards are the same jobs with different kinds.

Storage#

  • control.db: jobs, conversation bindings, channel health, allowlist, delivery progress, schedules, the board. Small and hot.
  • channels/whatsapp-<account>.db: WhatsApp device credentials, written only in transactions; backups/ keeps seven rotating copies.
  • channels/matrix-<account>.json: the Matrix sync position and the encrypted rooms already told that encryption is unsupported, written atomically.
  • transcripts.db: sessions and messages, plus a full-text index of their text (version 3) and session metadata: where the title came from, an archived flag and the session a fork was copied from (version 4). Large and cold. Retention prunes it.
  • state.db: small per-session state that chat commands and tools change (effort and persona overrides, standing goals, steering notes, task lists, a conversation's chosen agent, progress drafts). Losing it loses no transcript. Retention prunes it, except agent choices.
  • web.db: the web chat's message log, so a reconnecting client can catch up. Retention prunes it.
  • blobs/: content-addressed files for large tool outputs and media.
  • nodes.db: paired device nodes (name, a SHA-256 of the node's secret, platform, last offered capabilities, last seen) and pending pairing codes (hashed, single use, expiring). Its own file so habakkuk node pair and habakkuk nodes revoke can write it while habakkuk serve runs.
  • usage.db: one row per successful model call (provider, model, agent, session, tokens) and each session's /model choice. Retention prunes it.
  • auth.json: subscription login tokens and MCP OAuth logins (mcp:<server>), mode 0600, replaced atomically; refreshes hold auth.json.lock.
  • agents/<name>/memory/*.md: memory notes, the source of truth for memory. memory.db is a search index over them (plus cached embeddings, when notes were last read, and background review progress) and can be deleted: the index rebuilds from the files, notes are embedded again, and conversations of the last three days are reviewed again (saves replace by title, so that repeats little).
  • agents/<name>/USER.md: the agent's profile of the user, included in every new session's prompt.
  • skills/<name>/ and agents/<name>/skills/<name>/: skill folders. skills/.lock.json records installed skills, skills/.usage.json when each skill was last loaded, skills/.curator.json the curator's last run, and skills/.backups/ replaced, removed, merged and archived versions.
  • plugins/<name>/: installed plugins, with plugins/.lock.json (source, hash, approved, enabled) and plugins/.backups/.

All SQLite in WAL mode with busy timeouts, opened through one place (internal/core/store).

Models#

llm.Provider is a narrow interface: stream one request, return one neutral message. The router owns policy:

  • Classify every error (rate limit, overload, auth, billing, context overflow, bad request, network).
  • Retry transient errors with jittered backoff.
  • Rotate credentials on rate limit or auth failure, with per-key cooldowns.
  • Fall back down the agent's model chain on errors that a different model can fix.

SDK-level retries are disabled so the router is the single source of retry truth.

Adapters. anthropic (Messages), openai (Chat Completions, any compatible server), responses (OpenAI Responses, stateless: store: false, reasoning replayed from its encrypted content), gemini (generateContent over server-sent events; tools as parametersJsonSchema; thinking as a token budget on 2.x models and a level on 3 and later; thought signatures kept on their blocks and replayed exactly, with the documented bypass value on tool calls another model wrote) and bedrock (Converse stream through the AWS SDK: SigV4 with the standard credential chain, or a Bedrock API key as a bearer token; cache points only on models that support them). Each maps its errors onto the classes above: Gemini's FAILED_PRECONDITION billing errors are billing, token-limit messages are context overflow; Bedrock's exception names map to statuses (ThrottlingException 429, ServiceUnavailableException overloaded) because errors inside the event stream carry none.

Presets. config/providers.go holds a table of known services (type, base URL, key variable, whether a key is optional). A provider names one with preset:, or is named after one and sets no type. Local servers (Ollama, LM Studio, vLLM, llama.cpp, LiteLLM) and Bedrock get a keyless credential when no key is set.

Logins. A credential may carry a Token function instead of a fixed key; the router calls it before each attempt. A provider with auth: chatgpt or auth: copilot gets one such credential backed by auth.json. Get returns the stored token while it has more than five minutes left; otherwise it takes the process mutex and the auth.json.lock file lock, re-reads the file (another process may have refreshed already), refreshes, and writes the file atomically. A refresh the provider rejects means a new habakkuk auth login; a failing token function counts as an auth failure (fall back) unless it was a network error (retry). ChatGPT uses OpenAI's device-code sign-in and refresh-token grant, and the Responses API at the subscription backend with the account id header. Copilot uses GitHub's device flow; the GitHub token is the long-lived credential and a Copilot API token (about 30 minutes) is exchanged from it on demand. There is no Claude subscription login: Anthropic's terms do not permit third-party apps to use Claude plan credentials.

Usage. The turn puts its agent and session on the context (llm.WithCaller), so the router's OnUsage hook can attribute every successful call, the agent's own, compaction and the reviewer alike, and usage.db records it. Cost is computed when reported, from config pricing: overrides and then a built-in table of list prices (longest matching model-id prefix, ignoring gateway and Bedrock region prefixes), with long-prompt tiers; calls through a login cost $0. habakkuk usage totals by model and agent; /usage shows one session.

Per-session models. /model provider/model stores a choice for the session in usage.db; each turn reads it once and puts that model before the agent's own chain, which stays as fallbacks. Validation checks the provider is configured. The gateway takes /usage and /model as app-supplied commands (Gateway.Commands), and the CLI chat handles them locally.

Agents get the provider's hosted web search and fetch tools where the model supports them (web: true, the default). Their call and result blocks are stored verbatim as opaque blocks and replayed only to the same provider; a pause_turn stop resends the conversation unchanged so the provider resumes. The built-in fetch_url tool stays available for other providers, and for private URLs when tools.allow_private_urls is set (see Operations).

Prompt caching#

The system prompt is assembled once per session and frozen in the session row. Tools are sorted by name. The current time goes into the user message, not the system prompt. The transcript is append-only; nothing rewrites earlier turns (compaction, below, adds a record instead). This keeps the cache prefix stable and satisfies providers that bind reasoning blocks to an unedited history. The memory and skill listings in the prompt are a snapshot taken when the session starts, with no dates or counters that move; anything saved later is reached through the tools and shows up in the next session's listing.

Compaction#

Chat sessions can run for weeks, so a session whose context grows too large is compacted without rewriting anything. When the previous response's prompt (input plus cached tokens) reached the agent's compact_at (default 600000 tokens when the main model's provider is anthropic, 150000 otherwise; -1 turns it off), the turn makes one call down the agent's model chain with a fixed summarization prompt before its next model call. The summary keeps goals, decisions, facts about the user, open tasks and promises, and exact paths, URLs and ids; its input is the previous summary plus the messages being folded, rendered as text, with media and opaque hosted-search blocks as placeholders and long tool output cut. The result is appended as a compaction record (compactions table: summary, first message kept, transcript length when written). From then on the model is sent the latest summary followed by the messages after the cut; older messages stay in the transcript for habakkuk show and retention. The cut keeps as many recent turns verbatim as fit in a fifth of compact_at and always the last one, and falls only before a user message that starts a turn or before an assistant message, so a tool call is never separated from its result; only when the current turn alone is over that budget does it cut inside it. The record is written only while the worker owns the turn's lease and only if the transcript has not moved, so a crash between the summary call and the write leaves the session as it was and the resumed turn compacts again. A context-overflow error compacts once and retries even below the threshold. /compact in a chat and habakkuk sessions compact ID queue a compaction as a turn of the session, so it never runs beside one. The system prompt is unchanged, so its cache entry survives; the conversation prefix is cached again from the summary on.

Permissions#

Each agent runs in one mode:

Mode Reads File writes Exec / network
read-only allowed denied denied
ask allowed approval approval
workspace allowed allowed inside the workspace approval
full allowed allowed allowed

The memory and skill write tools (memory_save, memory_forget, skill_save) only touch the agent's own directory under $HABAKKUK_HOME/agents/<name>, so workspace mode runs them without asking (they implement tools.OwnStateTool). read-only still denies them and ask still asks.

Unattended runs (no approver attached) deny anything that needs approval. In a chat channel the approver is the chat: Habakkuk posts the request and waits for "yes" or "no" from an allowed sender (15 minutes, then it skips the step).

Approval reviewer#

Schedules, delegated sub-agents and other unattended runs have nobody to ask, so by default their exec calls are denied. An agent's approvals setting picks who decides calls that need approval (in ask and workspace modes). The default, human, never consults a model: the chat or terminal decides, and unattended runs deny. The other two put a reviewer model first:

approvals Reviewer says allow deny ask (unsure)
reviewer runs denied denied
reviewer_then_human runs denied goes to the chat or terminal; denied when there is none

The reviewer (internal/tools/review) is one model call per decision, on reviewer_model or else the agent's own chain, through the router like any other call. Its system prompt is fixed in code. It sees one JSON object: the turn's task (the user message, cut to 2,000 characters), the tool name, the tool's full input (cut at 32 KB, marked when cut) and the workspace path. JSON encoding keeps text inside the task or input from posing as instructions or ending its field early, and the prompt tells the model to treat it all as data. The rules: deny destructive or exfiltrating actions (deleting outside the workspace, mass deletes, curl | sh, sending secrets or environment variables to the network, editing system files, privilege escalation, payments, messages the task did not ask for); allow routine work that fits the task (reading, building, testing, editing in the workspace); ask when unsure.

The answer must be {"decision": "allow" | "deny" | "ask", "reason": "..."}. Parsing is defensive: a code fence or surrounding prose is tolerated, but anything that is not one such object with exactly one of those decisions is ask, and so is a failed or truncated model call. With no human to take an ask, the call is denied.

Every decision is logged (approval review with agent, session, tool, decision, reason and model) and published as a review event, which habakkuk chat prints. The transcript keeps it too: an allowed call's result ends with [approved by the reviewer: <reason>], and a denied call's error result carries the reason so the agent can take another route or ask. When a call goes on to a human, the chat prompt shows why the reviewer was unsure.

Working in the workspace#

Besides read_file, write_file, edit_file, list_dir and exec, the core toolset has:

  • apply_patch(patch) changes many files at once, from a unified diff or the *** Begin Patch format (*** Add File:, *** Update File: with an optional *** Move to:, *** Delete File:, hunks of , - and + lines under optional @@ <line the hunk follows> headers). Hunks are found by their text, not their line numbers: exact first, then ignoring trailing whitespace, then ignoring surrounding whitespace, nearest the stated line when a unified header gives one. CRLF files stay CRLF; \ No newline at end of file and *** End of File are honored. Every hunk is applied in memory before anything is written, so a hunk that does not apply writes nothing and the error names the hunk and, when part of it matched, the first line that differs. Workspace mode runs it without asking only when every path it touches is inside the workspace (tools.PathsTool).
  • search_files(pattern, path?, glob?, ignore_case?, files_only?, max_results?) searches file contents by regular expression. It runs rg when it is on PATH; otherwise a Go walker that skips hidden entries, symlinks, .git, binary files (a NUL in the first 8 KB) and files over 5 MB, and honors .gitignore files at every level (globs, **, anchoring, trailing /, ! negation; the last matching rule wins). Results stop at max_results (default 200, at most 2,000), lines are cut at 300 characters, and the walker gives up after 200,000 files or 60 seconds.

Background processes#

exec with background: true starts the command and returns a process id at once. process_list, process_output(id, since?, max_bytes?), process_wait(id, timeout_seconds?), process_kill(id) and process_write(id, stdin, eof?) act on it. Processes belong to the agent that started them (another agent cannot see them) and live as long as the Habakkuk process: closing it kills every process group and removes their output, and nothing is resumed after a restart.

  • Output (stdout and stderr together) goes to $HABAKKUK_HOME/procs/<id>.log in two segments of 4 MB: when one fills it becomes the previous one and the oldest is dropped, so a chatty server keeps its latest 4 to 8 MB. Offsets count every byte ever written, so process_output resumes from the since it returned last time and says how many bytes the cap discarded; a negative since reads the tail. Logs older than a day are removed at startup.
  • Limits. 16 running processes per agent; the 50 most recent finished ones are remembered. timeout_seconds on a background exec kills it after that long (default never).
  • Exit notices. With notify: true, a process that exits starts a turn in the session that started it: "Background process p_... (cmd) exited with code N after 2m3s". The turn ID derives from the process id, and the turn carries the chat's reply address, so the agent's answer reaches the chat. A process the agent killed, or one ended by shutdown, says nothing.
  • Permissions. Starting one is exec (approval below full); process_write is exec-risk too. Listing, reading and waiting are reads, and process_kill only stops the agent's own processes, so workspace mode allows it.

Sandboxes#

An agent's sandbox decides where exec and background processes run:

sandbox Commands run File tools
none (default) here, with bash, each in its own process group the local workspace
docker in one long-lived container per agent (habakkuk-<agent>) the local workspace, which is bind-mounted at the same path
ssh on a remote host, in workdir (a cwd inside the workspace maps under it) reads stay local (attachments, saved outputs); write_file, edit_file and apply_patch are refused, since the remote commands would never see the files; change files there with exec

Both drive the platform CLI (docker, ssh), so Habakkuk links no client library. Every command runs under a small sh wrapper that starts bash as a new session leader (setsid, where the system has it) and writes its process group id to a pid file there, so a timeout or process_kill kills the whole tree where it runs (with bash, whose kill takes a negative group id) and then the local client; killing only the docker exec or ssh client would leave the command running.

  • Docker (docker: {image, network, cpus, memory, user}): the container is created on first use with --init, the workspace mounted read-write at its own path, --network none when network: false, --cpus and --memory limits, and --user (default Habakkuk's own uid:gid so files written in the workspace stay yours; user: image keeps the image's user). It is labelled with a hash of those settings and recreated when they change; otherwise it outlives Habakkuk and is reused (seen-running is trusted for 30 seconds). The image needs bash; the default debian:stable-slim has it and setsid.
  • SSH (ssh: {host, port, user, key_env, workdir}): runs ssh -T -o BatchMode=yes with ControlMaster=auto and ControlPersist=10m, so commands share one connection; the socket lives in $HABAKKUK_HOME/ssh (or the temp directory when that path is too long for a socket). key_env names a variable holding a private key, written to $HABAKKUK_HOME/ssh/<agent>.key with mode 0600, or a key's path; without it ssh uses its agent and config. If the key is missing the agent's commands fail with the reason; they never fall back to running here. The remote needs bash.

Checkpoints#

Before the first call in a turn that may change the workspace (write_file, edit_file or apply_patch on a path inside it, or exec with a cwd inside it, background or not), the registry snapshots the workspace (tools.Mutator; one snapshot per turn even when calls run in parallel). A snapshot is a commit in a shadow repository, $HABAKKUK_HOME/checkpoints/<agent>, driven with GIT_DIR and GIT_WORK_TREE and no user or system git config (so no hooks, signing or filters run). The workspace's own .git is never read or written, and nested repositories are skipped. .gitignore applies; files over 20 MB are left out, and a snapshot whose new and changed files add up to more than checkpoint_max_mb (default 200) is skipped and logged. Without git on PATH, or with checkpoints: false, there are none. Agents with an ssh sandbox take none, since their changes happen elsewhere.

  • habakkuk checkpoints [-agent A] [-n 20] lists them: id, time, kind (turn, undo, rollback), session and the turn's request.
  • habakkuk rollback [-agent A] [-y] ID [PATH] prints the diff stat of what restoring would change, asks, then restores the whole workspace or one path. Files that are in the workspace now but not in the checkpoint are removed; ignored, oversized and nested-repository files are left alone. The state it replaces is snapshotted first, so a rollback can be rolled back.
  • /undo in a chat restores the workspace to before the conversation's latest turn that changed it and replies with the diff stat. It refuses while a turn is running. Repeated, it walks further back through that conversation's turns. The transcript is not changed, so tell the agent if it should know.

MCP#

Habakkuk is an MCP client, so any Model Context Protocol server (mail, calendar, files, integration hubs) can lend its tools to an agent. Servers are configured under mcp_servers, each either a command Habakkuk spawns (stdio) or a url (Streamable HTTP). It uses the official Go SDK.

  • Toolsets. Server x's tools go in toolset mcp:x; an agent opts in by listing it (toolsets: [core, "mcp:x"]). Config validation rejects a toolset naming an unconfigured server.
  • Names. Each tool is mcp__<server>__<tool>, with characters model APIs reject replaced by _. Names over 64 characters (or two names that sanitize alike) are cut and given a short hash of the original, so the spelling is deterministic; the tool keeps the server's real name for calls.
  • Startup. chat, run, serve and habakkuk mcp connect to every server in parallel and wait at most 20 seconds in total. A server that is not up by then keeps retrying in the background and its tools appear once it connects. Other commands never spawn servers. Tools are only added or replaced, never removed, and keep their names across reconnects, so a session's tool list does not churn from turn to turn.
  • Failure. A server that dies, or a call that fails at the transport level, drops the session; the next call reconnects. Repeated failures back off (1s doubling to 5 minutes). While a server is down its tools return an error result naming the reason at once, so one broken server never blocks startup or other tools. A transport failure mid-call tells the model the call may or may not have run.
  • Calls. Each call has a timeout (timeout, default 120s). Text content is returned as is, images, audio and binary resources as a one-line placeholder, structured content as JSON (unless the text already carries it). Results over 1 MiB are cut; the registry moves anything over its inline budget to a blob as usual. A result with isError becomes an error result.
  • Risk. A server declares one risk for its tools (read, write, network or exec; default network, which workspace mode allows without asking). Tools listed in approve are treated as exec and always need approval below full. allow exposes only the named tools.
  • Isolation. A stdio server gets PATH, HOME and the variables in its env map, never Habakkuk's whole environment, and runs in its own process group; shutdown closes its stdin, then signals, then kills the whole group so helpers it started do not linger. Secrets in env and headers are named, not stored: values come from the environment or $HABAKKUK_HOME/.env, and habakkuk doctor reports any that are missing.
  • OAuth. A url server with auth: oauth signs in through the MCP authorization flow, run by habakkuk mcp login NAME (habakkuk mcp logout NAME forgets it). Habakkuk sends a token-less initialize, reads resource_metadata and scope from the 401's WWW-Authenticate header, fetches the protected resource metadata (RFC 9728; the header's URL, then /.well-known/oauth-protected-resource/<path>, then the origin's; with none, the origin is the authorization server as in MCP 2025-03-26), then the authorization server metadata (RFC 8414 and OpenID discovery, through the Go SDK's helpers). It registers itself dynamically (RFC 7591, a public native client) unless the server sets client_id (and client_secret_env). The sign-in is the authorization code flow with PKCE (S256), state and the iss check (RFC 9207), redirected to a listener on 127.0.0.1 (any free port, or callback_port), with the server's URL as the resource indicator (RFC 8707) in every request. Scopes are scopes, else the challenge's, else the metadata's, plus offline_access when offered, to get a refresh token. The URL is printed and opened in a browser when there is one; on a machine without one (SSH, or Linux with no display) or with -device, it uses the device flow (RFC 8628) when the authorization server offers it, and otherwise says to sign in on a machine with a browser and copy auth.json. The client, tokens, token endpoint and resource are stored in auth.json as mcp:<server> and refreshed under its file lock like every login, so several processes share one. The SDK's own AuthorizationCodeHandler keeps tokens in memory only, so the transport gets Habakkuk's OAuthHandler instead: it hands out the stored token, and on a 401 marks it used up so the retried request refreshes it; a second refusal within a minute, a refused refresh token or no login at all ends in an error naming habakkuk mcp login NAME, which habakkuk mcp and habakkuk doctor show.
  • Catalog. habakkuk mcp catalog [QUERY] lists a curated catalog of public servers embedded in the binary (internal/protocols/mcp/catalog.yaml: how each runs, the variables it needs, programs it expects on PATH, {param} placeholders). habakkuk mcp add NAME [param=value] appends one to mcp_servers by editing config.yaml as text after the section's last entry, so comments and layout stay; the edited file must load, and the old one is kept as config.yaml.bak.

Plugins#

A plugin is a folder or repository with a habakkuk-plugin.yaml that bundles MCP servers, a folder of skills, hooks and slash commands (a prompt the agent runs as a message, or a script whose output is the reply). habakkuk plugins install uses the same sources, lock file, scan and backups as skills, into $HABAKKUK_HOME/plugins. Nothing in a plugin loads until habakkuk plugins enable, which shows what it runs and approves it. config.Load merges each enabled plugin into the configuration it returns without touching config.yaml: its servers become mcp:<plugin> toolsets (added to agents on the default toolsets), its hooks run from the plugin folder, its skills are shared below $HABAKKUK_HOME/skills, and its commands join the chat commands unless the name is taken. A plugin that fails to load is reported, never fatal. The format is in the plugins guide.

Browser#

The toolset browser (internal/tools/browser) drives Chrome or Chromium over the Chrome DevTools Protocol with a small client of its own: no browser-automation library, no Node. One connection carries every target through flattened sessions.

  • One browser per agent. The first browser call of an agent starts it; calls of one agent run one at a time; after idle_timeout (default 10 minutes) unused it is closed. In mode launch Habakkuk starts the binary itself with --remote-debugging-pipe, so CDP runs over two inherited pipes and no debugging port is open to other local users, and with the profile $HABAKKUK_HOME/browser/<agent> (mode 0700), so cookies and sign-ins persist. The browser runs in its own process group, and it exits by itself when the pipe closes, so a Habakkuk killed with -9 leaves no browser behind; a pid file and the profile's SingletonLock let the next start kill one that hung anyway. In mode connect Habakkuk dials cdp_url (resolving an http:// address through /json/version) and works in tabs of a browser someone else runs: it opens its own tabs, may attach to existing ones when asked, and on idle or shutdown closes only the tabs it opened and detaches from the rest.
  • Crashes. A dead browser shows up as a closed connection (pipe EOF or WebSocket close); the next call starts a new one and its result says earlier tabs are gone. Every call has a deadline (timeout, default 60s; downloads 5 minutes, or timeout_seconds), and waiting for a page to load gives up a second before it, reporting the page as still loading rather than failing.
  • Snapshots. browser_snapshot renders Accessibility.getFullAXTree as indented lines: headings, text, landmarks, lists, tables and every interactive element (links, fields, buttons, checkboxes, selects with their first options, focusable script widgets) with its state and a [ref=N]. Same-process iframes are inlined under their <iframe>; a cross-process (usually cross-site) frame's nodes live in another session, so it is one line with its URL, which the model can open. Refs map to DOM backend node ids, keep their number for the same element across snapshots, are forgotten when the main frame navigates, and never reuse a number, so a stale ref fails instead of hitting another element. Text without letters or digits, empty unnamed containers and same-site link origins are dropped to save tokens; output stops at 24,000 characters (below the registry's inline budget) with a note giving the offset that continues it.
  • Actions. Clicks scroll the element into view and dispatch real mouse events at the middle of its first visible box; typing focuses (and selects, unless append) the field by script, then uses Input.insertText, so frameworks see ordinary input events; select sets the option by value or label and fires input and change. An action that starts a navigation within half a second waits for the load. Alerts are accepted, confirm and prompt dismissed unless the call says accept_dialogs, and the next result says so. A popup a driven page opens becomes the current tab.
  • Files. Screenshots (PNG; a full page is cut at 8,000 pixels, and one over 5 MB goes to the model as JPEG), PDFs (headless only) and downloads are written inside the workspace, never outside it. A download sets Browser.setDownloadBehavior to a temporary directory for the one call, starts it from a background tab (url) or a click (ref), waits for completion and moves the file to its sanitized suggested name.
  • Tool media. A tool result carries text only, so a tool shows the model an image with tools.AttachMedia: the bytes go to the blob store and the block rides on the call's context. Registry.ExecuteWithMedia returns them, and the turn puts every attached block after all the tool results in the same user message, the order providers require. The transcript stores the blob reference, compaction renders it as a placeholder, and a session whose model rejects media continues text-only as with attachments.
  • URL guard. Every driven page session enables Fetch interception for all requests, and auto-attaches its out-of-process frames and workers paused until the same is in place, so navigations, redirects, frames, scripts and form posts all pass Guard.Check. Pages themselves attach automatically at the browser level and start paused: a popup whose opener is a driven tab is resumed only after its own interception is on, so its first request is checked too, and any other new page is resumed at once (in mode connect, a tab the owner opens while Habakkuk is attached waits that one round trip). blocked_urls applies to every request, allowed_urls (when set) to documents only, so pages keep their CDN assets. Hosts that are, or resolve to, loopback, private, link-local, CGNAT, unspecified or multicast addresses (IPv4-mapped IPv6 included, localhost and *.localhost without a lookup) are refused unless a pattern in allow_private names them. Any one private answer refuses the host. Lookups are cached for 30 seconds; a name that does not resolve is let through for the browser to fail. A refused document shows up as a [blocked: ...] note, refused subresources as one count per call, and file:, chrome: and javascript: URLs are refused outright. Known gaps: WebSocket handshakes are not intercepted, service workers are not attached, and the guard resolves names separately from the browser, so a DNS answer that changes between the two (rebinding) is not caught.
  • Untrusted content. Snapshots, script results and tab lists start with a line marking what follows as page content to be treated as data. browser_evaluate is exec risk; every other browser tool is network, so workspace mode runs them without asking and read-only mode denies them.

Computer#

The toolset computer (internal/tools/computer) shows an agent a desktop and lets it use the mouse and keyboard. The binary stays free of cgo: backends run command-line tools, built as argument lists (never a shell line) so they can be tested without a display.

  • Backends. linux drives an X display with xdotool (one chained command per action) and captures the root window with import, scrot or ffmpeg -f x11grab; in a Wayland session (or with only WAYLAND_DISPLAY) it uses grim and ydotool, which has no pointer position or window names. macos captures the main display with screencapture -x and moves the pointer with cliclick when installed, else with JavaScript for Automation posting Quartz mouse events through osascript; keys and typing go through AppleScript System Events, with any text passed as script arguments rather than spliced into the script. xvfb gives each agent its own Xvfb on the first free display from display (default :99), started with -nolisten tcp and -auth on an Xauthority file holding a fresh cookie (family Wild), so only Habakkuk's commands and whoever reads that file can connect; a window manager starts with it when one is installed. Its state (display, pid, cookie file) is in $HABAKKUK_HOME/computer/<agent>.json, which habakkuk computer view reads and which lets the next start kill a server a killed Habakkuk left behind. Desktops stop when Habakkuk closes.
  • Coordinates. A screenshot is decoded, scaled with a box filter so neither side exceeds 1366 pixels, and attached with tools.AddMedia; the desk remembers three sizes: the image the model saw, the pixels captured and the input space (the same as the pixels, except on a Retina Mac, where input is in points). Every coordinate the model gives is in the latest image and is mapped to input space; one outside the image is an error. An action before any screenshot learns the geometry from one the model does not see. zoom (native tool) crops the full-resolution capture.
  • Native tool. When every model in an agent's chain is a Claude 5 or later model on an anthropic provider, ServerTools gets computer and the toolset computer is dropped: the adapter declares computer_toolset_20260801 (generally available, no beta header) instead of the function tools. Its calls are tool_use blocks with a toolset_name; the adapter stores one as a call of the tool computer with the member's name as action (llm.Block.Toolset remembers the toolset), splits it back on replay, echoes toolset_name on the result and moves the call's screenshot (an image block carrying the call's ToolUseID) inside the result, as the API wants. Older Claude models, other providers and mixed chains get the computer_* tools. computer_open is offered with both (toolset computer-open), since the native tool cannot start apps. A per-session /model switch to another provider keeps the agent's tools as built; that is a known gap.
  • Safety. Every action is exec risk (computer_wait is read). Each turn may take max_actions actions (default 150; waits and pointer queries are free), counted per run of a turn on its tool environment. Two registry hooks, both checked before approval: tools.RefusingTool lets a tool refuse inputs in every mode, and the computer tools refuse to type (or open) text matching the log redactor's credential patterns, a private key, one of the configured secret values or a Luhn-valid card number; tools.ConfirmTool makes a call need a human whatever the mode, and the computer tools use it when the app in front (its process name or window title, matched without case) or the app being opened is in confirm_apps, or when the app in front cannot be told. A reviewer cannot approve those alone. Screenshots taint the turn like web content and say that the screen is untrusted. Agents whose commands run in a sandbox are refused, since the desktop is on this machine, and agents that do not list the toolset are refused even if a model names the tools. Known gaps: typing is not checked for secrets the redactor does not know, and the app in front is read before the action, not atomically with it.

Meetings#

The toolset meeting (internal/tools/meeting) attends Google Meet, Zoom and Microsoft Teams calls as a named guest. meeting_join accepts only links of those platforms (meet.google.com, zoom.us and its subdomains, teams.microsoft.com, teams.live.com, teams.cloud.microsoft; a Zoom /j/ID link becomes the web client's /wc/join/ID), writes a record under $HABAKKUK_HOME/meetings/<id> and queues a job of kind meeting, which only the serving process runs (its own pool of three workers). The reply address is the turn's origin, so a scheduled turn that calls meeting_join reports to the schedule's chat; a session with no chat gets a turn with the notes instead.

  • Browser. Each attendance gets a Chrome of its own through browser.LaunchDedicated: launched over the pipe like the toolset's, with a fresh profile in the attendance's directory, the URL guard (no allowed list; the browser section's blocked_urls and allow_private), --lang=en-US, the ordinary Chrome user agent in place of the headless one, and fake media: --use-fake-ui-for-media-stream grants camera and microphone, and --use-file-for-fake-audio-capture and --use-file-for-fake-video-capture point them at one second of silence and one black frame written beside the profile.
  • Reading the page. Every step evaluates one script with an operation: probe returns the visible text without the caption and chat regions, the labels of visible clickable elements (aria-label, else text; switches end in [on] or [off]) and of text fields, and whether a captions container is present. Classify turns a probe into a state (launcher page, pre-join, lobby, in call, denied, ended, invalid link) with per-platform text and label patterns written in the regular-expression subset Go and JavaScript share; find and focus locate an element by the same patterns, and the click is a real mouse event at its middle, typing is Input.insertText. Text people said never decides a state, since captions and chat are cut out of the probe.
  • Joining. On the pre-join page Habakkuk types its name, presses each "turn off microphone/camera" control once, then "Ask to join" or "Join now"; a launcher page ("Continue on this browser") is followed, cookie and tip dialogs are dismissed. The lobby has lobby_timeout (default 15m); any other page that does not change for 3 minutes fails the attendance with its title. Once in, it posts " has joined to take notes." in the meeting chat (opening the panel first) unless meeting.announce is false.
  • Captions. Habakkuk turns captions on by the deepest control showing (a menu item before the menu that holds it: Teams' More, Language and speech, Turn on live captions), with Meet's c shortcut as a fallback once per round, and retries every two minutes when that fails. Then it installs a MutationObserver with Runtime.evaluate that numbers each caption block element and reports changed blocks and removed ones, debounced, through a Runtime.addBinding function. The Assembler merges a block's updates (growth, revised last words, a start that scrolled away), finishes a block when it is removed, reused for another sentence or unchanged for 5 seconds, and drops a finished line that repeats or overlaps a recent one of the same speaker; finished lines are appended to transcript.jsonl with the time their first words appeared.
  • Staying and leaving. Every three seconds the page is probed again. Habakkuk leaves (pressing Leave, and confirming) when meeting_leave or habakkuk meetings leave wrote the leave file, the time limit passes (duration_limit, capped by max_duration, default 3h, counted from the first join), or the page has said nobody else is there for alone_timeout; it stops when the page shows the call ended or Habakkuk was removed (twice in a row, or four times for a page it does not recognize).
  • Crashes. A browser that dies mid-call is replaced and the meeting joined again, twice at most per attempt. A Habakkuk that stops mid-meeting leaves the job's lease to expire; the resumed job joins again and appends to the same transcript, its assembler seeded with the last lines so captions still on screen are not written twice. Notes are written once (notes flag) and sent once (delivered flag, and a delivery job ID derived from the meeting).
  • Notes. One model call (summary_model, else the agent's chain) with a fixed system prompt and the transcript (at most 400,000 characters: the start and the end) returns a summary, decisions, action items and open questions; the user's notes_prompt rides along as data. When Habakkuk never got in, the notes say why; when the model fails, they say so and point to meeting_transcript.
  • Not supported. Speaking in the meeting (no text-to-speech into the call) and recording audio; captions are the only source. Joining with a signed-in account, and platforms' native apps.

Channels#

platform ──> adapter ──> supervisor ──> gateway ──> turn job ──> agent
                ^                                       │
                └──────── delivery job <────────────────┘
  • Adapters speak one platform each: Telegram (long polling, no public URL), Slack (one Socket Mode connection for every workspace the app is installed in, a bot token per workspace), WhatsApp (a linked device), Discord, Signal and Matrix (below). They convert messages in, and format and send text out.
  • Discord receives over the Gateway websocket and sends over REST. It identifies with the guild message, direct message and message content intents, heartbeats on the interval the server gives and treats a beat with no ack since the previous one as a dead connection. The session id and last sequence number outlive a run, so a restart after a dropped socket, a missed ack or a reconnect request (op 7) resumes on the resume URL and the server replays what was missed; an invalid session that cannot be resumed (op 9) or a sequence the server no longer has starts a fresh one. Close codes for a bad token or a disallowed intent are fatal. A thread is its own channel, so it is its own conversation; a message counts as addressed to the bot when it mentions the bot, replies to it, or is in a thread the bot has replied in. Replies quote the message they answer and never ping anyone the text happens to name.
  • Signal goes through a signal-cli daemon in HTTP mode, which holds the account's keys: messages arrive on its Server-Sent Events stream and go out through JSON-RPC (send, sendTyping, getAttachment). A message id is its timestamp and author, so a reply can quote it. The sender is the account id, with the phone number as the alternative id that allow_from and pairing use. Markdown becomes plain text with Signal text styles. Files go as base64 data URIs, so the daemon needs no access to Habakkuk's disk.
  • Matrix uses the client-server API with an access token. A /sync long poll receives; its position is saved after each batch is handed over, so a crash replays at most one batch, which the gateway drops as already seen. The first start takes the current position without answering history. Invitations are accepted only from senders the gateway allows (allow_from or paired). A send's transaction id is a hash of its delivery job and chunk (the gateway puts that key in the send's context), so a retried delivery repeats the same transaction and the server returns the event it already made. Text goes out as Markdown with an HTML rendering; media is uploaded to the media repository and downloaded through the authenticated media endpoints, falling back to the legacy ones. There is no end-to-end encryption: the first encrypted message in a room gets one notice that encrypted rooms are not supported, remembered across restarts.
  • Adapters speak one platform each: Telegram (long polling, no public URL), Slack (one Socket Mode connection for every workspace the app is installed in, a bot token per workspace), WhatsApp (a linked device). They convert messages in, and format and send text out.
  • More adapters. Email reads IMAP (IDLE, else polling) from the last UID seen and replies over SMTP inside the thread; Mattermost holds a WebSocket; IRC holds one TLS connection with SASL; ntfy publishes to a topic and can read another as a JSON stream. SMS (Twilio), Microsoft Teams, Google Chat, BlueBubbles (iMessage) and LINE are webhook adapters: the HTTP server mounts each at /channels/<account>, outside the API token, and the adapter checks every request itself (Twilio's HMAC-SHA1 signature over URL and form, Bot Framework and Google RS256 tokens against the platforms' published keys, LINE's HMAC-SHA256, a secret in BlueBubbles' URL). A request that arrives while the adapter restarts gets a 503, so the platform retries. Platforms that fetch outbound media from a URL (MMS, LINE images) get one-hour unguessable URLs served from memory under the same path. State a platform cannot give back (mailbox position and thread headers, Teams service URLs, the last ntfy message) lives in channels/<type>-<account>.json, not control.db.
  • WhatsApp agents. whatsappagent long-polls the agent platform's GET /updates (20-second holds, polls spaced at least 4.3 seconds apart to stay under its 15 a minute) and stores next_offset in channels/whatsappagent-<account>.json after the batch is handed to the gateway; updates stay on the platform for 30 days and polling does not consume them, so a crash between delivery and save only re-reads messages the gateway already dropped as redeliveries. The first start reads from offset 0 and skips messages older than ten minutes before it. Participant ids are user:<id>; the conversation is the id without the prefix (addresses use colons), and the conversation creator resolves to the last sender, the only person a beta agent may message. A 409 (another poller took the key) and an invalid key are fatal; media is fetched with the key only from Meta's media hosts. It shares metagraph's client and error mapping, which now accepts a 204 with no body.
  • Business messaging adapters. The WhatsApp Business Cloud API, Messenger and Instagram share metagraph: the hub.challenge handshake, X-Hub-Signature-256 over the body, and Graph error codes mapped to Fatal, RetryAfter and Undeliverable. WhatsApp's 24-hour customer-service window is tracked per customer in channels/whatsappcloud-<account>.json (WhatsApp cannot be asked); outside it the adapter sends the configured approved template or fails the delivery with the reason, and a 131047 from the API closes the window early. Feishu/Lark (AES-256-CBC events keyed by the SHA-256 of the Encrypt Key, plus a SHA-256 signature and the verification token), DingTalk (HMAC-SHA256 of timestamp and secret, within an hour) and WeCom (SHA-1 signature, AES-256-CBC with the receiver id checked) take app access tokens from apptoken, which caches one per adapter, renews it five minutes before expiry and drops it when the platform calls it stale, so the request is retried once with a fresh one. Feishu sends carry a uuid derived from the delivery's SendKey, so a retried delivery posts once.
  • The supervisor restarts an adapter whenever it stops, with exponential backoff (1s to 5m, reset after a healthy minute). Adapters beat on every message and keepalive; one that stays connected but silent past its limit is torn down and reconnected, so a half-open socket cannot drop messages for hours. Health is recorded in channel_status (habakkuk channels, /status).
  • The gateway decides who may talk (config allow_from, or a one-hour pairing code the owner approves with habakkuk pair), whether a group message is addressed to the bot, and which agent and session a conversation maps to (routes, a fresh session after session_idle). It handles /new, /stop, /status, /compact, /undo and /help.
  • Exactly-once where it matters. A turn's job ID is derived from the platform message ID, so a redelivered message never runs twice. A finished turn enqueues a delivery job with an ID derived from the turn, so a turn that reruns after a crash does not reply twice. A delivery records each chunk it sends, so a retry resumes at the first unsent chunk. Undeliverable sends (bot blocked, channel gone) stop retrying.
  • Attachments. Photos, files and voice notes are downloaded (up to 25 MB), stored as blobs and copied to the agent's workspace/inbox/ so tools can open them. Images and PDFs also go to the model directly as content blocks, referenced by blob in the transcript so the transcript stays small. Voice notes are transcribed through any OpenAI-compatible /audio/transcriptions endpoint (transcription: in config).
  • Outbound files. send_file(path, caption?) (toolset core, registered when channels are configured) sends a file to the chat the turn belongs to: the gateway hands each channel turn's reply address to its tools, and a session bound to a chat falls back to that chat. The tool stores the file as a blob and queues a delivery job whose ID is derived from the session and the tool call ID, on the same serial key as replies, so the file arrives before the turn's reply and a retried call does not queue it twice. Adapters implement FileSender: Telegram sendPhoto (JPEG or PNG up to 10 MB) or sendDocument (50 MB), Slack's external upload into the channel and thread (needs files:write, capped at 100 MB), WhatsApp an encrypted upload sent as an image (up to 16 MB) or document (100 MB), Discord a multipart message (10 MB), Signal a data URI attachment (100 MB), Matrix an upload then an image, video, audio or file message (50 MB). Workspace files are always sendable; elsewhere only in full mode, with approval in ask, or by asking the user in workspace mode. In a session with no chat (CLI) the tool returns an error saying so.
  • Progress. While a turn runs, Telegram, WhatsApp, Discord, Signal and Matrix show a typing indicator, refreshed every 4 seconds for the whole turn. Slack bots have no typing indicator, so a turn that runs past 60 seconds posts one short "still working" notice (once per turn, and not while an approval prompt is waiting).
  • Chat commands. Beyond the built-ins, commands live in one registry (commands() in internal/channel/commands.go) that /help prints:
    • /retry reruns the last message the user typed (time stamp, task list and Habakkuk's own prompts skipped); attachments are referred to by their saved paths.
    • /btw <question> answers in a throwaway session of the same agent, seeded with the main conversation as text (the latest summary and the messages after it, newest 40 KB), on its own serial key so it runs beside the main turn. Nothing is added to the main transcript.
    • /steer <text> queues guidance in state.db; the turn loop appends it as a text block after the next batch of tool results, so it reaches the very next model call. Guidance that arrives after the turn's last tool call becomes a follow-up turn. With nothing running it is an ordinary message.
    • /queue <text> starts a turn now; turns of one session run in order, so it waits for the one in progress.
    • /think LEVEL and /personality NAME set per-session overrides: the effort sent with each request, and a persona appended to the frozen system prompt (built-in concise, warm, formal, technical, playful, or $HABAKKUK_HOME/personalities/NAME.md, read on every turn). The session's own prompt is untouched, so the cache prefix survives until the persona changes. /new starts a session without them.
    • /goal [-turns N] <text> sets a standing goal (default 10 turns, at most 100). After each turn of the session the gateway prompts it again with the goal, until a reply contains GOAL DONE or GOAL BLOCKED, or the budget is spent, and says which. Follow-up job IDs derive from the finished turn, and a turn is counted once, so a rerun never doubles a step; no follow-up is queued while another turn of the session is waiting.
    • /export sends the transcript as a Markdown file through the send_file delivery path. /whoami shows the sender, how they were allowed in, the channel, agent and session. /agents lists agents; /agent NAME switches this conversation (kept in state.db) and starts a fresh session; /agent default goes back to routing.
    • /fork copies the session up to its last finished turn into a new one (same frozen prompt, so the cache still applies; messages, and the compactions that cover them, copied in one transaction) and binds the conversation to it, so another direction can be tried; /rewind [N] does the same without the last N user turns. The original stays as it was. /title [text] shows or sets the title; a title set by hand is never replaced. /subagents lists the conversation's delegated tasks still queued or running, with elapsed time, and /subagents stop ID (an id prefix is enough) cancels one.
  • Buttons. Adapters that implement ButtonSender (Telegram inline keyboards, Slack Block Kit buttons, Discord message components) show approvals with Approve and Deny buttons and a single clarify question's choices as one button each; typing still works. A button's data is fy|<nonce>|<index>: the nonce names one prompt, so a press on an old prompt is told it expired. Presses come back through the inbox as a Message with Callback set (Telegram callback_query, Slack Socket Mode interactive envelopes, Discord INTERACTION_CREATE over the gateway); the adapter acknowledges the press and strips the buttons, and the gateway checks the sender against the allowlist before acting. Slack needs Interactivity on for the app.
  • Reactions. With reactions: true on a channel, the user's message gets 👀 when its turn starts and 👍 when it ends (😢 when it fails for good; none when stopped). Adapters implement Reactor (Telegram setMessageReaction, which replaces; Slack reactions.add and reactions.remove, needing reactions:write; Discord's reaction endpoints).
  • Polls. poll(question, options, multiple?) (toolset core, with channels) posts a native poll where the adapter implements Poller (Telegram sendPoll, Discord polls open for a day) and a numbered list elsewhere. Votes are not read back.
  • Live progress drafts. Adapters that can edit messages implement Editor (Telegram sendMessage/editMessageText/deleteMessage, Slack chat.postMessage/chat.update/chat.delete). A turn that runs past 8 seconds posts one silent "working" message and edits it with a line per tool call from the events bus (tool name and a title such as the command or path; the last 8 lines), at most once every 3 seconds. The draft's message id is stored under the turn in state.db, so a turn that reruns after a crash edits the same draft. The reply is still a delivery job: it carries the turn's id, removes the draft, then sends the reply as usual (so the reply notifies, threads under the user's message, and is never lost with the draft). A silent or stopped turn queues a delivery that only removes its draft. progress: off on a channel turns drafts off; with drafts on, Slack's 60-second notice is not sent.
  • Credentials are never written as loose files: WhatsApp keys live in SQLite transactions, backups are VACUUM INTO a temp file, fsync, rename, and are skipped when the disk is nearly full.

HTTP#

With an http section, habakkuk serve also listens on listen (default 127.0.0.1:8484). The chat is an ordinary channel adapter of type web (account web unless the config names one), so a web message goes through the gateway like a Telegram message: idempotent turns, durable deliveries, chat approvals and /new, /stop, /status. The HTTP server itself runs outside the channel supervisor, so health checks and webhooks keep answering while the adapter restarts.

  • Auth. /api/* needs Authorization: Bearer <token>, the value of the variable token_env names; tokens compare in constant time. Without token_env the server refuses any address but loopback, and answers only requests whose Host is a loopback name, so a web page cannot reach it through DNS rebinding. POST /api/chat only takes application/json, which forces browsers to preflight cross-site requests that the server never approves. GET /healthz needs no token and reports only channel states and queue depth. The page at / holds no data; it asks for the token once and keeps it in localStorage.
  • Chat. POST /api/chat {conversation, text, agent?, id?} hands the message to the gateway and returns the turn it started (none for a command or an approval answer). The conversation id is the client's choice; agent picks the agent, and a conversation keeps its agent until a message names another. A client id makes a resend a no-op, as a redelivered platform message is.
  • Events. The adapter's Send appends each reply to web.db and wakes the event streams open on that conversation. GET /api/chat/{conversation}/events?after=N (or Last-Event-ID) first replays everything after sequence N, user messages included, then streams new ones, with typing events while a turn runs and a keepalive comment every 20 seconds. Sequence numbers only grow, so catch-up is one exact query.
  • Dashboard. /app/ is a control dashboard (overview, chat, sessions, board, schedules, memory, skills, usage, approvals, config), plain HTML, CSS and JavaScript embedded in the binary, over a JSON API under /api/ with the same token (HTTP API). The app hands the web server its stores in one web.Dash. Its chat follows the events bus for live tool cards, sends files as attachments and recordings for transcription (multipart POST /api/chat, which needs an X-Habakkuk-Client header so a cross-site form cannot post it), reads replies aloud through tts, and with realtime: offers Live mode (see Media). Approvals carry their tool and summary while they wait (Gateway.Approvals), so the dashboard can answer one in the chat's place. The config view redacts the values of every variable the config names, secret-named fields and URL credentials. A service worker makes it installable and caches only the static shell.
  • Webhooks. POST /hooks/<name> reads at most 1 MB, checks X-Habakkuk-Signature: sha256=<HMAC-SHA256 of the body> (or a bearer secret) in constant time, and answers 202 once the turn is queued. The turn ID derives from X-Habakkuk-Delivery, or the body's hash, so a retried or replayed delivery runs once. Each delivery is a fresh, unattended session whose prompt is the hook's text/template over {{.Body}} and the body's top-level JSON fields, framed as outside data rather than instructions. The reply goes to deliver (ACCOUNT:CONVERSATION) or to the web conversation hook-<name>.

Other clients#

Programs that are not chat apps reach Habakkuk three ways. All of them run ordinary turn jobs, so a turn survives the client going away; a client that waits follows the session's live events and trusts the job's result, since another process may run the turn. A session with a turn already queued or running refuses a second streamed one rather than mixing their events.

  • OpenAI-compatible API (on the HTTP surface, same bearer token). GET /v1/models lists one model per agent, habakkuk/<agent>. POST /v1/chat/completions and POST /v1/responses answer whole or as Server-Sent Events of the model's text as it arrives (with a blank line where a tool call fell between two pieces of text). X-Habakkuk-Session, the user field (remembered in web.db) or previous_response_id continue a session with the request's last user message; otherwise a fresh session is seeded with the request's earlier messages, system and developer messages becoming a note at the start. Every answer carries X-Habakkuk-Session. Idempotency-Key derives the turn's ID, so a retry attaches to the first request's turn. The agent runs with its own tools: a request with tools gets a 400. Nobody can approve calls, so ones that need approval are denied.
  • MCP server. habakkuk mcp serve (stdio) and, with mcp_server.http: true, /mcp (Streamable HTTP, same token) offer habakkuk_ask(agent?, message, session?), habakkuk_sessions_list, habakkuk_session_read, habakkuk_board_create, habakkuk_board_list and habakkuk_schedules_list. habakkuk_send(account, conversation, text) queues a durable delivery and exists only with mcp_server.allow_send: true.
  • Editors (ACP). habakkuk acp [-agent A] speaks the Agent Client Protocol on stdio. A session's cwd is its workspace (and the one its frozen prompt names). Turns are jobs of their own kind that only the habakkuk acp process runs, since they need its connection: model text and thinking stream as message chunks, tool calls as tool_call and tool_call_update with a title, kind and file locations, and an update_plan tool (only in editor sessions) as plan updates. Calls that need approval become session/request_permission (allow once, allow for the session, deny). When the editor offers fs.readTextFile and fs.writeTextFile, read_file, write_file and edit_file go through it, so they see unsaved buffers. session/cancel cancels the job and its context at once and the prompt answers cancelled; session/load replays a session's messages.
  • Terminal UI. habakkuk tui (and habakkuk chat when stdin and stdout are a terminal; -plain keeps the line-based chat) is a Bubble Tea program in internal/tui over a narrow Backend interface, so its model is tested by feeding it messages. It runs the turn workers itself and wraps the turn handler so Ctrl+C cancels the running turn's context at once, as session/cancel does. Text and thinking stream from the events bus (Markdown re-rendered at most every 80 ms while streaming); tool calls are cards paired by call id, whose full output is read from the transcript when expanded. The attached approver posts approvals (allow once, allow for the session, deny) and clarify questions into the program and waits; a stopped turn withdraws its prompt. -resume opens a picker of recent sessions. Colors follow the terminal's background (asked over OSC 11, so it works over SSH) and NO_COLOR drops them.
  • Desktop app. apps/desktop is a separate Go module (Wails v3, CGO, a system webview), so the habakkuk binary keeps building without either. It shows the connected Habakkuk's own /app/ dashboard through a loopback reverse proxy that adds the token, runs habakkuk serve as a supervised child for a local home, follows web conversations' event streams and polls approvals for notifications, and keeps remote tokens in the OS keychain (desktop app).

habakkuk send -to CHANNEL:CHAT[:THREAD] [-file PATH] TEXT queues a message or file to a chat as a delivery job, without a turn; habakkuk serve sends it.

Agent2Agent#

Habakkuk speaks the Agent2Agent protocol (A2A) over JSON-RPC, both as a server and as a client (internal/protocols/a2a). Two versions are in use and both are spoken: 1.0, the current specification (methods SendMessage, SendStreamingMessage, GetTask, CancelTask, SubscribeToTask; enums like TASK_STATE_COMPLETED and ROLE_USER; parts told apart by which of text, data, url, raw is set; results wrapped as {"task": ...}), and 0.3, which most deployed agents still speak (message/send, message/stream, tasks/get, tasks/cancel, tasks/resubscribe; lowercase states and roles; a kind on every object; a bare task as the result; final on the last status update). The server answers each call in its method's version and refuses an A2A-Version header other than 0.3 or 1.x with -32009.

  • Server. With a2a.serve: true (and the http section), habakkuk serve publishes an Agent Card at /.well-known/agent-card.json whose skills are the served agents (a2a.agents, default all; each value is the skill's description), and JSON-RPC at /a2a, where a message goes to the agent named in its metadata.agent (or the request's), else habakkuk, else the first by name. Each agent also has its own card and endpoint at /a2a/<agent>. The card is in 0.3 shape (url, preferredTransport, security) unless the request asks for 1.0 (supportedInterfaces, securityRequirements); the 0.3 card also lists the 1.0 interface, which 0.3 readers ignore. Cards are public; every call needs the bearer token from a2a.token_env, else the http token (none on a loopback-only server).
  • Mapping. A message starts a Habakkuk turn: the task id is the turn's job id and the context id is the session id, so a message with contextId continues that session (only sessions A2A started, recorded in web.db, so a client cannot continue a chat's session or read its tasks). The turn's id derives from the agent, context and messageId, so a resent message attaches to its first task. A message that names a taskId is refused (-32004), since Habakkuk's tasks never wait for input. Text and data parts become the turn's text, file URLs are named, inline text files are included; other inline files are refused (-32005). message/send waits for the turn unless returnImmediately (1.0) or blocking: false (0.3); the reply is the artifact reply and the last message of the history. Pending and running turns are submitted and working; done, failed and canceled turns are completed (with the reply), failed (with the error as the status message) and canceled. tasks/cancel cancels the job; a finished task gives -32002. Push notifications are not supported (-32003).
  • Streaming. message/stream sends the task, a working status, working statuses whose message holds the model's text so far (batched, at most every 300 ms, marked metadata.partial), then the reply as an artifact update with lastChunk and the final status, and closes; a keepalive comment goes out every 15 seconds. A client that disconnects leaves the turn running; tasks/resubscribe follows it again.
  • Client. The toolset a2a has a2a_discover(to) (the card: name, skills, protocol, auth) and a2a_send(to, message, context_id?, metadata?), which sends a blocking message, polls tasks/get while the task still works (up to the peer's timeout, default 5 minutes) and returns the reply with its state, task and context id. to is a peer under a2a.peers (a base URL or a card URL, with headers resolved like MCP headers) or a URL: a URL goes through the guarded HTTP client (no private addresses unless tools.allow_private_urls) and sending to it needs approval below full mode. The client fetches cards with A2A-Version: 1.0, uses the 1.0 JSON-RPC interface when a card offers one and 0.3 otherwise, and reads both shapes.

Home Assistant#

The homeassistant section gives agents that list the toolset homeassistant three tools over Home Assistant's REST API with a long-lived access token (token_env): ha_states(filter?, domain?, details?, limit?) (ids and friendly names, * wildcards), ha_call_service(domain, service, entity_id?, data?) and ha_history(entity, hours?). A service call is allowed only when its domain and every target entity's domain are in allow_domains or confirm; a confirm domain (locks, alarms, covers) asks a human for every call in every permission mode and is denied when no one is attending. The generic homeassistant domain may not target areas, devices, floors or labels, which would reach any domain. Results are framed as data, and the tools taint the turn like every network tool.

triggers follow the event bus over the WebSocket API (/api/websocket: auth, then one subscribe_events per event type) and only in habakkuk serve, reconnecting with backoff. A trigger matches event_type (default state_changed), entity_id (a * pattern), to and from (a state_changed trigger fires only when the state itself changed, not an attribute), and data fields; after firing it ignores matches for cooldown (default 30 seconds). A match starts a turn for agent in a fresh session, as a webhook does: the prompt is the trigger's text/template (.EventType, .EntityID, .Name, .From, .To, .Time, .Data) framed as outside data, the reply goes to deliver (ACCOUNT:CONVERSATION), and the turn's id derives from the event's context id so a replayed event runs once.

Media#

internal/tools/mediatools gives agents media beyond text. Providers are plain HTTP; keys come from the variable each section names (key_env, with a per-provider default such as OPENAI_API_KEY).

  • Seeing. A tool can attach images to its result with tools.AddMedia; the turn loop puts them after the tool results in the same user message, by blob reference like attachments, so any vision model sees them. view_image(path) loads a workspace file or an http(s) URL (JPEG, PNG, GIF, WebP; sniffed, not trusted by extension), scaling anything over 5 MB or 2048 px down to 1568 px JPEG. A URL counts as network use: denied in read-only, asked in ask.
  • Documents. read_document(path) extracts text from PDF (pure Go parser, per page), DOCX (body, footnotes, endnotes), XLSX (tab-separated rows per sheet, shared and inline strings), PPTX (per slide, with notes) and HTML. Zip parts are capped at 64 MB uncompressed; output is cut at 400 KB and the registry's blob budget applies as usual.
  • Images. image_generate(prompt, size?, n?, edit_from?) (images: with provider openai, gemini or fal) saves results under workspace/images/ and shows them to the model; the agent sends one with send_file. OpenAI uses /images/generations and multipart /images/edits; Gemini generateContent with image output, once per image; fal submits to its queue, polls the status URL and downloads the result. Input images must be in the workspace unless the mode is full or ask.
  • Video. video_generate(prompt, seconds?, image?) (video: with provider openai, gemini or fal, plus model, image_model for fal, size, seconds (default 8) and timeout (default 10m)) submits a job, polls it until it is done or the timeout passes, and saves the clip under workspace/videos/; the agent sends it with send_file. OpenAI uses the Videos API (POST /videos with an optional input_reference, GET /videos/{id}, then /content; Sora lengths snap to 4, 8 or 12 seconds); Gemini runs Veo as a long-running operation (models/{model}:predictLongRunning, then the operation until done, then the sample's file); fal uses its queue as for images.
  • Search. web_search(query, n) (search: with brave, tavily, exa, searxng with url, or duckduckgo) returns titles, URLs and snippets. Without a search section it scrapes DuckDuckGo's no-script page, best effort.
  • Speech. speak(text, voice?) (tts: with openai, elevenlabs or edge) saves the audio under workspace/voice/ and queues it to the chat as a voice note: the delivery's file carries a voice flag and a length, and adapters that implement VoiceSender post it as one (Telegram sendVoice, WhatsApp a push-to-talk audio message); others get an audio file. Voice notes must be Ogg Opus: OpenAI and ElevenLabs are asked for Opus, and anything else is converted with ffmpeg when it is installed, or sent as a plain audio file. The edge provider is the browser's free read-aloud service over a websocket (speech.config and SSML text frames out; audio frames with a two-byte header length, then turn.end, back), authenticated by a time-derived token that is recomputed from the server's Date after a 403.
  • Live mode. With a realtime: section (provider openai, model default gpt-realtime-2.1, voice default marin, key_env default OPENAI_API_KEY, base_url, transcription default gpt-transcribe, instructions, ttl default 10m), the dashboard chat gets a Live button. Habakkuk never carries the audio: POST /api/realtime/session mints a short-lived client secret (POST {base_url}/realtime/client_secrets with expires_after and a session of type realtime: model, instructions, audio.output.voice, audio.input.transcription.model, and one flat function tool), and the page posts its WebRTC offer straight to {base_url}/realtime/calls with that secret as the bearer token (Content-Type: application/sdp, the answer SDP comes back); events travel on the oai-events data channel. The dashboard's Content-Security-Policy adds the provider's origin to connect-src only when Live mode is configured. The provider's voice activity detection handles turn taking and barge-in. The voice model's one tool is ask_habakkuk(request): on response.function_call_arguments.done the page posts the call to POST /api/realtime/tool, which delivers it through the gateway as a message of the conversation (so it gets the conversation's session, agent, tools, approvals and an idempotent turn ID from the call id), waits for the turn's reply, and returns it; the page sends it back as a function_call_output item and asks for a response. A turn that stops on an approval returns at once with what Habakkuk wants to do; the voice model asks the user and calls ask_habakkuk with yes or no, which decides the approval and resumes waiting on the same turn. A call that runs past four minutes tells the user the answer will appear in the chat. Transcripts (conversation.item.input_audio_transcription.completed and response.output_audio_transcript.done) are posted to POST /api/realtime/transcript: each line goes into the chat's log at once, and the lines the agent has not seen are kept in memory per conversation and put in front of the next ask_habakkuk request. When the call ends, POST /api/realtime/end writes what is left into the conversation's session as a user note and a short acknowledgement, after waiting for any running turn, so the transcript stays append-only and alternating; lines still in memory are lost if the process restarts mid-call.
  • Local voice. habakkuk talk [-agent A] [-session ID] [-wake PHRASES] [-no-wake] (internal/tools/voice, settings under talk:) is a voice loop for a laptop or a Raspberry Pi with no cgo: ffmpeg streams the default microphone as 16 kHz mono PCM (avfoundation :0 on macOS; pulse default on Linux when pactl or pipewire-pulse is installed, else alsa default; talk.input and talk.device override), read in 20 ms frames. An energy detector finds utterances: speech starts after 60 ms above the threshold (talk.threshold, default 0.02 RMS, or three times the noise floor it learns while nobody talks), keeps 300 ms of pre-roll, and ends after talk.silence (default 800 ms) of quiet; anything under 300 ms of speech is dropped and anything over 30 s is cut. Each utterance is sent as WAV to the transcription: provider and becomes a turn of the session (Runner.Ask); approvals are answered at the keyboard. The reply is printed, stripped of Markdown, cut at a sentence to tts.max_chars, synthesized with tts: and played with afplay (macOS) or ffplay, paplay or aplay (talk.player), converted to WAV with ffmpeg first for players that need it. Barge-in (talk.barge_in, default on): when speech starts while a reply plays, the player is killed; while playing, speech must be twice as loud, since there is no echo cancellation (headphones help). Wake phrases (-wake "hey habakkuk", comma-separated, or talk.wake) are matched on the transcript: the first words (after up to two fillers such as "um") must be within an edit distance of a quarter of the phrase's length, merged words ("heyhabakkuk") included, and the rest of the utterance is the request; the phrase alone plays a short tone and waits. After a reply finishes, talk.follow_up (default 30s) needs no wake phrase. Because the phrase is found by transcribing, every utterance in wake mode costs a transcription call, heard or not; a local, offline wake-word engine is future work.
  • Voice replies. A channel's voice_replies is off (default), mirror (reply by voice when the user's message carried audio) or always. After the turn, the gateway queues the text reply, then speaks a Markdown-stripped copy (up to tts.max_chars, default 2000) as a voice note on the same delivery serial key. voice_text: false sends only the voice note; if speaking fails, the text goes instead.

Schedules#

schedule_create (or habakkuk schedules add) stores a prompt with a cron expression, @every, or a one-off at YYYY-MM-DD HH:MM, interpreted in the config timezone. The scheduler polls every 15 seconds. For each due slot it enqueues a job whose ID is the schedule plus the slot time, then advances next_run with a compare-and-swap, so a slot runs at most once even with several processes polling or a crash between the two steps. A slot more than an hour late (Habakkuk was down) is skipped, not run late. Each run is a fresh session; results go to the chat the schedule was created from. Scheduled runs are unattended, so anything that needs approval is denied unless the agent has an approval reviewer (approvals: reviewer or reviewer_then_human). habakkuk schedules run ID queues an extra run now, under its own job ID.

Script schedules run a command instead of a prompt, for frequent jobs (sync a ledger, check an inbox, send reminders) where a model call per run would be slow and costly. habakkuk schedules add -run CMD [-on-output deliver|agent|silent] [-timeout 2m] [-workdir DIR] [-deliver CHANNEL:CHAT[:THREAD]] stores the command beside the timetable; the same scheduler and at-most-once slots apply.

  • Who can add one. Only the owner, through the CLI. schedule_create has no command field and refuses one, so an agent cannot turn scheduling into unattended shell access. Agents may still list, pause or delete their own script schedules.
  • Running. /bin/sh -c in the workdir (default the agent's workspace; a relative workdir is inside it), with the process environment plus $HABAKKUK_HOME/.env (re-read each run; the process wins) and HABAKKUK_AGENT, HABAKKUK_SCHEDULE_ID, HABAKKUK_SCHEDULE_NAME. The command gets its own process group, so a timeout (default 120s) kills everything it started. Stdout and stderr are each captured up to 64 KB. Scheduled jobs have their own worker pool, so slow scripts never hold up chat turns.
  • Output. Empty stdout, or a line that is exactly [SILENT], means nothing to say. Otherwise deliver sends stdout to the chat, agent starts a turn with the schedule's prompt and the output appended (the existing prompt path; the agent may answer [SILENT], which the gateway never sends), and silent sends nothing. The last 4 KB of output and the status stay on the schedule (habakkuk schedules list).
  • Failures. A non-zero exit or a timeout records error: ... with the end of stderr and is not retried. The third failure in a row sends one notice to the chat; the streak stays quiet after that, and the first success after a notice says the script works again.
  • No overlap. Runs of one script share a serial key and get one attempt each, so a crash never reruns a script with side effects. A slot that comes due while the previous run is still queued or running is skipped (skipped: still running), not queued behind it.

Delegation#

delegate hands a self-contained task to a sub-agent (another configured agent or a fresh copy of the caller). The task becomes a subturn job with an ID derived from the parent session and the call, so a parent that crashed and repeats the call reattaches to the running or finished child. Several delegate calls in one response run in parallel. Sub-turns have their own worker pool, so parents waiting on children can never take every worker, and sub-agents cannot delegate further.

Options ride in the sub-turn's payload (SubturnSpec), so a resumed sub-turn keeps them, and join the job ID only when given, so plain calls keep their IDs:

  • model (provider/model, checked like /model) is tried first, the agent's chain after it.
  • toolsets must be a subset of the parent's; the child gets those it also has itself.
  • output_schema is a JSON Schema (type, enum, const, properties, required, additionalProperties, items, prefixItems, length, pattern, numeric bounds, allOf, anyOf, oneOf, not, local $ref). The child is told to end with a matching JSON value; its answer's final JSON (the whole answer, the last fenced block, or the outermost value that runs to the end) is validated. A mismatch appends one user message with the errors to the child's transcript and the loop goes on; a second mismatch fails the sub-turn. The retry message is in the transcript, so a rerun after a crash never asks twice. The parent gets just the JSON.
  • worktree: true adds a git worktree of the repository holding the parent's workspace at <workspace>/.habakkuk-worktrees/<job> on branch habakkuk/<job> (listed in .git/info/exclude, never the user's .gitignore; git runs with hooks off). The child's session prompt and tools use it as the workspace. When the child ends, its changes are committed to the branch (--no-verify, author Habakkuk) and the report ends with the branch, the base commit and git diff --stat.
  • background: true returns the job ID at once. When the sub-turn ends for good (done, or failed with no retries left) it queues a turn in the parent session, with an ID derived from its own, carrying the report and the delegating turn's reply address, so the parent's answer reaches the chat. /subagents stop cancels one and tells the parent it was stopped.

Board#

Delegation waits for an answer; the board does not. A card is work one agent (or the user, with habakkuk board add) hands to another, and it outlives the conversation and the process that created it.

  • Cards have a title, body, assignee, creator, status (todo, doing, blocked, done, cancelled), priority, result, optional parent, dependencies, comments, an event log, and the reply address of the chat they came from.
  • Dispatch. The dispatcher (in serve) looks every 5 seconds, and at once when a tool changes the board, for cards that are todo, assigned, and whose dependencies are all done. It claims each with a compare-and-swap (todo to doing, attempt + 1) and enqueues a card job whose ID is the card ID plus the attempt number. Only one process can win the swap and the ID makes the enqueue idempotent, so an attempt runs at most once; a crash between the two is repaired on the next look, which enqueues the missing job under the same ID. A job that fails for good blocks its card.
  • Runs. The card job is itself the assignee's turn, in a fresh session whose first message is the card, the results of its dependencies and its comments, so a crash resumes the same transcript. The agent ends the card with board_complete (a result with proof is required) or board_block; if the turn ends with the card still in progress, the card is blocked with the agent's last reply as a comment.
  • Results go back. When a card becomes done or blocked, the creator's session gets a new turn carrying the result or the reason, with the chat's reply address, so the creator can act on it and its answer reaches the chat. Cards with no creator session (the CLI, -to) send the result straight to their chat. Notices have IDs derived from the card's revision, so each is sent once.
  • Rules. Agents complete or block only cards assigned to them, and update (reassign, reprioritise, cancel, reopen) only cards they created or hold. Reassigning or cancelling a card in progress cancels its running job. Dependencies must exist when a card is created and cannot be added later, so cycles cannot form. Reading, commenting, completing and blocking are allowed in every permission mode; creating and updating cards are network-risk, like delegate.

Memory#

Long-term memory is a folder of Markdown notes per agent, agents/<name>/memory/<slug>.md, each with a small frontmatter:

---
title: Coffee preference
tags: [food, preferences]
created: "2026-11-06T09:12:00Z"
updated: "2026-11-06T09:12:00Z"
---

Flat white with oat milk, no sugar.
  • Files are the truth. People can read, edit, add or delete notes with any editor. A note without frontmatter is still a memory, titled by its file name and dated by its mtime. Writes go through store.WriteFileAtomic.
  • The index follows the files. memory.db holds an FTS5 table (porter stemming, unicode) over title, tags and body. Before every search and after every write, Habakkuk compares each file's mtime and size with the index and rewrites only the rows that differ, so a hand edit is searchable on the next search and a restart costs one directory scan. When nothing changed no write transaction is opened.
  • Ranking. Queries are split into words, each quoted (so the model cannot write FTS syntax errors), and OR-ed; bm25 ranks with title matches weighted 10, tags 5, body 1. Results carry a snippet with the matched words in brackets. A tag filter requires every given tag.
  • Tools (toolset memory): memory_save(title, content, tags?) creates or replaces the note whose slug derives from the title (an update keeps created, and keeps tags unless new ones are given); memory_search(query, tags?, limit?); memory_read(slug); memory_forget(slug), which moves the file to memory/.trash/ instead of deleting it; user_profile_read() and user_profile_update(section, content, mode?).
  • Prompt snapshot. A new session's system prompt gets a # Memory section: how to use the tools, then up to 50 of the most recently updated titles with their slugs, capped at 4 KB, and a count of the rest.
  • No secrets. Every write (notes, the profile, the review's changes) passes a check for well-known key formats, private keys, credentials in URLs, password: ... style assignments, long random-looking tokens and card numbers (Luhn), and is refused when one matches.
  • Provenance. Frontmatter carries source (agent, user for /remember and the CLI, review), session and trust. Each run of a turn has a taint flag (tools.Taint on the tool environment): the registry sets it after any tool of network risk runs (fetch, search, browser, MCP servers by default, delegation), the turn sets it when the model used hosted web search, and a session bound to an email channel starts tainted. memory_save in a tainted turn writes trust: untrusted; rewriting an untrusted note keeps the mark unless the user writes it (habakkuk memory trust vouches for it). Untrusted notes are listed with [untrusted] in the prompt snapshot and (untrusted) in search, and memory_read puts a warning line first. exec output does not taint (it is mostly the agent's own work); that is a known gap, as are webhook prompts.
  • Semantic search (memory.embeddings: a provider of type openai, which covers local servers such as preset: ollama, or gemini). The embedded text is the title, tags and body (first 8,000 bytes); its SHA-256 is stored on the note's index row and vectors are cached in embeddings (model, hash) as unit-length little-endian float32, so reindexing never re-embeds unchanged text and switching models never mixes vectors. Notes are embedded after every write, in batches of 64, and once a minute by habakkuk serve (hand edits). A search ranks with FTS (a deeper list, three times the limit) and by cosine similarity over the agent's vectors (a floor of 0.25 keeps unrelated notes out), then fuses the two with reciprocal rank fusion (k = 60); a note found by FTS keeps its highlighted snippet. Any embedding error pauses embedding for a minute and the search returns FTS results alone.
  • User profile. agents/<name>/USER.md is ## Section blocks, edited one section at a time (append skips text already there, replace, remove), capped at 6,000 bytes. The prompt includes it in a # About the user section, after the standing instructions, cut at the same size if a hand edit made it longer. A tainted turn cannot change it.
  • Chat commands. /remember <text> saves the words as a new note (source: user, tag remembered, never replacing one with the same title); /forget <query> forgets a note by slug, or the single note a search finds (or the one whose title is exactly the query), and otherwise lists the matches.
  • Background review (memory.review: true). Once a minute habakkuk serve lists sessions quiet for review_idle (default 15 minutes) and changed in the last three days, and for each with messages past the reviewed position (reviews table) queues a memory_review job with id memrev_<session>_<length> on serial key memory:<agent>, run by a one-worker pool. The job renders the new messages as tagged lines (tool output marked as such, newest 60,000 characters), adds the titles of up to 100 notes, up to 8 related notes in full and the profile, and sends one call to review_model or else the agent's last fallback (the rest of its chain behind it) with a fixed system prompt that treats all of it as data. The answer is a list of changes: save (create or update by title), merge (only notes it was shown in full), profile (only facts the user stated). Changes from tool output, and everything from an email-inbox session, are saved untrusted and never reach the profile; changes that look like secrets are dropped.
  • Nightly pass. At memory.nightly local time (default 03:30) each agent gets a memory_nightly job, id memnight_<agent>_<date>. It groups notes whose vectors have cosine 0.92 or more, or whose titles share 80% of their words (two words or more; a plural "s" ignored), into clusters of two to four, asks the model to merge each (at most five a night; it may say they differ), and archives to .trash notes neither updated nor read (memory_read) for stale_days (default 365), never those tagged pinned or keep.
  • Crash safety. Both jobs first store their planned changes in review_runs, then apply them one by one, recording progress after each, and finally write one log line (memory/.review.log, skipped if the job's line is there) and mark the run done in one transaction with the review position. A job resumed after a crash reuses the stored plan without calling the model and continues at the first unrecorded change. Each change is idempotent (saves replace by title, forgetting a missing note is a no-op, profile appends skip text already present), so a crash between a change and its record repeats nothing visible.

Skills#

A skill is a folder holding SKILL.md and any supporting files it refers to:

---
name: weekly-report
description: Write the Habakkuk weekly report from the board and the week's sessions.
---

1. ...
  • Where. Shared skills live in $HABAKKUK_HOME/skills/<name>/; an agent's own in $HABAKKUK_HOME/agents/<agent>/skills/<name>/, which wins on a name clash. The folder name is the skill name and must match ^[a-z0-9][a-z0-9-]{0,63}$; folders that do not, or that lack SKILL.md, are skipped.
  • Prompt snapshot. A # Skills section lists each skill's name and description, sorted, capped at 100 skills and 8 KB, and tells the model to load a skill with skill_view before a task it covers.
  • Tools (toolset skills): skill_view(name) returns the instructions and lists supporting files; skill_file(name, path) reads one supporting file, opened through os.Root so .., absolute paths and symlinks leading out of the folder are refused; skill_save(name, description, body) writes the agent's own copy atomically and keeps the previous SKILL.md as SKILL.md.bak.
  • Explicit invocation. /skill NAME [request] in a chat (and in habakkuk chat) and habakkuk run -skill NAME [request] put the skill's whole SKILL.md body and its list of supporting files into the turn's message, followed by the request, so the model follows it without having to decide to load it. /skills lists them. Both record the skill as used, as skill_view does.
  • Learning. /learn <topic, URL or path> starts a turn that tells the agent to study the source (fetch_url, read_file and list_dir, or web_search for a topic), treating what it reads as material rather than instructions, to improve a matching skill rather than duplicate it, to save the result with skill_save, and to reply with the skill's name, what it covers and where it is. It is refused for an agent without the skills toolset.
  • Installing (habakkuk skills install SOURCE) goes through internal/knowledge/hub: from a GitHub repository or folder (the tarball at a resolved commit, over the API, without git), a URL to a SKILL.md or an archive, a local path, or a name in a registry index (skills.registries). The download is unpacked into a staging folder with limits and no symlinks or executable bits, hashed (a tree hash over paths, sizes and contents, which a registry entry may pin), and scanned for piped downloads, environment or credential exfiltration, writes outside the folder, sudo and obfuscated payloads; flagged packages need -y. skills/.lock.json records source, ref, resolved commit or version and hash, so habakkuk skills update fetches the same source again and replaces only what changed, keeping the old version in skills/.backups. Installed skills are untrusted until habakkuk skills approve: skill_view and /skill precede their instructions with a caution not to run their scripts or follow them into side effects without asking. Details in the plugins guide.
  • Curator. With skills.curate: daily | weekly | monthly, habakkuk serve's maintenance pass (at start and every 6 hours) runs the curator when it is due. It looks only at agents' own skills: near-duplicates (the same body, the same name but for a plural, or descriptions sharing at least 70% of their words) are merged into the more recently used one (its instructions kept, the other's appended under a heading, its supporting files copied into merged/<name>/), and skills not loaded for skills.archive_after_days (default 90, counting from the last change when never loaded) are archived. Every change goes to skills/.backups first; habakkuk skills restore NAME undoes it, and habakkuk skills curate [-dry-run] runs it by hand.

Session tools#

  • todo (core): a per-session task list in state.db (add, update, complete, list, clear; at most 50 items). While any item is open, the list is appended to each turn's user message when the turn starts, so the model sees it every turn and the transcript stays append-only.
  • clarify (core; chat sessions and the terminal UI): posts 1 to 5 questions, with optional lettered choices, and waits for the user's next message, through the same machinery as chat approvals (one prompt at a time per conversation, commands still get through, 15-minute timeout after which the model is told to go on with stated assumptions). When the turn's approver is also a tools.Asker (the terminal UI), the questions go to it instead, shown with their choices; without channels the tool is registered only by the terminal UI.
  • session_search (memory): full-text search over the agent's other sessions. transcripts.db version 3 adds message_text (the text blocks of user and assistant messages, cut to 8000 characters; tool calls and results are left out) and an FTS5 index over it, both kept by triggers on messages and removed with the session by its foreign key; existing messages are indexed by the migration. Queries are quoted word by word and ranked with bm25; a second call with session and seq shows the messages around a hit.
  • Titles. A session's title starts as its first message. After the first turn, one model call on title_model (default the agent's chain; off disables it) writes a three-to-six-word title in the background; it replaces only a first-message title, and /title sets one that nothing replaces.
  • Sessions on the CLI. habakkuk sessions export ID [-format md|json|html] [-o FILE] (JSON keeps the blocks as stored; the HTML page is self-contained, script-free and escaped), habakkuk sessions search [-agent A] QUERY (every agent unless filtered), habakkuk sessions archive|unarchive ID... (archived sessions leave the lists; habakkuk sessions archived shows them) and habakkuk sessions rm [-y] ID... (asks first).

An agent whose toolsets hold more tool specs than tool_search_over (default 60; -1 turns it off), usually from MCP servers, is offered only the tools of the core toolset plus tool_search(query, limit?) and tool_describe(name). tool_search ranks the agent's catalog by the query's words (whole name words first, then parts of names, then descriptions; anything under two thirds of the best score is left out); tool_describe shows one tool's description and schema. Both end their result with a [callable now: ...] line, and the tools it names are appended to the request's tool list from the next model call on, after the ones already there, so the start of the list (and the cached prefix) never moves. Later turns read those lines back from the transcript and offer the same tools from the start, in the order found, so the list is stable for the session and survives restarts.

Device nodes#

Other machines lend Habakkuk capabilities. With a nodes section (which needs http), habakkuk node pair on the server makes a one-time code (ten characters from a 31-letter alphabet, stored hashed, single use, nodes.pair_ttl default 10 minutes) and prints the command to run on the device plus a QR code of <nodes.url>/app/pair.html#CODE for phones. habakkuk node run -server URL -code CODE -name NAME redeems the code at POST /nodes/pair, which answers with a fresh 256-bit node secret; the device keeps it in node.json (mode 0600) and the server keeps only its hash. Pairing a name again replaces its secret, and habakkuk nodes revoke NAME (or the dashboard) deletes it. Twenty failed pairings or logins within a minute throttle the endpoint for everyone.

  • Connection. The device holds a websocket to /nodes (outside the API token, like /channels: every request carries a code or a secret). Its first frame is hello {name, secret, caps, platform}; the server checks the secret against the hash in constant time and answers welcome or an error and a policy-violation close, after which the client stops retrying. Then the server sends invoke {id, command, args} frames and the node answers result {id, result: {text, files, error}}, files as base64 (25 MB each, 48 MB a frame). Both sides ping every 20 seconds and drop a connection whose ping goes unanswered; the client reconnects with jittered backoff (1s doubling to 1m, reset after a minute connected). On every heartbeat the server re-reads the node from nodes.db, so a revoke from another process cuts a live node off within one beat. A second connection under the same name replaces the first.
  • Capabilities are what the device can do, found when it starts: system.notify (osascript with the text as arguments, or notify-send), screen.snapshot (screencapture, grim on Wayland, ImageMagick import), location.get (CoreLocationCLI on macOS; on Linux GeoClue's own where-am-i client, since GeoClue ties a client to the D-Bus connection that made it and a chain of one-shot gdbus calls cannot hold one), clipboard.read/clipboard.write (pbpaste/pbcopy, wl-clipboard, xclip or xsel), and only when asked for: system.run (-allow-run), camera.snap (-allow-camera, ffmpeg with avfoundation or v4l2) and files.get/files.put (-dir DIR; paths resolve inside it, symbolic links included).
  • Browser nodes. A browser cannot set headers on a websocket, so the dashboard (and /app/pair.html, for a phone without the API token) uses Server-Sent Events at GET /nodes/events with X-Habakkuk-Node and the secret as a bearer token, and posts results to POST /nodes/result. It offers what the user ticks when pairing, each behind the browser's own permission prompt: notifications (through the service worker where it can), geolocation and a camera snapshot (getUserMedia, one frame as JPEG). The secret lives in that browser's localStorage; a Web Lock keeps one tab as the node.
  • Tools. nodes_list and node_invoke(node, command, args, timeout_seconds?) join the core toolset when nodes are on. node_invoke gates itself, in every permission mode: a command listed for the node (or "*") in nodes.allow runs; anything else goes through tools.Ask, the same reviewer-then-human path as other approvals, so a chat gets the usual prompt and an unattended run is denied; read-only agents cannot use nodes. Files that come back are saved under nodes/<node>/ in the workspace, images are also shown to the model through AddMedia (scaled as view_image scales), and files.put with from sends a workspace file. What a device returns counts as external content for the turn's taint.

Localization#

Habakkuk's own text (command replies and /help, approval prompts and buttons, pairing notices, errors shown in a chat, and the habakkuk node/habakkuk nodes CLI) comes from internal/ops/i18n: one flat YAML catalog per locale embedded in the binary, keys to fmt formats (a translation may reorder arguments with %[2]s; tests check that every catalog has exactly English's keys and verbs). Counted messages use key.one/key.other. Config language: sets the default and a channel's language: overrides it; tags such as pt-BR or fr_CA.UTF-8 map to their base language; anything unknown falls back to English, and a key missing from a catalog falls back to English too. The CLI uses the config's language, else LC_ALL/LC_MESSAGES/LANG. Approval replies accept the chat language's yes and no words as well as English ones. Catalogs ship for English, French, Spanish, Portuguese, German, Yoruba, Arabic, Chinese (Simplified), Japanese and Hindi; the non-English ones are machine-translated and say so at the top. The model is told to answer in the user's language, and a configured language is named in the system prompt for when it cannot tell (and for unprompted messages).

Hooks#

hooks: in config is a list of {event, command, timeout, match}. Events: turn_start, turn_end, tool_before, tool_after (fired by the turn loop), message_in, message_out (gateway), session_new (runner), schedule_run (scheduler). match is a regular expression on the event's subject: the tool for tool events, the agent for turn and session events, the channel account for messages, the schedule name for schedule_run. The command runs with /bin/sh -c in $HABAKKUK_HOME, gets the event as JSON on stdin and HABAKKUK_EVENT and HABAKKUK_HOME in its environment, in its own process group, killed at its timeout (default 10s). Only tool_before is waited for: a non-zero exit blocks the call and the model gets an error result with the hook's stdout as the reason; a hook that times out or cannot start also blocks, so a guard never fails open. Every other event runs its hooks in the background. Turn hooks fire on every run of a turn, so a turn resumed after a crash fires them again.

Setup and maintenance#

  • habakkuk setup asks for a provider (Anthropic, OpenAI, OpenRouter or any OpenAI-compatible URL), the key (hidden input, stored in $HABAKKUK_HOME/.env with mode 0600; other lines are kept), the model, the workspace and optionally a Telegram bot token and the user's Telegram id. It validates the config it built before writing it, keeps a replaced config as config.yaml.bak, and finishes with habakkuk doctor.
  • habakkuk update reads the latest GitHub release, downloads habakkuk_<os>_<arch>.tar.gz, checks it against the release's SHA256SUMS (refusing a release without one), and renames the new binary over the running one. -check only reports; a release that is not newer is skipped unless -force.
  • habakkuk backup [-o FILE] [-env] [-workspaces] writes a tar.gz of config.yaml, agents/ (without default workspaces unless -workspaces), skills/, personalities/ and a VACUUM INTO snapshot of control.db, transcripts.db, state.db and web.db, consistent while Habakkuk runs. -env adds .env and the WhatsApp credential databases. memory.db (rebuilt from the notes), logs and blobs are left out. habakkuk restore [-force] FILE accepts only those names as regular files, replaces each database together with its stale WAL, and refuses to overwrite an existing config without -force; stop habakkuk serve first.
  • Identity and the welcome. IDENTITY.md holds the name, emoji and manner the user chose in chat (set_identity, refused in a tainted turn since it reaches every prompt); the prompt states it after SOUL.md, so a hand-written soul keeps its place and the chosen name wins. Runner.NewSessionWith(..., SessionOptions{Chat: true}) is used for direct messages, habakkuk chat and the TUI; when the agent has no identity, no USER.md and no .onboarding marker, it creates the marker with O_EXCL and puts the welcome instructions in that session's frozen prompt, so two chats opening at once welcome once and a user who ignores the questions is not asked again. Schedules, webhooks, the board and other automated sessions never take it.
  • Hot reload. SOUL.md, AGENTS.md, USER.md and the memory and skill listings are read when a session starts, and persona files on every turn, so their edits reach every new session (/new) without a restart; an existing session keeps its frozen prompt. habakkuk serve polls $HABAKKUK_HOME every 3 seconds: hand-edited memory notes are reindexed at once, hooks: changes apply in place, and changes to any other section of config.yaml (providers, agents, channels, MCP servers, http, transcription, timezone, workers, retention) are logged as needing a restart. An invalid config.yaml is reported and ignored.

Agents get the core, memory, skills, schedule, delegate and board toolsets by default. A config that lists toolsets explicitly gets exactly that list, and the prompt only mentions memory or skills when the agent has the matching toolset.

Operations#

Packages: internal/ops/telemetry (metrics registry and Prometheus text, OTLP/HTTP trace export, the observer), internal/ops/logs (rotation, redaction, reading), internal/ops/security (the audit), internal/ops/secrets (secret managers). The CLI side is in cmd/habakkuk/logs.go, security.go, expose.go and status.go.

  • Metrics. GET /metrics on the HTTP surface, Prometheus text format, behind the API token unless metrics.public: true. The app wraps three things when it opens, so no package below it knows about metrics: each endpoint's llm.Provider (one count, duration, token and cost sample per request attempt; the outcome is ok or the error class; the wrapper keeps ModelLister), the runner's agent.Hooks (turn start and end, tool before and after, so a vetoed call counts as blocked), and in Pools every job handler (outcome done, retry, failed or lost, mirroring what the pool will do; deliveries by channel as sent, retry, failed). Queue depth by kind and status, channel connected and adapter restarts are read from control.db at scrape time. Families: habakkuk_turns_{started,finished,failed}_total{agent}, habakkuk_turn_duration_seconds, habakkuk_model_calls_total{provider,model,outcome}, habakkuk_model_call_duration_seconds, habakkuk_model_tokens_total{provider,model,kind}, habakkuk_model_cost_usd_total, habakkuk_tool_calls_total{tool,outcome}, habakkuk_tool_call_duration_seconds, habakkuk_deliveries_total{channel,outcome}, habakkuk_channel_connected, habakkuk_channel_reconnects_total, habakkuk_queue_depth{kind,status}, habakkuk_jobs_total{kind,outcome}, habakkuk_job_retries_total{kind}, habakkuk_build_info{version}, habakkuk_start_time_seconds. Counters live in the process; a restart starts them at zero, which Prometheus handles.
  • Traces. With telemetry.otlp_endpoint, spans go to <endpoint>/v1/traces as OTLP/HTTP JSON (standard library only), batched every 5 seconds from one goroutine; a full queue (2048) drops spans rather than grow, and a failing collector is logged once a minute. Each turn run is a root span; model requests (kind client, gen_ai.* attributes, tokens and cost) and tool calls are its children, matched by session because a session runs one turn at a time. telemetry.headers_env names a variable in the OTEL_EXPORTER_OTLP_HEADERS form. Spans carry names, ids, sizes and outcomes only; telemetry.include_content: true adds the user message, the reply, tool input and tool output (each cut to 4 KB).
  • Logs. habakkuk.log is JSON from log/slog, written through a redactor and then a size-rotating file: past logging.max_mb (default 20) it becomes habakkuk.log.1 and older copies shift up to logging.backups (default 5). Several processes append to the same log, so rotation takes habakkuk.log.lock and rechecks the size, and a writer that finds the file renamed under it reopens the new one. The redactor replaces, before anything reaches the disk, the value of every variable the config names as a secret (every *_env setting, found by walking the config, plus MCP env and header variables and secrets.refs), secret-looking .env entries, and well-known token shapes (provider keys, Slack, GitHub, AWS, Google, Telegram, JWTs, private keys, bearer credentials). habakkuk logs [-f] [-level warn] [-since 1h] [-grep X] [-n 100] prints records readably across the rotated files and follows the log across rotations.
  • Secret managers. secrets.sources declares where values come from (command, run with /bin/sh -c and the reference in $HABAKKUK_SECRET_REF; 1password, op read; bitwarden, bw get; file), each with a cache period (default 5m), and secrets.refs maps variable names to <source>:<reference> (op:// references go to the 1password source). App.Secret reads the process environment, then a reference, then .env. A failing source is logged with the variable and the error, never a value, and left alone for 30 seconds; command errors carry stderr, never stdout.
  • Hardline commands. exec and process_write input passes a blocklist before the permission policy, so no mode, reviewer or human can allow it: recursive rm of /, the home directory or a top-level system folder, mkfs and friends, dd or a redirect onto a disk device, fork bombs, and a download from a non-loopback host piped into a shell or interpreter (also bash <(curl ...) and sh -c "$(curl ...)"). It is a guard against a confused or manipulated model, not a sandbox.
  • SSRF guard. fetch_url refuses loopback, private, carrier-NAT, link-local, multicast and cloud metadata addresses (including IPv4-mapped, NAT64 and 6to4 forms) unless tools.allow_private_urls is set. The URL is checked first for a clear message; the dialer checks every connection after DNS resolution, and redirects are rechecked, so a public name that resolves or redirects inward is refused too. The guarded client ignores proxy settings, since through a proxy the target cannot be checked.
  • Security audit. habakkuk security audit [-fix] checks modes under $HABAKKUK_HOME (agent workspaces excluded): the home and folders 0700, credentials, databases and logs 0600, files owned by someone else; agents in full mode, and agents in full mode or with approvals: reviewer that outsiders reach (a non-web channel with allow_from: "*", or a webhook); open channels; HTTP listening off loopback without an https public_url, a missing or short token, public metrics; webhooks with a missing or short secret; MCP servers with risk: exec (worse when their agents are reachable or in full mode) or on plain http; skills others can write, that belong to someone else, link outside the home or ship executables; hook scripts others can write; backup archives readable by others, the newest archive's age, stale credential copies in backups/; free disk space. Findings are sorted by severity, each with a fix; -fix applies only permission changes. It exits non-zero while a high or critical finding remains.
  • Remote access. habakkuk expose tailscale [-port 443] [-print] runs tailscale serve --bg --https=PORT http://127.0.0.1:<port>, which shares the listener with the tailnet only; Habakkuk never runs tailscale funnel. It refuses without http.token_env, since every tailnet device could reach the API (and a tokenless server refuses the proxied host name anyway). habakkuk expose status shows tailscale serve status and warns when Funnel is on; habakkuk expose off stops sharing.
  • Status. habakkuk serve writes serve.json (pid, version, start, listen address) every 15 seconds and removes it on exit. habakkuk status reports the version, whether serve is running (pid alive and a beat within 45 seconds) and its uptime, channel states, queue depth by kind, today's tokens and cost, and the last errors of the past day from failed jobs and ERROR log records.