SDKs
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; 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):
npm install -g @immiscible/claude-code-hook
immiscible-claude-code-hook --print-config # a PreToolUse entry with the absolute pathnpx immiscible init installs the same hook into a project for you, after showing the change to .claude/settings.json.
From your Immiscible server:
mkdir -p ~/.immiscible
curl -s "$IMMISCIBLE_URL/downloads/claude-code-hook.mjs" -o ~/.immiscible/claude-code-hook.mjsThen the environment, in your shell profile (the key is a secret; keep it out of settings files you commit):
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:
{
"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.
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 setslocalWrites: "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:
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:
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:
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 is the conversation: the model calls it when it decides to ask, and gets a receipt it can hand a merchant. Use both:
claude mcp add --transport http immiscible "$IMMISCIBLE_URL/mcp" --header "Authorization: Bearer $IMMISCIBLE_AGENT_KEY"#Check it works
cd packages/immiscible-claude-code && npm testThe 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.