Use this file to discover all available pages before exploring further.
Production agents face a challenge that local demos never surface: how do you authenticate dozens of users against Gmail, Slack, or Notion without handing out shared credentials or building OAuth flows from scratch? Arcade.dev solves this by acting as a unified authentication and execution layer between your LangGraph agent and every external service it calls. This tutorial walks through building a multi-user agent system—from a basic conversational agent to a full production setup with human-in-the-loop (HITL) approval for sensitive write operations.
OAuth2 handled for you
Arcade manages the full authorization code flow for each user, per provider.
User isolation built in
Token storage and scoped execution are tied to a user_id, not a shared secret.
HITL approval workflow
Wrap any tool with an interrupt gate so users approve writes before they execute.
You need two API keys: one from OpenAI and one from Arcade.
import getpassimport osdef _set_env(key: str, default: str | None = None): if key not in os.environ: if default: os.environ[key] = default else: os.environ[key] = getpass.getpass(f"{key}:")_set_env("OPENAI_API_KEY")_set_env("ARCADE_API_KEY")_set_env("ARCADE_USER_ID") # must match the email used to create the Arcade account
ARCADE_USER_ID is the email address tied to your Arcade account. Arcade uses it to store and scope each user’s OAuth tokens, so every user in a multi-user deployment passes their own identifier at runtime.
A single-user agent that reads your own Gmail is straightforward. Scaling that to many users requires separating concerns:Arcade sits between your agent and external APIs. It stores OAuth refresh tokens per user_id, checks whether the current user has already authorized a provider, and returns an authorization URL when they haven’t.
Start with a no-tools agent to establish the LangGraph pattern you’ll extend throughout this tutorial.
from langgraph.prebuilt.chat_agent_executor import create_react_agentfrom langgraph.checkpoint.memory import MemorySaverfrom langchain_core.messages import HumanMessageimport uuidcheckpointer = MemorySaver()agent_a = create_react_agent( model="openai:gpt-4o", prompt=( "You are a helpful assistant that can help with everyday tasks." " If the user's request is confusing you must ask them to clarify" " their intent, and fulfill the instruction to the best of your" " ability. Be concise and friendly at all times." ), tools=[], # no tools yet checkpointer=checkpointer,)
Use this helper to stream responses throughout the tutorial:
from langgraph.graph.state import CompiledStateGraphdef run_graph(graph: CompiledStateGraph, config, input): for event in graph.stream(input, config=config, stream_mode="values"): if "messages" in event: event["messages"][-1].pretty_print()
Each conversation thread gets a unique thread_id. The MemorySaver checkpointer keeps context across turns.
config = { "configurable": { "thread_id": uuid.uuid4() }}user_message = {"messages": [HumanMessage(content="summarize my latest 3 emails please")]}run_graph(agent_a, config, user_message)# → The agent has no email access and says so.
Arcade checks whether the current user already granted access. If not, it returns an OAuth URL.
def authorize_tool(tool_name, user_id, manager): auth_response = manager.authorize( tool_name=tool_name, user_id=user_id ) if auth_response.status != "completed": print( f"The app wants to use the {tool_name} tool.\n" f"Please click this url to authorize it {auth_response.url}" ) manager.wait_for_auth(auth_response.id)authorize_tool(gmail_tool.name, os.getenv("ARCADE_USER_ID"), manager)
Once the user clicks the URL and grants access, the function returns and the token is stored in Arcade’s vault for future calls.
4
Create an agent with Gmail capabilities
Pass user_id in the LangGraph config so Arcade can look up the correct token at execution time.
agent_b = create_react_agent( model="openai:gpt-4o", prompt=( "You are a helpful assistant that can help with everyday tasks." " If the user's request is confusing you must ask them to clarify" " their intent, and fulfill the instruction to the best of your" " ability. Be concise and friendly at all times." " Use the Gmail tools that you have to address requests about emails." ), tools=[gmail_tool], checkpointer=checkpointer,)config = { "configurable": { "thread_id": uuid.uuid4(), "user_id": os.getenv("ARCADE_USER_ID"), }}user_message = {"messages": [HumanMessage(content="summarize my latest 3 emails please")]}run_graph(agent_b, config, user_message)
Once you add multiple tools across different OAuth providers, you want to batch the authorization flows so the user only clicks once per provider.
def authorize_tools(tools, user_id, client): # Group scopes by provider so each provider gets one auth URL provider_to_scopes = {} for tool in tools: provider = tool.requirements.authorization.provider_id if provider not in provider_to_scopes: provider_to_scopes[provider] = set() if tool.requirements.authorization.oauth2.scopes: provider_to_scopes[provider] |= set( tool.requirements.authorization.oauth2.scopes ) for provider, scopes in provider_to_scopes.items(): auth_response = client.auth.start( user_id=user_id, scopes=list(scopes), provider=provider, ) if auth_response.status != "completed": print(f"Please click here to authorize: {auth_response.url}") print("Waiting for authorization completion...") client.auth.wait_for_completion(auth_response)
Register Gmail send, Slack, and Notion as a batch:
Build the multi-service agent using manager.to_langchain() to get all tools in LangChain format:
agent_c = create_react_agent( model="openai:gpt-4o", prompt=( "You are a helpful assistant that can help with everyday tasks." " If the user's request is confusing you must ask them to clarify" " their intent, and fulfill the instruction to the best of your" " ability. Be concise and friendly at all times." " Use the Gmail tools to address requests about reading or sending emails." " Use the Slack tools to address requests about interactions with users and channels in Slack." " Use the Notion tools to address requests about managing content in Notion Pages." " In general, when possible, use the most relevant tool for the job." ), tools=manager.to_langchain(), checkpointer=checkpointer,)
Add human-in-the-loop approval for sensitive operations
Some tools—sending emails, posting to Slack, creating Notion pages—have real-world consequences. Wrap them with a HITL gate using LangGraph’s interrupt mechanism.
agent_hitl = create_react_agent( model="openai:gpt-4o", prompt=( "You are a helpful assistant that can help with everyday tasks." " If the user's request is confusing you must ask them to clarify" " their intent, and fulfill the instruction to the best of your" " ability. Be concise and friendly at all times." " Use the Gmail tools to address requests about reading or sending emails." " Use the Slack tools to address requests about interactions with users and channels in Slack." " Use the Notion tools to address requests about managing content in Notion Pages." " In general, when possible, use the most relevant tool for the job." ), tools=protected_tools, checkpointer=checkpointer,)
config = { "configurable": { "thread_id": uuid.uuid4(), "user_id": os.getenv("ARCADE_USER_ID"), }}prompt = ( 'send an email with subject "confidential data" and body ' '"this is top secret information" to random-dude@example.com')user_message = {"messages": [HumanMessage(content=prompt)]}run_graph(agent_hitl, config, user_message)# Agent pauses here — inspect the interrupt stateprint(agent_hitl.get_state(config).interrupts)# Resume after user decisionhandle_interrupts(agent_hitl, config)
If you call handle_interrupts and the user selects “no”, the tool call is cancelled and the agent receives a message explaining that the user did not allow execution. The agent can then respond accordingly without retrying the blocked action.
In a web application, replace input() with a WebSocket or REST endpoint. The thread_id becomes the session identifier, and user_id comes from your authentication middleware. LangGraph’s checkpointer persists state between requests.