@nightowlsdev/hooks
EngineThe @mastra-free decision-hook substrate: the plain types and dispatcher the platform's billing, approval, and mutation gates hang off.
What it does
@nightowlsdev/hooks is the deliberately tiny, @mastra-free substrate that carries Night Owls' decision-hook contracts AND the runtime that applies them, kept engine-vendor-free by design so @nightowlsdev/core can depend on it without leaking engine types into core's public API. It defines the declarative decision shapes (HookDecision allow/deny, the three-way ToolDecision allow/deny/ask) and the SwarmHooks bundle of optional decision hooks: preGeneration (per-model-launch gate, e.g. billing reserve), preToolCall (action-approval gate honoring a non-removable ToolApprovalPolicy of flag vs all-side-effecting), and guardDefinitionMutation (who may publish/rollback an agent definition). It ships real runtime code too, not just types: the HookDispatcher / createHookDispatcher that applies the bundle uniformly and fail-closed (a hook that throws becomes a deny, so safety/billing vetoes never silently allow on error), the defineHook identity helper, the decision constructors deny()/ask(), and the canonical constants ALLOW / ALLOW_TOOL / DEFAULT_READ_ONLY_TOOLS. Hosts normally configure these via SwarmConfig.hooks; the values and types are re-exported through @nightowlsdev/core so most consumers never import this package directly.
Install
pnpm add @nightowlsdev/hooksKey exports
- HookDispatcher / createHookDispatcher
- defineHook
- deny / ask / ALLOW / ALLOW_TOOL
- DEFAULT_READ_ONLY_TOOLS
- types: HookDecision, ToolDecision, SwarmHooks, PreGenerationHook, PreToolCallHook, GuardMutationHook, ToolApprovalPolicy
Usage
import { defineHook, deny, ask, ALLOW } from "@nightowlsdev/hooks";
import type { SwarmHooks } from "@nightowlsdev/hooks";
const hooks: SwarmHooks = {
// Gate every side-effecting tool behind a human decision.
preToolCall: defineHook(async ({ tool }) =>
tool.sideEffecting ? ask("Approve this action?") : ALLOW,
),
// Veto a model launch when out of budget.
preGeneration: defineHook(async ({ budgetRemaining }) =>
budgetRemaining > 0 ? ALLOW : deny("out of budget"),
),
};What it provides
hooks is the deliberately tiny, @mastra-free substrate that carries Night Owls' decision-hook contracts AND the runtime that applies them. It defines the declarative decision shapes (HookDecision allow/deny, the three-way ToolDecision allow/deny/ask) and the SwarmHooks bundle of decision hooks — preGeneration (billing reserve before a model launch), preToolCall (action-approval HITL on a side-effecting tool), and guardDefinitionMutation (who may publish/rollback an agent definition) — plus the fail-closed HookDispatcher, the defineHook helper, the deny()/ask() constructors, and the ALLOW / ALLOW_TOOL / DEFAULT_READ_ONLY_TOOLS constants. It also ships the FR-044 tool risk vector + danger level + approval modes and the FR-060 hostMayWiden escape hatch. Being engine-vendor-free is the whole point: core can depend on it without leaking engine types into a host's public .d.ts.
When to use it
- You are wiring the platform's cross-cutting gates: a billing reserve before generation, human approval on a side-effecting tool, or an admin-only definition publish.
- You need a non-removable ToolApprovalPolicy floor that forces approval on tools regardless of the per-tool needsApproval flag an author shipped (an untrusted consumer pack).
- You are annotating tools with a risk vector (ToolRisk) and letting a deployment pick manual / auto / permissive approval modes.
- You are building something that must stay engine-vendor-free and needs the decision contracts without pulling in all of core.
When not to
- You are a normal host wiring hooks on a swarm — you do not import this directly; set SwarmConfig.hooks and use the symbols re-exported through @nightowlsdev/core.
- You expected the hooks to run a loop themselves — this is contracts + a dispatcher; the engine (core) is what invokes them at the right seams.
- You want observer hooks (onGeneration, telemetry taps) — the bundle is decision hooks only today.
Alternatives
- Import the same symbols from @nightowlsdev/coreYou already depend on core (most hosts). core re-exports the whole hooks API, so you wire everything from one import; reach for @nightowlsdev/hooks directly only from a package that must stay core-free / @mastra-free.
- The per-tool needsApproval flag on defineToolA per-tool default is enough and you need no non-removable floor or risk-based modes. hooks' ToolApprovalPolicy exists precisely because a per-tool flag cannot force-ask a tool an author shipped as needsApproval:false.
- Custom middleware in your routeYou want gating outside the engine loop. You give up the fail-closed uniformity, the durable suspend-and-ask, and the engine-integrated event stream that decision hooks get for free.
Strengths
- Dependency-free and @mastra-free — the keystone of the engine wall: core depends on it as a normal dependency without pulling vendor types into host .d.ts files.
- Fail-closed by construction: a decision hook that THROWS becomes deny, so a billing or safety veto never silently allows on error.
- Three-way ToolDecision (allow/deny/ask) with a durable suspend-and-ask, not just a boolean block.
- A non-removable ToolApprovalPolicy floor — a consumer pack can force-ask every side-effecting tool beyond what an author flagged, and the per-tool flag cannot weaken it.
- The FR-044 danger matrix is total and pure (an unannotated tool is critical — unknown means unsafe), and the operator-selectable modes only ever NARROW when composed.
- FR-060 hostMayWiden lets a deployment that DID annotate its tools relax a baseline ask to allow — while ENFORCE rules still win last.
Limits & trade-offs
- It is a substrate, not a feature: the value only shows when the engine invokes it, so you rarely import it directly.
- The danger matrix deliberately deviates from FR-044's own matrix for reads (documented in §2.1) — you must read the rules, not guess them.
- Composition order matters and is easy to misjudge: hostMayWiden overrides only the resolved BASELINE, not an enforce deny/ask, and enforce rules fold above the host.
- Observer hooks (onGeneration, etc.) are not shipped yet — the bundle is decision hooks only.
How it works
The package defines the decision shapes and a HookDispatcher that applies a SwarmHooks bundle uniformly. defineSwarm builds one internally (createHookDispatcher) from SwarmConfig.hooks, the ToolApprovalPolicy, and the layer-0 baseline. The engine awaits each decision hook at its seam: preGeneration before a model launch (a deny vetoes the generation — no model call), preToolCall before a tool's side effect (an ask suspends-and-asks the human through the durable loop), and guardDefinitionMutation before a definition publish/rollback. Every hook is fail-closed — a throw resolves to deny. The FR-044 layer computes a DangerLevel from a tool's ToolRisk vector via toolDanger, and the approval-mode resolver folds a deployment ApprovalModeConfig with an optional per-call config most-restrictive-wins; FR-060's hostMayWiden (on the ToolApprovalPolicy) lets a host preToolCall own the baseline decision so it can widen as well as tighten, while code enforce-level rules still win last.
Examples
Author a decision-hook bundle
defineSwarm calls createHookDispatcher internally; you just author the SwarmHooks.
import { defineHook, deny, ask, ALLOW } from "@nightowlsdev/hooks";
import type { SwarmHooks } from "@nightowlsdev/hooks";
const hooks: SwarmHooks = defineHook({
// Billing reserve: veto a model launch when the tenant is out of credits.
async preGeneration(ev) {
return (await hasCredits(ev.tenantId, ev.modelId)) ? ALLOW : deny("Out of credits.");
},
// Action-approval HITL: suspend an expensive tool for a human decision.
async preToolCall(ev) {
return ev.toolName === "send_email" ? ask("Approve before sending?") : ALLOW;
},
// Definition-mutation gate: agents may never publish a definition.
async guardDefinitionMutation(ev) {
return ev.actor.type === "agent" ? deny("Agents may not mutate definitions.") : ALLOW;
},
});
// Wire it on a swarm: defineSwarm({ agents, hooks });The risk vector + a non-removable approval floor
An unannotated tool is critical (unknown = unsafe); a consumer pack forces approval on every side-effecting tool.
import { toolDanger, type ToolApprovalPolicy } from "@nightowlsdev/hooks";
toolDanger({}); // 3 — unannotated ⇒ critical (unknown = unsafe)
toolDanger({ mutating: false, egress: false }); // 0 — a pure, non-exfiltrating read
toolDanger({ mutating: true, reversible: true, destructive: false, egress: false, scope: "single", live: false }); // 1
// Force-ask EVERY side-effecting tool regardless of its author's needsApproval flag.
const policy: ToolApprovalPolicy = { mode: "all-side-effecting" };
// defineSwarm passes this through the engine; the per-tool flag cannot weaken it.FR-060 — let the host WIDEN the baseline
Off by default (narrow-only). Opt in so an annotated deployment can auto-run its safe tools.
import { ALLOW_TOOL, type ToolApprovalPolicy } from "@nightowlsdev/hooks";
// hostMayWiden makes the host preToolCall OWN the tool decision — it may relax a baseline ask
// to allow, as well as tighten. Fail-safe by default (unset = the shipped narrow-only behaviour).
const policy: ToolApprovalPolicy = { mode: "flag", hostMayWiden: true };
const preToolCall = async (ev) =>
ev.danger !== undefined && ev.danger <= 1 ? ALLOW_TOOL : ask("Approve?");
// ⚠ ENFORCE tool rules still win LAST — a widening host can never escape an enforce deny/ask.Doing the parts it doesn't support
- Actually running the hookshooks defines the contracts + dispatcher; the engine (@nightowlsdev/core) invokes them. Set SwarmConfig.hooks and defineSwarm builds the dispatcher (createHookDispatcher) for you — you do not call it yourself in normal use.
- Observer hooks (onGeneration, telemetry taps)Not shipped yet — the bundle is decision hooks only. They land later as additive optional fields on SwarmHooks; for now, emit telemetry from the run's SwarmEvent stream instead.
- Persisting an approval / the 'Needs you' queuepreToolCall returns ask; the durable suspend/resume and the queue row are the runner's and host's job (runner-background + a StorageAdapter). This package only produces the decision.
Related
- core — Re-exports the whole hooks API and is where hooks are wired (SwarmConfig.hooks) and invoked.
- approval-modes — The FR-044 tool risk vector + manual/auto/permissive modes this package's types back.
- agent-config-surface — guardDefinitionMutation gates who may publish/rollback an agent definition there.
- runner-background — Turns a preToolCall ask into a durable suspend-and-ask across process death.
- react — Renders the approval prompt an ask decision surfaces in the run's event stream.