hyperhive/docs/conventions.md
atlas 58588a6866 fix(#3412): swarm-controller answers errors as RFC 9457 problem+json
Its three error paths returned a bare string with a status code, which forces
a caller to treat the whole body as prose. hive-c0re converted some time ago,
so swarm-controller was the last backend on the old shape -- and it is the one
behind the hive status page's 503, where the body is frequently the entire
diagnosis rather than a summary.

Adds the commitment to docs/conventions.md, since it was implied by the code
in one daemon and written down nowhere: an endpoint of ours answering with a
bare string is a bug to file, not something callers work around.

The test asserts the rendered response -- media type plus an addressable
detail -- rather than the problem_details value, because a handler that built
the value correctly and then returned it as a string would satisfy a test
written against the type alone.
2026-08-17 21:30:39 +02:00

30 KiB

Conventions

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

Naming

  • Containers are length-bounded by nixos-container (≤ 11 chars).
  • Sub-agents are h-<name> with <name> ≤ 9 chars.
  • One agent is the bootstrap/root container, with a fixed name (ruth today).
  • MAX_AGENT_NAME in hive-c0re/src/lifecycle/mod.rs enforces the cap.
  • Per-agent web UI port = WEB_PORT_BASE + FNV1a(name) % WEB_PORT_RANGE (8100..8999) for every agent; dashboard cfg.dashboardPort (default 7000).

Hive identity (label + domain + display names)

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

  • HIVE_LABEL — short, hive-local agent label (iris, damocles). label() returns it; falls back to empty string if the env var is missing so downstream callers can decide how to surface "unknown agent" rather than getting a panic from this module.
  • HYPERHIVE_HIVE_DOMAIN — the hive's canonical DNS domain (e.g. darkest.space), set by nix/host-modules/hive-c0re/environment.nix from services.hyperhive.domain. When configured, qualified_label() returns ${label}@${domain} (e.g. iris@darkest.space); when unset (single-hive deployments, dev/test) it degrades to just the short label so existing callers see no change. The qualified form surfaces in the per-agent web UI title, the system-prompt template, and /api/state.qualified_label.
  • HYPERHIVE_HIVE_NAME — human display name of this hive (pr1ma). Read by hive_name(); None when unset.
  • HYPERHIVE_SWARM_NAME — human display name of the wider swarm this hive belongs to (constellat1on). Read by swarm_name(); federated hives at different DNS domains can share a swarm name.

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 still use the domain-based convention untouched.

qualify(label) is the same shape as qualified_label() but applies to an arbitrary label the caller already has (e.g. a peer name from the broker); it's the right surface when rendering a peer's name when the caller knows it's hive-local.

Identity = socket

There are no auth tokens 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

AgentRequest::Wake { from, body } (and the manager-flavour mirror) is the wake-event-injection surface. 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.). Typical caller: an in-container background task (the matrix daemon, a scraper, the forge-notify webhook subscriber) that needs to signal "external work has arrived" without going through the broker as a peer agent.

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

A few recipient names are reserved by the broker and 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.

  • * — broadcast: deliver to every running agent except the sender (socket_server::handle_send fans out via Coordinator::broadcast_send).
  • operator — the human at the dashboard. Messages accumulate in the inbox view; no agent ever recv's them.
  • <parent> — the sender's parent per topology.json. Rewritten at send time by topology::resolve_recipient: looks up parent_of(sender) and falls back to operator when the sender is a root agent (or absent from topology entirely). Lets agents address their parent without learning the label, so runtime reparenting propagates with zero agent-side restart.
  • <children> — fan-out to every direct descendant of the sender per topology.json. Resolved in socket_server::handle_send via topology::children_of(sender): one message is delivered to each child, bypassing the allow-list check (structural fan-out targets are never user-listed peers). No-op for leaf agents (returns Ok when the child set is empty). Lets a sub-manager nudge its subtree without enumerating labels.

When a <children> or <parent> send resolves to real recipients, the broker stores the resolved label(s) as the message recipient(s) — the dashboard and recv side see the real routes. The sentinels are purely send-time addressing conveniences.

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.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 does not expose this parameter — it always passes wait_seconds: None, an immediate peek.

Per-row bookkeeping inside the broker:

  • delivered_at = NOW set on every popped row.
  • Each recipient has an in-memory unacked_ids list of every row delivered since the last AckTurn.
  • redelivered = true on a row if RequeueInflight resurfaced it (the harness prepends a "may already be handled" hint when this flag is set so the per-message warning is visible).

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 IS 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).

Question routing (Ask / Answer)

AgentRequest::Ask (and the manager-flavour mirror) surfaces a structured question that either lands in the operator's dashboard queue or in a peer agent's inbox. The recipient is the to field:

  • to = None or to = Some("operator") — routes to the operator-question queue. The dashboard renders the question with any options as a chip strip plus a free-text fallback (Other…) so the operator is never trapped by an incomplete list. The legacy AskOperator variant collapses into this case.
  • to = Some(<agent>) — peer Q&A. The target agent receives a HelperEvent::QuestionAsked { id, asker, question, options, multi } in their inbox. They reply via AgentRequest::Answer (or ManagerRequest::Answer if they're the manager); the answer threads back to the asker as a HelperEvent::QuestionAnswered event.

Shape fields are uniform across both targets:

  • options is advisory — the dashboard chips are decoration over a free-text fallback; peer-agent recipients see the list in their QuestionAsked event and can return any string.
  • multi = true lets the answerer pick multiple options (checkboxes in the dashboard, a hint in the peer-agent event). The answer comes back as a single string with selections joined by ", ".
  • ttl_seconds auto-cancels with answer [expired] (and answerer: "ttl-watchdog") when the wait becomes moot. None = wait indefinitely or until manual cancel.

Response shape is always QuestionQueued { id } — the asker stores the id and correlates the asynchronous answer event when it lands. Authorisation on Answer: only the question's target agent (or the operator via the dashboard) is permitted to reply; an answer attempt from anyone else fails the wire-side check.

Loose-ends wire shape

LooseEnd is the per-row response shape for GetLooseEnds (both the agent-flavour and manager-flavour requests). Tagged enum so new thread kinds (forge PRs, long-running approvals from a privileged bot, 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.

Per-flavour scoping is uniform across the three variants:

  • agent-flavour GetLooseEnds only surfaces rows the calling agent has standing in. Approval rows only appear when the calling agent is the manager (sub-agents don't submit approvals). Question rows surface where the agent is asker OR target (the routing semantics from the Ask/Answer subsection above). Reminder rows are scoped to owner == self.
  • manager-flavour GetLooseEnds lists every pending row in the swarm — full audit view.

Per-variant fields:

  • Approval { id, agent, commit_ref, description?, age_seconds }agent is the affected agent (target of the spawn / config commit), not the asker. description is the manager's free-text blurb shown on the dashboard card. commit_ref is the kind-specific payload (see docs/approvals.md::Approval kinds (wire shapes)).
  • Question { id, asker, target?, question, age_seconds }target = None = operator-routed (dashboard); Some(agent) = peer-to-peer thread.
  • Reminder { id, owner, message, due_at, age_seconds }due_at is the absolute time the scheduler is targeting (RFC 3339 on the wire, see Timestamps on the wire below); clients compute time-until-fire against it.
  • PendingMessages { count } — undelivered inbox messages the agent still owes itself a recv for. Informational + not cancellable (drain with recv); only emitted when count > 0, and surfaced first in the agent-flavour list as the most actionable signal. Counted host-side from the broker (count_pending), so it reflects what's genuinely still queued — the wake-message that drove the current turn is already delivered and not counted.
  • UnreadMatrix { rooms, summary } — unread matrix notifications. Informational + not cancellable (clear with mark_read). Unlike the others this is injected by the in-container harness, not hive-c0re, because the matrix daemon lives inside the agent.

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

CancelLooseEnd { kind, id } is the matching write surface. The kind enum (Question / Reminder / Approval) selects which underlying store the dispatcher reaches into. Question and Reminder cancel from either surface subject to ownership checks (asker for the question, scheduler for the reminder). Approval is manager-only — sub-agents don't submit approvals so they have nothing of their own to withdraw; their wire surface returns a clear error if they try. Cancelling an approval transitions the row to ApprovalStatus::Cancelled and fires ApprovalResolved { status: "cancelled" } so the dashboard pulls the card out of the pending pane.

Agent metadata

AgentRequest::GetAgentMeta { name } returns identity + status for an agent. Self-introspection when name = None (replaces the older Whoami request); target query when name = Some.

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

  • hyperhive_rev: None only when the configured flake URL has no canonical path. Otherwise carries the rev the target is currently pinned at.
  • running: whether the target's container is currently up. When false, the host clears status_text / status_set_at — on-disk values from before the stop are stale snapshots and shouldn't be shown as live status. Defaults to true on the wire (older harnesses never serialised it, and the host only knew how to ask about live containers — keeps backwards-compat with pre-running-field payloads).
  • status_text / status_set_at: last value written via SetStatus, plus its unix timestamp. Both None when the target has never set a status, when the agent name is unknown, or when running = false (see above).
  • hive_name / swarm_name: display names read from HYPERHIVE_HIVE_NAME / HYPERHIVE_SWARM_NAME env (sourced from services.hyperhive.hiveName / services.hyperhive.swarm.name). Both None when the options aren't configured.
  • matrix_accounts: one MatrixIdentity per configured + live matrix account the agent can act as. Empty for agents with no matrix provisioning.

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 leniently accepts both the string form and the legacy bare integer (rolling-deploy skew, persisted blobs). Input-direction fields agents compute as epoch (first_fire_at_unix, schedule-edit next_fire_at_unix, Wakeup::At) stay integers. The *_unix field names are kept 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 of ours returning a bare string or a bespoke error shape is a bug to file against the backend, 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.

Tool groups

The MCP tool surface an agent receives is derived from a set of named ToolGroup values (hive_sh4re::permissions::ToolGroup), not from a hardcoded binary flavor.

Group Tools
messaging send, recv, ack_until, ask, answer
meta get_agent_meta (set_status is always-on, see below)
inbox get_loose_ends, cancel_loose_end, remind
execution vestigial — mcp__bash__run / mcp__bash__status are always available unconditionally via extraMcpServers; this group's entries expand to non-existent mcp__hyperhive__run / mcp__hyperhive__status and have no effect. See docs/tools/bash.md.
lifecycle kill, start, restart, update, list_containers (privileged)
approvals request_init_config, request_update_meta_inputs (privileged)
scheduling request_schedule_prompt, fire_schedule_now, cancel_schedule, edit_schedule, list_schedules (privileged)
diagnostics get_logs (privileged)
forge create_repo — create git repos through hive-c0re (operator-gated merge)
web_tools none (gates the Claude built-ins WebFetch/WebSearch, not an MCP tool)

Always-on toolsset_status, compact, and mark_todos_done are exposed to every agent regardless of which groups it holds (ToolGroup::ALWAYS_ON_TOOLS). The operator dashboard depends on every agent being able to report its status chip, and the server-side SetStatus handler has no tool-group 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 todos are pushed to an agent independent of whether it holds inbox — an agent without that group still needs a way to clear them.

Config storage — per-agent tool groups live in /var/lib/hyperhive/meta/tool-groups.json (hive-c0re-owned, committed to the meta repo alongside topology.json). Format: { "alice": ["messaging", "meta", "inbox", "lifecycle"], "bob": ["messaging", "meta", "inbox"] }. An absent entry means "use role default". Tool permissions are intentionally NOT configurable from agent.nix — that file goes through the manager's approval flow, so letting it declare its own groups would let the manager grant itself any tool by submitting a config commit, bypassing the operator gate.

Setting groups — the operator sets groups via the dashboard or hive-c0re::tool_groups::set_groups(name, groups). After a change meta::sync_agents commits the updated file; the next agent rebuild picks up the new HIVE_TOOL_GROUPS env var. Agents with no entry get no var.

Runtime resolution — at session start the harness reads HIVE_TOOL_GROUPS (a comma-separated list of snake_case group names injected by the meta renderer from tool-groups.json). Unrecognised tokens are logged and skipped. Falls back to ToolGroup::AGENT_DEFAULT (messaging, meta, inbox, execution) when the var is absent or empty.

Updating the surface — when a new #[tool] fn is added to AgentServer in hive-agent-mcp/src/mcp/mod.rs, add its name to the matching ToolGroup::tools() slice in hive-sh4re/src/permissions.rs. That's the single source of truth; mcp_config::allowed_mcp_tools (in hive-agent/src/mcp_config.rs) reads it at session start.

Capabilities

Capabilities gate system-level access that goes beyond the MCP tool surface — things an agent can access, not just call. Parallel to tool groups but orthogonal: an agent can have a tool group that registers a tool AND a capability that allows the underlying resource access.

Capability Effect
manage_root_agent may lifecycle-manage the root/manager agent via kill/start/restart
read_host_journal get_host_journal MCP tool is registered + GET /journal-host requests are served
query_agent_state may call get_loose_ends / CountPendingReminders targeting non-child agents
infra_admin may call restart(name) on hive infrastructure containers (hive-ci, hive-forge, hive-matrixnot hive-gateway, which is the host's nginx and is operator-only); each restart is logged to the dashboard AUDIT trail

Config storage — per-agent capabilities live in /var/lib/hyperhive/meta/capabilities.json alongside tool-groups.json. Format: { "atlas": ["read_host_journal"], "ruth": ["manage_root_agent"] }. An absent entry means "no extra capabilities". render_flake in meta.rs reads this file and injects HIVE_CAPABILITIES (comma-separated snake_case names) into each agent's systemd service env; absent entries emit no env var so agents without capabilities don't trigger a spurious rebuild.

Setting capabilities — the operator sets capabilities via the C4P4B1L1T13S section in the dashboard's P3RM1SS10NS tab. hive-c0re::capabilities::set_caps(name, caps) is the write path. After a change meta::sync_agents commits the updated file; the next agent rebuild picks up the new HIVE_CAPABILITIES env var.

Runtime resolution — at session start the harness reads HIVE_CAPABILITIES and resolves each token to a Capability variant. Unrecognised tokens are logged and skipped. An absent or empty var means no extra capabilities.

Capability NOT configurable from agent.nix — same reasoning as tool groups: an agent that could grant its own capabilities via a config commit would bypass the operator approval gate.

Adding a new capability — add a variant to Capability in hive-sh4re/src/permissions.rs + an arm to as_str. Add it to Capability::ALL (the source of truth for the permissions UI columns). Implement the access check in the relevant handler (hive-c0re/src/socket_server/mod.rs, hive-c0re/src/socket_server/lifecycle_handlers.rs, coordinator.rs, or a handler under hive-c0re/src/dashboard/).

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) or data-prompt="…" (for a window.prompt() whose answer goes into a hidden input named by data-prompt-field, default note).

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: write_dropins (the nspawn-conf rewrite — PRIVATE_NETWORK=0, clears HOST_ADDRESS / LOCAL_ADDRESS, sets EXTRA_NSPAWN_FLAGS — plus the systemd resource-limits drop-in) is folded into the Swap node, then nixos-container update + stop + start runs across the StopForUpdate → Swap → RebuildBookkeeping brace and the tail Reconcile node. flake.nix itself is no longer regenerated host-side on rebuild — it's tracked in the agent's proposed/applied repos and rides along on every fetch (see docs/approvals.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 are factored

approve / deny / 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 if non-obvious; 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:

  • too_many_lines — extract a helper function or a sub-struct (the TurnAccum extraction in stats.rs is a worked example).
  • doc_markdown (brand name without backticks in a doc comment) — add backticks: `DOMPurify` instead of DOMPurify.
  • must_use / unused_results — actually handle or explicitly discard the return value (let _ = … is fine when intentional).

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 are not the full nix flake check. Clippy / fmt / cargo test cover most gates, but nix flake check runs extra check derivations they don't:

  • hivectl-docs regenerates docs/tools/hivectl-cli.md from hivectl's clap tree and fails if the committed copy is stale. So after any change to a hivectl verb or flag, regenerate it:
    nix develop -c cargo run --bin hivectl -- markdown-docs > docs/tools/hivectl-cli.md
    
    clippy / fmt / cargo test all pass without this — only the flake check catches the drift, and ci-log often can't show you why (it 500s on a fast failure), so you're left guessing "builder flake" when it's a stale doc.
  • swarmctl-docs is the same check for swarmctl / docs/tools/swarmctl-cli.md:
    nix develop -c cargo run --bin swarmctl -- markdown-docs > docs/tools/swarmctl-cli.md
    
  • there's also a flake cargo-test check and NixOS module evaluation in the set.

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, e.g. 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 hive-c0re writes into the state dir at provisioning time:

  • tea-login — writes ~/.config/tea/config.yml from the forge-token written by hive-c0re::forge::ensure_user_for, so tea repos create / tea pulls create work without interactive prompts.
  • forge-avatar-sync — uploads hyperhive.icon SVG to the agent's Forgejo profile, so the icon shows up on commits / PRs / issue comments.

(The matrix profile avatar is not a oneshot — hive-matrix-daemon sets it over its live authenticated Client; see docs/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 is not 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/persistence.md::Matrix per-agent daemon).

The artefact lives under the agent user's home where applicable (~/.config/tea/config.yml) and is chown'd to that user, but the service itself 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 tea config, no avatar) but never blocks the container from coming up. The operator notices through journalctl -u <unit> rather than a broken switch-to-configuration.