Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Agent Dispatch Loop

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.

How it works

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.

Quick start

git clone https://github.com/gstarr-cigna/agent-dispatch-loop.git
cd agent-dispatch-loop
npm install

Set 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.

Why this exists

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.

The code (< 100 lines)

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);

Adding a tool

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") };
  },
},

Adding a hook

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}`);
});

Verifying it bypasses managed settings

Run the included test script:

chmod +x verify-bypass.sh
./verify-bypass.sh

This runs two tests:

  1. Claude Code hooks — shows they're blocked by managed settings
  2. This dispatch loop — shows hooks fire regardless

Project structure

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

Requirements

  • Node.js 18+
  • An Anthropic API key

License

MIT

About

Minimal agent loop with pre/post tool-call hooks. Bypasses Claude Code managed settings that disable custom hooks.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages