Approvals
By default, your agent can run any tool in a session as soon as it decides to. You can configure the session to require the user's approval before any tool runs, such as tools that send, post or delete things on their behalf.
When your agent calls a tool that requires approval, Composio doesn't run it. It returns an elicitation that asks the user to approve the call, and runs the tool once it receives their answer.
Experimental
Approvals are experimental. They need composio newer than 0.27.0 or @composio/core newer than 0.24.0.
Choosing which tools need approval
You choose which tools need approval with their tags when you create a session. For example, this session asks the user before any tool that creates, updates or deletes something:
session = composio.create(
user_id="user_123",
# Everywhere: ask before tools that create, update or delete
tags={"require_approval": ["createHint", "updateHint", "destructiveHint"]},
)To handle a toolkit differently, override the session-wide tags in tools.<toolkit>.tags. This session asks before the same tools everywhere, except GitHub, where it only asks before tools that delete:
session = composio.create(
user_id="user_123",
# Everywhere: ask before tools that create, update or delete
tags={"require_approval": ["createHint", "updateHint", "destructiveHint"]},
tools={
# GitHub: ask only before tools that delete
"github": {"tags": {"require_approval": ["destructiveHint"]}},
},
)The override replaces the session-wide tags for that toolkit, including any tags you block, so list everything you still want for it.
Asking the user
When a tool needs approval, session.execute() raises an error:
from composio.exceptions import ToolInputRequiredError
arguments = {"recipient_email": "priya@acme.com", "subject": "Q3 numbers"}
try:
session.execute("GMAIL_SEND_EMAIL", arguments=arguments)
except ToolInputRequiredError as error:
questions = error.input_requests
request_state = error.request_stateThe error's input_requests holds the questions to ask the user, keyed by ID. Each question has a message describing the tool call, and a requested_schema describing the answer the user can give:
{
"elicit_0f2f8872eea523ed": {
"type": "elicitation",
"mode": "form",
"message": "Your agent wants to run the following tool:\n\nGMAIL_SEND_EMAIL({\"recipient_email\": \"priya@acme.com\", \"subject\": \"Q3 numbers\"})",
"requested_schema": {
"type": "object",
"properties": {
"decision": {
"type": "string",
"title": "Decision",
"oneOf": [
{ "const": "approve", "title": "Approve" },
{ "const": "always_approve", "title": "Always approve" },
{ "const": "deny", "title": "Deny" }
]
}
},
"required": ["decision"]
}
}
}Show the user the message and a form built from requested_schema. Build the form from the schema, since the choices vary between questions.
One tool execution can return several questions, for example when the agent runs several tools at once with COMPOSIO_MULTI_EXECUTE_TOOL. Answer all of them when you send the call again.
The questions follow the format of MCP elicitation requests, so you can reuse an existing elicitation UI.
Sending the answer
Send the same call again with the user's answers:
result = session.execute(
"GMAIL_SEND_EMAIL",
arguments=arguments,
input_responses={
"elicit_0f2f8872eea523ed": {
"action": "accept",
"content": {"decision": "approve"},
}
},
request_state=request_state,
)When the user picks Always approve, their approval lasts for the rest of the session. Later calls to that tool on the same connected account run without asking again.
Approvals in MCP clients
Approvals use MCP elicitation, which lets a server ask the user for input through the client. When your agent connects to the session over MCP, clients that support elicitation, like Claude Code, ask the user for you. With clients that don't support elicitation, calls that need approval are denied. To run them without approval instead, set no_elicitation_support_fallback to "allow":
session = composio.create(
user_id="user_123",
tools={
"gmail": {
"enable": ["GMAIL_FETCH_EMAILS", "GMAIL_SEND_EMAIL"],
"require_approval": ["GMAIL_SEND_EMAIL"],
}
},
experimental={"no_elicitation_support_fallback": "allow"},
)Current limitations
- Workbench and bash scripts. Composio uses a model to find the tools a
COMPOSIO_REMOTE_WORKBENCHorCOMPOSIO_REMOTE_BASH_TOOLscript calls, so it can miss some. If approval must never be skipped, disable the sandbox. - Proxy execute.
session.proxy_execute()is currently disabled for a toolkit when any of its tools need approval. - Tags. Rules based on tags are only as accurate as the tags, which Composio assigns on a best-effort basis. List tools explicitly for anything you must gate.
- Providers. The SDK's
handle_tool_callsand framework providers don't handle approval yet. Run tool calls withsession.execute(), as in the full example below.
Full example
An agent loop with Claude that asks for approval in the terminal:
import json
import anthropic
from composio import Composio
from composio.exceptions import ToolInputRequiredError
from composio_anthropic import AnthropicProvider
composio = Composio(provider=AnthropicProvider())
client = anthropic.Anthropic()
session = composio.create(
user_id="user_123",
toolkits={"enable": ["gmail"]},
tools={
"gmail": {
# The agent can fetch and send emails
"enable": ["GMAIL_FETCH_EMAILS", "GMAIL_SEND_EMAIL"],
# but needs the user's approval to send one
"require_approval": ["GMAIL_SEND_EMAIL"],
}
},
)
tools = session.tools()
def ask_user(question):
"""Show one question in the terminal and return the user's answer."""
# Describes the tool call, like which email the agent wants to send
print(question["message"])
# Ask for each field in requested_schema, like the decision to approve or deny
content = {}
for name, field in question["requested_schema"]["properties"].items():
choices = [choice["const"] for choice in field.get("oneOf", [])]
content[name] = input(f"{field.get('title', name)} ({'/'.join(choices)}): ")
return {"action": "accept", "content": content}
def run_tool(block):
"""Run one tool call, asking the user whenever Composio needs their input."""
# No answers yet on the first try
input_responses, request_state = None, None
while True:
try:
result = session.execute(
block.name,
arguments=block.input,
input_responses=input_responses,
request_state=request_state,
)
break
except ToolInputRequiredError as error:
# The tool needs approval: ask the user every question, keyed by its ID
input_responses = {
question_id: ask_user(question)
for question_id, question in error.input_requests.items()
}
# Send this back with the answers when you retry the call
request_state = error.request_state
# Return the result to Claude
return {
"type": "tool_result",
"tool_use_id": block.id,
"content": json.dumps({"data": result.data, "error": result.error}),
"is_error": result.result_type == "failed",
}
messages = [
{"role": "user", "content": "Email priya@acme.com with the subject 'Q3 numbers' and say the report is ready."}
]
response = client.messages.create(
model="claude-opus-4-6", max_tokens=4096, tools=tools, messages=messages
)
# Run the tools Claude calls until it has a final answer
while response.stop_reason == "tool_use":
messages.append({"role": "assistant", "content": response.content})
messages.append({
"role": "user",
"content": [run_tool(block) for block in response.content if block.type == "tool_use"],
})
response = client.messages.create(
model="claude-opus-4-6", max_tokens=4096, tools=tools, messages=messages
)
for block in response.content:
if block.type == "text":
print(block.text)Next
Configuring sessions
Enable toolkits and tools, filter by tags, and update a session's configuration.