Tool Confirmation and Custom Tools
Giving agents tools is giving them the ability to take actions in the world — permission policies and custom tool design are how you control exactly what actions they can take.
Every tool you give an agent is a capability to take action in the world. Web search can retrieve information — safe. Bash can run arbitrary commands — powerful. A custom database tool can modify production records — consequential. The tool is the mechanism; the permission policy is the safety gate; the schema is the contract.
These are not independent concerns. An agent with broad tools and loose permission policies in a production environment is not an autonomous agent — it is a liability waiting to materialize.
Permission Policies
Permission policies control whether the agent can call a tool automatically or whether confirmation is required. There are exactly two policies — always_allow and always_ask — and they are expressed as permission_policy objects on the per-tool configs inside agent_toolset (and mcp_toolset) entries:
agent = client.beta.agents.create(
name="data-pipeline-agent",
model="claude-sonnet-4-6",
system="...",
tools=[
{
"type": "agent_toolset_20260401",
"default_config": {"enabled": False},
"configs": {
"read": {
"enabled": True,
"permission_policy": {"type": "always_allow"}
},
"bash": {
"enabled": True,
"permission_policy": {"type": "always_ask"}
}
}
}
]
)
always_allow — the agent can call this tool at any time without confirmation. Use for read-only tools, low-risk actions, and any tool where the cost of a wrong call is low and reversible.
always_ask — each call requires explicit confirmation from a human or a confirmation handler before it executes. Use for destructive actions, external communications, financial operations, and anything where the cost of a wrong call is high or irreversible.
There is no deny policy. If a tool should never execute, do not enable it — disable it in configs (or leave it off via default_config). Blocking-by-policy is not part of the model; scoping is.
The Confirmation Handler
When a tool has always_ask, the pending call surfaces as an agent.tool_use event whose evaluated_permission is "ask", and the session goes idle with stop_reason: requires_action until you answer. The answer is a user.tool_confirmation event sent back into the session:
with client.beta.sessions.events.stream(session_id=session.id) as stream:
for event in stream:
if event.type == "agent.tool_use" and event.evaluated_permission == "ask":
print(f"Agent wants to call: {event.tool_name}")
print(f"With input: {event.tool_input}")
# Your approval logic here
should_approve = review_tool_call(event.tool_name, event.tool_input)
if should_approve:
client.beta.sessions.events.send(
session_id=session.id,
event={
"type": "user.tool_confirmation",
"tool_use_id": event.tool_use_id,
"result": "allow"
}
)
else:
client.beta.sessions.events.send(
session_id=session.id,
event={
"type": "user.tool_confirmation",
"tool_use_id": event.tool_use_id,
"result": "deny",
"deny_message": "Request does not meet approval criteria"
}
)
The agent receives the confirmation result and handles it appropriately — either proceeding with the tool result or adapting its approach given the denial. The deny_message is how you steer: it lands in the agent's context as the reason the call was refused.
Confirmations in Unattended Pipelines
There is a structural tension to design around: Managed Agents is sold as fire-and-forget, but an always_ask tool requires a connected consumer to answer. If a 3am unattended session hits an always_ask call and nothing is attached, the session does not fail and does not proceed — it goes idle with stop_reason: requires_action and waits. A pipeline that only checks for completed sessions will see it as silently stuck.
Three ways to resolve the tension, in order of preference:
- Design unattended agents so
always_asknever fires. Scope tools so the unattended path only needsalways_allowoperations — narrow tools over guarded tools. - Run an automated confirmation worker. A small always-on consumer that streams sessions (or polls for
requires_action), applies your approval policy in code, and answers withuser.tool_confirmation. The "human" in the loop becomes a policy engine with an audit log. - Treat
requires_actionas an alert. If neither of the above fits, your monitoring must page someone when a session sits inrequires_action— otherwise the pause becomes an outage.
What you must not do is ship an unattended pipeline with always_ask tools and no plan for who answers.
Routing Tool Results in Multi-Agent Sessions
In multi-agent sessions, tool calls happen in specific threads. The agent.tool_use event includes a session_thread_id that identifies which thread's tool call is waiting for confirmation. Route confirmations to the correct thread:
if event.type == "agent.tool_use" and event.evaluated_permission == "ask":
thread_id = event.session_thread_id
print(f"Tool confirmation needed in thread: {thread_id}")
# Route to appropriate approval workflow for this thread
Custom Tools
The built-in toolset (bash, the file tools, web_search) covers common use cases. For domain-specific capabilities — querying an internal database, calling a proprietary API, accessing a custom data source — custom tools extend the agent's action space.
A custom tool is declared with {"type": "custom"} and uses the same JSON Schema format as standard Messages API tools:
custom_database_tool = {
"type": "custom",
"name": "query_customer_database",
"description": "Query the customer database for account information. Use this when you need customer records, account status, or transaction history. Returns structured JSON with customer data.",
"input_schema": {
"type": "object",
"properties": {
"customer_id": {
"type": "string",
"description": "The unique customer identifier (format: CUS-XXXXXXXX)"
},
"fields": {
"type": "array",
"items": {"type": "string"},
"description": "List of fields to return. Options: name, email, account_status, recent_transactions, subscription_tier",
"default": ["name", "email", "account_status"]
}
},
"required": ["customer_id"]
}
}
agent = client.beta.agents.create(
name="customer-service-agent",
model="claude-sonnet-4-6",
system="...",
tools=[
custom_database_tool,
{
"type": "agent_toolset_20260401",
"default_config": {"enabled": False},
"configs": {"bash": {"enabled": True}}
}
]
)
Handling Custom Tool Calls
Custom tools execute in your code, not in the sandbox. When the agent calls one, an agent.custom_tool_use event is emitted with the tool name and input, and the session waits. Your consumer executes the tool and returns the result as a user.custom_tool_result event:
def handle_tool_call(tool_name: str, tool_input: dict) -> str:
if tool_name == "query_customer_database":
customer_id = tool_input["customer_id"]
fields = tool_input.get("fields", ["name", "email", "account_status"])
# Execute against your actual database
result = database.query_customer(customer_id, fields)
return json.dumps(result)
raise ValueError(f"Unknown tool: {tool_name}")
with client.beta.sessions.events.stream(session_id=session.id) as stream:
for event in stream:
if event.type == "agent.custom_tool_use" and event.tool_name == "query_customer_database":
result = handle_tool_call(event.tool_name, event.tool_input)
# Return the result to the session
client.beta.sessions.events.send(
session_id=session.id,
event={
"type": "user.custom_tool_result",
"tool_use_id": event.tool_use_id,
"content": result
}
)
Because execution happens client-side, a custom tool has the same unattended-pipeline constraint as an always_ask confirmation: a session that calls a custom tool with no consumer attached waits in requires_action. Custom tools in unattended pipelines require an always-on worker to execute them.
Writing Effective Tool Descriptions
The tool description and input schema are what the agent reads to understand how to use the tool. Write them for the agent, not for a human reading the code:
Description: Explain what the tool does, when to use it (and when not to), and what it returns. "Query the customer database" is insufficient. "Query the customer database for account information. Use this when you need customer records, account status, or transaction history. Returns structured JSON with customer data." gives the agent the context to use the tool correctly.
Parameter descriptions: Every parameter needs a description that explains what it is, what format it expects, and what valid values look like. "description": "The customer ID" is insufficient. "description": "The unique customer identifier (format: CUS-XXXXXXXX)" prevents malformed calls.
Required vs optional: Mark only genuinely required parameters as required. Optional parameters with good defaults reduce the cognitive load on the agent.