Setup Arcade with OpenAI Agents SDK
The OpenAI Agents SDK is a popular Python library for building AI . It is built on top of the OpenAI API, and provides a simple interface for building agents.
Outcomes
Learn how to integrate Arcade tools using OpenAI Agents primitives. You will implement a CLI agent that can user Arcade tools to help the user with their requests. Tools that require authorization will be handled automatically by the , so don’t need to worry about it.
You will Learn
- How to retrieve Arcade tools and transform them into OpenAI
- How to build an OpenAI Agents
- How to integrate Arcade tools into the OpenAI flow
- How to implement “just in time” (JIT) authorization using Arcade’s client
Prerequisites
The agent architecture you will build in this guide
The OpenAI SDK provides an Agent class that implements a . It provides a simple interface for you to define the system prompt, the model, the , and possible sub-agents that can be used with handoffs. In this guide, you will manually keep track of the agent’s history and state, and use the run method to invoke the agent in an agentic loop.
Integrate Arcade tools into an OpenAI Agents agent
Create a new project
Create a new directory for your and initialize a new virtual environment:
mkdir openai-agents-arcade-example
cd openai-agents-arcade-example
uv venv
source .venv/bin/activateInstall the necessary packages:
uv pip install openai-agents arcadepyCreate a new file called .env and add the following environment variables:
# Arcade API key
ARCADE_API_KEY=YOUR_ARCADE_API_KEY
# Arcade user ID (this is the email address you used to login to Arcade)
ARCADE_USER_ID={arcade_user_id}
# OpenAI API key
OPENAI_API_KEY=YOUR_OPENAI_API_KEYImport the necessary packages
Create a new file called main.py and add the following code:
from agents import Agent, Runner, TResponseInputItem
from agents.run_context import RunContextWrapper
from agents.tool import FunctionTool
from agents.exceptions import AgentsException
from arcadepy import AsyncArcade
from arcadepy.types.execute_tool_response import ExecuteToolResponse
from dotenv import load_dotenv
from functools import partial
from typing import Any
import os
import asyncio
import jsonThis is quite a number of imports, let’s break them down:
- Arcade imports:
AsyncArcade: The , used to interact with the .ExecuteToolResponse: The response type for the execute response.
- OpenAI imports:
Agent: The OpenAI Agents , used to define an agent.Runner: The OpenAI Agents runner, used to run the in an agentic loop.TResponseInputItem: The response input item type, determines the type of message in the conversation history.RunContextWrapper: Wraps the run , providing information such as the user ID, the tool name, tool arguments, and other contextual information different parts of the may need.FunctionTool: OpenAI definition format.AgentsException: The OpenAI exception, used to handle errors in the agentic loop.
- Other imports:
load_dotenv: Loads the environment variables from the.envfile.functools.partial: Partially applies a function to a given set of arguments.typing.Any: A type hint for the any type.os: The operating system module, used to interact with the operating system.asyncio: The asynchronous I/O module, used to interact with the asynchronous I/O.json: The JSON module, used to interact with JSON data.
Configure the agent
These variables are used in the rest of the code to customize the and manage the . Feel free to configure them to your liking.
# Load environment variables
load_dotenv()
# The Arcade User ID identifies who is authorizing each service.
ARCADE_USER_ID = os.getenv("ARCADE_USER_ID")
# This determines which MCP server is providing the tools, you can customize this to make a Notion agent. All tools from the MCP servers defined in the array will be used.
MCP_SERVERS = ["Slack"]
# This determines individual tools. Useful to pick specific tools when you don't need all of them.
TOOLS = ["Gmail_ListEmails", "Gmail_SendEmail", "Gmail_WhoAmI"]
# This determines the maximum number of tool definitions Arcade will return per MCP server
TOOL_LIMIT = 30
# This prompt defines the behavior of the agent.
SYSTEM_PROMPT = "You are a helpful assistant that can assist with Gmail and Slack."
# This determines which LLM model will be used inside the agent
MODEL = "gpt-4o-mini"Write a custom error and utility functions to help with tool calls
Here, you define ToolError to handle errors from the Arcade . It wraps the AgentsException and provides an informative error message that can be handled in the agentic loop in case anything goes wrong.
You also define convert_output_to_json to convert the output of the Arcade tools to a JSON string. This is useful because the output of the Arcade tools is not always a JSON object, and the OpenAI SDK expects a JSON string.
# Arcade to OpenAI agent exception classes
class ToolError(AgentsException):
def __init__(self, result: ExecuteToolResponse):
self.result = result
@property
def message(self):
return self.result.output.error.message
def __str__(self):
return f"Tool {self.result.tool_name} failed with error: {self.message}"
def convert_output_to_json(output: Any) -> str:
if isinstance(output, dict) or isinstance(output, list):
return json.dumps(output)
else:
return str(output)Write a helper function to authorize Arcade tools
This helper function is how you implement “just in time” (JIT) tool authorization using Arcade’s client. When the tries to execute a that requires authorization, the result object’s status will be "pending", and you can use the authorize method to get an authorization URL. You then wait for the to complete the authorization and retry the tool call. If the user has already authorized the tool, the status will be "completed", and the OAuth dance is skipped silently, which improves the user experience.
This function captures the authorization flow outside of the agent’s context, which is a good practice for security and context engineering. By handling everything in the , you remove the risk of the LLM replacing the authorization URL or leaking it, and you keep the free from any authorization-related traces, which reduces the risk of hallucinations.
async def authorize_tool(client: AsyncArcade, context: RunContextWrapper, tool_name: str):
if not context.context.get("user_id"):
raise ToolError("No user ID and authorization required for tool")
result = await client.tools.authorize(
tool_name=tool_name,
user_id=context.context.get("user_id"),
)
if result.status != "completed":
print(f"{tool_name} requires authorization to run, please open the following URL to authorize: {result.url}")
await client.auth.wait_for_completion(result)Write a helper function to execute Arcade tools
This helper function is how the OpenAI framework invokes the Arcade tools. It handles the authorization flow, and then calls the using the execute method. It handles the conversion of the arguments from JSON to a dictionary (expected by Arcade) and the conversion of the output from the Arcade tool to a JSON string (expected by the OpenAI Agents framework). Here is where you call the helper functions defined earlier to authorize the tool and convert the output to a JSON string.
async def invoke_arcade_tool(
context: RunContextWrapper,
tool_args: str,
tool_name: str,
client: AsyncArcade,
):
args = json.loads(tool_args)
await authorize_tool(client, context, tool_name)
print(f"Invoking tool {tool_name} with args: {args}")
result = await client.tools.execute(
tool_name=tool_name,
input=args,
user_id=context.context.get("user_id"),
)
if not result.success:
raise ToolError(result)
print(f"Tool {tool_name} called successfully, {MODEL} will now process the result...")
return convert_output_to_json(result.output.value)Retrieve Arcade tools and transform them into LangChain tools
Here you get the Arcade tools you want the agent to use, and transform them into OpenAI Agents tools. The first step is to initialize the , and get the tools you want to use. Since OpenAI is itself an inference provider, the provides a convenient endpoint to get the tools in the OpenAI format, which is also the format expected by the OpenAI framework.
This helper function is fairly long, here’s a breakdown of what it does for clarity:
- retrieve tools from all configured servers (defined in the
MCP_SERVERSvariable) - retrieve individual (defined in the
TOOLSvariable) - get the Arcade to OpenAI-formatted tools
- create a list of FunctionTool objects, mapping each tool to a partial function that invokes the tool via the .
async def get_arcade_tools(
client: AsyncArcade | None = None,
tools: list[str] | None = None,
mcp_servers: list[str] | None = None,
) -> list[FunctionTool]:
if not client:
client = AsyncArcade()
# if no tools or MCP servers are provided, raise an error
if not tools and not mcp_servers:
raise ValueError(
"No tools or MCP servers provided to retrieve tool definitions")
# Use the Arcade Client to get OpenAI-formatted tool definitions
tool_formats = []
# Retrieve individual tools if specified
if tools:
# OpenAI-formatted tool definition
tasks = [client.tools.formatted.get(name=tool_id, format="openai")
for tool_id in tools]
responses = await asyncio.gather(*tasks)
for response in responses:
tool_formats.append(response)
# Retrieve tools from specified toolkits
if mcp_servers:
# Create a task for each toolkit to fetche the formatted tool definition concurrently.
tasks = [client.tools.formatted.list(toolkit=tk, format="openai")
for tk in mcp_servers]
responses = await asyncio.gather(*tasks)
# Combine the tool definitions from each response.
for response in responses:
# Here we assume the returned response has an "items" attribute
# containing a list of ToolDefinition objects.
tool_formats.extend(response.items)
# Create a list of FunctionTool objects, mapping each tool to a partial function that invokes the tool via the Arcade client.
tool_functions = []
for tool in tool_formats:
tool_name = tool["function"]["name"]
tool_description = tool["function"]["description"]
tool_params = tool["function"]["parameters"]
tool_function = FunctionTool(
name=tool_name,
description=tool_description,
params_json_schema=tool_params,
on_invoke_tool=partial(
invoke_arcade_tool,
tool_name=tool_name,
client=client,
),
strict_json_schema=False,
)
tool_functions.append(tool_function)
return tool_functionsCreate the main function
The main function is where you:
- Get the tools from the configured Servers
- Create an with the configured
- Initialize the conversation
- Run the loop!
The loop is a simple while loop that captures the user input, appends it to the conversation history, and then runs the . The agent’s response is then appended to the conversation history, and the loop continues.
The loop is interrupted when the ’s response contains a call, and the tool call is handled by the helper function you wrote earlier.
async def main():
# Get tools from the configured MCP Servers
tools = await get_arcade_tools(mcp_servers=MCP_SERVERS,
tools=TOOLS)
# Create an agent with the configured tools
agent = Agent(
name="Inbox Assistant",
instructions=SYSTEM_PROMPT,
model=MODEL,
tools=tools,
)
# initialize the conversation
history: list[TResponseInputItem] = []
# run the loop!
while True:
prompt = input("You: ")
if prompt.lower() == "exit":
break
history.append({"role": "user", "content": prompt})
try:
result = await Runner.run(
starting_agent=agent,
input=history,
context={"user_id": ARCADE_USER_ID},
)
history = result.to_input_list()
print(f"Assistant: {result.final_output}")
except ToolError as e:
# Something went wrong with the tool call, print the error message and exit the loop
print(e.message)
break
# Run the main function as the entry point of the script
if __name__ == "__main__":
asyncio.run(main())Run the agent
uv run main.pyYou should see the responding to your prompts like any model, as well as handling any calls and authorization requests. Here are some example prompts you can try:
- “Send me an email with a random haiku about OpenAI ”
- “Summarize my latest 3 emails”
Key takeaways
- Arcade tools can be integrated into any agentic framework like OpenAI , all you need is to transform the Arcade into OpenAI Agents tools and handle the authorization flow.
- isolation: By handling the authorization flow outside of the ’s context, you remove the risk of the LLM replacing the authorization URL or leaking it, and you keep the context free from any authorization-related traces, which reduces the risk of hallucinations.
Next Steps
- Try adding additional tools to the or modifying the in the catalog for a different use case by modifying the
MCP_SERVERSandTOOLSvariables. - Try implementing a fully deterministic flow before the agentic loop, use this deterministic phase to prepare the for the , adding things like the current date, time, or any other information that is relevant to the task at hand.
Example code
from agents import Agent, Runner, TResponseInputItem
from agents.run_context import RunContextWrapper
from agents.tool import FunctionTool
from agents.exceptions import AgentsException
from arcadepy import AsyncArcade
from arcadepy.types.execute_tool_response import ExecuteToolResponse
from dotenv import load_dotenv
from functools import partial
from typing import Any
import os
import asyncio
import json
# Load environment variables
load_dotenv()
# The Arcade User ID identifies who is authorizing each service.
ARCADE_USER_ID = os.getenv("ARCADE_USER_ID")
# This determines which MCP server is providing the tools, you can customize this to make a Notion agent. All tools from the MCP servers defined in the array will be used.
MCP_SERVERS = ["Slack"]
# This determines individual tools. Useful to pick specific tools when you don't need all of them.
TOOLS = ["Gmail_ListEmails", "Gmail_SendEmail", "Gmail_WhoAmI"]
# This determines the maximum number of tool definitions Arcade will return per MCP server
TOOL_LIMIT = 30
# This prompt defines the behavior of the agent.
SYSTEM_PROMPT = "You are a helpful assistant that can assist with Gmail and Slack."
# This determines which LLM model will be used inside the agent
MODEL = "gpt-4o-mini"
# Arcade to OpenAI agent exception classes
class ToolError(AgentsException):
def __init__(self, result: ExecuteToolResponse):
self.result = result
@property
def message(self):
return self.result.output.error.message
def __str__(self):
return f"Tool {self.result.tool_name} failed with error: {self.message}"
def convert_output_to_json(output: Any) -> str:
if isinstance(output, dict) or isinstance(output, list):
return json.dumps(output)
else:
return str(output)
async def authorize_tool(client: AsyncArcade, context: RunContextWrapper, tool_name: str):
if not context.context.get("user_id"):
raise ToolError("No user ID and authorization required for tool")
result = await client.tools.authorize(
tool_name=tool_name,
user_id=context.context.get("user_id"),
)
if result.status != "completed":
print(f"{tool_name} requires authorization to run, please open the following URL to authorize: {result.url}")
await client.auth.wait_for_completion(result)
async def invoke_arcade_tool(
context: RunContextWrapper,
tool_args: str,
tool_name: str,
client: AsyncArcade,
):
args = json.loads(tool_args)
await authorize_tool(client, context, tool_name)
print(f"Invoking tool {tool_name} with args: {args}")
result = await client.tools.execute(
tool_name=tool_name,
input=args,
user_id=context.context.get("user_id"),
)
if not result.success:
raise ToolError(result)
print(f"Tool {tool_name} called successfully, {MODEL} will now process the result...")
return convert_output_to_json(result.output.value)
async def get_arcade_tools(
client: AsyncArcade | None = None,
tools: list[str] | None = None,
mcp_servers: list[str] | None = None,
) -> list[FunctionTool]:
if not client:
client = AsyncArcade()
# if no tools or MCP servers are provided, raise an error
if not tools and not mcp_servers:
raise ValueError(
"No tools or MCP servers provided to retrieve tool definitions")
# Use the Arcade Client to get OpenAI-formatted tool definitions
tool_formats = []
# Retrieve individual tools if specified
if tools:
# OpenAI-formatted tool definition
tasks = [client.tools.formatted.get(name=tool_id, format="openai")
for tool_id in tools]
responses = await asyncio.gather(*tasks)
for response in responses:
tool_formats.append(response)
# Retrieve tools from specified toolkits
if mcp_servers:
# Create a task for each toolkit to fetche the formatted tool definition concurrently.
tasks = [client.tools.formatted.list(toolkit=tk, format="openai")
for tk in mcp_servers]
responses = await asyncio.gather(*tasks)
# Combine the tool definitions from each response.
for response in responses:
# Here we assume the returned response has an "items" attribute
# containing a list of ToolDefinition objects.
tool_formats.extend(response.items)
# Create a list of FunctionTool objects, mapping each tool to a partial function that invokes the tool via the Arcade client.
tool_functions = []
for tool in tool_formats:
tool_name = tool["function"]["name"]
tool_description = tool["function"]["description"]
tool_params = tool["function"]["parameters"]
tool_function = FunctionTool(
name=tool_name,
description=tool_description,
params_json_schema=tool_params,
on_invoke_tool=partial(
invoke_arcade_tool,
tool_name=tool_name,
client=client,
),
strict_json_schema=False,
)
tool_functions.append(tool_function)
return tool_functions
async def main():
# Get tools from the configured MCP Servers
tools = await get_arcade_tools(mcp_servers=MCP_SERVERS,
tools=TOOLS)
# Create an agent with the configured tools
agent = Agent(
name="Inbox Assistant",
instructions=SYSTEM_PROMPT,
model=MODEL,
tools=tools,
)
# initialize the conversation
history: list[TResponseInputItem] = []
# run the loop!
while True:
prompt = input("You: ")
if prompt.lower() == "exit":
break
history.append({"role": "user", "content": prompt})
try:
result = await Runner.run(
starting_agent=agent,
input=history,
context={"user_id": ARCADE_USER_ID},
)
history = result.to_input_list()
print(f"Assistant: {result.final_output}")
except ToolError as e:
# Something went wrong with the tool call, print the error message and exit the loop
print(e.message)
break
# Run the main function as the entry point of the script
if __name__ == "__main__":
asyncio.run(main())