Tools & taking actions
Tools are how your agent stops being a chatbot and starts doing real work. We'll cover tool definition, calling external APIs, error handling, the Model Context Protocol (MCP), and the design rules that separate a tool the model uses well from one it bumbles around.
1. The mental model of a tool
A tool is the combination of three things:
- A name the model sees (
open_it_ticket). - A description — a docstring telling the model when to use it.
- A typed input schema (Pydantic / Zod) describing the arguments.
You also write the function body, which is what runs on your machine when the model decides to call the tool. The model never sees the body — only the name, description, and schema.
The model's only way to know what your tool does is the name + description + schema. Spend 80% of your tool-building time on those three. If the model misuses your tool, the answer is almost never "smarter model" — it's "clearer description".
2. Defining a real tool — Jarvis's IT ticket opener
from typing import Literal
from pydantic import BaseModel, Field
from langchain_core.tools import tool
import httpx
class TicketArgs(BaseModel):
"""Open an IT helpdesk ticket in Acme's ticketing system."""
category: Literal["hardware", "software", "network", "access"] = Field(
description="The kind of issue. Use 'access' for password / permission requests."
)
summary: str = Field(description="One-line description of the problem, as the user reported it.")
urgency: Literal["low", "medium", "high"] = Field(
description="'high' only if the user is blocked or it affects revenue."
)
user_email: str = Field(description="The reporting user's email, e.g. priya@acme.com.")
@tool(args_schema=TicketArgs)
async def open_it_ticket(category, summary, urgency, user_email) -> str:
"""Open an IT helpdesk ticket. Returns the ticket ID."""
async with httpx.AsyncClient() as client:
r = await client.post(
"https://helpdesk.acme.com/api/tickets",
json={"category": category, "summary": summary, "urgency": urgency, "reporter": user_email},
timeout=10.0,
headers={"Authorization": f"Bearer {os.environ['HELPDESK_TOKEN']}"},
)
r.raise_for_status()
return r.json()["id"] # e.g. "TICK-4821"
import { tool } from "@langchain/core/tools";
import { z } from "zod";
const TicketArgs = z.object({
category: z.enum(["hardware", "software", "network", "access"])
.describe("The kind of issue. Use 'access' for password / permission requests."),
summary: z.string().describe("One-line description of the problem, as the user reported it."),
urgency: z.enum(["low", "medium", "high"])
.describe("'high' only if the user is blocked or it affects revenue."),
user_email: z.string().email().describe("The reporting user's email."),
}).describe("Open an IT helpdesk ticket in Acme's ticketing system.");
export const openItTicket = tool(
async ({ category, summary, urgency, user_email }) => {
const r = await fetch("https://helpdesk.acme.com/api/tickets", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.HELPDESK_TOKEN}`,
},
body: JSON.stringify({ category, summary, urgency, reporter: user_email }),
});
if (!r.ok) throw new Error(`Helpdesk API error ${r.status}`);
const data = await r.json();
return data.id;
},
{
name: "open_it_ticket",
description: "Open an IT helpdesk ticket. Returns the ticket ID.",
schema: TicketArgs,
},
);
Notice the docstrings on each field. Those become part of the JSON schema sent to the model. They're the difference between the model calling your tool correctly the first time and arguing with you for three turns.
3. Tool design — the seven rules
- Verb-noun names.
open_it_ticket, nottickets;find_room, notrooms_handler. - One job per tool. If a tool can do five things based on a flag, split it into five tools. Models pick well between tools — they pick badly between modes of one tool.
- Concrete descriptions. "Use this to look up an employee's leave balance for a calendar year. Returns days as a float (half-days are 0.5)."
- Typed inputs with enums where possible. Free strings are an invitation for the model to invent garbage.
- Useful return values. Return strings that the model can quote or use in the next step: ticket IDs, links, error reasons. Not just
"ok". - Idempotent when feasible. If the agent retries, you don't want two tickets. Use deduping keys or check-then-create.
- Cheap by default, opt-in for expensive. Add a separate
send_emailif email is risky — don't bury it inside a genericnotify.
4. The prebuilt ToolNode
The hand-rolled run_tools from Module 4 is fine, but LangGraph ships a polished version: ToolNode. It handles parallel tool calls, error formatting, and edge cases (missing tools, validation failures) for you.
from langgraph.prebuilt import ToolNode
tool_node = ToolNode([open_it_ticket, find_room, schedule_meeting])
graph.add_node("tools", tool_node)
import { ToolNode } from "@langchain/langgraph/prebuilt";
const toolNode = new ToolNode([openItTicket, findRoom, scheduleMeeting]);
graph.addNode("tools", toolNode);
5. Error handling — let the model recover
What happens if open_it_ticket raises? You have two reasonable options:
- Fail the whole run. Sometimes you genuinely want the loop to abort and bubble the error up. Wrap the agent call in a try/except in your application code.
- Let the model see the error and try again. Catch inside the tool, return a string starting with
"ERROR: …", let the LLM read it on the next turn and decide whether to retry, ask the user, or give up. This is the right default for transient failures.
@tool
async def open_it_ticket(category, summary, urgency, user_email) -> str:
"""Open an IT helpdesk ticket. Returns the ticket ID."""
try:
# ... real call ...
return ticket_id
except httpx.HTTPStatusError as e:
return f"ERROR: helpdesk API returned {e.response.status_code}: {e.response.text[:200]}"
except httpx.TimeoutException:
return "ERROR: helpdesk API timed out. You can ask the user to retry, or pick a different action."
export const openItTicket = tool(
async ({ category, summary, urgency, user_email }) => {
try {
// ... real call ...
return ticketId;
} catch (e: any) {
return `ERROR: helpdesk API failed: ${e?.message ?? "unknown"}. You can ask the user to retry, or pick a different action.`;
}
},
{ name: "open_it_ticket", description: "...", schema: TicketArgs },
);
If you let unhandled exceptions escape the tool, the whole graph run fails. The model has no chance to recover. Default to returning informative error strings; only let exceptions escape for truly unrecoverable conditions.
6. Passing context the model shouldn't see (RunnableConfig)
The user's email, auth token, or current request ID shouldn't be part of the tool's argument schema — the LLM might hallucinate values, and you don't want the LLM choosing who the request is from. Pass them in via RunnableConfig instead:
from langchain_core.runnables import RunnableConfig
@tool
async def open_it_ticket(category: str, summary: str, urgency: str, *, config: RunnableConfig) -> str:
"""Open an IT helpdesk ticket. Returns the ticket ID."""
user_email = config["configurable"]["user_email"] # set by the caller, hidden from the LLM
# ... call API with user_email ...
# Invoking the agent, pass user_email via config:
agent.invoke(
{"messages": [("user", "Printer jammed")], },
config={"configurable": {"user_email": "priya@acme.com", "thread_id": "convo-123"}},
)
import type { RunnableConfig } from "@langchain/core/runnables";
export const openItTicket = tool(
async ({ category, summary, urgency }, config?: RunnableConfig) => {
const userEmail = config?.configurable?.user_email as string;
// ... call API with userEmail ...
return "TICK-4821";
},
{ name: "open_it_ticket", description: "...", schema: TicketArgs },
);
await agent.invoke(
{ messages: [{ role: "user", content: "Printer jammed" }] },
{ configurable: { user_email: "priya@acme.com", thread_id: "convo-123" } },
);
Everything that's about the request, not chosen by the LLM belongs in config.configurable: user identity, tenant id, feature flags, the thread id. The LLM never sees these, so it can't lie about them. You'll use this pattern constantly.
7. The Model Context Protocol (MCP)
MCP is a recent open standard for tool servers — separate processes that publish tools that any MCP-aware client (Claude Desktop, Cursor, your LangGraph agent) can connect to and use. Think USB for AI tools.
Why bother? Because you stop having to re-implement integrations. Lots of common ones (Slack, GitHub, Postgres, Linear, the filesystem) ship as ready-to-run MCP servers; you point your agent at the server and the tools just appear.
from langchain_mcp_adapters.client import MultiServerMCPClient
client = MultiServerMCPClient({
"slack": {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-slack"], "transport": "stdio"},
"linear": {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-linear"], "transport": "stdio"},
})
mcp_tools = await client.get_tools()
agent = create_react_agent(model=model, tools=[*native_tools, *mcp_tools], prompt="...")
import { MultiServerMCPClient } from "@langchain/mcp-adapters";
const client = new MultiServerMCPClient({
slack: { command: "npx", args: ["-y", "@modelcontextprotocol/server-slack"], transport: "stdio" },
linear: { command: "npx", args: ["-y", "@modelcontextprotocol/server-linear"], transport: "stdio" },
});
const mcpTools = await client.getTools();
const agent = createReactAgent({ llm: model, tools: [...nativeTools, ...mcpTools], prompt: "..." });
For Jarvis we'll mix and match: keep first-party tools for Acme's bespoke systems (ticketing, internal HR API), use MCP servers for shared standard integrations (Slack, Google Calendar, GitHub).
8. Long-running and external-trigger tools
Some actions are slow ("provision a new laptop") or asynchronous ("wait for a manager's approval"). Two patterns:
- Polling. Tool returns a ticket id; another tool checks status. Agent loops until status is "done".
- Interrupt and resume. Use LangGraph's
interrupt()primitive — the run pauses, your application code does the asynchronous thing, then resumes the run with the result. We cover this in Module 8.
9. Jarvis at the end of Module 5
Jarvis can now actually do things: file real tickets, query the calendar, message Slack channels — using a mix of bespoke tools and MCP-served integrations. Next we give it memory, so it remembers what you asked yesterday and what it knows about you.
Quick check
1. What does the LLM "see" of your tool?
2. Where should you pass the current user's email and auth token to a tool?
3. Default strategy when a tool's external API fails with a transient error?
4. What is MCP, in one line?