Carabiner eliminates the protocol ceremony of writing Claude Code hooks. You write a typed function. Carabiner handles stdin, stdout, JSON formatting, exit codes, error routing, timeouts, and logging. The logic is yours. The plumbing is ours.
Zero external dependencies. Built on Bun.
I've been spending most of my days working alongside Claude Code. Hooks are the mechanism that let you set guardrails on what the agent can and can't do — block destructive commands, enforce quality gates before the agent stops, auto-approve safe operations, inject context at session start. The spec is genuinely powerful: 14 events, three decision patterns, prompt-based AI evaluation, agent-based deep inspection.
Almost none of it gets used.
Here's what writing a hook looks like today:
#!/bin/bash
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command')
if echo "$COMMAND" | grep -q 'rm -rf'; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Destructive command blocked"
}
}'
else
exit 0
fiThe logic — "block rm -rf" — is one line. The plumbing is the other fifteen. And every hook repeats the same plumbing: read JSON from stdin, parse it, run your logic, produce a different JSON shape to stdout (and you'd better remember which of the three output patterns your event type uses), manage exit codes, make sure errors go to stderr and not stdout (which would corrupt the JSON parser), and hope your shell profile doesn't print a welcome message that blows up the whole thing.
Here's the same hook in Carabiner:
import { hook, deny } from "@carabiner/core";
hook.preToolUse((input) => {
if (input.tool_input.command.includes("rm -rf")) {
return deny("Destructive command blocked");
}
});That's it. Same behavior. No ceremony.
Carabiner is not a hooks framework. It's a ceremony eliminator — a thin layer that does the protocol work once, correctly, and gets out of the way. The author writes policy as typed functions. Carabiner handles the translation between those functions and the spec's stdin/stdout/exit-code contract.
The API surface is deliberately small:
- Typed event constructors —
hook.preToolUse(),hook.stop(), etc. - Decision helpers —
deny(),allow(),block(),warn(),permission() - Actions —
action()for external invocations (shell, prompt, agent) - Composition —
checks()for sequential evaluation with short-circuit - Batteries — configurable factory functions for common patterns
That's the entire surface. Everything else is implementation detail that Carabiner handles silently.
Every hook event gets a typed method on the hook object. These aren't just syntactic sugar — they enforce which decisions are valid for which event at compile time.
// Tool lifecycle
hook.preToolUse((input) => { ... }) // before tool runs
hook.postToolUse((input) => { ... }) // after tool succeeds
hook.postToolUseFailure((input) => { ... }) // after tool fails
hook.permissionRequest((input) => { ... }) // intercepts permission dialog
// User input
hook.userPromptSubmit((input) => { ... }) // before prompt is sent
// Agent lifecycle
hook.stop((input) => { ... }) // agent wants to stop
hook.subagentStop((input) => { ... }) // subagent wants to stop
hook.teammateIdle((input) => { ... }) // teammate went idle
hook.taskCompleted((input) => { ... }) // task marked complete
hook.subagentStart((input) => { ... }) // subagent starting
// Session
hook.sessionStart((input) => { ... }) // session begins
hook.sessionEnd((input) => { ... }) // session ends
hook.preCompact((input) => { ... }) // before context compaction
hook.notification((input) => { ... }) // notification receivedTwo things matter here.
First, input is typed per event. When you write hook.preToolUse, the input includes tool_name, tool_input, and tool_use_id. When you write hook.stop, those fields don't exist — you get stop_hook_active instead. When you write hook.sessionStart, you get source and model. TypeScript tells you exactly what data is available for each event.
Second, the return type is constrained. If you try to return deny() from a hook.stop() handler, that's a compile-time error — Stop only supports block. If you try to return permission() from a hook.postToolUse() handler, that's also an error — the tool already ran. The type system prevents you from writing decisions the spec can't honor.
Every constructor does the same protocol work:
| Concern | How | Author touches it? |
|---|---|---|
| Read JSON from stdin | Bun.stdin.text() |
Never |
| Parse and validate input | Type narrowing per event | Never |
| Produce correct output JSON | Decision helpers → event-specific shapes | Never |
| Exit codes | 0 for success, 2 for blocking, managed automatically | Never |
| Timeouts | Race handler against Bun.sleep() |
Optional override |
| Error handling | Catch → stderr → exit 1 | Never |
| Structured logging | JSON to stderr with timing and trace ID | Never |
| Trace correlation | Bun.randomUUIDv7() per invocation |
Never |
The file IS the hook. Point your hooks config at bun run my-hook.ts and it works.
The spec adds new events periodically. Carabiner shouldn't block you from using them:
hook.on("SomeNewEvent", (input) => {
// input is loosely typed — you're on your own
// all decisions are available, no compile-time restriction
});Use hook.on() when the typed constructor doesn't exist yet. Migrate to the typed version when we ship one.
Decisions are what the hook returns to Claude Code. They're the hook's output — what should happen next. Five helpers, each producing the right JSON shape for whatever event you're handling.
Hard block. The operation is prevented. In PreToolUse, the tool call doesn't execute. In UserPromptSubmit, the prompt is rejected. The reason is shown to the agent.
hook.preToolUse((input) => {
if (input.tool_input.command.includes("rm -rf /")) {
return deny("Destructive command blocked by policy");
}
});Under the hood, for PreToolUse this produces:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Destructive command blocked by policy"
}
}For UserPromptSubmit, the same deny() call produces:
{
"decision": "block",
"reason": "Destructive command blocked by policy"
}Different events, different JSON shapes, same helper function. You never think about output formats.
Explicitly permit. In PreToolUse, the tool call proceeds without a permission prompt. In PermissionRequest, the permission dialog is auto-approved. When no decision is returned from a handler, the behavior is equivalent to allow — but calling it explicitly communicates intent and, in the case of PermissionRequest, can carry additional payload:
hook.permissionRequest((input) => {
if (isSafeCommand(input.tool_input.command)) {
return allow({
updatedInput: { command: sanitize(input.tool_input.command) },
applyRule: { type: "toolAlwaysAllow", tool: "Bash" },
});
}
});This maps directly to the spec's updatedInput and updatedPermissions fields. The user won't be prompted again for this tool.
Prevent the agent from stopping or completing. This is the Stop/SubagentStop/TeammateIdle/TaskCompleted decision — "you're not done yet." The reason is fed back to the agent as a signal to continue working.
hook.stop(async (input) => {
const tests = await action({ type: "command", command: "bun test --bail" });
if (tests.exitCode !== 0) return block("Tests are failing — fix them before stopping");
});Show a message but let the operation proceed. Non-blocking. The message appears as a system warning to the user. Good for nudges, reminders, and soft guidance without interrupting the agent's flow.
hook.postToolUse((input) => {
if (input.tool_name === "Write" && input.tool_input.file_path.endsWith(".env")) {
return warn("You just wrote to a .env file. Make sure it's in .gitignore.");
}
});Defer to the user. Triggers the permission prompt — the user sees the reason and decides whether to allow or deny. This is the "selectively permissive" pattern: hard-code the obvious blocks, let the human call the ambiguous ones.
hook.preToolUse((input) => {
const cmd = input.tool_input.command;
// Hard block — never allowed
if (cmd.includes("rm -rf /")) return deny("System-level delete blocked");
// Ask the human — sometimes necessary
if (cmd.includes("rm -rf")) return permission("Confirm recursive delete");
// Everything else — let it through
});permission() is a terminal decision, not an action. The hook doesn't run anything — it tells Claude Code to show the prompt. Under the hood it maps to { "permissionDecision": "ask" } in the spec.
Not a decision — an injection. Some events support adding information that the agent can see and act on.
hook.sessionStart((input) => {
return context("Current branch: main\nRecent commits: ...");
});
hook.userPromptSubmit((input) => {
return context("Remember: this project uses Bun, not Node");
});Not every decision is valid for every event. The type system enforces this, but here's the full matrix:
| Event | deny | allow | block | warn | permission |
|---|---|---|---|---|---|
| PreToolUse | Yes | Yes | - | Yes | Yes |
| PermissionRequest | Yes | Yes | - | Yes | - |
| PostToolUse | - | - | - | Yes | - |
| PostToolUseFailure | - | - | - | Yes | - |
| UserPromptSubmit | Yes | - | - | Yes | - |
| Stop | - | - | Yes | Yes | - |
| SubagentStop | - | - | Yes | Yes | - |
| TeammateIdle | - | - | Yes | - | - |
| TaskCompleted | - | - | Yes | - | - |
| SessionStart | - | - | - | Yes | - |
| SessionEnd | - | - | - | - | - |
| Notification | - | - | - | - | - |
| SubagentStart | - | - | - | - | - |
| PreCompact | - | - | - | - | - |
The last four are informational only — you can observe but not influence. SessionStart and UserPromptSubmit support context() injection.
Actions are what the hook does during execution — calling a model, running a shell command, delegating to an agent. Unlike decisions (which are returned to Claude Code), actions produce results that the handler evaluates before making its decision.
action(config: ActionConfig): Promise<ActionResult>Three types, one function.
const tests = await action({
type: "command",
command: "bun test --bail",
});
if (tests.exitCode !== 0) return block("Tests are failing");const review = await action({
type: "prompt",
prompt: "Is this bash command safe to run in a production environment?",
model: "haiku",
});
if (!review.ok) return deny(review.reason);const audit = await action({
type: "agent",
prompt: "Check if this change breaks any existing tests. Read the test files if needed.",
timeout: 120,
});
if (!audit.ok) return block(audit.reason);For logging, metrics, and other non-blocking side effects:
action({
type: "command",
command: "logger -t carabiner 'hook executed'",
async: true,
});The async: true flag means the hook doesn't wait for completion. The agent continues immediately.
When using prompt or agent actions, you can enforce structured JSON output with a Zod schema. No free-text parsing — the model returns typed data:
const review = await action({
type: "prompt",
prompt: "Evaluate this command for safety",
schema: z.object({
safe: z.boolean(),
reason: z.string(),
confidence: z.number().min(0).max(1),
}),
});
if (!review.data.safe) return deny(review.data.reason);
if (review.data.confidence < 0.7) return permission(review.data.reason);interface ActionConfig {
type: "command" | "prompt" | "agent";
command?: string; // for type: "command"
prompt?: string; // for type: "prompt" | "agent"
model?: string; // model override (default: fast/cheap)
timeout?: number; // seconds (default: 30 for prompt, 120 for agent)
schema?: ZodSchema; // structured output
async?: boolean; // fire-and-forget (default: false)
}
interface ActionResult {
ok: boolean; // from model evaluation
reason?: string; // from model evaluation
data?: unknown; // typed when schema provided
exitCode?: number; // from command execution
stdout?: string; // from command execution
stderr?: string; // from command execution
}Decisions are what the hook tells Claude Code. Actions are what the hook does while deciding. A handler might run several actions — call a model, run tests, check a file — and then make one decision based on the combined results.
hook.stop(async (input) => {
// Action: log execution (fire-and-forget)
action({ type: "command", command: "log.sh", async: true });
// Action: run tests
const tests = await action({ type: "command", command: "bun test --bail" });
if (tests.exitCode !== 0) return block("Tests are failing");
// Action: AI quality check
const quality = await action({
type: "agent",
prompt: "Is the work described in the session complete?",
timeout: 120,
});
if (!quality.ok) return block(quality.reason);
// Decision: all good, allow stop (implicit)
});The distinction matters because it keeps the mental model clean. You do things (actions) and then you decide something (decision). The handler is an async function with early returns — nothing more.
Run multiple check functions in sequence. Short-circuits on the first restrictive decision.
import { hook, checks } from "@carabiner/core";
import { bashBlocklist, pathBoundary, secretScanner } from "@carabiner/policies";
hook.preToolUse(checks(
bashBlocklist(),
pathBoundary(),
secretScanner(),
));A "check" is any function that takes event input and returns a decision (or void for allow). The batteries, your own functions, and inline lambdas are all checks. They compose uniformly.
Here's how checks() evaluates:
deny,block, orpermission→ stop, return that decisionwarn→ accumulate, continue to next checkallow→ continue (does NOT short-circuit — defense in depth)- void/undefined → continue to next check
- All checks pass → implicit allow (accumulated warnings are included)
The key design choice: allow() does not short-circuit. If the first check explicitly allows but the third check would deny, the deny wins. Every check gets to vote. This is defense in depth — the most restrictive decision wins.
The natural ordering: deterministic checks first, AI second, human last. You only pay for AI when the simple checks can't decide. You only interrupt the human when both code and AI are uncertain.
hook.preToolUse(checks(
// Tier 1: deterministic (free, instant)
bashBlocklist(),
pathBoundary(),
secretScanner(),
// Tier 2: AI evaluation (cheap, fast)
(input) => action({
type: "prompt",
prompt: `Is "${input.tool_input.command}" contextually appropriate?`,
}).then(r => r.ok ? undefined : deny(r.reason)),
// Tier 3: human judgment (interrupts flow)
(input) => {
if (isAmbiguous(input)) return permission("This looks unusual — confirm?");
},
));checks() is optional. For simple hooks or when you want full control over flow, write a plain async function with early returns:
hook.stop(async (input) => {
const tests = await action({ type: "command", command: "bun test --bail" });
if (tests.exitCode !== 0) return block("Tests are failing");
const lint = await action({ type: "command", command: "bun run lint" });
if (lint.exitCode !== 0) return block("Lint errors remain");
});No composition primitives. No framework abstractions. Just a function.
Batteries are configurable factory functions that return check functions. They encode the patterns everyone writes so you don't have to.
Matches bash commands against dangerous patterns. Ships with a curated default list covering rm -rf, chmod 777, mkfs, raw device writes, and more.
import { bashBlocklist } from "@carabiner/policies";
// Use defaults — curated dangerous command patterns
bashBlocklist()
// Override entirely
bashBlocklist(["rm\\s+-rf", "chmod\\s+777", "mkfs", "dd\\s+if="])
// Mixed decisions per pattern
bashBlocklist([
{ pattern: "mkfs", decision: "deny" }, // always block
{ pattern: "rm\\s+-rf", decision: "permission" }, // ask the user
{ pattern: "git push.*--force", decision: "permission" }, // ask the user
])Prevents file operations outside a defined boundary. Catches path traversal and workspace escapes.
import { pathBoundary } from "@carabiner/policies";
// Default: cwd
pathBoundary()
// Custom root
pathBoundary({ root: "/home/user/project" })
// Dynamic (future: Agent SDK per-user paths)
pathBoundary({ root: (input) => input.user?.workspace ?? process.cwd() })Scans file content for credential patterns. Ships with defaults for AWS access keys, generic password=/secret= assignments, private key headers, API tokens.
import { secretScanner } from "@carabiner/policies";
// Use defaults
secretScanner()
// Extend defaults with custom patterns
secretScanner({ additional: [/CUSTOM_TOKEN_[A-Z0-9]{32}/] })Blocks operations on sensitive file types using glob patterns.
import { fileTypeGuard } from "@carabiner/policies";
fileTypeGuard({ deny: ["*.env", "*.pem", "*.key", "*.p12"] })Runs a shell command via action(). If it exits non-zero, returns block with the reason. Designed for Stop hooks — quality gates that must pass before the agent stops:
import { shellCheck } from "@carabiner/policies";
hook.stop(checks(
shellCheck("bun test --bail", "Tests must pass"),
shellCheck("bun run lint", "Lint must be clean"),
shellCheck("bun run typecheck", "Type errors remain"),
));Three lines. Three quality gates. Deterministic, instant, free.
Test hooks without running Claude Code. Pure function calls, no stdin/stdout, no process spawning.
import { describe, test, expect } from "bun:test";
import { simulate, fixture } from "@carabiner/testing";
import myHook from "./my-hook";
test("blocks rm -rf /", async () => {
const result = await simulate(myHook, fixture.preToolUse({
tool_name: "Bash",
tool_input: { command: "rm -rf /" },
}));
expect(result.decision).toBe("deny");
expect(result.reason).toContain("Destructive");
});
test("asks permission for rm -rf in project dir", async () => {
const result = await simulate(myHook, fixture.preToolUse({
tool_name: "Bash",
tool_input: { command: "rm -rf ./dist" },
}));
expect(result.decision).toBe("permission");
});
test("allows safe commands", async () => {
const result = await simulate(myHook, fixture.preToolUse({
tool_name: "Bash",
tool_input: { command: "bun test" },
}));
expect(result.decision).toBeUndefined(); // implicit allow
});Feeds input to a hook handler, captures the decision. No stdin/stdout involved — it's a pure function call.
Generates a realistic event payload with sensible defaults — random session ID, realistic paths, current working directory, etc. Override specific fields as needed. The fixtures are typed to match the event constructors:
fixture.preToolUse({ tool_name: "Write", tool_input: { ... } })
fixture.stop({})
fixture.sessionStart({ source: "cli", model: "claude-sonnet-4-5-20250929" })
fixture.teammateIdle({ teammate_name: "researcher", team_name: "my-team" })Deterministic action response for testing hooks that use action():
const result = await simulate(
myHook,
fixture.stop({}),
{ action: mockAction({ ok: true }) }
);
// Or for structured output:
const result = await simulate(
myHook,
fixture.preToolUse({ ... }),
{ action: mockAction({
ok: false,
data: { safe: false, reason: "Risky command", confidence: 0.3 }
})}
);Test your AI-powered checks without making API calls. Deterministic, fast, free.
Five packages. Clear boundaries. Minimal dependency graph.
The engine. Contains typed event constructors (hook.preToolUse, hook.stop, etc.), all decision helpers (deny, allow, block, warn, permission), action(), checks(), context(), hook.on() escape hatch, and the provider interface.
Zero dependencies in package.json. Bun's built-in APIs cover everything:
| Need | Bun API |
|---|---|
| stdin/stdout | Bun.stdin, process.stdout |
| Shell execution | Bun.$ |
| File I/O | Bun.file(), Bun.write() |
| Glob matching | Bun.Glob |
| Hashing | Bun.hash() |
| YAML parsing | Bun.YAML |
| UUID generation | Bun.randomUUIDv7() |
| Timing | Bun.nanoseconds() |
| Sleep/timeout | Bun.sleep() |
No js-yaml, no uuid, no glob, no execa. The core package has no transitive dependency tree to audit.
Maps between Carabiner's decision model and the official Claude Code hooks spec. This is the translation layer:
- Event input shapes — stdin JSON → typed handler input
- Decision output shapes — decision helpers → event-specific JSON (hookSpecificOutput for PreToolUse/PermissionRequest, top-level decision for Stop/UserPromptSubmit, exit code for TeammateIdle/TaskCompleted)
- Exit code semantics — 0, 1, 2 mapping
- Matcher patterns — regex matching on tool name, session source, notification type
Ships as the default provider. Carabiner works with Claude Code out of the box.
The provider interface is intentionally thin. One interface, one implementation today. The seam exists so a future provider — Agent SDK, another runtime — can plug in without breaking the core API. But we're not building a provider registry or plugin system. That's premature abstraction we don't need.
The batteries. bashBlocklist, pathBoundary, secretScanner, fileTypeGuard, shellCheck. Configurable factory functions that return check functions.
Zero dependencies beyond @carabiner/core.
simulate(), typed fixture.*() factories, mockAction(). Test hooks as pure functions.
Built on bun:test. Zero additional dependencies.
Scaffolding and validation tools. Ships after the core stabilizes.
carabiner init— generate a starter hooks setup with sensible defaultscarabiner validate— check hook files for syntax/schema errorscarabiner doctor— summarize active hooks, check for conflicts
Here's a complete, production-grade PreToolUse hook that shows how the pieces fit together. One file, three tiers of safety:
// .claude/hooks/bash-safety.ts
import { hook, deny, permission, checks, action } from "@carabiner/core";
import { bashBlocklist, pathBoundary } from "@carabiner/policies";
import { z } from "zod";
hook.preToolUse(checks(
// Tier 1: Deterministic — free, instant
bashBlocklist([
{ pattern: "chmod\\s+777", decision: "deny" },
{ pattern: "mkfs", decision: "deny" },
{ pattern: "dd\\s+if=", decision: "deny" },
{ pattern: "rm\\s+-rf\\s+/", decision: "deny" },
{ pattern: "rm\\s+-rf", decision: "permission" },
{ pattern: "git push.*--force", decision: "permission" },
{ pattern: "git reset --hard", decision: "permission" },
]),
pathBoundary(),
// Tier 2: AI evaluation — only runs if deterministic checks pass
async (input) => {
if (input.tool_name !== "Bash") return; // skip non-bash
const review = await action({
type: "prompt",
prompt: `Evaluate this command for safety in a development context: ${input.tool_input.command}`,
model: "haiku",
schema: z.object({
safe: z.boolean(),
reason: z.string(),
confidence: z.number().min(0).max(1),
}),
});
if (!review.data.safe) return deny(review.data.reason);
if (review.data.confidence < 0.7) return permission(review.data.reason);
},
));And the Stop hook that gates quality:
// .claude/hooks/quality-gate.ts
import { hook, block, checks, action } from "@carabiner/core";
import { shellCheck } from "@carabiner/policies";
hook.stop(checks(
shellCheck("bun test --bail", "Tests must pass before stopping"),
shellCheck("bun run lint", "Lint must be clean before stopping"),
shellCheck("bun run typecheck", "Type errors must be resolved"),
// AI completeness check — only runs if all hard checks pass
async (input) => {
if (input.stop_hook_active) return; // avoid infinite loops
const review = await action({
type: "agent",
prompt: "Review the session transcript. Did the agent complete everything that was asked? Look for unfinished TODO items, partial implementations, or promises made but not delivered.",
timeout: 120,
});
if (!review.ok) return block(review.reason);
},
));Two files. Complete guardrails. Each one reads top to bottom.
The TypeScript core is the foundation — stable, typed, testable. But not everyone wants to write TypeScript for every hook. The roadmap includes a convenience layer on top of the core:
# .carabiner/bash-safety.yml
event: PreToolUse
match: Bash
deny:
- pattern: rm\s+-rf
reason: Destructive command
- pattern: chmod\s+777
reason: Overly permissive
permission:
- pattern: git push
reason: Confirm before pushing---
event: PreToolUse
match: Bash
---
# Bash Safety
## Hard blocks
Commands that are never allowed. No exceptions, no overrides.
```yaml hook:deny
- chmod\s+777
- mkfs
- dd\s+if=
```
## Ask first
Powerful commands that are sometimes necessary.
```yaml hook:permission
- rm\s+-rf
- git push.*--force
```The markdown format is where Carabiner's philosophy is most visible. The prose explains the intent. The code blocks define the behavior. The file is simultaneously documentation, configuration, and executable. Open it, read it, understand it. A new team member, a security reviewer, an auditor — anyone can read this file and know exactly what the guardrails are.
Both YAML and markdown compile down to typed event constructors + checks() calls. They're frontends to the same engine. The core doesn't know or care how the hook was authored. It runs typed functions and produces protocol-correct output.
The progression: start with YAML for quick rules. Graduate to markdown when you want documented policy. Drop to TypeScript when you outgrow declarative config. They all compose in the same pipeline.
Today, hooks are developer guardrails — the developer and the user are the same person. But when the Claude Agent SDK ships apps where end users interact with agents, hooks become a runtime authorization layer. Different users, different roles, different permissions.
Carabiner handles this without changes to the core:
- The provider determines context shape.
provider-claudesends event data. A futureprovider-agent-sdksends event data plus user/role/tenant context. The hook handlers don't change. - Policies are parameterizable.
pathBoundary({ root: process.cwd() })today becomespathBoundary({ root: (input) => input.user.workspace })tomorrow. Same policy, same shape, different configuration source. - The core doesn't know about users or roles. It moves decisions around. Domain knowledge lives in the provider and the policies.
We're not building RBAC. We're building the seams that make RBAC possible later.
The zero-dependency constraint isn't ideological — it's practical. Hooks run on every tool call. They need to be fast, predictable, and auditable. A transitive dependency tree of 47 packages is a liability in a security-critical path. Bun gives us everything we need natively. The constraint forces us to keep the core small and the surface area auditable.
A few design decisions still need resolution before implementation. The API shapes described above are stable regardless of how these resolve.
The spec defines prompt and agent hooks as separate handler types where Claude Code makes the model call. But Carabiner's action() runs inside a command handler — a Bun process. Three options:
- Carabiner makes the API call directly. Full control over ordering and short-circuiting. Requires API credentials. The most powerful but most opinionated option.
- Carabiner generates hooks.json entries. At config time, register separate prompt/agent hook entries that Claude Code runs. But this loses the deterministic-first-then-AI ordering guarantee.
- Hybrid. Deterministic checks as command hook. AI evaluation as parallel prompt hook. Most restrictive result wins.
Sequential evaluation via checks() makes this simple — first restrictive decision wins. But if checks ever run in parallel (hybrid model above), we need explicit resolution. Current recommendation: most restrictive wins. deny > permission > block > warn > allow.
- Malformed handler return → error, exit 1
- Handler throws → catch, stderr, exit 1
- Handler times out → kill, exit 1
action()model call fails → configurable: fail-open or fail-closedaction()command fails → result has non-zero exitCode; handler decides
Default: fail-closed for security hooks, fail-open for informational hooks.
Carabiner's value isn't the API. It's the thirty lines of protocol plumbing you never write again. The JSON shapes you never look up. The exit codes you never get wrong. The stderr/stdout separation you never debug.
The core is TypeScript all the way down — typed, testable, composable. The convenience layers (YAML, markdown) are frontends that compile to the same core. Everything is a function that takes input and returns a decision.
Agents will write most of these hooks. Humans will read them. Both need to find them clear.
That's the design constraint that shaped everything.