A programmable policy engine for Claude Code. Ctrl-Loop intercepts agent lifecycle events, evaluates them against TypeScript rules, and returns decisions that control what the agent is allowed to do.
Claude Code → hook event (stdin) → Ctrl-Loop adapter → engine evaluates rules → decision (stdout) → Claude Code
Rules are plain TypeScript functions — they can call APIs, read files, inspect session history, or query fact providers. There is no DSL or config schema to learn.
bun installRequires Bun 1.2+.
Claude Code fires hook events at key points in the agent lifecycle. Ctrl-Loop runs as the hook handler: it reads the event from stdin, evaluates your rules, and writes a hookSpecificOutput JSON decision to stdout.
Supported events
| Event | When |
|---|---|
SessionStart |
New session begins |
UserPromptSubmit |
User submits a prompt |
PreToolUse |
Before a tool call executes |
PostToolUse |
After a tool call succeeds |
PostToolUseFailure |
After a tool call fails |
Stop |
Agent signals completion |
SubagentStart / SubagentStop |
Subagent lifecycle |
TaskCreated / TaskCompleted |
Task lifecycle |
Decisions a rule can return
| Decision | Effect |
|---|---|
allow |
Action proceeds |
deny |
Action blocked outright |
ask |
Pause and ask the user to approve |
defer |
Defer to external approval |
block |
Halt the agent (Stop events only) |
modifyInput |
Rewrite tool parameters before execution |
context |
Inject guidance text without blocking |
noop / undefined |
No opinion; pass through |
When multiple rules disagree, the engine resolves by precedence:
deny > ask > defer > block > modifyInput > context > allow > noop
The rule() builder is the preferred way to write rules. It handles event scoping, tool filtering, mode gating, path matching, and async predicates declaratively, then calls your handler only when all conditions match.
import { rule } from "./src/index.js";
const denyDangerousCommands = rule("deny-dangerous-commands")
.on("PreToolUse")
.tool("Bash")
.when(({ event }) => /rm\s+-rf/.test(String(event.tool?.input.command ?? "")))
.then(({ deny }) => deny("Destructive rm command is blocked."));Builder methods
| Method | Effect |
|---|---|
.on(...events) |
Scope to specific event kinds |
.tool(...names) |
Match only named tools |
.mode(...modes) |
Match only when session is in these SDLC modes |
.paths(...globs) |
Match file_path / path tool input against glob patterns |
.excludePaths(...globs) |
Skip when path matches any of these globs |
.when(predicate) |
Arbitrary sync/async boolean gate |
.timeout(ms) |
Abort handler after ms milliseconds |
.onTimeout(policy) |
"noop" | "deny" | "allow" on timeout |
.then(handler) |
Compile to RuleDefinition (required, terminates the chain) |
The fluent handler receives a context object with all decision helpers pre-bound:
rule("example")
.on("PreToolUse")
.then(({ event, session, facts, deny, ask, allow, context, modifyInput, noop }) => {
// return deny("reason") | ask("reason") | allow() | context("msg") | modifyInput({...}) | noop() | undefined
});You can also construct a RuleDefinition directly when you need full control:
import type { RuleDefinition } from "./src/index.js";
const denyDangerousCommands: RuleDefinition = {
id: "deny-dangerous-commands",
events: ["PreToolUse"],
handler: (event, session, facts) => {
const command = event.tool?.input.command;
if (typeof command === "string" && /rm\s+-rf/.test(command)) {
return { kind: "deny", reason: "rm -rf is not allowed" };
}
return undefined;
},
};type RuleHandler = (
event: NormalizedEvent, // normalized event with typed fields
session: SessionState, // mutable state persisted across this session
facts: FactAccessor, // named fact providers (async, cached)
) => Promise<Decision | undefined> | Decision | undefined;event.kind // "PreToolUse" | "PostToolUse" | "Stop" | ...
event.sessionId // session identifier
event.tool?.name // "Bash" | "Write" | "Edit" | ...
event.tool?.input // typed tool parameters
event.tool?.response // tool output (PostToolUse only)
event.tool?.phase // "pre" | "post" | "postFailure"
event.lastAssistantMessage // last message the agent sent (Stop events)
event.isSubagent // true when event originates from a subagent
event.raw // original unmodified Claude hook payloadSession state is mutable and persists for the lifetime of one session.
session.codeEdited // true if any source file was written this session
session.editedFiles // string[] of modified file paths
session.testsRan // true if a test command ran
session.testsPassed // true if the last test run passed
session.activeMode // current SDLC mode (see below)
session.modeLocked // true if mode switching is locked
session.subagentHistory // SubagentEntry[]
session.taskHistory // TaskEntry[]
session.extra // Record<string, unknown> for rule-managed stateReturn context to inject guidance without blocking:
return {
kind: "context",
contextLines: [
"You are in implementation mode.",
"Write a failing test before editing the source.",
],
};Return modifyInput to rewrite tool parameters before execution:
return {
kind: "modifyInput",
updatedInput: { ...event.tool!.input, timeout: 30_000 },
};Rules can be async and can query named fact providers:
import type { FactProviderRegistration, RuleDefinition } from "./src/index.js";
const gitBranchFact: FactProviderRegistration = {
name: "currentBranch",
scope: "session", // "event" re-evaluates each event; "session" caches
provider: () => execSync("git rev-parse --abbrev-ref HEAD", { encoding: "utf8" }).trim(),
};
const blockForcePushOnMain: RuleDefinition = {
id: "block-force-push-on-main",
events: ["PreToolUse"],
handler: async (event, _session, facts) => {
const command = event.tool?.input.command;
if (typeof command !== "string" || !/git push.*--force/.test(command)) return undefined;
const branch = await facts.get("currentBranch");
if (branch === "main") {
return { kind: "deny", reason: "Force-pushing to main is not allowed." };
}
return undefined;
},
};The adapter reads one Claude Code hook event from stdin and writes hookSpecificOutput JSON to stdout.
# pass a real hook payload
echo '{"hook_event_name":"PreToolUse","session_id":"abc","tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' \
| bun .ctrl-loop/index.ts
# exit codes
# 0 — allow / deny / ask / defer / noop (all structured decisions)
# 2 — policy block (Stop event blocked by a block decision)
# 1 — runtime error (invalid JSON, engine crash)Use runAdapter to wire your rules into the adapter:
import { runAdapter } from "./src/index.js";
import { myRule, anotherRule } from "./rules.js";
await runAdapter([myRule, anotherRule], {
stateDir: ".ctrl-loop/sessions", // where session state is persisted
});Add hooks to .claude/settings.json for each event type you want to intercept:
{
"hooks": {
"PreToolUse": [
{
"matcher": "*",
"hooks": [
{ "type": "command", "command": "CTRL_LOOP_LOG=log bun ./.ctrl-loop/index.ts" }
]
}
],
"Stop": [
{
"matcher": "*",
"hooks": [
{ "type": "command", "command": "CTRL_LOOP_LOG=log bun ./.ctrl-loop/index.ts" }
]
}
]
}
}Ctrl-Loop supports seven SDLC modes that scope what the agent is allowed to do. Only one mode is active at a time. The agent switches modes via /ctrl-loop mode <name>.
| Mode | What it allows | What it blocks |
|---|---|---|
planning |
Reads, git inspection | All writes and mutations |
design |
BDR/ADR/spec writes | Source file edits |
implementation |
Source edits (+ TDD guidance) | — |
testing |
Test runs, source edits | Test file edits without approval |
verification |
Diagnostics, git inspection | All writes |
review |
Reads, diagnostics | All writes, git mutations |
release |
Build, test | Publish without passing tests; dirty worktree |
/ctrl-loop mode planning → switch to planning mode
/ctrl-loop mode implementation → switch to implementation mode
/ctrl-loop mode lock → prevent further mode switches
/ctrl-loop mode unlock → re-enable mode switching
Active mode is stored in session.activeMode and available to all rule handlers.
Control logging via the CTRL_LOOP_LOG environment variable:
| Value | Output | File |
|---|---|---|
off |
Silent | — |
info |
One line per event | .ctrl-loop/debug.log |
debug |
Event + session state + raw payload | .ctrl-loop/debug.log |
log |
Structured NDJSON record per event | .ctrl-loop/events.ndjson |
CTRL_LOOP_LOG=log bun .ctrl-loop/index.ts
# query the structured log
jq 'select(.tool == "Bash")' .ctrl-loop/events.ndjson
jq 'select(.session.codeEdited)' .ctrl-loop/events.ndjsonRules are pure functions. Test them by passing normalized events and a session object directly to Engine.process:
import { describe, expect, test } from "bun:test";
import { Engine, createEmptySession } from "./src/index.js";
import { denyDangerousCommands } from "./rules.js";
const engine = new Engine({ rules: [denyDangerousCommands] });
describe("denyDangerousCommands", () => {
test("denies rm -rf", async () => {
const session = createEmptySession("test-session");
const event = {
kind: "PreToolUse",
sessionId: "test-session",
tool: { name: "Bash", input: { command: "rm -rf /tmp" }, response: undefined, phase: "pre" },
lastAssistantMessage: undefined,
isSubagent: false,
agentId: undefined,
agentType: undefined,
agentTranscriptPath: undefined,
raw: {},
};
const decision = await engine.process(event, session);
expect(decision.kind).toBe("deny");
});
});Use real captured payloads as fixtures for integration tests:
import fixture from "./test/fixtures/pre-tool-use.json";
const event = normalizeEvent(fixture);
const decision = await engine.process(event, createEmptySession(event.sessionId));bun test
bun test --coveragesrc/
index.ts public API
engine.ts rule evaluation and precedence resolution
adapter.ts stdin → engine → stdout wiring
normalizer.ts Claude hook JSON → NormalizedEvent
rule.ts fluent rule builder and matchesGlob
session.ts SessionState factories and file/memory stores
precedence.ts conflict resolution
types.ts all exported types
.ctrl-loop/
index.ts adapter entry point (your rules live here)
rules/ rule modules by feature area
sessions/ persisted session state (auto-created)
events.ndjson structured event log (CTRL_LOOP_LOG=log)
debug.log text event log (CTRL_LOOP_LOG=info|debug)
test/
fixtures/ captured Claude hook payloads
integration/ fixture-based engine tests
docs/
SPEC.md full behavioral specification
adr/ architecture decision records
bdr/ behavior decision records (one per feature)
specs/ layered stack specs
bun test # run all tests
bun run typecheck # TypeScript type check
bun run lint # Biome lint
bun run format # Biome format (auto-fix)All changes must pass typecheck and lint before commit. Commit messages follow Conventional Commits. Tests use the red-green-refactor TDD cycle — write a failing test first.
- Specification — full behavioral spec (46 BDRs)
- BDRs — one behavior decision record per feature
- ADRs — architecture and toolchain decisions
- Examples — annotated rule examples covering all decision types