# Claude Code and the Claude Agent SDK

Source: https://immiscible.fly.dev/docs/sdks/integrations/claude-code

# Claude Code and the Claude Agent SDK

Claude Code runs a command before each matching tool call and lets it allow, refuse or escalate the call. Immiscible ships that command: a PreToolUse hook that sends the call to `POST /v1/actions/authorize` as a `tool.call` action and turns the decision into Claude Code's answer.

| Immiscible says | Claude Code does |
|---|---|
| `allow` | carries on, subject to its own permission settings |
| `deny` | refuses the call; the model sees the reasons |
| `approval_required` | asks you, with the reasons and the approval link |

It fails closed: no key, no answer within the timeout, or an error, and the call is refused. If you need to work offline, remove the hook; do not teach it to allow on error.

The hook is run by Claude Code, not the model, so the model cannot skip it. Provenance comes from the session, not the model's own account: if the transcript shows a web fetch, a web search or an MCP tool result, the request says so. It also sends Claude Code's `session_id`, so when the session's model traffic goes through the gateway (`ANTHROPIC_BASE_URL`), the gate compares the hook's account with what the gateway saw enter the context.

## Install

Two ways to get the same file. The source of truth is [`scripts/claude-code-hook.mjs`](https://github.com/efr7-7/immiscible/blob/main/scripts/claude-code-hook.mjs); the package carries a byte-for-byte copy (its test fails if they drift). Either one reads `IMMISCIBLE_*`, or the older `ASSAY_*`.

**From npm** (`@immiscible/claude-code-hook`):

```shell
npm install -g @immiscible/claude-code-hook
immiscible-claude-code-hook --print-config   # a PreToolUse entry with the absolute path
```

`npx immiscible init` installs the same hook into a project for you, after showing the change to `.claude/settings.json`.

**From your Immiscible server**:

```shell
mkdir -p ~/.immiscible
curl -s "$IMMISCIBLE_URL/downloads/claude-code-hook.mjs" -o ~/.immiscible/claude-code-hook.mjs
```

Then the environment, in your shell profile (the key is a secret; keep it out of settings files you commit):

```shell
export IMMISCIBLE_URL=https://immiscible.fly.dev
export IMMISCIBLE_AGENT_KEY=ask_...
export IMMISCIBLE_TIMEOUT_MS=10000                 # optional
```

## The PreToolUse configuration

`~/.claude/settings.json` for every project, or `.claude/settings.json` for one:

```json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash|Write|Edit|MultiEdit|NotebookEdit|WebFetch|mcp__(?!immiscible__(check_action_status|explain_decision|spend_summary|find_waste|unwatched_keys)$).*",
        "hooks": [{ "type": "command", "command": "node /path/to/immiscible/packages/immiscible-claude-code/bin/immiscible-claude-code-hook.mjs || exit 2", "timeout": 60 }]
      }
    ]
  },
  "env": {
    "ANTHROPIC_BASE_URL": "https://immiscible.fly.dev/anthropic"
  }
}
```

`--print-config` prints this entry with the real path filled in. With the downloaded file, the command is `node ~/.immiscible/claude-code-hook.mjs || exit 2`. Keep the `|| exit 2`: Claude Code blocks a call only when a hook exits with code 2, so without it a missing file or a crash would let the call through. The `matcher` decides which tools are checked: the list above covers everything that changes files, runs commands, reaches the network or calls an MCP tool. Leave out `Read` and `Glob` unless you want every read on the record. `ANTHROPIC_BASE_URL` sends the session's model traffic through the gateway, which is what makes the provenance check observed rather than declared; give Claude Code an Immiscible key for it (`ANTHROPIC_API_KEY`, or `apiKeyHelper`).

A ready file: [`packages/immiscible-claude-code/examples/settings.json`](https://github.com/efr7-7/immiscible/blob/main/packages/immiscible-claude-code/examples/settings.json).

The agent needs a mandate first: **Agents**, **Agent limits**, **Add a rule**, then the **Coding agent** template (`tool.call`, with the domains it may reach, such as `github.com` and `registry.npmjs.org`).

What goes ahead without a person, under Coding agent and under General tasks (the rule `npx immiscible init` creates):

- Read-only calls (`ls`, `git status`, `git diff`, reading a file), at every standing.
- Once the agent is past its intern stage, edits, test runs and builds inside its project (`localWrites: "allow-after-intern"`, signed into the rule and shown on the agent's page). An intern asks before every write. To have a person approve every edit and test run, replace the rule with one that sets `localWrites: "ask"`.

"Inside its project" is read from the project the hook sends, Claude Code's `CLAUDE_PROJECT_DIR` or else the working directory, and from the paths in the call: a path outside it, through `~`, `..` or a variable, asks. These ask at every standing and under every rule: destructive commands (`rm`, `git push --force`, `git reset --hard`, `git clean -fdx`, history rewrites, recursive `chmod` or `chown`, `dd`, `mkfs`), any `git push`, deploys (`fly deploy`, `vercel`, `kubectl apply`), publishes (`npm publish`, `twine upload`), package installs from the network (`npm install`, `pip install`, `npx`, `curl ... | sh`), anything run with `sudo`, and changes to `.claude/settings.json`, the hook itself, `.mcp.json` or git's hooks. A secrets file or the environment leaving the machine is refused.

### How many tool calls before a person is asked

A coding agent makes hundreds of tool calls an hour, so tool calls (`tool.call`, `tool.*` and `mcp.*`) are counted on their own: by default **200 in 10 minutes**, after which the next call asks a person (`velocity`). They never use up the separate line for payments and other actions, which is 10 in 10 minutes. A workspace owner or admin raises either line in the workspace settings:

```shell
curl -X PUT "$IMMISCIBLE_URL/api/w/$IMMISCIBLE_WORKSPACE/settings" \
  -H "cookie: __Host-sid=$IMMISCIBLE_SESSION" -H "x-immiscible-csrf: 1" -H "content-type: application/json" \
  -d '{ "agentVelocity": { "toolVelocityCount": 500 } }'
```

`velocityCount` (payments and other actions) and `velocityMinutes` (the window, for both) go in the same object; `GET /api/w/:wid/settings` shows the lines in force. On a local http server the cookie is called `sid`; the server accepts either name there.

## The Claude Agent SDK

The Agent SDK runs the same hooks. Load them from settings (`settingSources: ['project']` in TypeScript, `setting_sources=["project"]` in Python) and the `.claude/settings.json` above applies unchanged. To register the hook in code instead, run the script as the SDK's `PreToolUse` callback would:

```ts
import { query } from '@anthropic-ai/claude-agent-sdk';
import { spawn } from 'node:child_process';

const immiscibleHook = (input) => new Promise((resolve) => {
  const p = spawn('node', ['packages/immiscible-claude-code/bin/immiscible-claude-code-hook.mjs'], { stdio: ['pipe', 'pipe', 'inherit'] });
  let out = '';
  p.stdout.on('data', (c) => { out += c; });
  p.on('close', () => resolve(JSON.parse(out)));     // { hookSpecificOutput: { permissionDecision, permissionDecisionReason } }
  p.stdin.end(JSON.stringify(input));
});

for await (const m of query({
  prompt: 'Tidy the README and push a branch',
  options: { hooks: { PreToolUse: [{ matcher: 'Bash|Write|Edit|MultiEdit|NotebookEdit|WebFetch|mcp__(?!immiscible__(check_action_status|explain_decision|spend_summary|find_waste|unwatched_keys)$).*', hooks: [immiscibleHook] }] } },
})) { /* ... */ }
```

For your own tools inside an Agent SDK agent (a purchasing tool, a deploy step), gate the consequential step with the SDK, so the receipt and the settlement are recorded too:

```ts
import { Immiscible, toolAction } from '@immiscible/sdk';
const immiscible = new Immiscible().run({ client: 'claude-code' });
await immiscible.guard(toolAction('deploy', { service: 'api', env: 'production' }, { domain: 'mycompany.com' }), deploy);
```

## Hook and MCP together

The hook is the guarantee: Claude Code runs it whatever the model decides. The [MCP server](https://immiscible.fly.dev/docs/sdks/integrations/mcp.md) is the conversation: the model calls it when it decides to ask, and gets a receipt it can hand a merchant. Use both:

```shell
claude mcp add --transport http immiscible "$IMMISCIBLE_URL/mcp" --header "Authorization: Bearer $IMMISCIBLE_AGENT_KEY"
```

## Check it works

```shell
cd packages/immiscible-claude-code && npm test
```

The test runs the packaged hook against the fake server and checks an allowed `WebFetch` to `github.com`, a refused `curl` to a blocked domain, an `ask` for a deploy, and refusal when the key is missing or the server is down.
