Claude Agent SDK
Claude Agent SDK human in the loop with canUseTool. Your user taps Approve or Deny on their phone before a tool runs, and no answer means the tool is denied.
The Claude Agent SDK asks your code before it runs a tool that needs permission. It calls canUseTool, and whatever you return decides. @pushary/claude-agent-sdk adds human in the loop to the Claude Agent SDK: it is a canUseTool that sends the request to your user's phone and returns their answer. The tool runs only if they tap Approve.
npm i @pushary/claude-agent-sdk @anthropic-ai/claude-agent-sdk
export PUSHARY_API_KEY=pk_xxx.xxxGet the key at Partner onboarding.
Set up Claude Agent SDK human in the loop
Connect a user once
import { connect } from '@pushary/claude-agent-sdk'
const { universalLink } = await connect({ apiKey: process.env.PUSHARY_API_KEY! }, user.id)Show universalLink to the signed-in user as a button or QR code. They open it on their phone once. They install the free Pushary app and never sign up or pay.
Pass pusharyCanUseTool to query
import { query } from '@anthropic-ai/claude-agent-sdk'
import { pusharyCanUseTool } from '@pushary/claude-agent-sdk'
for await (const message of query({
prompt: 'Refund order 1234',
options: { canUseTool: pusharyCanUseTool({ externalId: user.id }) },
})) {
if (message.type === 'result') console.log(message.subtype)
}Run it and answer on the phone
When Claude wants a tool that needs permission, your user's phone buzzes with the tool name and its input. Approve runs the tool with its input unchanged.
Tools you allow with allowedTools never reach canUseTool, so your user only sees the calls that need them.
What happens when nobody answers
The tool does not run. Each question waits up to timeoutMs (55 seconds by default). The model gets a denial it can read:
| Outcome | Message the model reads |
|---|---|
| Your user taps Deny | The approver denied this action. |
| Nobody answers in time | No answer from the approver, so this was denied. Do not retry the same action. |
| The run is cancelled while waiting | The run was cancelled before anyone answered. Do not retry the same action. |
| Pushary cannot be reached | Pushary could not reach a person to decide, so this was denied. Do not retry the same action. |
Cancelling the run stops the wait and denies at once.
Who answers
externalId is your own id for the user. Pass a string, or a function that picks the person for each call. The function also runs for AskUserQuestion:
pusharyCanUseTool({ externalId: (toolUse) => ownerOf(toolUse.toolName) })Never take the person from the tool input. The model writes the tool input, so a prompt injection could send the approval to someone else.
Clarifying questions from AskUserQuestion
When Claude calls AskUserQuestion, the same canUseTool puts each question on the person's phone as a choice between Claude's options, with each option's description. It asks one question at a time and returns their picks as the answers. Each question waits up to timeoutMs.
If a question goes unanswered, Pushary takes it off the phone, denies the call and tells Claude: Nobody answered the question in time. Do not assume an answer. If Pushary cannot take it back within five seconds, the reason goes to console.warn and the question expires on its own.
The phone takes one choice per question, so a multi-select question says "(choose one)" and comes back with the one option the person picked. A question too long for the phone is denied, and Claude is told to ask it in plain text instead. Your rules are not asked about questions, including a rule that names AskUserQuestion, because a question is not an action.
Site rules are off unless you turn them on
Claude's tools have the same names in your own Claude Code: Bash, Read, Write, WebFetch. A rule you wrote for your own Claude Code sessions would also answer your users' calls, so by default every gated call goes to a person.
Set policy: true to ask your site's rules first. A rule can then allow a call without paging anyone, or deny it outright. Only turn it on for a site whose rules you wrote for this agent. Rules need a server key from Settings > API keys. With the key from onboarding, policy: true has no effect and every gated call still goes to a person.
Options
| Option | What it does |
|---|---|
externalId | The person who answers. Required. |
question | Builds the approval text the person sees. Defaults to the tool name and its input. Not used for AskUserQuestion. |
sessionId | Groups one session's calls, so a replayed turn does not ask twice. |
timeoutMs | How long to wait for an answer before denying. Applies to each clarifying question in turn. |
expiresInSeconds | How long the question stays answerable. |
requireReachable | Deny at once when the person has no connected phone. |
policy | Ask your site's rules before a person. Defaults to false. |
agentName | Shown on the approval so the person knows which agent is asking. |
apiKey | Defaults to PUSHARY_API_KEY. |
Source: pushary-claude-agent-sdk on GitHub. Anthropic reference: permissions in the Agent SDK.
Next steps
Try a simulated decision
Approve or deny a refund in your browser before you connect a phone.
See the Claude Agent SDK approval workflow
The whole flow for Claude Agent SDK agents, from connect to answer.
Test your integration
Use a sandbox key to check approve, deny and no answer without a phone.