Adding commercial guardrails to Claude Agent SDK agents
You add commercial guardrails to Claude Agent SDK agents with a PreToolUse hook: before a matched tool runs, the hook posts the tool input to EvalLayer's /authorize endpoint and translates the decision into the hook's own vocabulary, allow passes the call, rewrite passes it with updated input, require_approval returns an ask, and block returns a deny with the reason. The mapping is unusually clean because the SDK's hook output was designed for exactly these four verbs.
Why Agent SDK deployments create commercial risk
The Claude Agent SDK is Claude Code's harness packaged as a library: the full agent loop with built-in filesystem, Bash, and web tools, plus subagents, sessions, and MCP for everything else. Teams use it to build agents that do real work unattended, and the moment one of those agents gets an email tool, a CRM connector, or a payment-capable MCP server, its output stops being text and starts being commitments. An autonomous agent that drafts and sends in the same loop has no gap between a generous impulse and a delivered promise unless you install one.
The SDK's control story is genuinely strong on access: permission modes, allow and deny rules, hooks at multiple lifecycle points, and a canUseTool callback for interactive approval. All of it governs which tools may run. None of it evaluates what the tool call contains, whether the discount inside the email body is under the cap, whether the reply promises a delivery date, whether the required disclaimer is present. That content-level judgment is the layer you attach, and the PreToolUse hook is the attachment point the SDK provides for it.
The interception point: a PreToolUse hook
PreToolUse hooks are in-process callbacks that fire before a matched tool executes. The hook receives the tool name and input, and its output can permit the call, deny it with a reason, escalate to an ask, and even supply updated input, which is precisely the rewrite path. Field names below follow the documented hook output shape; verify against the SDK's hooks reference for your version.
async function commercialGuardrail(input) {
if (input.tool_name !== 'send_email') return {};
const res = await fetch('https://api.evallayer.ai/authorize', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + process.env.EVALLAYER_KEY,
'Content-Type': 'application/json'
},
body: JSON.stringify({
action_type: 'send_email',
content: input.tool_input.body,
context: { recipient: input.tool_input.to },
policy: {
max_discount_pct: 10,
commitment_authority_usd: 1000,
forbidden_commitments: ['delivery_date', 'refund_promise'],
approved_claims: ['soc2_certified'],
required_disclaimers: ['pricing_subject_to_change'],
blocked_topics: []
}
})
});
const result = await res.json();
const out = { hookEventName: 'PreToolUse' };
if (result.decision === 'allow') {
out.permissionDecision = 'allow';
} else if (result.decision === 'rewrite') {
out.permissionDecision = 'allow';
out.updatedInput = Object.assign({}, input.tool_input, {
body: result.approved_rewrite
});
} else if (result.decision === 'require_approval') {
out.permissionDecision = 'ask';
out.permissionDecisionReason =
'Exceeds commercial authority: ' + result.violations[0].detail;
} else {
out.permissionDecision = 'deny';
out.permissionDecisionReason =
'Blocked by policy. Ref ' + result.authorization_id;
}
return { hookSpecificOutput: out };
}
// In your query options:
// hooks: { PreToolUse: [{ matcher: 'send_email|send_quote|issue_refund',
// hooks: [commercialGuardrail] }] }
The four decisions in hook vocabulary
- allow maps to permission decision allow: the tool runs with its original input.
- rewrite maps to allow plus
updatedInput: the tool runs, but the outbound body is theapproved_rewrite. The agent's flow never breaks, and the customer receives the compliant version. - require_approval maps to ask: the SDK surfaces the call for a human decision through canUseTool or your UI, with the violation detail as the reason. Your reviewer sees why it was escalated, not just that it was.
- block maps to deny: the tool never runs, the model sees the reason and can change course, and the
authorization_idpreserves the attempt.
Latency and matcher scope
Scope the matcher to side-effect tools; Read, Grep, and Bash used for local work should not pay an authorization round trip (gate Bash separately if it can reach the network in ways that send). Deterministic-only policies return in milliseconds, semantic checks in one to two seconds, negligible inside an agent loop where each model turn already costs more. Decide the failure mode in the hook: on timeout, deny for refunds and high-value sends, allow-and-log for low-stakes actions if you prefer availability.
What to log
Put the authorization_id in the permission decision reason (as above) so it lands in session history, and mirror it with the session ID to your own store. The result is an audit trail the SDK cannot produce alone: every consequential tool call carries the policy applied, the violations found, the decision, and the exact text that went out after any rewrite.
Try the mapping
Wire the hook to /demo/authorize (three free checks per day, no key) with a sandbox send tool, or preview decisions in the interactive demo. The decision semantics live in the authorization guide, and policy design covers what belongs in the policy object before you scale the agent's authority.