Extending
hiveloom's catalog is open: tools, guardrails, validators, loop policies,
compaction methods, event hooks, eval datasets/scorers, and model providers are
catalog entries,
and extensions register new entries through one API. A registered entry shows
up in hiveloom catalog, validates in specs like a builtin, and appears in the
generator's meta-prompt — so hiveloom generate can weave harnesses with a
capability the moment its pack is installed. MCP tools are the dynamic
exception: they come from declared servers at run time and appear in
hiveloom mcp list-tools, not hiveloom catalog.
Writing an extension
An extension is any module exposing hiveloom_extension(hive):
# my_ext.py
from hiveloom.ext import ExtensionAPI
def hiveloom_extension(hive: ExtensionAPI) -> None:
# a plain function tool (schema derived from type hints)
def ping() -> str:
"""Reply with pong."""
return "pong"
hive.register_function_tool(ping)
# a configurable tool: factory(params, ctx) -> Tool; params come from the
# spec entry `{builtin: slack_post, default_channel: "#ops"}`
hive.register_tool(
"slack_post", make_slack_tool,
description="Post a message to a Slack channel.",
tags=["network", "write"],
params=[{"name": "default_channel", "type": "str"}],
)
hive.register_guardrail("pii_filter", make_pii_guardrail,
description="Block outputs containing PII.")
hive.register_validator("json_diff", make_json_diff,
description="Diff output against a golden file.",
params=[{"name": "golden", "type": "str", "required": True}])
hive.register_policy("reflexion", lambda p, c: ReflexionPolicy(),
description="One self-critique pass before finishing.")
hive.register_compaction("keep_files", lambda p, c: KeepFilesCompaction(),
description="File-aware summarization.")
hive.register_hook("audit_log", make_audit_handler,
description="Record every tool call to the audit sink.")
hive.register_dataset("local_cases", make_dataset,
description="Load held-out local eval cases.")
hive.register_scorer("task_quality", make_scorer,
description="Score a verified run against expected data.")
hive.register_blueprint("scraper", "Always add no_network_write. Task: $ARGUMENTS")
# ambient: runs for every harness in this process (e.g. org-wide audit)
@hive.on("run_finished")
def report(event):
...
Base classes to implement: hiveloom.tools.registry.Tool,
hiveloom.guardrails.base.Guardrail, hiveloom.verify.base.Verifier,
hiveloom.loop.policies.LoopPolicy,
hiveloom.context.manager.CompactionMethod.
Eval factories return a loader with load() or a scorer with
score(ScorerContext); see evaluating.md.
Validators may keep the legacy two-argument method or request structured run-local evidence with a third argument:
from hiveloom import VerificationContext
from hiveloom.verify import VerdictResult, Verifier
class SelectedRecordsExist(Verifier):
name = "selected_records_exist"
def validate(
self,
run_output: str,
run_context: dict,
verification_context: VerificationContext,
) -> VerdictResult:
calls = verification_context.tool_calls
return VerdictResult(passed=any(call.name == "lookup" for call in calls))
VerificationContext contains bounded, redacted tool_calls, structured step
receipts, declared artifacts, and the current run_id. Tool evidence includes
only allowed calls that executed in this run. Read the typed context directly;
do not parse a trace or retain its private payloads. The same context is also
available at run_context["verification_context"] for code-hook compatibility.
Where extensions load from
| Source | Scope | Failure behavior |
|---|---|---|
pip packages with a hiveloom.extensions entry point (packs) |
everywhere | collected, never crashes (hiveloom extensions shows errors) |
~/.hiveloom/extensions/*.py |
this user | collected, never crashes |
extensions: list in harness.yaml (paths or module names) |
that harness | SpecError — the harness can't run without it |
A pack declares its entry point in pyproject.toml:
[project.entry-points."hiveloom.extensions"]
acme-tools = "acme_tools:hiveloom_extension"
hiveloom package records the packs a spec's entries come from in
hiveloom.lock (packs: with name + version), and a run against a missing
pack fails with a message naming it. hiveloom extensions lists everything
loaded plus any load errors.
The extensions: spec path is always frozen — evolution can never add or
change the code a harness loads.
Model providers
model.provider is a registry name, not a hard-coded literal. Builtin
providers already cover the major labs (claude, openai, gemini,
mistral, deepseek, xai), the aggregators (groq, openrouter,
together, fireworks), and local servers (ollama, vllm) — run
hiveloom models to list them. models.md is the full
reference: endpoints, key variables, pricing rules, open vs fixed catalogs,
and ~/.hiveloom/models.yaml customisation.
What belongs here is the programmatic path, for anything that is not an OpenAI-compatible HTTP endpoint:
def setup(hive):
hive.register_provider(
"mylab",
lambda ctx: MyProvider(), # must return a ModelProvider
api_key_env="MYLAB_API_KEY",
base_url="https://api.mylab.example/v1",
open_catalog=False, # only the ids below validate
models=[{"id": "mylab-small", "input_cost_per_mtok": 0.1,
"output_cost_per_mtok": 0.4,
"supports_tool_calling": True,
"supports_structured_output": False,
"supports_reasoning_replay": True}],
)
Registering a name that already exists raises, so two packs cannot silently
fight over one provider name. User provider declarations can override
registrations; models.yaml loads after workbench-written providers.yaml.
Pricing lives in the registry, and unknown models fall back to conservative
Haiku-class fallback pricing. This is an estimate and can under-count an
expensive unlisted model; declare accurate prices for budgeting.
Provider adapters return the old ModelResponse(text=..., usage=...) minimum
or add provenance that Hiveloom can carry without knowing the vendor:
from hiveloom.models import ModelResponse, Usage
return ModelResponse(
text=answer,
usage=Usage(input_tokens=120, output_tokens=20),
model=raw["model"], # identity actually reported
provider_request_id=raw["id"],
billed_cost=raw["usage"]["cost"], # charge for this call
billed_currency="USD",
reasoning=replay_data, # opaque JSON needed on the next turn
provider_metadata={"route": "fast"},
)
If a provider bills in another currency, retain the original amount and set
billed_cost_usd only after the adapter performs a real conversion. Otherwise
Hiveloom keeps the charge as provenance and uses its token-price estimate for
the USD cost guardrail. Each provider call reports cost_source as billed
or estimated in the journal and public run result.
reasoning and provider_metadata must be JSON-safe and are size-bounded.
Metadata reaches the trace through the normal logging.redact policy, but it
is deliberately omitted from the run-result receipt. Do not put credentials in
metadata; use the harness environment and provider constructor.
OpenAI-compatible extensions can use supported codecs instead of copying private helpers:
from hiveloom.models.openai_compat import (
normalize_openai_response,
to_openai_messages,
to_openai_tool,
)
The pre-1.1 underscore names remain aliases for compatibility, not extension API. Reasoning fields normalized by this codec are replayed only to the same OpenAI-compatible provider and are dropped by Hiveloom at a model swap.
Generate/evolve can use any provider too: --model ollama/qwen3:32b
(provider/model-id). Note model stays in ALWAYS_FROZEN — the registry
widens what a human or generator may choose, never what evolution can mutate.
Adapters may override ModelProvider.probe_capabilities(config) for a
provider-specific live check. Return only the generic
ProviderCapabilityObservation; routing policy and vendor response bodies stay
inside the extension. Core compares the reported effective model against the
caller's exact or alias policy and never treats an alias as the requested ID.
MCP servers
A harness can declare mcp_servers; their tools join the loop as ordinary
tools (mcp__<server-name>__<tool>), discovered eagerly when the tool
registry is built (including run --dry-run):
hiveloom add mcp-server --name search --stdio-command npx \
--stdio-arg -y --stdio-arg @foo/mcp-search \
--env-from-host API_KEY=FOO_SEARCH_API_KEY --dir ./h
hiveloom add mcp-server --name jira --url https://mcp.acme.com/mcp \
--header-env 'Authorization=ACME_MCP_TOKEN' --tool search_issues --dir ./h
hiveloom mcp list-tools --dir ./h # see what a declared server actually exposes
A stdio server is spawned with a minimal environment (HOME, PATH,
SHELL, TERM, USER, LOGNAME), plus HIVELOOM_HOME/HIVELOOM_DB when
this process has them — locations, so a child hiveloom mcp serve shares this
machine's Hive and trust store. Credentials are never forwarded implicitly:
name each one in --env-from-host (env_from_host_env), or give the server's
own folder a .env. The same applies to a hiveloom mcp serve launched by an
agent host (Claude Code, Claude Desktop): configure the key in the host's
server entry.
A stdio server (--stdio-command) is arbitrary local exec — the same
trust boundary as any other code hook. mcp_servers is always frozen from
evolution, the same risk class as extensions. A remote tool's own
annotations (e.g. readOnlyHint/destructiveHint) are self-reported by
an untrusted server and are never a security boundary — the real boundaries
are the always-frozen set plus harness trust gating.
Lifecycle event hooks
One event taxonomy serves the spec's hooks: section, ambient hive.on(...)
handlers, and the trace. Handlers get one dict payload; returning a dict
steers the run, returning None observes:
| Event | A returned dict may… |
|---|---|
context_assemble |
{"messages": [...]} — replace the message list |
before_provider_request |
{"system": ...} / {"messages": [...]} / {"tools": [...]} — patch the outgoing request (this request only; runs after guardrails) |
before_tool_call |
{"block": True, "reason": ...} or {"input": {...}} |
after_tool_call |
{"content": ...} / {"is_error": ...} — patch the result |
before_verification |
{"output": ...} — replace final output before guardrails and validators |
before_compaction |
{"cancel": True} or {"summary": "..."} |
run_started / before_model_call / after_provider_response / after_model_response / verification / run_finished |
observe only |
hiveloom add hook --on before_tool_call --code hooks/audit.py:audit --dir ./h
hiveloom add hook --on before_verification --builtin strip_json_fence --dir ./h
(In harness.yaml the field is event: — unquoted on: is a YAML boolean.)
Handlers must not raise; one that does is logged as a hook_error trace event
and skipped. Guardrails remain the frozen safety layer and always run first;
hooks are the extensible middleware layer.
Skills, deferred tools, tool ergonomics
Skills (progressive disclosure): hiveloom add skill pdf-report --description "..." scaffolds skills/pdf-report/SKILL.md and lists it in the
spec. Only name + description enter the system prompt; the model reads the full
file on demand with file_read.
Deferred tools: mark a tool deferred: true and it stays out of the model's
payload until the auto-added search_tools tool activates it — keeps context
small for harnesses with many tools.
Tool authors get: guidelines (usage rules injected while the tool is
active), prepare(kwargs) (normalize model-mangled args before validation),
run_with_updates(kwargs, on_update) (+supports_updates) for streaming
progress as tool_update trace events, and ToolResult(terminate=True) to
end the run without a final model call (honored when every result in the batch
terminates).
Handle-typed parameters
A large tool result is stored whole and reaches the model as a preview plus a handle (see Large tool results). A tool that consumes such a result should not make the model retype it. Name the string parameters that may be given a handle instead of a literal:
from hiveloom.tools import tool
@tool(description="Index a document for retrieval.", handles=["text"])
def index_document(text: str, label: str = "") -> str:
return f"indexed {len(text)} characters as {label}"
At dispatch the runtime replaces a value matching the handle pattern in full
with the stored object's text, resolved under the run's authority; anything
else is passed through unchanged. An unresolvable handle is a tool error rather
than a literal, one expansion is capped at 4 MiB, and the journal and the
provider both keep the handle — the tool sees the bytes, the transcript does
not. A Tool subclass declares the same thing as handle_params = ("text",);
the builtin file_write declares content that way.
Blueprints (generator house style)
A blueprint is a markdown fragment appended to hiveloom generate's
meta-prompt — preferred tools, guardrail posture, validator patterns for a
family of tasks. $ARGUMENTS/$@ expand to the task, $1..$9 to its words.
hiveloom generate "extract top HN stories" -o ./hn --blueprint scraper
Lookup order: ~/.hiveloom/blueprints/<name>.md, then pack-registered
(hive.register_blueprint).
Trust
Harness folders carry executable code. Folders built on this machine
(via init/construct/generate) are trusted automatically; a foreign folder
(unzipped artifact, clone) is gated before any of its code loads:
hiveloom trust ./foreign-harness # or: hiveloom run ./foreign --approve
HIVELOOM_TRUST=always hiveloom run . # CI; `never` refuses instead
Decisions live in ~/.hiveloom/trust.json. Frozen paths protect a harness
from its evolution; trust protects a machine from a foreign harness.
Embedding hiveloom
Any language — stream trace events as JSONL over stdout:
hiveloom run ./h --input notes.txt --stream
# {"type":"run_started",...}
# {"type":"tool_call",...}
# ...
# {"type":"run_result","ok":true,"status":"success",...}
Python — the semver-stable SDK:
from hiveloom import run_harness, generate_harness, Hive
result = run_harness("./h", "notes.txt", on_event=lambda e: print(e.type))
$HIVELOOM_HOME relocates everything user-level (extensions, models.yaml,
blueprints, trust.json, the default Hive DB) — useful for tests and CI.
Provider metadata and hook imports in 1.3.0
Models may declare legacy and restricted presentation flags through
ModelInfo, registration dictionaries, or user provider YAML. These filter
default workbench offers while preserving registered IDs for existing specs.
Output capabilities still validate model.max_tokens; flags do not establish
endpoint availability or account authorization.
The workbench writes custom endpoint declarations to
$HIVELOOM_HOME/providers.yaml, loaded before hand-written models.yaml.
Provider YAML also accepts a display label. See Models.
Hook imports register the module before executing it, as a normal Python import would. This allows postponed annotations, such as TypedDict tool argument schemas, to resolve correctly. Import failures still surface as spec errors; it does not change the trust requirement for executing hook code.
Embedding callers can pass run_harness(tool_policy=...), an object exposing
apply(registry), to tighten a built registry. Research uses this to deny tools
or replace them with recorded results. It is an embedding extension point, not
a new harness spec field or permission to widen declared capabilities. See
Research programs.