Conventions

Code-style and process expectations across the workspace. Most of these exist because something already went wrong without them.

Naming

Hive identity (label + domain + display names)

Four env vars cover the identity surface, read by hive-agent/src/identity.rs:

Inside an agent container, the hive's name and domain don't come from the agent's build, and neither do the hive's other per-host values: the dashboard port (HIVE_DASHBOARD_PORT), the pinned claude-code (HYPERHIVE_CLAUDE_CODE_PATH) and extra OTEL labels (HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES). hive-priv writes them to /etc/hyperhive-hive-facts before every container start, leaving out any the hive doesn't set; the agent then uses its own default. The agent's environment generator (nix/agent-modules/hive-facts.nix) passes them to every unit, including hivectl choom sessions, and /etc/set-environment exports them in login shells that no unit starts (nixos-container root-login). None is part of an agent's closure, and an agent started on another hive reports that hive.

hive_name + swarm_name are distinct from HYPERHIVE_HIVE_DOMAIN: the domain may carry the hive name as its leftmost label by convention, but the convention isn't machine-readable, and federated hives at different DNS domains can share a swarm name. Humans want both: the address (@darkest.space) AND the prose name (pr1ma). Matrix MXIDs use the domain-based convention.

Identity = socket

No auth tokens exist on the per-agent unix sockets. The socket path identifies the principal; perms come from "who has the bind-mount." A sub-agent only sees its own /run/hive/mcp.sock; hive-c0re owns the host admin socket.

Wake injection

hive_core_agent_sock::Request::Wake { from, body } is the wake-event-injection surface on the host-served per-agent socket. Recipient is implicit — the agent the socket belongs to — and from is caller-chosen so the wake prompt can label the source verbatim ("matrix: new message in #general", "forge: PR #42 opened", etc.). hive-c0re handles it; matrix, bash and forge don't call it — their notifications upsert a todo on the harness's in-agent socket (HIVE_AGENT_SOCKET), which signals the same turn loop without a hive-c0re round-trip. See docs/turn-loop/mcp.md § Waking the agent from inside the container.

Identity = socket means anything that can connect to /run/hive/mcp.sock is implicitly trusted to inject wakes. That's fine: the bind-mount only exposes the socket inside the agent's own container, so the trust boundary is the container's process namespace, not the wire surface.

Recipient sentinels

The broker reserves a few recipient names, which have special meaning that ordinary agent labels can never collide with — agent name validation rejects any character outside [a-z0-9_-], so the angle-bracket and asterisk shapes below are structurally safe.

The broker stores the recipient exactly as the agent passed it.

Wire protocol

JSON line-delimited over unix sockets in both directions (host admin / manager / agent). SSE streams (/dashboard/stream on hive-c0re, /events/stream on the per-agent web UIs) are text/event-stream; each frame carries a seq field for the snapshot-dedupe dance (see docs/web-ui/shape.md). Request/response types live in hive-sh4re — change them in one place. The dashboard event vocabulary lives in hive-c0re::dashboard_events::DashboardEvent.

Broker delivery + ack cycle

AgentRequest::Recv is the only path that delivers messages to an agent. Always returns a list (Messages { messages }) — empty when nothing's pending, single-pop when max = None (default 1, the single-message behaviour), batched up to max when caller asks for more (server-side cap is 5; values above clamp silently). The wire request still carries an optional wait_seconds (long-poll the first message, once one arrives — or one is already pending — the call drains up to max in total): the harness's own turn-driving loop uses it internally (hive-agent's recv_next, 180s). The agent-facing MCP recv tool doesn't expose this parameter — it always passes wait_seconds: None, an immediate peek.

Per-row bookkeeping inside the broker:

AgentRequest::AckTurn closes out the in-memory list — the harness fires it after TurnOutcome::Ok, marking every message popped since the last ack as fully handled. Claude doesn't see this surface; it's strictly a harness↔broker pairing. On TurnOutcome::Failed the harness intentionally skips the ack so the unacked rows stay in-flight in the DB and get picked up by the next requeue sweep.

AgentRequest::RequeueInflight is the recovery pair: fired by the harness exactly once at boot, before the serve loop starts. Catches the crashed-mid-turn / OOM-killed / container-restarted cases where a previous harness session popped messages but never drove them to a clean turn-end. Resets delivered_at back to NULL on every unacked row (so the next Recv pops them again), and remembers each id in a per-recipient in-memory set so the next Recv can tag the row with redelivered: true. Idempotent + cheap when there's nothing in flight, so the at-boot fire is unconditional.

AgentRequest::AckUntil { up_to } is the agent-facing bulk-triage escape hatch (mcp__hyperhive__ack_until). Unlike AckTurn it's visible to claude: each recv row and wake prompt carries a [msg #<id>] marker (the broker row id; transient pings show no marker — their sentinel id 0 has nothing to ack), and ack_until(up_to: n) marks every one of the agent's rows with id <= n handled in a single UPDATE — pending and delivered alike. This bounds the redelivered-flood cost after a restart: instead of popping dozens of already-handled messages one turn at a time, the agent notes the highest id it has seen and acks up to it. Recipient-scoped (an agent can only ack its own rows); also drains the in-memory unacked_ids / requeued_ids bookkeeping below the cutoff so a later AckTurn doesn't double-update and a stale redelivery tag can't outlive its row. The operator-side sibling is the dashboard's "mark all read" (unbounded, per-agent).

Loose-ends wire shape

LooseEnd is the per-row response shape for GetLooseEnds — one request shape, no per-socket flavours. Tagged enum so new thread kinds (forge PRs, etc.) can land later without breaking existing handlers. Each row carries enough context that the caller renders it directly as a bulleted list, no follow-up fetch needed.

GetLooseEnds takes no target — it always returns the caller's own rows. Reminder rows always carry owner == self.

Per-variant fields:

age_seconds saturates at zero on any clock anomaly (back-step, unsynchronised wall clock, etc.) so the bulleted list never shows nonsense ages.

cancel_loose_end is the matching write surface, handled in the harness without a hive-c0re round-trip: reminder deletes one of the caller's own pending reminders, todo clears one of its todos.

Agent metadata

AgentRequest::GetAgentMeta { name } returns identity + status for an agent. Self-introspection when name = None; target query when name = Some.

Response is AgentMeta { name, running, hyperhive_rev, status_text, status_set_at, hive_name, swarm_name, matrix_accounts }:

Timestamps on the wire

Timestamp fields that cross a JSON boundary (dashboard API + SSE, the wire structs in hive-sh4re) serialize as RFC 3339 UTC strings (2026-07-02T18:30:00Z) via hive_sh4re::wire_time — Rust keeps the fields as i64 unix seconds internally, only the JSON representation changes, and deserialization accepts both the string form and a bare integer (rolling-deploy skew, persisted blobs). Input-direction fields agents compute as epoch (remind's at_unix_timestamp) stay integers. The *_unix field names stay for now — renaming is the wire-types refactor's concern. The dashboard frontend parses via util.js::epochSec wherever it needs arithmetic and feeds the string straight to new Date(s) for display.

HTTP error bodies

Every HTTP API in this repo answers failures with RFC 9457 application/problem+json ({ type, title, status, detail }), with the human-readable cause in detail. An endpoint returning a bare string or a bespoke error shape is a bug to file against the daemon that returned it, not something for the caller to work around.

Use the problem_details crate (features = ["axum"]), which the daemons already depend on: type a handler Result<_, ProblemDetails> and hand ProblemDetails::from_status_code(...).with_detail(...) to Err.

The reason is the consumer, not tidiness. The UIs show errors through one shared component with a copy button, so a caller has to know which part of the body is the message. A bare string forces it to treat the whole payload as prose, which is the difference between offering "copy the cause" and dumping a response — and the cause is frequently the entire diagnosis (a JetStream permission refusal, a TLS chain failure) rather than a summary.

Not in scope: the hivectl host-admin and in-agent unix sockets. Those are a JSON-line protocol with their own result types; RFC 9457 is an HTTP format.

Permissions

One set of named Permission values (hive_types::Permission) decides what an agent may do: which MCP and built-in tools it gets, and which host access it has.

Permission Grants
messaging send, recv, ack_until
meta get_agent_meta (set_status is always-on, see below)
inbox get_loose_ends, cancel_loose_end, remind
execution the bash MCP server (mcp__bash__run/status/kill), rendered into the agent's MCP config only with it; a subagent gets Claude's own Bash instead. See docs/tools/bash.md.
web_tools the Claude built-ins WebFetch/WebSearch
manage_root_agent every agent's state (rw) + config (ro) bind-mounted into the holder's container, plus /applied and /meta (ro)

Always-on tools — Permission::ALWAYS_ON_TOOLS exposes set_status, compact, and mark_todos_done to every agent regardless of its permissions. The operator dashboard depends on every agent being able to report its status chip, and the server-side SetStatus handler has no permission check (only length validation), so gating it would only desync the --allowedTools list from what the host actually accepts. Revoking meta therefore drops get_agent_meta but never set_status. mark_todos_done is here because producers push todos to an agent whether or not it holds inbox, and an agent without it still needs to clear them.

Where they live — in the swarm, not on the hive: the permissions field of the agent's key in the agent-permissions KV bucket (swarm-wire/src/agent_permissions.rs), which swarm-controller alone writes. Set them with swarmctl agent permissions get|set|clear <agent> (/api/agents/{name}/permissions), or on the swarm UI's create-agent screen (permissions in POST /api/agents). A missing key or an unset field means the default (Permission::DEFAULT): messaging, meta, inbox, execution, for every agent whatever its name. "permissions": [] grants none.

How a change applies —

Every reader keeps its last good value on a failed read.

Moving off the meta repo — on its first boot with this code a hive copies meta/capabilities.json and meta/tool-groups.json into the stored value, sends them to swarm-controller once as one list per agent (without lifecycle and forge, which gave no tools; the controller writes a key only where there is none, and only for agents that hive's wanted state declares), and deletes both files from the meta repo when the controller answers (workers/permissions_import.rs). Until then hive-c0re reads no keys, and an agent that reads its own key unset keeps the grant in its copy.

Not configurable from agent.nix — that file changes through config PRs an agent can open, so letting it declare its own permissions would let an agent grant itself any tool by submitting a config commit, bypassing the operator gate.

Adding to the set — add a variant to Permission in hive-types/src/permission.rs and to Permission::ALL, and enforce it where it applies. A new #[tool] fn on AgentServer (hive-agent-mcp/src/mcp/mod.rs) goes into the matching Permission::tools() slice: that's the single source of truth mcp_config::allowed_mcp_tools (hive-agent/src/mcp_config.rs) reads each turn.

Async forms

Dashboard + per-agent mutating forms carry data-async; the shared bindAsyncForms submit listener (frontend/packages/shared/src/forms.js, imported as @hive/shared/forms.js and wired up from tabs.js on the dashboard and app.js on the per-agent UI) intercepts, shows a spinner, POSTs application/x-www-form-urlencoded (axum's Form extractor rejects multipart), calls refreshState() on success. New mutating forms should add data-async and optionally data-confirm (for a JS-side confirm() prompt).

refreshState defers automatically when document.activeElement sits inside a managed section so the operator's typing isn't lost; collapsible <details data-restore-key=…> survive the re-render via snapshotOpenDetails / restoreOpenDetails.

rebuild is the reconcile verb

job_queue::templates::rebuild builds the DAG that reconciles a container to its wanted state: it folds write_dropins (the nspawn-conf rewrite — PRIVATE_NETWORK=1, HOST_ADDRESS = the bridge gateway IP, sets EXTRA_NSPAWN_FLAGS — plus the systemd resource-limits drop-in) into the Swap node, then runs nixos-container update + stop + start across the StopForUpdate → Swap → RebuildBookkeeping brace and the tail Reconcile node. flake.nix itself lives in the agent's proposed/applied repos and rides along on every fetch (see docs/agent-lifecycle/config-changes.md::Two repos per agent).

Anything that changes per-container state on the host should be re-applied here so a manual ↻ R3BU1LD from the dashboard is sufficient to recover.

Actions live in one place

destroy (and the lifecycle helper) live in actions.rs / hive-c0re/src/dashboard/. The admin socket and the dashboard POST handlers both call into them so the two surfaces never drift.

Commit messages

Short, lowercase, no Co-Authored-By trailer. Imperative mood, no period. Body explains why when the reasoning isn't self-evident; otherwise the subject alone is fine. Wrap at ~72 cols.

Commit before test

Stage and commit when work looks ready, then run validation (cargo check, nix flake check, real deploy). Failures get a follow-up commit rather than an amend. The commit history is the work log; rewriting it loses signal.

Building & local checks

Build through the flake devshell, not a bare toolchain — agent containers ship no global rust. nix develop -c <cmd> runs one command inside the project-pinned env (cargo/clippy/rustfmt plus the C compiler + libsqlite3/ring link deps); outside it a bare cargo build fails with failed to find tool "cc" / cannot find -lsqlite3. One command per invocation — agents run each task as a fresh non-interactive process, so there's no persistent shell to reuse.

nix develop -c cargo clippy --all-targets -- -D warnings
nix develop -c cargo test
nix fmt                       # treefmt — authoritative, NOT bare cargo fmt

nix fmt (treefmt) is the formatter CI gates on; bare cargo fmt misses the non-rust files treefmt also covers, so always run nix fmt before pushing.

Clippy discipline — never add #[allow(clippy::…)]. All lints are CI-fatal at -D warnings (pedantic included); every warning that fires must be fixed, not silenced. Common patterns:

If a lint seems wrong for a specific call site, file an issue and ask mara — don't add #[allow] speculatively. The gate is intentional.

The devshell checks aren't the full nix flake check. Clippy / fmt / cargo test cover most gates, but nix flake check runs extra check derivations they don't:

When local clippy/fmt/test pass but CI's nix flake check fails, don't assume a transient builder problem — reproduce the real gate locally: nix flake check (shares the build farm, use sparingly) or build just the suspect check, for example nix build .#checks.x86_64-linux.hivectl-docs.

Best-effort oneshot services

The harness ships a family of one-shot systemd services that configure agent-side surfaces from values delivered to the agent at provisioning time:

(The matrix profile avatar is not a oneshot — hive-matrix-daemon sets it over its live authenticated Client; see docs/agent-lifecycle/persistence.md::matrix avatar.)

Shape contract — every one of these:

  1. Always exit 0, even on internal failure. A non-zero exit would mark the unit failed, which in turn aborts nixos-container update and blocks rebuilds. The agent's capability surface isn't allowed to gate the container build.
  2. No set -e in the script body. Subshell failures must not propagate. Use ... || true on every external call that can fail (forge unreachable, missing icon, parse error, etc.)
  3. Skip silently when prerequisites are missing: no token file, no icon, no reachable upstream → echo a short skip line + exit 0. The next boot tries again.
  4. Wired to multi-user.target so they run on every boot (lets a rotated token / new icon take effect without systemctl restart gymnastics).
  5. Re-runnable: a second invocation produces the same final state (idempotent uploads, idempotent config rewrites). Used by the .path watchers that re-fire on token appearance (see docs/agent-lifecycle/persistence.md::Matrix per-agent daemon).

The service stays root-owned so the bootstrap ordering doesn't need a user-existence check before each fire.

This pattern keeps the rebuild path resilient: any failure inside these services degrades the corresponding surface (no avatar) but never blocks the container from coming up. The operator notices through journalctl -u <unit> rather than a broken switch-to-configuration.