Your first agent
A real, working, tool-calling agent in ~30 lines of code. We'll meet the four LangChain primitives (model, messages, prompt, tool), then snap them together with LangGraph's prebuilt ReAct agent.
1. Primitive #1 — the chat model
Every modern provider speaks the same shape: "given a list of messages, return the next assistant message." LangChain wraps each provider so you can swap models with one line.
from langchain_anthropic import ChatAnthropic
from langchain_openai import ChatOpenAI
# Default for this course
model = ChatAnthropic(model="claude-sonnet-4-6", temperature=0)
# Swap to OpenAI by changing one line:
# model = ChatOpenAI(model="gpt-4o", temperature=0)
response = model.invoke("In one sentence: what is a printer jam?")
print(response.content)
import { ChatAnthropic } from "@langchain/anthropic";
import { ChatOpenAI } from "@langchain/openai";
// Default for this course
const model = new ChatAnthropic({ model: "claude-sonnet-4-6", temperature: 0 });
// Swap to OpenAI by changing one line:
// const model = new ChatOpenAI({ model: "gpt-4o", temperature: 0 });
const response = await model.invoke("In one sentence: what is a printer jam?");
console.log(response.content);
.invoke() takes either a plain string (treated as a user message) or a full list of messages. It returns an AIMessage.
2. Primitive #2 — messages
A conversation is a list of typed messages. There are four common types:
- SystemMessage — instructions for the model (the "system prompt"). One, at the start.
- HumanMessage — what the user said.
- AIMessage — what the model said. May contain plain text and/or tool calls.
- ToolMessage — the result of running a tool the AI asked for.
from langchain_core.messages import SystemMessage, HumanMessage
messages = [
SystemMessage(content="You are Jarvis, a helpful internal assistant for Acme Robotics. Be concise."),
HumanMessage(content="What's our policy on unused leave at year-end?"),
]
print(model.invoke(messages).content)
import { SystemMessage, HumanMessage } from "@langchain/core/messages";
const messages = [
new SystemMessage("You are Jarvis, a helpful internal assistant for Acme Robotics. Be concise."),
new HumanMessage("What's our policy on unused leave at year-end?"),
];
console.log((await model.invoke(messages)).content);
Everything your agent does at runtime — every user turn, every model reply, every tool call, every tool result — ends up as another message appended to this same list. The list is the conversation. The list is the agent's working memory in this module.
3. Primitive #3 — the prompt template
Most system prompts have variables ("today is {date}", "you're talking to {user_name}"). Templates make those explicit:
from langchain_core.prompts import ChatPromptTemplate
prompt = ChatPromptTemplate.from_messages([
("system", "You are Jarvis at Acme Robotics. Today is {date}. The user is {user_name} from {department}."),
("human", "{question}"),
])
chain = prompt | model # the pipe operator composes runnables
print(chain.invoke({
"date": "2026-05-23",
"user_name": "Priya",
"department": "Engineering",
"question": "Where do I file a request for a new laptop?",
}).content)
import { ChatPromptTemplate } from "@langchain/core/prompts";
const prompt = ChatPromptTemplate.fromMessages([
["system", "You are Jarvis at Acme Robotics. Today is {date}. The user is {user_name} from {department}."],
["human", "{question}"],
]);
const chain = prompt.pipe(model); // .pipe() composes
console.log((await chain.invoke({
date: "2026-05-23",
user_name: "Priya",
department: "Engineering",
question: "Where do I file a request for a new laptop?",
})).content);
That | / .pipe() chains LangChain "runnables" together — the same idea as a Unix pipe.
4. Primitive #4 — structured output
Often you want a typed object back, not a paragraph of prose. Use with_structured_output with a Pydantic (Python) or Zod (TS) schema:
from pydantic import BaseModel, Field
from typing import Literal
class TicketDraft(BaseModel):
"""A draft IT ticket extracted from a user request."""
category: Literal["hardware", "software", "network", "access"] = Field(...)
summary: str
urgency: Literal["low", "medium", "high"]
structured = model.with_structured_output(TicketDraft)
ticket = structured.invoke("The printer on floor 3 is jammed and nobody can print payslips.")
print(ticket)
# TicketDraft(category='hardware', summary='Printer jammed on floor 3', urgency='high')
import { z } from "zod";
const TicketDraft = z.object({
category: z.enum(["hardware", "software", "network", "access"]),
summary: z.string(),
urgency: z.enum(["low", "medium", "high"]),
}).describe("A draft IT ticket extracted from a user request.");
const structured = model.withStructuredOutput(TicketDraft);
const ticket = await structured.invoke("The printer on floor 3 is jammed and nobody can print payslips.");
console.log(ticket);
// { category: 'hardware', summary: 'Printer jammed on floor 3', urgency: 'high' }
Structured output is how agents talk to the rest of your software. You don't want to regex out a category from "Sure! It sounds like this is a hardware issue, specifically…" — you want a typed object the next step can rely on.
5. Primitive #5 — tools
A tool is a typed function the LLM is allowed to call. Define it once, hand it to the model with bind_tools(), and the model can choose to call it.
from langchain_core.tools import tool
@tool
def open_it_ticket(category: str, summary: str, urgency: str) -> str:
"""Open an IT helpdesk ticket. Returns the ticket ID."""
# In a real app this would call your ticketing API.
print(f"[TOOL] opening ticket: {category=} {summary=} {urgency=}")
return "TICK-4821"
model_with_tools = model.bind_tools([open_it_ticket])
ai = model_with_tools.invoke("The printer on floor 3 is jammed.")
print(ai.tool_calls)
# [{'name': 'open_it_ticket', 'args': {'category': 'hardware', 'summary': 'Printer jammed on floor 3', 'urgency': 'medium'}, 'id': '...'}]
import { tool } from "@langchain/core/tools";
import { z } from "zod";
const openItTicket = tool(
async ({ category, summary, urgency }) => {
console.log(`[TOOL] opening ticket:`, { category, summary, urgency });
return "TICK-4821";
},
{
name: "open_it_ticket",
description: "Open an IT helpdesk ticket. Returns the ticket ID.",
schema: z.object({
category: z.string(),
summary: z.string(),
urgency: z.string(),
}),
},
);
const modelWithTools = model.bindTools([openItTicket]);
const ai = await modelWithTools.invoke("The printer on floor 3 is jammed.");
console.log(ai.tool_calls);
Important: bind_tools doesn't execute the tool — it just tells the model the tools exist and lets it request a call. Actually running the tool and feeding the result back is the agent loop's job.
6. Putting it together — your first real agent
You could write the agent loop yourself (call model → check for tool calls → execute them → append results → repeat). It's about 30 lines. But for a standard ReAct agent, LangGraph ships create_react_agent which does exactly that. Use it now; in Module 4 we'll build it from scratch to demystify it.
from langchain_anthropic import ChatAnthropic
from langchain_core.tools import tool
from langgraph.prebuilt import create_react_agent
model = ChatAnthropic(model="claude-sonnet-4-6", temperature=0)
@tool
def open_it_ticket(category: str, summary: str, urgency: str) -> str:
"""Open an IT helpdesk ticket. Returns the ticket ID."""
return "TICK-4821"
@tool
def find_room(date: str, time: str, capacity: int) -> str:
"""Find an available conference room for a given date, time and capacity."""
return "Room 4B"
@tool
def schedule_meeting(room: str, with_person: str, time: str) -> str:
"""Schedule a meeting in a room with a person at a given time."""
return f"Scheduled in {room} with {with_person} at {time}"
agent = create_react_agent(
model=model,
tools=[open_it_ticket, find_room, schedule_meeting],
prompt="You are Jarvis, the internal AI assistant for Acme Robotics. Help employees by using your tools. Confirm what you did at the end.",
)
result = agent.invoke({
"messages": [
("user", "The printer on floor 3 is jammed AGAIN. Also can you book a small conference room tomorrow 2pm with Priya?"),
]
})
for m in result["messages"]:
m.pretty_print()
import { ChatAnthropic } from "@langchain/anthropic";
import { tool } from "@langchain/core/tools";
import { z } from "zod";
import { createReactAgent } from "@langchain/langgraph/prebuilt";
const model = new ChatAnthropic({ model: "claude-sonnet-4-6", temperature: 0 });
const openItTicket = tool(
async ({ category, summary, urgency }) => "TICK-4821",
{
name: "open_it_ticket",
description: "Open an IT helpdesk ticket. Returns the ticket ID.",
schema: z.object({ category: z.string(), summary: z.string(), urgency: z.string() }),
},
);
const findRoom = tool(
async ({ date, time, capacity }) => "Room 4B",
{
name: "find_room",
description: "Find an available conference room.",
schema: z.object({ date: z.string(), time: z.string(), capacity: z.number() }),
},
);
const scheduleMeeting = tool(
async ({ room, with_person, time }) => `Scheduled in ${room} with ${with_person} at ${time}`,
{
name: "schedule_meeting",
description: "Schedule a meeting.",
schema: z.object({ room: z.string(), with_person: z.string(), time: z.string() }),
},
);
const agent = createReactAgent({
llm: model,
tools: [openItTicket, findRoom, scheduleMeeting],
prompt: "You are Jarvis, the internal AI assistant for Acme Robotics. Help employees by using your tools. Confirm what you did at the end.",
});
const result = await agent.invoke({
messages: [
{ role: "user", content: "The printer on floor 3 is jammed AGAIN. Also can you book a small conference room tomorrow 2pm with Priya?" },
],
});
for (const m of result.messages) console.log(m);
Run that. You'll see Jarvis call open_it_ticket, then find_room, then schedule_meeting, then summarise. You just built an agent. 🎉
Right now Jarvis is a single agent with three fake tools. Over the next nine modules we'll: give it real tools, give it memory, split it into specialists, add approvals, and deploy it behind a control plane. But the core loop you just ran is exactly what every one of those upgrades sits on top of.
7. Picking a model — pragmatic advice
- For tool-calling agents (most of what we build), use a frontier model. In 2026 that means Claude Opus 4.x / Sonnet 4.x or GPT-4o-class. Tool-calling accuracy matters more than raw chat quality.
- For latency-sensitive specialists, use a Haiku-class small model on the inner agents and a bigger model on the supervisor.
- For local/private, Ollama + a tool-calling Llama variant works in Python via
langchain-ollama. Quality is lower; expect to iterate on prompts more. - Always set
temperature=0in tool-calling agents unless you have a specific reason not to. You want deterministic tool-call decisions.
Quick check
1. What does model.bind_tools(tools) do?
2. Which message type carries a tool's return value back to the model?
3. You want the model to return an object with category, summary, urgency. What do you use?
4. Sensible default for temperature when building a tool-calling agent?