Module 03 · ~60 min

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);
Mental model

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' }
Why structured output matters

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. 🎉

★ Jarvis status

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=0 in 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?