Skip to content
Night Owls.dev
Jump to a page

@nightowlsdev/hooks

Engine

The @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/hooks

Key exports

  • HookDispatcher / createHookDispatcher
  • defineHook
  • deny / ask / ALLOW / ALLOW_TOOL
  • DEFAULT_READ_ONLY_TOOLS
  • types: HookDecision, ToolDecision, SwarmHooks, PreGenerationHook, PreToolCallHook, GuardMutationHook, ToolApprovalPolicy

Usage

hooks.ts
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.

hooks-example-1.ts
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.

hooks-example-2.ts
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.

hooks-example-3.ts
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

  • coreRe-exports the whole hooks API and is where hooks are wired (SwarmConfig.hooks) and invoked.
  • approval-modesThe FR-044 tool risk vector + manual/auto/permissive modes this package's types back.
  • agent-config-surfaceguardDefinitionMutation gates who may publish/rollback an agent definition there.
  • runner-backgroundTurns a preToolCall ask into a durable suspend-and-ask across process death.
  • reactRenders the approval prompt an ask decision surfaces in the run's event stream.