What is LangGraph?
LangGraph is a framework from LangChain that enables developers to build stateful, multi-step agent workflows using graph-based control flow. Unlike traditional sequential chains, LangGraph represents agent logic as a directed graph where nodes perform actions and edges define transitions—including conditional branching based on runtime decisions. This makes it ideal for building conversational agents that need to reason, call tools, validate results, and loop back when necessary.
At its core, LangGraph provides a StateGraph class that lets you define a state schema, add nodes (functions or runnables), and wire them together with edges. The graph execution engine handles state propagation, making each node's output available to downstream nodes automatically.
What is a Meeting Scheduler Agent?
A Meeting Scheduler Agent is an AI-powered assistant that can autonomously handle the end-to-end process of scheduling meetings. Given a natural language request like "Schedule a 30-minute sync with Alice next Tuesday afternoon," the agent should:
- Parse the intent and extract key details (participants, duration, time preferences)
- Check the user's calendar for availability
- Check the invitee's calendar (or propose times)
- Find mutually available time slots
- Book the meeting and send invitations
- Confirm back to the user with the scheduled time
Building this with LangGraph allows the agent to iterate through these steps intelligently—if a proposed time is unavailable, it can loop back, find alternatives, and try again without losing context.
Why It Matters
Scheduling meetings is one of the most common yet time-consuming tasks in professional life. An agent that handles this reliably saves hours per week. Beyond the productivity gain, this use case is a perfect demonstration of agentic patterns: it requires tool calling, state management, conditional routing, and graceful error handling. Learning to build a scheduler agent gives you transferable skills for building customer support agents, data pipeline orchestrators, and more.
Setting Up Your Environment
Before diving into code, install the required dependencies. You'll need LangGraph, LangChain, an LLM provider (we'll use OpenAI), and optionally a date-handling library.
pip install langgraph langchain langchain-openai python-dateutil
Set your OpenAI API key as an environment variable:
export OPENAI_API_KEY="your-api-key-here"
For production, you would integrate with a real calendar API like Google Calendar or Microsoft Graph. In this tutorial, we'll simulate calendar operations so you can run everything locally, but we'll structure the code so swapping in real API calls is straightforward.
Step-by-Step Implementation
Step 1: Define the Agent State
The state is the backbone of a LangGraph agent. It carries all information through the graph's execution. For our scheduler, we need to track the conversation, extracted meeting details, available slots, and the final booking status.
from typing import TypedDict, List, Optional, Annotated
from datetime import datetime
from langgraph.graph.message import add_messages
import operator
class SchedulerState(TypedDict):
messages: Annotated[List, add_messages] # Conversation history
intent: Optional[str] # What the user wants
participants: List[str] # People to invite
duration_minutes: int # Meeting length, default 30
date_range_start: Optional[str] # Preferred date window start
date_range_end: Optional[str] # Preferred date window end
proposed_slots: List[dict] # Slots being considered
available_slots: List[dict] # Slots where all are free
booked_slot: Optional[dict] # The confirmed booking
booking_confirmed: bool # Final confirmation flag
error_message: Optional[str] # Error feedback for retry
The Annotated[List, add_messages] type for messages is a LangGraph convention that automatically appends new messages rather than overwriting them—essential for maintaining conversation history across iterations.
Step 2: Build Simulated Calendar Tools
We'll create functions that simulate calendar operations. In a real app, these would call the Google Calendar or Outlook API. The simulation uses a pre-populated "busy" list so the agent has constraints to reason about.
from datetime import datetime, timedelta
import json
# Simulated busy slots for the current user and common teammates
MOCK_CALENDARS = {
"user@example.com": [
{"start": "2025-01-15T09:00:00", "end": "2025-01-15T10:00:00"},
{"start": "2025-01-15T14:00:00", "end": "2025-01-15T15:30:00"},
{"start": "2025-01-16T11:00:00", "end": "2025-01-16T12:00:00"},
],
"alice@example.com": [
{"start": "2025-01-15T09:30:00", "end": "2025-01-15T10:30:00"},
{"start": "2025-01-15T13:00:00", "end": "2025-01-15T14:00:00"},
{"start": "2025-01-16T10:00:00", "end": "2025-01-16T11:30:00"},
],
"bob@example.com": [
{"start": "2025-01-15T10:00:00", "end": "2025-01-15T11:00:00"},
{"start": "2025-01-16T14:00:00", "end": "2025-01-16T15:00:00"},
],
}
def check_availability(email: str, date_start: str, date_end: str) -> List[dict]:
"""Return busy slots for a participant in the given date range."""
busy = MOCK_CALENDARS.get(email, [])
range_start = datetime.fromisoformat(date_start)
range_end = datetime.fromisoformat(date_end)
busy_in_range = []
for slot in busy:
slot_start = datetime.fromisoformat(slot["start"])
slot_end = datetime.fromisoformat(slot["end"])
if slot_start < range_end and slot_end > range_start:
busy_in_range.append(slot)
return busy_in_range
def book_meeting(participants: List[str], start_time: str, end_time: str, title: str) -> dict:
"""Simulate booking a meeting and return the booking confirmation."""
booking = {
"id": f"meeting_{hash(start_time) % 10000}",
"participants": participants,
"start": start_time,
"end": end_time,
"title": title,
"status": "confirmed",
"calendar_link": f"https://calendar.example.com/meeting/{hash(start_time) % 10000}",
}
return booking
These functions are deliberately simple. The check_availability function filters busy slots by date range, and book_meeting simulates creating a calendar event. In production, you'd replace these with API calls to your calendar provider.
Step 3: Create the Tool Definitions for the LLM
LangChain tools wrap functions with schemas so the LLM knows how to call them. We use the @tool decorator to define each tool with a description and parameter types.
from langchain.tools import tool
from typing import List
@tool
def tool_check_availability(email: str, date_start: str, date_end: str) -> str:
"""Check a person's calendar availability between date_start and date_end (ISO format dates).
Returns busy slots as JSON. Use this before proposing meeting times."""
busy = check_availability(email, date_start, date_end)
if not busy:
return json.dumps({"status": "completely_free", "busy_slots": []})
return json.dumps({"status": "has_conflicts", "busy_slots": busy})
@tool
def tool_book_meeting(participants: List[str], start_time: str, end_time: str, title: str) -> str:
"""Book a meeting with the given participants, start_time, end_time (ISO format), and title.
Returns booking confirmation as JSON. Only call after confirming availability."""
booking = book_meeting(participants, start_time, end_time, title)
return json.dumps(booking)
@tool
def tool_propose_slots(duration_minutes: int, date_start: str, date_end: str,
avoid_slots: str = "[]") -> str:
"""Generate reasonable time slots of duration_minutes between date_start and date_end.
avoid_slots is a JSON string of busy periods to skip. Returns list of proposed slots."""
# In production, this would use a smarter algorithm or free-busy API
avoid = json.loads(avoid_slots) if isinstance(avoid_slots, str) else avoid_slots
start_date = datetime.fromisoformat(date_start)
end_date = datetime.fromisoformat(date_end)
proposals = []
current_day = start_date
while current_day < end_date:
# Propose 9am, 10:30am, 1pm, 3pm slots on each day
for hour in [9, 10, 13, 15]:
slot_start = current_day.replace(hour=hour, minute=0 if hour != 10 else 30)
slot_end = slot_start + timedelta(minutes=duration_minutes)
if slot_start < start_date or slot_end > end_date:
continue
# Check against avoid_slots (simplified overlap check)
conflicts = False
for busy in avoid:
b_start = datetime.fromisoformat(busy["start"])
b_end = datetime.fromisoformat(busy["end"])
if slot_start < b_end and slot_end > b_start:
conflicts = True
break
if not conflicts:
proposals.append({
"start": slot_start.isoformat(),
"end": slot_end.isoformat(),
})
current_day += timedelta(days=1)
return json.dumps(proposals[:5]) # Return top 5 proposals
# Collect all tools into a list for the LLM
tools = [tool_check_availability, tool_book_meeting, tool_propose_slots]
Step 4: Set Up the LLM with Tool Calling
We bind the tools to the LLM so it can decide when to invoke them. OpenAI's function-calling models work seamlessly with LangChain's tool abstraction.
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-4o", temperature=0.1)
llm_with_tools = llm.bind_tools(tools)
Using a low temperature (0.1) ensures the agent makes consistent, predictable decisions—important for a scheduling workflow where randomness could cause infinite loops.
Step 5: Build the Agent Node Functions
Each node in the graph is a Python function that takes the current state and returns an update. We need four key nodes: one for the LLM reasoning step, one for executing tools, one for validating results, and one for finalizing the booking.
from langchain_core.messages import SystemMessage, ToolMessage, AIMessage
import json
def agent_node(state: SchedulerState) -> dict:
"""The main reasoning node: calls the LLM with current state and returns its response."""
system_prompt = """You are a helpful meeting scheduler assistant. Follow this workflow:
1. Extract meeting intent, participants, duration, and date preferences from the user message.
2. Check availability for ALL participants using tool_check_availability.
3. Based on busy slots, propose available time slots using tool_propose_slots.
4. Present proposed slots to the user and ask for confirmation.
5. Once user confirms a slot, book it using tool_book_meeting.
6. Report the booking confirmation.
CRITICAL RULES:
- Always check availability before proposing times.
- Always ask the user to confirm before booking.
- If no slots are available in the initial range, expand the date range and try again.
- Track what you've already checked to avoid infinite loops.
- Use ISO format for all dates (YYYY-MM-DDTHH:MM:SS)."""
messages = state.get("messages", [])
if not any(isinstance(m, SystemMessage) for m in messages):
messages = [SystemMessage(content=system_prompt)] + messages
response = llm_with_tools.invoke(messages)
return {
"messages": [response],
}
def tool_executor_node(state: SchedulerState) -> dict:
"""Execute any pending tool calls from the last AI message."""
messages = state.get("messages", [])
if not messages:
return {}
last_message = messages[-1]
if not hasattr(last_message, "tool_calls") or not last_message.tool_calls:
return {}
tool_messages = []
for tool_call in last_message.tool_calls:
tool_name = tool_call["name"]
tool_args = tool_call["args"]
if tool_name == "tool_check_availability":
result = tool_check_availability.invoke(tool_args)
elif tool_name == "tool_book_meeting":
result = tool_book_meeting.invoke(tool_args)
elif tool_name == "tool_propose_slots":
result = tool_propose_slots.invoke(tool_args)
else:
result = json.dumps({"error": f"Unknown tool: {tool_name}"})
tool_messages.append(
ToolMessage(content=result, tool_call_id=tool_call["id"])
)
return {"messages": tool_messages}
def validate_and_extract_node(state: SchedulerState) -> dict:
"""Validate extracted meeting details and update structured state fields."""
messages = state.get("messages", [])
updates = {}
# Extract the last AI message content to parse structured data
for msg in reversed(messages):
if isinstance(msg, AIMessage) and hasattr(msg, "tool_calls") and msg.tool_calls:
for tc in msg.tool_calls:
if tc["name"] == "tool_book_meeting":
updates["booked_slot"] = {
"start": tc["args"].get("start_time"),
"end": tc["args"].get("end_time"),
"participants": tc["args"].get("participants"),
"title": tc["args"].get("title"),
}
updates["booking_confirmed"] = True
return updates
Notice how tool_executor_node iterates through tool_calls and returns ToolMessage objects—this is how LangGraph feeds tool results back into the conversation for the LLM to process.
Step 6: Define the Routing Logic
Conditional edges determine which path the graph takes after each node. This is where the agent's decision-making becomes explicit.
from langgraph.graph import StateGraph, END
def route_after_agent(state: SchedulerState) -> str:
"""Decide whether to execute tools, finalize, or end after the agent node."""
messages = state.get("messages", [])
if not messages:
return "end"
last_message = messages[-1]
# If the last AI message has tool calls, route to tool executor
if isinstance(last_message, AIMessage) and hasattr(last_message, "tool_calls"):
if last_message.tool_calls:
return "tool_executor"
# If booking is confirmed, route to end
if state.get("booking_confirmed"):
return "end"
# If the message looks like a final answer (no tool calls needed), end
if isinstance(last_message, AIMessage):
content = last_message.content if hasattr(last_message, "content") else str(last_message)
if "confirmed" in content.lower() or "all set" in content.lower():
return "end"
# Otherwise, loop back to agent for further reasoning
return "agent"
def route_after_tools(state: SchedulerState) -> str:
"""After tools execute, always go back to the agent to process results."""
return "agent"
The routing functions return the name of the next node. LangGraph uses these strings to traverse the graph. The route_after_agent function embodies the core agent loop: if tools need to run, go execute them; if the job is done, end; otherwise, keep reasoning.
Step 7: Assemble the Graph
Now we wire everything together into a StateGraph. This is where LangGraph shines—the graph structure is explicit and easy to visualize.
def create_scheduler_graph() -> StateGraph:
"""Build and compile the meeting scheduler agent graph."""
# Initialize graph with our state schema
workflow = StateGraph(SchedulerState)
# Add nodes
workflow.add_node("agent", agent_node)
workflow.add_node("tool_executor", tool_executor_node)
workflow.add_node("validator", validate_and_extract_node)
# Set entry point: execution starts at the agent node
workflow.set_entry_point("agent")
# Add edges
# After agent, decide: tools needed? → tool_executor; done? → validator; else → agent
workflow.add_conditional_edges(
"agent",
route_after_agent,
{
"tool_executor": "tool_executor",
"agent": "agent",
"end": "validator",
}
)
# After tool executor, always go back to agent for processing
workflow.add_edge("tool_executor", "agent")
# After validator, end the run
workflow.add_edge("validator", END)
# Compile the graph (with checkpointer for persistence if needed)
return workflow.compile()
# Create the runnable graph
scheduler_graph = create_scheduler_graph()
The graph structure is: agent → (conditional) → tool_executor → agent (looping as needed) and agent → validator → END when complete. This looping pattern is the essence of agentic behavior—the agent iterates until it has fulfilled the user's request.
Step 8: Run the Agent End-to-End
With the graph compiled, we can invoke it with user input. The graph handles all the iteration internally.
def run_scheduler(user_input: str, initial_state: dict = None) -> dict:
"""Run the scheduler agent with a natural language request."""
base_state = {
"messages": [],
"intent": None,
"participants": [],
"duration_minutes": 30,
"date_range_start": None,
"date_range_end": None,
"proposed_slots": [],
"available_slots": [],
"booked_slot": None,
"booking_confirmed": False,
"error_message": None,
}
if initial_state:
base_state.update(initial_state)
# Add the user message
from langchain_core.messages import HumanMessage
base_state["messages"] = [HumanMessage(content=user_input)]
# Invoke the graph
result = scheduler_graph.invoke(base_state)
return result
# Example usage
if __name__ == "__main__":
user_request = "Schedule a 45-minute brainstorming session with alice@example.com and bob@example.com sometime between January 15 and January 17, 2025."
result = run_scheduler(user_request)
print("\n=== FINAL RESULT ===")
print(f"Booking confirmed: {result.get('booking_confirmed')}")
if result.get("booked_slot"):
slot = result["booked_slot"]
print(f"Meeting: {slot.get('title')}")
print(f"Time: {slot.get('start')} → {slot.get('end')}")
print(f"Participants: {', '.join(slot.get('participants', []))}")
print("\n=== MESSAGE HISTORY ===")
for i, msg in enumerate(result.get("messages", [])):
msg_type = type(msg).__name__
content = msg.content if hasattr(msg, "content") else str(msg)
if len(content) > 200:
content = content[:200] + "..."
print(f"[{i}] {msg_type}: {content}")
When you run this, the agent will: parse the request, check Alice and Bob's availability, propose open slots, ask for confirmation (in this demo it will auto-confirm since there's no interactive loop), and book the meeting. The message history shows every step.
Step 9: Adding Human-in-the-Loop Confirmation
For production, you typically want the user to confirm before booking. LangGraph supports interrupt for this pattern. Here's how to add a confirmation step:
from langgraph.checkpoint import MemorySaver
from langgraph.types import interrupt
def confirmation_node(state: SchedulerState) -> dict:
"""Pause execution and ask user to confirm the proposed slot."""
proposed = state.get("proposed_slots", [])
if not proposed:
return {"error_message": "No slots to confirm"}
# Use interrupt to pause and get human feedback
human_response = interrupt(
f"Proposed meeting times:\n" +
"\n".join([f"- {s['start']} to {s['end']}" for s in proposed]) +
"\n\nType 'yes' to confirm, or specify preferences."
)
if "yes" in human_response.lower():
return {"booking_confirmed": True}
return {"error_message": f"User feedback: {human_response}", "booking_confirmed": False}
The interrupt function pauses graph execution and returns a value that the caller provides. In a web app, you'd surface this to the UI and resume the graph when the user responds. This pattern keeps the agent's state intact while waiting for human input.
Step 10: Full Production-Ready Graph with Memory
For multi-turn conversations, add a checkpointer so state persists across invocations:
def create_production_scheduler():
"""Create a scheduler graph with memory and human-in-the-loop."""
workflow = StateGraph(SchedulerState)
workflow.add_node("agent", agent_node)
workflow.add_node("tool_executor", tool_executor_node)
workflow.add_node("validator", validate_and_extract_node)
workflow.add_node("confirm", confirmation_node)
workflow.set_entry_point("agent")
workflow.add_conditional_edges(
"agent",
route_after_agent,
{
"tool_executor": "tool_executor",
"agent": "agent",
"confirm": "confirm",
"end": "validator",
}
)
workflow.add_edge("tool_executor", "agent")
workflow.add_conditional_edges(
"confirm",
lambda s: "agent" if not s.get("booking_confirmed") else "validator",
{"agent": "agent", "validator": "validator"}
)
workflow.add_edge("validator", END)
# Add memory for multi-turn conversations
memory = MemorySaver()
return workflow.compile(checkpointer=memory)
# Usage with thread_id for session persistence
prod_graph = create_production_scheduler()
config = {"configurable": {"thread_id": "user-session-123"}}
# First turn
result1 = prod_graph.invoke(
{"messages": [HumanMessage(content="Schedule a meeting with Alice next week")]},
config
)
# Second turn (state persists)
result2 = prod_graph.invoke(
{"messages": [HumanMessage(content="Make it 45 minutes instead")]},
config
)
The MemorySaver checkpointer stores state keyed by thread_id. This enables long-running conversations where the agent remembers previous context—essential for scheduling where users often refine their requests across multiple messages.
Best Practices for Building Scheduler Agents
1. Design Your State Thoughtfully
Include both conversational state (messages) and structured state (extracted fields like participants, dates). The structured fields let you add validation logic and prevent the LLM from forgetting critical details across iterations. Keep the state minimal—only track what downstream nodes actually need.
2. Use a System Prompt with an Explicit Workflow
The system prompt in agent_node gives the LLM a clear step-by-step procedure. Without this, the model may skip availability checks or book without confirming. Explicit instructions dramatically reduce errors in agentic workflows.
3. Implement Graceful Error Recovery
Wrap tool calls in try-except blocks and return structured error messages as ToolMessage objects. The LLM can then reason about failures and retry intelligently. For example, if check_availability fails due to an invalid email, the agent can ask the user to clarify rather than crashing.
4. Keep Tools Focused and Simple
Each tool should do one thing well. check_availability only returns busy slots—it doesn't also propose alternatives. propose_slots handles generation. This separation lets the LLM compose tools flexibly rather than relying on a monolithic "schedule everything" function.
5. Add Validation Between Steps
The validate_and_extract_node acts as a safety net. It extracts structured data from tool calls and sets state fields. In production, add assertions: check that dates are in the future, participants are valid email addresses, and durations are reasonable. Validation prevents garbage data from propagating through the graph.
6. Test with a Variety of Scenarios
Simulate edge cases: no availability in the date range, ambiguous participant names, requests spanning weekends, and multi-participant coordination. Each scenario tests a different branch of your conditional routing. Write unit tests for individual nodes and integration tests for the full graph.
7. Monitor and Log Graph Trajectories
LangGraph provides tracing capabilities. Log the path taken through the graph (which nodes fired, what routing decisions were made) for debugging. A scheduler that loops infinitely between agent and tool_executor indicates a problem with your routing logic or system prompt.
8. Keep the Human in the Loop for High-Stakes Actions
Booking a meeting modifies a real calendar—a side effect that shouldn't happen without confirmation. Use interrupt before destructive operations. The confirmation step costs a few seconds but prevents embarrassing mistakes like booking a 3am meeting.
Conclusion
Building a Meeting Scheduler Agent with LangGraph teaches you the fundamental patterns of agentic AI: state management, tool orchestration, conditional routing, and human-in-the-loop interaction. The graph-based approach makes the agent's behavior explicit and debuggable—you can trace exactly which nodes executed and why. By starting with simulated calendar tools, you can develop and test the entire reasoning flow before integrating real APIs. The same architecture extends naturally to other agent use cases: customer support triage, data analysis pipelines, or any workflow where an LLM needs to reason across multiple steps with external tools. As you move to production, focus on robust validation, comprehensive error handling, and preserving conversation context across sessions. The result is an agent that not only understands natural language requests but reliably executes multi-step tasks that previously required manual coordination.