LangGraph
Build a LangGraph agent that reads Gmail as the user with AgentKit: wire Scalekit tools into a ToolNode graph, or connect to a Virtual MCP server.
Build a LangGraph agent that reads a user’s Gmail inbox. The graph has two nodes: the model, and a ToolNode that runs the Scalekit tools the model asks for. In Python, Scalekit returns tools as LangChain tool objects. In Node.js, you wrap each tool with LangChain’s tool() helper.
Using LangChain’s agent loop without a graph? See LangChain.
Before you start
Section titled “Before you start”- Create a Gmail connection in AgentKit > Connections. The samples use the connection name
gmail, so change it if yours differs. See Set up a connection. - Have the
.envfile from the Quickstart, and add your OpenAI API key asOPENAI_API_KEYto it. - The samples load
.envwithload_dotenv()frompython-dotenvin Python, andimport 'dotenv/config'in Node.js.
Install
Section titled “Install”pip install scalekit-sdk-python python-dotenv langgraph langchain-openainpm install @scalekit-sdk/node dotenv @langchain/langgraph @langchain/core @langchain/openaiFor Node.js, save the code in a .mts file, such as agent.mts, and run it with npx tsx agent.mts. The .mts extension makes the file an ES module, so it can use top-level await.
Initialize
Section titled “Initialize”import osfrom dotenv import load_dotenvfrom scalekit import ScalekitClient
load_dotenv()
scalekit_client = ScalekitClient( client_id=os.getenv("SCALEKIT_CLIENT_ID"), client_secret=os.getenv("SCALEKIT_CLIENT_SECRET"), env_url=os.getenv("SCALEKIT_ENVIRONMENT_URL"),)actions = scalekit_client.actionsimport 'dotenv/config';import { ScalekitClient } from '@scalekit-sdk/node';
const scalekit = new ScalekitClient( process.env.SCALEKIT_ENVIRONMENT_URL!, process.env.SCALEKIT_CLIENT_ID!, process.env.SCALEKIT_CLIENT_SECRET!,);Later blocks reuse this setup.
Connect the user to Gmail
Section titled “Connect the user to Gmail”# Connect the user's Gmail account, and wait until it's ACTIVE before calling toolsconnection_name = "gmail"identifier = "user_123" # your app's unique user ID
response = actions.get_or_create_connected_account( connection_name=connection_name, identifier=identifier)if response.connected_account.status != "ACTIVE": link = actions.get_authorization_link( connection_name=connection_name, identifier=identifier ) print("Authorize Gmail:", link.link) input("Press Enter after authorizing...") # Fetch the account again to pick up the new status response = actions.get_or_create_connected_account( connection_name=connection_name, identifier=identifier )
if response.connected_account.status != "ACTIVE": raise RuntimeError( f"Gmail is {response.connected_account.status}, not ACTIVE. Authorize it and run again." )import { createInterface } from 'node:readline/promises';import { ConnectorStatus } from '@scalekit-sdk/node';
// Connect the user's Gmail account, and wait until it's ACTIVE before calling toolsconst connectionName = 'gmail';const identifier = 'user_123'; // your app's unique user ID
let { connectedAccount } = await scalekit.actions.getOrCreateConnectedAccount({ connectionName, identifier,});if (connectedAccount?.status !== ConnectorStatus.ACTIVE) { const { link } = await scalekit.actions.getAuthorizationLink({ connectionName, identifier }); console.log('Authorize Gmail:', link); const rl = createInterface({ input: process.stdin, output: process.stdout }); await rl.question('Press Enter after authorizing...'); rl.close(); // Fetch the account again to pick up the new status ({ connectedAccount } = await scalekit.actions.getOrCreateConnectedAccount({ connectionName, identifier, }));}
if (connectedAccount?.status !== ConnectorStatus.ACTIVE) { throw new Error(`Gmail is not ACTIVE (status ${connectedAccount?.status}). Authorize it and run again.`);}See Authorize a user for production auth handling.
Build and run the graph
Section titled “Build and run the graph”build_graph (buildGraph in Node.js) takes a list of tools and returns a compiled graph. The model node calls the LLM. When the LLM asks for tools, tools_condition routes to the ToolNode, which runs them and returns their results to the model. When the LLM answers without tool calls, the graph ends.
Fetch the tools scoped to this user and run the graph. Use built-in tools explains the scoped tool list, paging past 100 tools, and how to read data when the app returns an error.
from langchain_openai import ChatOpenAIfrom langgraph.graph import START, MessagesState, StateGraphfrom langgraph.prebuilt import ToolNode, tools_condition
def build_graph(tools): llm = ChatOpenAI(model="gpt-4o").bind_tools(tools)
def call_model(state: MessagesState): return {"messages": [llm.invoke(state["messages"])]}
builder = StateGraph(MessagesState) builder.add_node("agent", call_model) builder.add_node("tools", ToolNode(tools)) builder.add_edge(START, "agent") # Run the tools the model asked for, or end when it answers without tool calls builder.add_conditional_edges("agent", tools_condition) builder.add_edge("tools", "agent") return builder.compile()
tools = actions.langchain.get_tools( identifier=identifier, connection_names=[connection_name], page_size=100, # up to 100 tools per page; page with page_token for more)graph = build_graph(tools)result = graph.invoke( {"messages": [("user", "Fetch my last 5 unread emails and summarize them")]})print(result["messages"][-1].content)import { ChatOpenAI } from '@langchain/openai';import { tool, type StructuredToolInterface } from '@langchain/core/tools';import { MessagesAnnotation, START, StateGraph } from '@langchain/langgraph';import { ToolNode, toolsCondition } from '@langchain/langgraph/prebuilt';
function buildGraph(tools: StructuredToolInterface[]) { const llm = new ChatOpenAI({ model: 'gpt-4o' }).bindTools(tools); return new StateGraph(MessagesAnnotation) .addNode('agent', async (state) => ({ messages: [await llm.invoke(state.messages)] })) .addNode('tools', new ToolNode(tools)) .addEdge(START, 'agent') // Run the tools the model asked for, or end when it answers without tool calls .addConditionalEdges('agent', toolsCondition) .addEdge('tools', 'agent') .compile();}
const { tools: scopedTools } = await scalekit.tools.listScopedTools(identifier, { filter: { connectionNames: [connectionName] }, pageSize: 100, // up to 100 tools per page; page with nextPageToken for more});// Tool definitions are typed as generic JSON, so read each field explicitlyconst definitions = scopedTools.flatMap((t) => (t.tool?.definition ? [t.tool.definition] : []));const tools = definitions.map((definition) => tool( async (input) => { const result = await scalekit.actions.executeTool({ toolName: String(definition.name), identifier, connector: connectionName, toolInput: input as Record<string, unknown>, }); return JSON.stringify(result.data); }, { name: String(definition.name), description: String(definition.description ?? ''), schema: definition.input_schema as Record<string, unknown>, }, ),);
const graph = buildGraph(tools);const result = await graph.invoke({ messages: [{ role: 'user', content: 'Fetch my last 5 unread emails and summarize them' }],});console.log(result.messages.at(-1)?.content);When the app itself rejects a request, the tool doesn’t raise: the app’s error response comes back as the tool’s result, and the model reads it like any other result. If a tool raises an error instead, such as a network failure, the two languages differ:
- Node.js:
ToolNodereturns the error to the model. - Python:
ToolNodereturns only invalid-argument errors to the model, and raises the rest. To return every error to the model, create the node withToolNode(tools, handle_tool_errors=True).
Check it worked
Section titled “Check it worked”The script prints a summary of the user’s unread emails. result["messages"] (result.messages in Node.js) holds the whole run: the user message, the model’s tool calls, a tool message with each Gmail response, and the final answer.
Use MCP instead
Section titled “Use MCP instead”To give the graph the tools of a Virtual MCP server, load them with LangChain’s MCP adapters and pass them to the same build_graph. Pass the server URL and a session token for this user.
pip install "langchain-mcp-adapters>=0.3,<1"import asyncioimport osfrom datetime import timedelta
from langchain_mcp_adapters.client import MultiServerMCPClient
# Returned by create_config when you created the Virtual MCP serverconfig_id = os.environ["SCALEKIT_MCP_CONFIG_ID"]mcp_url = os.environ["SCALEKIT_MCP_SERVER_URL"]
# Mint a fresh session token for this user before each agent runmcp_token = actions.mcp.create_session_token( mcp_config_id=config_id, identifier=identifier, expiry=timedelta(hours=1),).token
async def run(): client = MultiServerMCPClient( { "scalekit": { "transport": "streamable_http", "url": mcp_url, "headers": {"Authorization": f"Bearer {mcp_token}"}, } } ) graph = build_graph(await client.get_tools()) result = await graph.ainvoke( {"messages": [("user", "Fetch my last 5 unread emails and summarize them")]} ) print(result["messages"][-1].content)
asyncio.run(run())npm install @langchain/mcp-adaptersimport { MultiServerMCPClient } from '@langchain/mcp-adapters';
// Returned by createConfig when you created the Virtual MCP serverconst configId = process.env.SCALEKIT_MCP_CONFIG_ID!;const mcpUrl = process.env.SCALEKIT_MCP_SERVER_URL!;
// Mint a fresh session token for this user before each agent runconst session = await scalekit.actions.mcp.createSessionToken({ mcpConfigId: configId, identifier, expirySeconds: 60 * 60,});
const mcpClient = new MultiServerMCPClient({ servers: { scalekit: { transport: 'http', url: mcpUrl, headers: { Authorization: `Bearer ${session.token}` }, }, },});try { const mcpGraph = buildGraph(await mcpClient.getTools()); const mcpResult = await mcpGraph.invoke({ messages: [{ role: 'user', content: 'Fetch my last 5 unread emails and summarize them' }], }); console.log(mcpResult.messages.at(-1)?.content);} finally { await mcpClient.close();}See Create a Virtual MCP server and Mint session tokens to create the server, check the user’s connections and mint a token.
API reference
The endpoints this page's code calls, with every field and the SDK method for each: