Architecture
How Habakkuk is built: layers, durable jobs, storage, models, permissions, channels, HTTP, memory, skills and operations.
On this page
- Goals, measurable
- Layers
- Durable work
- Storage
- Models
- Prompt caching
- Compaction
- Permissions
- Approval reviewer
- Working in the workspace
- Background processes
- Sandboxes
- Checkpoints
- MCP
- Plugins
- Browser
- Computer
- Meetings
- Channels
- HTTP
- Other clients
- Agent2Agent
- Home Assistant
- Media
- Schedules
- Delegation
- Board
- Memory
- Skills
- Session tools
- Tool search
- Device nodes
- Localization
- Hooks
- Setup and maintenance
- Operations
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:
- 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).
- If the last message needs a model reply, call the model and append the assistant message.
- If the reply contains tool calls, run them (in parallel), append all results in one message, go to 2.
- 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 sohabakkuk node pairandhabakkuk nodes revokecan write it whilehabakkuk serveruns.usage.db: one row per successful model call (provider, model, agent, session, tokens) and each session's/modelchoice. Retention prunes it.auth.json: subscription login tokens and MCP OAuth logins (mcp:<server>), mode 0600, replaced atomically; refreshes holdauth.json.lock.agents/<name>/memory/*.md: memory notes, the source of truth for memory.memory.dbis 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>/andagents/<name>/skills/<name>/: skill folders.skills/.lock.jsonrecords installed skills,skills/.usage.jsonwhen each skill was last loaded,skills/.curator.jsonthe curator's last run, andskills/.backups/replaced, removed, merged and archived versions.plugins/<name>/: installed plugins, withplugins/.lock.json(source, hash, approved, enabled) andplugins/.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 Patchformat (*** 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 fileand*** End of Fileare 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 runsrgwhen it is onPATH; 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.gitignorefiles at every level (globs,**, anchoring, trailing/,!negation; the last matching rule wins). Results stop atmax_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>.login 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, soprocess_outputresumes from thesinceit returned last time and says how many bytes the cap discarded; a negativesincereads 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_secondson 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 belowfull);process_writeis exec-risk too. Listing, reading and waiting are reads, andprocess_killonly 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 nonewhennetwork: false,--cpusand--memorylimits, and--user(default Habakkuk's own uid:gid so files written in the workspace stay yours;user: imagekeeps 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 defaultdebian:stable-slimhas it andsetsid. - SSH (
ssh: {host, port, user, key_env, workdir}): runsssh -T -o BatchMode=yeswithControlMaster=autoandControlPersist=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_envnames a variable holding a private key, written to$HABAKKUK_HOME/ssh/<agent>.keywith 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./undoin 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 toolsetmcp: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,serveandhabakkuk mcpconnect 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 withisErrorbecomes an error result. - Risk. A server declares one
riskfor its tools (read,write,networkorexec; defaultnetwork, whichworkspacemode allows without asking). Tools listed inapproveare treated asexecand always need approval belowfull.allowexposes only the named tools. - Isolation. A stdio server gets
PATH,HOMEand the variables in itsenvmap, 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 inenvandheadersare named, not stored: values come from the environment or$HABAKKUK_HOME/.env, andhabakkuk doctorreports any that are missing. - OAuth. A
urlserver withauth: oauthsigns in through the MCP authorization flow, run byhabakkuk mcp login NAME(habakkuk mcp logout NAMEforgets it). Habakkuk sends a token-lessinitialize, readsresource_metadataandscopefrom the 401'sWWW-Authenticateheader, 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 publicnativeclient) unless the server setsclient_id(andclient_secret_env). The sign-in is the authorization code flow with PKCE (S256),stateand theisscheck (RFC 9207), redirected to a listener on127.0.0.1(any free port, orcallback_port), with the server's URL as the resource indicator (RFC 8707) in every request. Scopes arescopes, else the challenge's, else the metadata's, plusoffline_accesswhen 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 copyauth.json. The client, tokens, token endpoint and resource are stored inauth.jsonasmcp:<server>and refreshed under its file lock like every login, so several processes share one. The SDK's ownAuthorizationCodeHandlerkeeps tokens in memory only, so the transport gets Habakkuk'sOAuthHandlerinstead: 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 naminghabakkuk mcp login NAME, whichhabakkuk mcpandhabakkuk doctorshow. - 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 onPATH,{param}placeholders).habakkuk mcp add NAME [param=value]appends one tomcp_serversby editingconfig.yamlas text after the section's last entry, so comments and layout stay; the edited file must load, and the old one is kept asconfig.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 modelaunchHabakkuk 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-9leaves no browser behind; a pid file and the profile'sSingletonLocklet the next start kill one that hung anyway. In modeconnectHabakkuk dialscdp_url(resolving anhttp://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, ortimeout_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_snapshotrendersAccessibility.getFullAXTreeas 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 theoffsetthat 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 usesInput.insertText, so frameworks see ordinary input events;selectsets the option by value or label and firesinputandchange. An action that starts a navigation within half a second waits for the load. Alerts are accepted,confirmandpromptdismissed unless the call saysaccept_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.setDownloadBehaviorto 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.ExecuteWithMediareturns 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
Fetchinterception 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 passGuard.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 modeconnect, a tab the owner opens while Habakkuk is attached waits that one round trip).blocked_urlsapplies 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,localhostand*.localhostwithout a lookup) are refused unless a pattern inallow_privatenames 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, andfile:,chrome:andjavascript: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_evaluateisexecrisk; every other browser tool isnetwork, soworkspacemode runs them without asking andread-onlymode 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.
linuxdrives an X display withxdotool(one chained command per action) and captures the root window withimport,scrotorffmpeg -f x11grab; in a Wayland session (or with onlyWAYLAND_DISPLAY) it usesgrimandydotool, which has no pointer position or window names.macoscaptures the main display withscreencapture -xand moves the pointer withcliclickwhen installed, else with JavaScript for Automation posting Quartz mouse events throughosascript; keys and typing go through AppleScript System Events, with any text passed as script arguments rather than spliced into the script.xvfbgives each agent its ownXvfbon the first free display fromdisplay(default:99), started with-nolisten tcpand-authon 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, whichhabakkuk computer viewreads 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
anthropicprovider,ServerToolsgetscomputerand the toolsetcomputeris dropped: the adapter declarescomputer_toolset_20260801(generally available, no beta header) instead of the function tools. Its calls aretool_useblocks with atoolset_name; the adapter stores one as a call of the toolcomputerwith the member's name asaction(llm.Block.Toolsetremembers the toolset), splits it back on replay, echoestoolset_nameon the result and moves the call's screenshot (an image block carrying the call'sToolUseID) inside the result, as the API wants. Older Claude models, other providers and mixed chains get thecomputer_*tools.computer_openis offered with both (toolsetcomputer-open), since the native tool cannot start apps. A per-session/modelswitch to another provider keeps the agent's tools as built; that is a known gap. - Safety. Every action is
execrisk (computer_waitisread). Each turn may takemax_actionsactions (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.RefusingToollets 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.ConfirmToolmakes 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 inconfirm_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'sblocked_urlsandallow_private),--lang=en-US, the ordinary Chrome user agent in place of the headless one, and fake media:--use-fake-ui-for-media-streamgrants camera and microphone, and--use-file-for-fake-audio-captureand--use-file-for-fake-video-capturepoint 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:
probereturns 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.Classifyturns 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;findandfocuslocate an element by the same patterns, and the click is a real mouse event at its middle, typing isInput.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.announceis 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
cshortcut as a fallback once per round, and retries every two minutes when that fails. Then it installs aMutationObserverwithRuntime.evaluatethat numbers each caption block element and reports changed blocks and removed ones, debounced, through aRuntime.addBindingfunction. TheAssemblermerges 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 totranscript.jsonlwith 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_leaveorhabakkuk meetings leavewrote theleavefile, the time limit passes (duration_limit, capped bymax_duration, default 3h, counted from the first join), or the page has said nobody else is there foralone_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 (
notesflag) and sent once (deliveredflag, 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'snotes_promptrides along as data. When Habakkuk never got in, the notes say why; when the model fails, they say so and point tomeeting_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 thatallow_fromand 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
/synclong 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_fromor 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 inchannels/<type>-<account>.json, notcontrol.db. - WhatsApp agents.
whatsappagentlong-polls the agent platform'sGET /updates(20-second holds, polls spaced at least 4.3 seconds apart to stay under its 15 a minute) and storesnext_offsetinchannels/whatsappagent-<account>.jsonafter 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 areuser:<id>; the conversation is the id without the prefix (addresses use colons), and the conversationcreatorresolves 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 sharesmetagraph'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: thehub.challengehandshake,X-Hub-Signature-256over the body, and Graph error codes mapped to Fatal, RetryAfter and Undeliverable. WhatsApp's 24-hour customer-service window is tracked per customer inchannels/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 fromapptoken, 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'sSendKey, 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 withhabakkuk pair), whether a group message is addressed to the bot, and which agent and session a conversation maps to (routes, a fresh session aftersession_idle). It handles/new,/stop,/status,/compact,/undoand/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/transcriptionsendpoint (transcription:in config). - Outbound files.
send_file(path, caption?)(toolsetcore, 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 implementFileSender: TelegramsendPhoto(JPEG or PNG up to 10 MB) orsendDocument(50 MB), Slack's external upload into the channel and thread (needsfiles: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 infullmode, with approval inask, or by asking the user inworkspacemode. 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()ininternal/channel/commands.go) that/helpprints:/retryreruns 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 instate.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 LEVELand/personality NAMEset per-session overrides: the effort sent with each request, and a persona appended to the frozen system prompt (built-inconcise,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./newstarts 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 containsGOAL DONEorGOAL 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./exportsends the transcript as a Markdown file through thesend_filedelivery path./whoamishows the sender, how they were allowed in, the channel, agent and session./agentslists agents;/agent NAMEswitches this conversation (kept instate.db) and starts a fresh session;/agent defaultgoes back to routing./forkcopies 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./subagentslists 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 isfy|<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 aMessagewithCallbackset (Telegramcallback_query, Slack Socket Modeinteractiveenvelopes, DiscordINTERACTION_CREATEover 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: trueon a channel, the user's message gets 👀 when its turn starts and 👍 when it ends (😢 when it fails for good; none when stopped). Adapters implementReactor(TelegramsetMessageReaction, which replaces; Slackreactions.addandreactions.remove, needingreactions:write; Discord's reaction endpoints). - Polls.
poll(question, options, multiple?)(toolsetcore, with channels) posts a native poll where the adapter implementsPoller(TelegramsendPoll, 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(TelegramsendMessage/editMessageText/deleteMessage, Slackchat.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 instate.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: offon 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 INTOa 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/*needsAuthorization: Bearer <token>, the value of the variabletoken_envnames; tokens compare in constant time. Withouttoken_envthe server refuses any address but loopback, and answers only requests whoseHostis a loopback name, so a web page cannot reach it through DNS rebinding.POST /api/chatonly takesapplication/json, which forces browsers to preflight cross-site requests that the server never approves.GET /healthzneeds 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 inlocalStorage. - 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;agentpicks the agent, and a conversation keeps its agent until a message names another. A clientidmakes a resend a no-op, as a redelivered platform message is. - Events. The adapter's
Sendappends each reply toweb.dband wakes the event streams open on that conversation.GET /api/chat/{conversation}/events?after=N(orLast-Event-ID) first replays everything after sequenceN, user messages included, then streams new ones, withtypingevents 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 oneweb.Dash. Its chat follows the events bus for live tool cards, sends files as attachments and recordings for transcription (multipartPOST /api/chat, which needs anX-Habakkuk-Clientheader so a cross-site form cannot post it), reads replies aloud throughtts, and withrealtime: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, checksX-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 fromX-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'stext/templateover{{.Body}}and the body's top-level JSON fields, framed as outside data rather than instructions. The reply goes todeliver(ACCOUNT:CONVERSATION) or to the web conversationhook-<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/modelslists one model per agent,habakkuk/<agent>.POST /v1/chat/completionsandPOST /v1/responsesanswer 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, theuserfield (remembered inweb.db) orprevious_response_idcontinue 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 carriesX-Habakkuk-Session.Idempotency-Keyderives the turn's ID, so a retry attaches to the first request's turn. The agent runs with its own tools: a request withtoolsgets a 400. Nobody can approve calls, so ones that need approval are denied. - MCP server.
habakkuk mcp serve(stdio) and, withmcp_server.http: true,/mcp(Streamable HTTP, same token) offerhabakkuk_ask(agent?, message, session?),habakkuk_sessions_list,habakkuk_session_read,habakkuk_board_create,habakkuk_board_listandhabakkuk_schedules_list.habakkuk_send(account, conversation, text)queues a durable delivery and exists only withmcp_server.allow_send: true. - Editors (ACP).
habakkuk acp [-agent A]speaks the Agent Client Protocol on stdio. A session'scwdis its workspace (and the one its frozen prompt names). Turns are jobs of their own kind that only thehabakkuk acpprocess runs, since they need its connection: model text and thinking stream as message chunks, tool calls astool_callandtool_call_updatewith a title, kind and file locations, and anupdate_plantool (only in editor sessions) asplanupdates. Calls that need approval becomesession/request_permission(allow once, allow for the session, deny). When the editor offersfs.readTextFileandfs.writeTextFile,read_file,write_fileandedit_filego through it, so they see unsaved buffers.session/cancelcancels the job and its context at once and the prompt answerscancelled;session/loadreplays a session's messages. - Terminal UI.
habakkuk tui(andhabakkuk chatwhen stdin and stdout are a terminal;-plainkeeps the line-based chat) is a Bubble Tea program ininternal/tuiover a narrowBackendinterface, 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, assession/canceldoes. 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.-resumeopens a picker of recent sessions. Colors follow the terminal's background (asked over OSC 11, so it works over SSH) andNO_COLORdrops them. - Desktop app.
apps/desktopis a separate Go module (Wails v3, CGO, a system webview), so thehabakkukbinary keeps building without either. It shows the connected Habakkuk's own/app/dashboard through a loopback reverse proxy that adds the token, runshabakkuk serveas 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 thehttpsection),habakkuk servepublishes an Agent Card at/.well-known/agent-card.jsonwhose 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 itsmetadata.agent(or the request's), elsehabakkuk, 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 froma2a.token_env, else thehttptoken (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
contextIdcontinues that session (only sessions A2A started, recorded inweb.db, so a client cannot continue a chat's session or read its tasks). The turn's id derives from the agent, context andmessageId, so a resent message attaches to its first task. A message that names ataskIdis 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/sendwaits for the turn unlessreturnImmediately(1.0) orblocking: false(0.3); the reply is the artifactreplyand the last message of the history. Pending and running turns aresubmittedandworking; done, failed and canceled turns arecompleted(with the reply),failed(with the error as the status message) andcanceled.tasks/cancelcancels the job; a finished task gives-32002. Push notifications are not supported (-32003). - Streaming.
message/streamsends the task, aworkingstatus,workingstatuses whose message holds the model's text so far (batched, at most every 300 ms, markedmetadata.partial), then the reply as an artifact update withlastChunkand the final status, and closes; a keepalive comment goes out every 15 seconds. A client that disconnects leaves the turn running;tasks/resubscribefollows it again. - Client. The toolset
a2ahasa2a_discover(to)(the card: name, skills, protocol, auth) anda2a_send(to, message, context_id?, metadata?), which sends a blocking message, pollstasks/getwhile the task still works (up to the peer'stimeout, default 5 minutes) and returns the reply with its state, task and context id.tois a peer undera2a.peers(a base URL or a card URL, withheadersresolved like MCP headers) or a URL: a URL goes through the guarded HTTP client (no private addresses unlesstools.allow_private_urls) and sending to it needs approval belowfullmode. The client fetches cards withA2A-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 inread-only, asked inask. - 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 provideropenai,geminiorfal) saves results underworkspace/images/and shows them to the model; the agent sends one withsend_file. OpenAI uses/images/generationsand multipart/images/edits; GeminigenerateContentwith 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 isfullorask. - Video.
video_generate(prompt, seconds?, image?)(video:with provideropenai,geminiorfal, plusmodel,image_modelfor fal,size,seconds(default 8) andtimeout(default 10m)) submits a job, polls it until it is done or the timeout passes, and saves the clip underworkspace/videos/; the agent sends it withsend_file. OpenAI uses the Videos API (POST /videoswith an optionalinput_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 untildone, then the sample's file); fal uses its queue as for images. - Search.
web_search(query, n)(search:withbrave,tavily,exa,searxngwithurl, orduckduckgo) returns titles, URLs and snippets. Without asearchsection it scrapes DuckDuckGo's no-script page, best effort. - Speech.
speak(text, voice?)(tts:withopenai,elevenlabsoredge) saves the audio underworkspace/voice/and queues it to the chat as a voice note: the delivery's file carries avoiceflag and a length, and adapters that implementVoiceSenderpost it as one (TelegramsendVoice, 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 withffmpegwhen it is installed, or sent as a plain audio file. Theedgeprovider 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, thenturn.end, back), authenticated by a time-derived token that is recomputed from the server'sDateafter a 403. - Live mode. With a
realtime:section (provideropenai,modeldefaultgpt-realtime-2.1,voicedefaultmarin,key_envdefaultOPENAI_API_KEY,base_url,transcriptiondefaultgpt-transcribe,instructions,ttldefault 10m), the dashboard chat gets a Live button. Habakkuk never carries the audio:POST /api/realtime/sessionmints a short-lived client secret (POST {base_url}/realtime/client_secretswithexpires_afterand asessionof typerealtime: 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/callswith that secret as the bearer token (Content-Type: application/sdp, the answer SDP comes back); events travel on theoai-eventsdata channel. The dashboard's Content-Security-Policy adds the provider's origin toconnect-srconly when Live mode is configured. The provider's voice activity detection handles turn taking and barge-in. The voice model's one tool isask_habakkuk(request): onresponse.function_call_arguments.donethe page posts the call toPOST /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 afunction_call_outputitem 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 callsask_habakkukwithyesorno, 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.completedandresponse.output_audio_transcript.done) are posted toPOST /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 nextask_habakkukrequest. When the call ends,POST /api/realtime/endwrites 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 undertalk:) is a voice loop for a laptop or a Raspberry Pi with no cgo:ffmpegstreams the default microphone as 16 kHz mono PCM (avfoundation:0on macOS;pulsedefaulton Linux whenpactlorpipewire-pulseis installed, elsealsadefault;talk.inputandtalk.deviceoverride), 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 aftertalk.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 thetranscription: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 totts.max_chars, synthesized withtts:and played withafplay(macOS) orffplay,paplayoraplay(talk.player), converted to WAV withffmpegfirst 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, ortalk.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_repliesisoff(default),mirror(reply by voice when the user's message carried audio) oralways. After the turn, the gateway queues the text reply, then speaks a Markdown-stripped copy (up totts.max_chars, default 2000) as a voice note on the same delivery serial key.voice_text: falsesends 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_createhas 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 -cin 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) andHABAKKUK_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. Otherwisedeliversends stdout to the chat,agentstarts 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), andsilentsends 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.toolsetsmust be a subset of the parent's; the child gets those it also has itself.output_schemais 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: trueadds agit worktreeof the repository holding the parent's workspace at<workspace>/.habakkuk-worktrees/<job>on branchhabakkuk/<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 andgit diff --stat.background: truereturns 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 stopcancels 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 aretodo, assigned, and whose dependencies are alldone. It claims each with a compare-and-swap (todotodoing, attempt + 1) and enqueues acardjob 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
cardjob 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 withboard_complete(a result with proof is required) orboard_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
doneorblocked, 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.dbholds 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;
bm25ranks 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 keepscreated, and keeps tags unless new ones are given);memory_search(query, tags?, limit?);memory_read(slug);memory_forget(slug), which moves the file tomemory/.trash/instead of deleting it;user_profile_read()anduser_profile_update(section, content, mode?). - Prompt snapshot. A new session's system prompt gets a
# Memorysection: 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,userfor/rememberand the CLI,review),sessionandtrust. Each run of a turn has a taint flag (tools.Tainton the tool environment): the registry sets it after any tool ofnetworkrisk 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 anemailchannel starts tainted.memory_savein a tainted turn writestrust: untrusted; rewriting an untrusted note keeps the mark unless the user writes it (habakkuk memory trustvouches for it). Untrusted notes are listed with[untrusted]in the prompt snapshot and(untrusted)in search, andmemory_readputs a warning line first.execoutput 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 typeopenai, which covers local servers such aspreset: ollama, orgemini). 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 inembeddings (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 byhabakkuk 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.mdis## Sectionblocks, edited one section at a time (appendskips text already there,replace,remove), capped at 6,000 bytes. The prompt includes it in a# About the usersection, 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, tagremembered, 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 minutehabakkuk servelists sessions quiet forreview_idle(default 15 minutes) and changed in the last three days, and for each with messages past the reviewed position (reviewstable) queues amemory_reviewjob with idmemrev_<session>_<length>on serial keymemory:<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 toreview_modelor 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.nightlylocal time (default 03:30) each agent gets amemory_nightlyjob, idmemnight_<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.trashnotes neither updated nor read (memory_read) forstale_days(default 365), never those taggedpinnedorkeep. - 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 lackSKILL.md, are skipped. - Prompt snapshot. A
# Skillssection lists each skill's name and description, sorted, capped at 100 skills and 8 KB, and tells the model to load a skill withskill_viewbefore 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 throughos.Rootso.., 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 previousSKILL.mdasSKILL.md.bak. - Explicit invocation.
/skill NAME [request]in a chat (and inhabakkuk chat) andhabakkuk run -skill NAME [request]put the skill's wholeSKILL.mdbody 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./skillslists them. Both record the skill as used, asskill_viewdoes. - Learning.
/learn <topic, URL or path>starts a turn that tells the agent to study the source (fetch_url,read_fileandlist_dir, orweb_searchfor a topic), treating what it reads as material rather than instructions, to improve a matching skill rather than duplicate it, to save the result withskill_save, and to reply with the skill's name, what it covers and where it is. It is refused for an agent without theskillstoolset. - Installing (
habakkuk skills install SOURCE) goes throughinternal/knowledge/hub: from a GitHub repository or folder (the tarball at a resolved commit, over the API, without git), a URL to aSKILL.mdor 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,sudoand obfuscated payloads; flagged packages need-y.skills/.lock.jsonrecords source, ref, resolved commit or version and hash, sohabakkuk skills updatefetches the same source again and replaces only what changed, keeping the old version inskills/.backups. Installed skills are untrusted untilhabakkuk skills approve:skill_viewand/skillprecede 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 intomerged/<name>/), and skills not loaded forskills.archive_after_days(default 90, counting from the last change when never loaded) are archived. Every change goes toskills/.backupsfirst;habakkuk skills restore NAMEundoes it, andhabakkuk skills curate [-dry-run]runs it by hand.
Session tools#
todo(core): a per-session task list instate.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 atools.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 addsmessage_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 onmessagesand 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 withsessionandseqshows 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;offdisables it) writes a three-to-six-word title in the background; it replaces only a first-message title, and/titlesets 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 archivedshows them) andhabakkuk sessions rm [-y] ID...(asks first).
Tool search#
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 ishello {name, secret, caps, platform}; the server checks the secret against the hash in constant time and answerswelcomeor an error and a policy-violation close, after which the client stops retrying. Then the server sendsinvoke {id, command, args}frames and the node answersresult {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 fromnodes.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) andfiles.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 atGET /nodes/eventswithX-Habakkuk-Nodeand the secret as a bearer token, and posts results toPOST /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_listandnode_invoke(node, command, args, timeout_seconds?)join thecoretoolset when nodes are on.node_invokegates itself, in every permission mode: a command listed for the node (or"*") innodes.allowruns; anything else goes throughtools.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 undernodes/<node>/in the workspace, images are also shown to the model throughAddMedia(scaled asview_imagescales), andfiles.putwithfromsends 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 setupasks for a provider (Anthropic, OpenAI, OpenRouter or any OpenAI-compatible URL), the key (hidden input, stored in$HABAKKUK_HOME/.envwith 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 asconfig.yaml.bak, and finishes withhabakkuk doctor.habakkuk updatereads the latest GitHub release, downloadshabakkuk_<os>_<arch>.tar.gz, checks it against the release'sSHA256SUMS(refusing a release without one), and renames the new binary over the running one.-checkonly reports; a release that is not newer is skipped unless-force.habakkuk backup [-o FILE] [-env] [-workspaces]writes a tar.gz ofconfig.yaml,agents/(without default workspaces unless-workspaces),skills/,personalities/and aVACUUM INTOsnapshot ofcontrol.db,transcripts.db,state.dbandweb.db, consistent while Habakkuk runs.-envadds.envand the WhatsApp credential databases.memory.db(rebuilt from the notes), logs and blobs are left out.habakkuk restore [-force] FILEaccepts only those names as regular files, replaces each database together with its stale WAL, and refuses to overwrite an existing config without-force; stophabakkuk servefirst.- Identity and the welcome.
IDENTITY.mdholds 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 chatand the TUI; when the agent has no identity, no USER.md and no.onboardingmarker, it creates the marker withO_EXCLand 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 servepolls$HABAKKUK_HOMEevery 3 seconds: hand-edited memory notes are reindexed at once,hooks:changes apply in place, and changes to any other section ofconfig.yaml(providers, agents, channels, MCP servers, http, transcription, timezone, workers, retention) are logged as needing a restart. An invalidconfig.yamlis 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 /metricson the HTTP surface, Prometheus text format, behind the API token unlessmetrics.public: true. The app wraps three things when it opens, so no package below it knows about metrics: each endpoint'sllm.Provider(one count, duration, token and cost sample per request attempt; the outcome isokor the error class; the wrapper keepsModelLister), the runner'sagent.Hooks(turn start and end, tool before and after, so a vetoed call counts asblocked), and inPoolsevery job handler (outcomedone,retry,failedorlost, mirroring what the pool will do; deliveries by channel assent,retry,failed). Queue depth by kind and status, channel connected and adapter restarts are read fromcontrol.dbat 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/tracesas 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_envnames a variable in theOTEL_EXPORTER_OTLP_HEADERSform. Spans carry names, ids, sizes and outcomes only;telemetry.include_content: trueadds the user message, the reply, tool input and tool output (each cut to 4 KB). - Logs.
habakkuk.logis JSON fromlog/slog, written through a redactor and then a size-rotating file: pastlogging.max_mb(default 20) it becomeshabakkuk.log.1and older copies shift up tologging.backups(default 5). Several processes append to the same log, so rotation takeshabakkuk.log.lockand 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*_envsetting, found by walking the config, plus MCP env and header variables andsecrets.refs), secret-looking.enventries, 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.sourcesdeclares where values come from (command, run with/bin/sh -cand the reference in$HABAKKUK_SECRET_REF;1password,op read;bitwarden,bw get;file), each with acacheperiod (default 5m), andsecrets.refsmaps variable names to<source>:<reference>(op://references go to the 1password source).App.Secretreads 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.
execandprocess_writeinput passes a blocklist before the permission policy, so no mode, reviewer or human can allow it: recursivermof/, the home directory or a top-level system folder,mkfsand friends,ddor a redirect onto a disk device, fork bombs, and a download from a non-loopback host piped into a shell or interpreter (alsobash <(curl ...)andsh -c "$(curl ...)"). It is a guard against a confused or manipulated model, not a sandbox. - SSRF guard.
fetch_urlrefuses loopback, private, carrier-NAT, link-local, multicast and cloud metadata addresses (including IPv4-mapped, NAT64 and 6to4 forms) unlesstools.allow_private_urlsis 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 infullmode, and agents infullmode or withapprovals: reviewerthat outsiders reach (a non-web channel withallow_from: "*", or a webhook); open channels; HTTP listening off loopback without an httpspublic_url, a missing or short token, public metrics; webhooks with a missing or short secret; MCP servers withrisk: 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 inbackups/; free disk space. Findings are sorted by severity, each with a fix;-fixapplies only permission changes. It exits non-zero while a high or critical finding remains. - Remote access.
habakkuk expose tailscale [-port 443] [-print]runstailscale serve --bg --https=PORT http://127.0.0.1:<port>, which shares the listener with the tailnet only; Habakkuk never runstailscale funnel. It refuses withouthttp.token_env, since every tailnet device could reach the API (and a tokenless server refuses the proxied host name anyway).habakkuk expose statusshowstailscale serve statusand warns when Funnel is on;habakkuk expose offstops sharing. - Status.
habakkuk servewritesserve.json(pid, version, start, listen address) every 15 seconds and removes it on exit.habakkuk statusreports 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 andERRORlog records.