A minimal, hackable agent loop for Claude that gives you full control over tool execution with pre/post hooks — in plain TypeScript.
Primary use case: When your org's managed settings disable Claude Code's custom hooks, this gives you the same power (validation, logging, caching, blocking) as a standalone process that isn't governed by those restrictions.
You type a prompt
│
▼
Claude decides which tool to call
│
▼
┌──────────────────────┐
│ dispatch(name, args)│
│ │
│ 1. Run preHooks[] │ ← log, validate, block, transform
│ 2. Run the tool │ ← your handler function
│ 3. Run postHooks[] │ ← log, cache, alert
│ │
└──────────────────────┘
│
▼
Result goes back to Claude → repeats until done
That's it. Every tool call goes through dispatch(). Hooks are just functions in an array.
git clone https://github.com/gstarr-cigna/agent-dispatch-loop.git
cd agent-dispatch-loop
npm installSet your API key:
export ANTHROPIC_API_KEY="sk-ant-..."Run it:
npx tsx index.ts "What time is it? Then add 5 and 7"You'll see:
Tool call: get_time
[pre-hook] about to call "get_time" with {}
[post-hook] "get_time" returned {"time":"2026-06-25T14:32:01.000Z"}
Tool call: add
[pre-hook] about to call "add" with {"a":5,"b":7}
[post-hook] "add" returned {"sum":12}
> The current time is 2:32 PM UTC, and 5 + 7 = 12.
Claude Code has hooks (PreToolUse, PostToolUse), but:
- They're shell commands only — no complex logic
- Managed settings can disable them entirely
- You can't share state between hook calls
This script is a separate Node.js process. It calls the Anthropic API directly. Your org's Claude Code settings have no control over it — same as any other program that hits an API.
import Anthropic from "@anthropic-ai/sdk";
// ── Define your tools ──────────────────────────────────────────────────────────
const tools = [
{
name: "echo",
description: "Echo the input back unchanged.",
parameters: { type: "object", properties: { message: { type: "string" } }, required: ["message"] },
handler: async ({ message }: any) => ({ echoed: message }),
},
{
name: "add",
description: "Add two numbers together.",
parameters: { type: "object", properties: { a: { type: "number" }, b: { type: "number" } }, required: ["a", "b"] },
handler: async ({ a, b }: any) => ({ sum: a + b }),
},
];
// ── Define your hooks ──────────────────────────────────────────────────────────
type Hook = (toolName: string, args: unknown, result?: unknown) => Promise<void>;
const preHooks: Hook[] = [
async (name, args) => console.log(` [pre-hook] about to call "${name}" with`, JSON.stringify(args)),
];
const postHooks: Hook[] = [
async (name, _args, result) => console.log(` [post-hook] "${name}" returned`, JSON.stringify(result)),
];
// ── The dispatcher (runs hooks around every tool call) ─────────────────────────
async function dispatch(toolName: string, args: unknown): Promise<unknown> {
const tool = tools.find((t) => t.name === toolName);
if (!tool) throw new Error(`Unknown tool: ${toolName}`);
for (const hook of preHooks) await hook(toolName, args);
const result = await tool.handler(args as any);
for (const hook of postHooks) await hook(toolName, args, result);
return result;
}
// ── The agent loop (Claude picks tools, we execute them) ───────────────────────
async function runAgent(prompt: string) {
const client = new Anthropic();
const toolSchemas = tools.map((t) => ({
name: t.name,
description: t.description,
input_schema: t.parameters,
}));
const messages: any[] = [{ role: "user", content: prompt }];
for (let turn = 0; turn < 10; turn++) {
const response = await client.messages.create({
model: "claude-sonnet-4-7-20251001",
max_tokens: 1024,
tools: toolSchemas,
messages,
});
// If Claude is done talking, print the final answer
if (response.stop_reason === "end_turn") {
const text = response.content.filter((c) => c.type === "text").map((c) => c.text).join("");
console.log("\n>", text);
return text;
}
// Otherwise, execute the tool calls Claude requested
const toolResults: any[] = [];
for (const block of response.content) {
if (block.type !== "tool_use") continue;
console.log(`\n Tool call: ${block.name}`);
let result: unknown;
let isError = false;
try {
result = await dispatch(block.name, block.input);
} catch (err) {
result = err instanceof Error ? err.message : String(err);
isError = true;
}
toolResults.push({ type: "tool_result", tool_use_id: block.id, content: JSON.stringify(result), is_error: isError });
}
// Send tool results back to Claude for the next turn
messages.push({ role: "assistant", content: response.content });
messages.push({ role: "user", content: toolResults });
}
throw new Error("Max turns reached");
}
// ── Run ────────────────────────────────────────────────────────────────────────
const prompt = process.argv.slice(2).join(" ") || "Use the echo tool to say hello";
runAgent(prompt).catch(console.error);Add an object to the tools array. Claude will see it and call it when relevant:
{
name: "read_file",
description: "Read a file from disk.",
parameters: {
type: "object",
properties: { path: { type: "string" } },
required: ["path"]
},
handler: async ({ path }: any) => {
const { readFile } = await import("fs/promises");
return { content: await readFile(path, "utf-8") };
},
},Push a function to preHooks or postHooks:
// Block dangerous commands
preHooks.push(async (name, args) => {
if (JSON.stringify(args).includes("rm -rf")) {
throw new Error("BLOCKED: dangerous command");
}
});
// Log every call to a file
postHooks.push(async (name, args, result) => {
const entry = { time: new Date().toISOString(), tool: name, args, result };
appendFileSync("/tmp/tool-log.jsonl", JSON.stringify(entry) + "\n");
});
// Rate limit
const counts = new Map<string, number>();
preHooks.push(async (name) => {
const n = (counts.get(name) ?? 0) + 1;
counts.set(name, n);
if (n > 20) throw new Error(`Rate limit hit for ${name}`);
});Run the included test script:
chmod +x verify-bypass.sh
./verify-bypass.shThis runs two tests:
- Claude Code hooks — shows they're blocked by managed settings
- This dispatch loop — shows hooks fire regardless
agent-dispatch-loop/
├── index.ts ← the entire agent (tools + hooks + dispatch + loop)
├── package.json ← dependencies (just @anthropic-ai/sdk + tsx)
├── USAGE.md ← detailed docs and advanced patterns
└── README.md ← you are here
- Node.js 18+
- An Anthropic API key
MIT