Skip to content

Hooks: your logic on the request path

Busbar owns the request path. Hooks are the sanctioned attachment points on it: the places where your own code sees what Busbar sees and steers what Busbar does. Every hook follows one design rule, enforced structurally rather than by convention: a hook can steer, observe, or rewrite, but a hook can never break the request path. A slow, crashed, or wrong hook degrades to a safe default; it never blocks, hangs, or fails a request on its own.

In 1.5.0, a hook is a kind: hook dlopen plugin — the same signed-tarball, hybrid-ABI, in-process model that store, secret, and auth plugins use. The 1.5.0 release retired the built-in out-of-process socket and webhook transports: a hook is now always a signed plugin. Out-of-process isolation is still available through the first-party busbar-webrequest-hook plugin, which forwards the decision to an HTTPS sidecar. Write a hook once and it runs against all six protocols and every provider, with failover and circuit breaking underneath it, in one hop.

A hook instance is a module ref whose module: names a loaded kind: hook plugin (by its signed-manifest name/alias); settings: is the plugin’s opaque config. Loading any hook plugin requires plugins.enabled: true and the signed tarball in the plugins directory — a module: that does not resolve to an installed plugin is a fail-closed boot error.

PostureHow it runsTrust anchor
In-process kind: hook pluginLoaded from a signed tarball at boot or on hot-reload. The hook is a cdylib exporting the frozen hybrid ABI: busbar_abi, busbar_plugin_kind, busbar_open, busbar_call, busbar_free, busbar_close. Operations ride busbar_call as op-discriminated JSON.ed25519 signature over the signed manifest; kind cross-checked at load
Out-of-process via busbar-webrequest-hookThe first-party forwarder plugin POSTs the projection to your HTTPS sidecar (any language) and returns its reply. The sidecar URL is settings.url, SSRF-guarded (loopback allowed; RFC-1918 / link-local / CGNAT / cloud-metadata rejected; remote must be https://).The plugin is signed + auto-trusted; the sidecar runs in its own process

In-process trust is signature-based, not process-based. A kind: hook plugin loads inside Busbar’s address space, verified by ed25519 against the signed manifest. For fault isolation of untrusted logic, forward it out-of-process with busbar-webrequest-hook (see Webrequest below); the choice is performance and integration in-process vs. process isolation via the forwarder.

Admin opt-in required. A kind: hook plugin cannot self-wire into a security-critical path. Wiring a hook with prompt above no or global: true requires full admin scope.

Grants are core-enforced, never plugin-driven. A plugin cannot self-grant access. The signed manifest needs field (set at pack time with plugin-pack --needs-prompt rw) declares intent; the core enforces the actual projection. The plugin only sees what the operator AND the declared need allow.

Every hook is one of two kinds. That is the only structural distinction: the rest is the same contract for both.

KindMechanicReply
tapfire-and-forget (watch)none: it observes, it never answers
gatefire-and-wait (decide)one reply arm: nothing / reject / restrict / order / rewrite

A tap watches: logging, audit, metering, shipping records to a SIEM. It can never delay or change a request. A gate decides: it can reject the request, restrict which pool members may serve it, re-order the failover walk, or rewrite the request body. The PII guard, the smart router, and the Headroom compressor are all gates: same wire, same timing, same fail-safe, different reply arm.

A hook instance is defined INLINE where it runs: in a pool’s hooks: [...] list or in the top-level global_hooks: [...] list (there is no separate registry block; a hook is a plugin, and its instance is a module ref at its point of use):

plugins:
enabled: true
dir: /etc/busbar/plugins # the signed kind:hook tarballs live here
global_hooks: # attach to EVERY request, ordered
- { module: busbar-audit-hook, kind: tap, prompt: ro } # your signed audit tap
- { module: busbar-pii-hook, kind: gate, prompt: ro, on_error: reject }
pools:
my-pool:
hooks:
- cheapest # this pool's base ordering strategy (a bare name)
- { module: busbar-webrequest-hook, # out-of-process forwarder to a sidecar
settings: { url: "https://hooks.internal/rtr" } }
- { module: busbar-headroom-hook } # first-party kind: hook plugin (in-process)
members:
- model: claude-opus
- model: claude-opus-bedrock
tags: ["baa"]

The module names a loaded kind: hook plugin by its signed-manifest name/alias (e.g. the first-party busbar-headroom-hook and busbar-webrequest-hook, or your own). settings: is the plugin’s opaque config — for busbar-webrequest-hook that includes the SSRF-guarded sidecar url. Loading any of these requires plugins.enabled: true and the tarball installed in plugins.dir; an unresolved module: refuses to boot.

Attach a hook two ways: an inline ref in a pool’s hooks: list (fires for that pool) or in global_hooks: (fires on every request). A pool’s hooks: list carries its ordering strategy (weighted/cheapest/fastest/least_busy/usage, a bare name, at most one) and any number of gates. In a pool list an unmarked ref defaults to kind: gate; in global_hooks it defaults to kind: tap.

Gates fire concurrently. All of a request’s decision gates (the pool’s own and every global) fire at once against the same candidate set, then reconcile deterministically: any reject wins (the lowest-priority gate’s status/message surfaces), restricts intersect, and with several orders the last in the priority chain wins, re-validated against the post-restrict set. Added latency is the slowest gate, not the sum.

A tap picks its observation stage with at: (default request):

at:ObservesExtra payload
requestthe effective (post-rewrite) requestprompt text per the prompt: ro grant
routethe routing decisionsurviving candidate count
attemptevery dispatch attemptattempt_number, model (the dispatched member), remaining_candidates, previous_failure
completionthe outcomeoutcome + status, including the synthetic rejected completion, so an audit tap sees denials, not just served traffic

Stage payloads ride a top-level stage object on the (shape-only) per-request projection, with only the stage’s own fields present:

{"op": "notify", "request": {...}, "candidates": [], "context": {},
"stage": {"at": "attempt", // "route" | "attempt" | "completion"
"model": "claude-opus", // the dispatched member (attempt)
"attempt_number": 2, // (attempt)
"remaining_candidates": 3, // (route, attempt)
"previous_failure": "...", // (attempt ≥ 2)
"outcome": "ok", "status": 200}} // (completion)

The completion outcome vocabulary is ok | failed | rejected_by_gate | rejected_by_auth and is append-only: treat unknown outcomes as “not ok”, never crash on one. In 1.3 the user: grant projects identity on gate decision payloads only; tap and transform payloads omit identity (adding it later is an append-only change; key your parser on field presence).

Access grants: what a hook is trusted to see

Section titled “Access grants: what a hook is trusted to see”

By default a hook sees shapes, not content: sizes, counts, flags, live lane signals, never prompt text, never caller identity. Two per-hook grants, both default off, opt a trusted hook into more:

GrantLevelsAdds
prompt:no (default) · ro · rwro sends the flattened system + messages text (for PII screening, guardrails, audit) — this includes reasoning/thinking text (Anthropic thinking, Bedrock reasoningText, Responses reasoning) when a client replays it into a multi-turn body, since a screening hook must see everything the provider receives (see “What a gate receives” below for the redacted-reasoning exception). rw additionally lets a gate return the rewrite arm.
user:no (default) · roro sends caller identity: the governance key’s id/name and the body’s end-user field. Never the secret/token, under any configuration.

Grants are a monotonic trust ladder (no ⊂ ro ⊂ rw) and are immutable after registration: you cannot register a hook with prompt: no, wire it in, then quietly raise it to rw. rw on a tap is a boot error (a tap never replies, so it can never rewrite).

For kind: hook plugins, the manifest needs field (set with --needs-prompt rw at pack time) declares the maximum grant the plugin may receive. The core enforces the actual projection: the plugin only sees what both its declared needs and the operator’s instance grant allow.

  • The request projection: pool, ingress_protocol, message_count, has_tools, total_chars (a size signal; token counts do not exist pre-dispatch), max_tokens, stream. With prompt: ro/rw, also the flattened system + messages text. With user: ro, also caller identity.
    • Reasoning/thinking text is included. No content block that reaches the provider is silently omitted: Anthropic thinking, Bedrock reasoningContent.reasoningText, and Responses reasoning text project like any other text block. This is a widened scope for the prompt grant as of this release — an operator who wired prompt: ro for PII screening before now also sees replayed chain-of-thought, which is the correct behavior for a screening gate (content the provider sees that the gate does not is a bypass, not a feature) but is worth knowing if your hook logs or forwards the projection verbatim.
    • Redacted reasoning (Anthropic redacted_thinking, Bedrock redactedContent, a Responses reasoning item carrying only an opaque encrypted_content blob with no content[]/summary[] text) projects as a fixed marker, [busbar:redacted_reasoning], never the ciphertext. Busbar cannot decrypt it, so there is nothing to screen and handing a hook the raw bytes would be a new disclosure (they would reach your prompt-forwarder sidecar, which never received provider ciphertext before). Treat the marker as a presence signal only, not a trust signal: a client can also send ordinary text that happens to equal this string, so do not gate a decision on the marker’s presence/absence alone. Also note rewrite (prompt: rw) is not index-aligned (see the rewrite arm below) — a hook that echoes the marker back writes it into a real, visible content block on the outgoing request.
  • The candidate projection: one entry per healthy member: cost_per_mtok (derived from the model’s rate_card entry), latency_ms (rolling EWMA), available_concurrency (free slots now), budget_remaining, rate_headroom (fraction: the tightest requests/tokens limit headroom across the key’s group chain), and your tier/tags labels. The full task/latency/cost/quality picture, every signal a built-in strategy ranks on is on the wire, so an external hook can implement any of them identically.
  • The budget-chain state (when the request carries a virtual key): the whole enforcement chain the request must clear, one entry per bucket from the key’s own attribution bucket out through every ancestor group’s budget-window buckets (bucket_id = group:<name>@<window>), each {bucket_id, budget_group?, spend_micros_at_current_rate, remaining_micros, window_start, budget_period}. spend_micros_at_current_rate is derived at hook-call time from the token ledger times the current top-level rate_card (micro-units, 10,000 per cent). This is the read surface for budget-aware routing: a gate can see how close the key or its team is to a cap and downshift to a cheaper tier. Busbar exposes the state only; the routing policy lives entirely in your hook.

A gate answers with exactly one of:

  • nothing / abstain: no opinion; Busbar proceeds as it normally would.
  • reject ({"reject": {"status": 451, "message": "..."}}): no upstream is dispatched; the caller gets a dialect-native error. Status clamped to 400–499 (default 403) so the caller’s SDK catches the right typed class (429 → rate-limit, 401 → auth, …); message sanitized. Fail-closed: a malformed reject degrades to the defaults, never to silently routing the request. With prompt: ro, this is the PII-screen primitive: see content, say no, before it leaves your network.
  • restrict ({"restrict": {"tags_any": ["baa"]}}): only members carrying one of those tags may serve. The restriction persists across failover (every hop stays inside the surviving set); an empty intersection follows the gate’s on_empty (default reject, fail-closed).
  • order ({"order": [idx, ...]}): rank the surviving candidates, most-preferred first (omitted members are demoted, not excluded). That order becomes the failover walk: Busbar tries your first choice, and on a pre-first-byte failure walks to your second. You choose the order; the breaker, concurrency caps, and failover budget still apply.
  • rewrite ({"rewrite": {"messages": [...], "tools": [...]}}): replace the request body (compression, redaction). Requires prompt: rw. Note the asymmetry: a hook receives messages as {role, text} (the flattened projection) but replies in body form ({role, content}); the system prompt is not rewritable; and a socket reply is capped at 64 KiB, which bounds very large rewrites. Body-only: a rewrite never changes routing, the principal, or the target dialect. It fires before dispatch and before the routing decision, so both the decision and every upstream see the rewritten body, and it persists across failover. Token accounting (budgets, metrics) is on the provider-reported usage of the rewritten body: the savings are real and measured. A malformed/oversized rewrite follows on_error (default: proceed with the body unmodified; a broken compressor never corrupts a request). Pre-existing hazard, not introduced by reasoning-text projection but now more visible because of it: the write-back is not index-aligned — a hook that echoes what it was projected as literal {role, content} text loses every image/tool_use/tool_result/signature/cache-control block in that turn (only its text survives), and now also promotes any projected reasoning text (or the redacted-reasoning marker) into a real, visible content block shipped upstream. If your rw hook only inspects and passes through, prefer returning no rewrite (abstain) over echoing the projection verbatim.
  • priority: <n> is the one ordering knob: it orders the rewrite transform chain (each rewrite sees the prior’s output) and tie-breaks the concurrent decision reconcile: which reject’s message surfaces, and which order counts as “last”. Ties keep globals first, then config order.
  • A pool that names no strategy gets the zero-cost inline weighted backstop. (The 1.4.x default: true registry flag is gone with the registry: name the base strategy per pool.)

What Busbar guarantees when a hook misbehaves

Section titled “What Busbar guarantees when a hook misbehaves”
FailureWhat happens
Hook is slowCut off at timeout_ms (default 1 ms; raise it when your hook hits a DB or the network), decision coerced to on_error
Hook errors, returns garbage, or is saturatedSame: on_error
on_error: nothing (default)Does not participate: the failing gate drops out of the decision entirely and can never displace another gate’s verdict. The right posture for gates whose job is orthogonal to routing (a compressor, a logger-gate): their failure should never reshape traffic.
on_error: weightedFalls back to the weighted floor: a broken hook is indistinguishable from no hook. Behaviorally identical to nothing (in the concurrent reconcile both mean “didn’t participate”); the two names exist so a config reads correctly: weighted for ordering gates, nothing for everything else.
on_error: firstConfig order, deterministic
on_error: rejectFail closed with a 503, for security gates, where an unscreened request is worse than none. Docs mandate this for security gates.
on_error: { hook: <name> }A named fallback (structured ref): when this gate fails, that hook fires in its place (its decision is honored exactly as a primary’s, projected per its own grants). Its own on_error chains further; Busbar proves at boot that every chain terminates: an unknown name, a tap, or a cycle is a startup error. weighted/reject/first are the reserved chain terminals; a ranking strategy name (cheapest, …) is also a valid, infallible fallback.

A tap, being fire-and-forget, has no on_error to speak of: its reply is discarded, its errors swallowed, its delivery bounded and dropped-under-pressure. It can never delay, reorder, or fail a request.

The sidecar wire contract (busbar-webrequest-hook)

Section titled “The sidecar wire contract (busbar-webrequest-hook)”

When a hook forwards out-of-process through busbar-webrequest-hook, Busbar exchanges the same op-discriminated JSON with your HTTPS sidecar: one POST body per message. (The in-process kind: hook plugin ABI carries the identical payload over busbar_call — see kind: hook plugin ABI below.) The projection is byte-identical whichever path carries it, so sidecar logic and plugin logic are the same. The rules a sidecar author must know:

  • Message discrimination. A message with a top-level configure, describe, or status key is a management message. Everything else is a per-request message and its op field says which kind: decide (a gate’s blocking decision, answer it), transform (a rewrite pass, answer it), notify (a tap observation, never answer it; on a socket, Busbar does not read a reply and an answered notify queues bytes forever).
  • Evolvability. The wire is append-only: Busbar may add fields and message kinds at any time. A hook MUST ignore unknown fields, MUST treat unknown op values and unknown management keys as “not for me” (reply {} on a socket; 200 {} on a webhook), and may attach extra fields to its own replies (Busbar ignores unknowns symmetrically).
  • Optional fields are absent, not null. Key your parser on field presence (e.g. "tier" in candidate), never on null-ness, and never on key order.
  • Abstain is an explicit reply. {} (or {"abstain": true}) is the abstain. An empty body, a non-2xx webhook status, a closed socket, or a missing newline is a transport ERROR, not an abstain. It routes to the gate’s on_error. Under the default on_error: nothing the two look identical; under on_error: reject an “abstain via 204” fails every request. A webhook’s reject must ride a 200 response body; a 4xx/5xx status is the hook erroring, not rejecting.
  • Transform precedence. A transform reply is read as reject > rewrite > abstain: a rewrite gate that also screens (a compressor with a PII check) returns {"reject": ...} and the request stops, exactly as on the decide path. restrict/order are decide-path verbs and are ignored on a transform reply.

For in-process plugins, the transport is busbar_call over the frozen hybrid ABI — six kind-neutral C symbols: busbar_abi, busbar_plugin_kind, busbar_open, busbar_call, busbar_free, busbar_close. (TRANSPORT_VERSION = 1 is the low-level C signature contract, frozen; abi_version in the manifest is the per-kind payload version — HOOK_ABI_VERSION = 1 for the hook kind.) Operations are the same op-discriminated JSON payload as socket/webhook: decide, transform, notify, configure, describe, status. The serialization is JSON over the C ABI rather than NDJSON over a socket, but the payload contract is identical — a hook’s decision logic is transport-agnostic.

Management messages: configure, describe, status

Section titled “Management messages: configure, describe, status”

Management messages apply across all transports. On socket and webhook they are NDJSON lines or HTTP POSTs; on kind: hook plugins they ride busbar_call with the same JSON payload.

  • configure: Busbar pushes the hook’s opaque settings map, stamped with the hook’s instance name, a settings_version, and Busbar’s version. It is the first message on every socket connection, always, including a hook with no settings (an empty settings: {} is valid desired-state), so a (re)started hook always hears its identity, current settings, and Busbar’s version before any traffic. It is also pushed live by PATCH /api/v1/admin/hooks/{name}/settings. One ack rule for both deliveries: reply {"ack": {"settings_version": <the exact version sent>}} (5s deadline). On the PATCH, no exact ack = nothing commits (the operator gets a 400); on the connection preamble, no exact ack = the connection is not used.
  • describe ({"describe": true}): reply with your self-description ENVELOPE: {"schema": <settings JSON Schema>}. Busbar extracts schema and serves it at GET /api/v1/admin/hooks/{name}/schema. The member is optional; don’t answer (or {}) and the API reports schema: null.
  • status ({"status": true}): the control-plane read: reply your observed state, {"status": {"settings_version": N, "settings": {...}, "metrics": [ ... ]}}, and Busbar surfaces it at GET /api/v1/admin/hooks/{name}/status with a desired-vs-reported drift verdict. The metrics ARRAY is how your hook feeds its own operational data to the control plane (a Headroom compressor reports chars_saved_total; a dashboard built on Busbar sees what each plug is doing) instead of running its own dashboard. Each entry is Prometheus/OpenMetrics-shaped:
    {"name": "chars_saved_total", // ^[a-z][a-z0-9_]{0,63}$ ; counters SHOULD end _total
    "type": "counter"|"gauge"|"histogram",
    "value": 812000, // counter/gauge scalar; a histogram's is its sample count
    "labels": {"pool": "chat"}, // Prometheus DIMENSIONS: several entries may share a name
    "quantiles": {"0.5": 12, "0.95": 34, "0.99": 51}, // a histogram's distribution (p50/p95/p99)
    "estimated": true, "ci_low": 27.7, "ci_high": 35.7, // mark + bound an ESTIMATE vs a measured fact
    "label": "Characters saved", "unit": "%", "viz": "counter"|"gauge"|"sparkline"|"histogram",
    "max": 100, "help": "..."}
    Beyond name+type everything is optional; the simplest hook sends {name, type, value}. labels is how you break a metric down by dimension (per-pool, per-model, per-strategy): a hook that runs on several pools reports one entry per pool (it receives request.pool on every message), so GET /hooks/{name}/status returns the whole picture and a dashboard drills down by label. A hook is ONE process no matter how many pools reference it, so this labeled self-report, not a per-pool endpoint, is how per-pool numbers surface. histogram+quantiles carries a latency distribution a mean would hide; estimated/ci_* marks a value your hook derived from a control group. Names/label keys are charset-enforced, every string sanitized + length-bounded, every number finite (a prompt: ro hook cannot smuggle content into a scrape). Busbar BOUNDS everything (64 entries/reply, 8 labels/entry); a malformed entry is dropped whole, a malformed optional member individually, never the reply. Time series are the CONSUMER’s job in 1.3 (a dashboard samples status and accumulates); an engine-retained series member is the reserved additive path. Optional: reply {} and Busbar treats status as unsupported.
  • Reserved: the reply field name report is reserved on per-request replies for per-request hook data (attached to the completion-stage tap payload in a future release); do not use it for anything else.

Fail-safety, precisely (don’t over-generalize): describe and status are fully optional: a hook that ignores them keeps working. The socket configure preamble is NOT optional: a socket hook that never acks it has every connection rejected (each delivery then lands on the gate’s on_error), because a hook running settings it never acknowledged is running blind. The exact-echo ack RULE is one; the DEADLINE is the delivery’s own budget: the admin PATCH/management calls allow 5s, but a request-path (re)connect acks within the gate’s timeout_ms (default 1 ms; ack configure immediately and apply settings asynchronously if application is slow). Webhooks have no connection preamble (each PATCH push is its own POST). None of the management messages can delay or fail request traffic: they ride fresh connections, never the request-path connection.

On a connection where you only ever receive notify (a tap), never write anything. Busbar does not read tap replies, so even the polite {}-for-unknown-ops rule is scoped to reply-expected connections; Busbar will never send a reply-expected op on a tap connection.

Two kind: hook plugins ship signed by release CI and are auto-trusted by the embedded key:

Headroom (busbar-headroom-hook) is a kind: hook prompt-compression rewrite gate. It compresses context before dispatch, saving tokens and latency. Deploy it as a prompt: rw gate; it fires before dispatch on the normalized IR, token accounting runs on the rewritten body (the savings are real and measured), and a malformed or slow rewrite proceeds with the original body untouched. It reports chars_saved_total and related metrics via the status op.

Webrequest (busbar-webrequest-hook) is a kind: hook HTTP-forwarder plugin — the migration path for code you don’t want in Busbar’s address space. It forwards the routing projection over HTTPS to an operator-run sidecar, so you get out-of-process isolation (the sidecar can be any language) without running an untrusted library in-process. The artifact itself is signed and auto-trusted; forwarding is SSRF-guarded; and the sidecar’s reply rides the same op-discriminated JSON contract.

Both plugins are installed from the release tarball and enabled under plugins: in the normal way. See plugins.md for the artifact and trust model.

Hooks are also lifecycle-managed over the frozen admin API: register, inspect, health-check, and remove at runtime, with a tamper-evident audit trail, and (opt-in) persistence across restart. See the Admin API guide.


Hooks fire on the normalized IR, after the request is understood and before dispatch. That is what makes one hook work across every protocol and provider at once, and what makes Busbar the place your middleware runs.