Migrating from LangGraph to Parlant

You've built a sophisticated support agent with LangGraph. The supervisor pattern routes queries beautifully, your specialized agents handle their domains, and you're proud of what you've shipped!
But as conversations scale, you're noticing the cracks: users asking about billing and data plans in the same breath, context getting lost between agent handoffs, prompts becoming unwieldy as you add more capabilities.
Sound familiar? You're not alone, and you don't need to rebuild everything from scratch.
If you're new to the differences between LangGraph and Parlant, I recommend starting with our Parlant vs LangGraph post to understand the fundamental architectural differences.
This post assumes you understand those differences and are ready to make the (partial) migration - specifically, the part that's in charge of conversational steering.
Contents:
What to Expect After Migration
Before we dive into the technical details, let me paint a picture of what your development experience will look like after migrating conversational control to Parlant:
For Developers:
- Your LangGraph code stays focused on what it does best: complex backend workflows, retrieval orchestration, and multi-step data processing
- Conversational behavior lives in declarative guidelines and journeys, separate from "backend" logic
- Adding new conversational capabilities means introducing guidelines, not refactoring your entire graph
- Your prompts are modular, task-specific, contextually-loaded, and don't suffer from the curse of instructions
For Business Experts:
- Product managers and domain experts can modify conversational behavior directly through specifying contextual guidelines, without getting involved in graphs and routers
- Changes to conversation flows don't require developer intervention for every iteration
- A/B testing different conversational approaches becomes straightforward
- Compliance requirements map cleanly to enforceable guidelines
For Users:
- Conversations flow naturally across multiple topics without awkward "let me route you" interruptions
- The agent maintains coherent context even as topics interweave
- Multi-message exchanges feel like real conversations, not rigid request-reply loops
- The agent can proactively follow up, handle interruptions, and manage complex multi-step interactions
This won't just be a technical migration, but more of a fundamental improvement in how you build, maintain, and scale conversational AI.
Understanding Your Starting Point
Let's start by examining a typical LangGraph supervisor pattern implementation for a telecom customer support system. This will help us identify what needs to migrate and what can stay.
The Supervisor Pattern in Action
In a telecom support context based on the supervisor pattern, you typically have specialized agents handling different domains:
Here's how this typically looks in terms of a LangGraph implementation:
from langgraph.graph import MessagesState, StateGraph, START, END
from langgraph.prebuilt import create_react_agent
from typing import Annotated
from langchain_core.tools import tool, InjectedToolCallId
from langgraph.prebuilt import InjectedState
from langgraph.types import Command
# Define specialized tools for each domain
@tool
def check_account_balance(customer_id: str) -> dict:
"""Check the customer's current account balance."""
# Implementation here
return {"balance": 150.00, "due_date": "2025-11-15"}
@tool
def get_current_plan(customer_id: str) -> dict:
"""Get customer's current data plan details."""
return {"plan": "Unlimited Pro", "data_used": "45GB", "data_limit": "Unlimited"}
@tool
def find_available_plans(usage_pattern: str) -> list:
"""Find data plans based on usage pattern."""
return [
{"name": "Basic 10GB", "price": 40, "data": "10GB"},
{"name": "Premium 50GB", "price": 60, "data": "50GB"},
{"name": "Unlimited Pro", "price": 80, "data": "Unlimited"}
]
# Create specialized agents
billing_agent = create_react_agent(
model="openai:gpt-4",
tools=[check_account_balance],
prompt=(
"You are a billing specialist for a telecom company.\n\n"
"INSTRUCTIONS:\n"
"- Help customers with bill inquiries, payment issues, and charge explanations\n"
"- Always verify the customer's account before discussing billing details\n"
"- Be clear and transparent about all charges\n"
"- If a customer disputes a charge, explain it thoroughly but escalate if unresolved\n"
"Respond ONLY with the results of your work."
),
name="billing_agent",
)
data_plan_agent = create_react_agent(
model="openai:gpt-4",
tools=[get_current_plan, find_available_plans],
prompt=(
"You are a data plan specialist.\n\n"
"INSTRUCTIONS:\n"
"- Help customers understand their current plan and usage\n"
"- Recommend plans based on their actual usage patterns\n"
"- Focus on finding the best value for the customer\n"
"- Explain the benefits of each plan clearly\n"
"Respond ONLY with the results of your work."
),
name="data_plan_agent",
)
# Create handoff tools for supervisor
def create_handoff_tool(*, agent_name: str, description: str):
name = f"transfer_to_{agent_name}"
@tool(name, description=description)
def handoff_tool(
state: Annotated[MessagesState, InjectedState],
tool_call_id: Annotated[str, InjectedToolCallId],
) -> Command:
tool_message = {
"role": "tool",
"content": f"Successfully transferred to {agent_name}",
"name": name,
"tool_call_id": tool_call_id,
}
return Command(
goto=agent_name,
update={**state, "messages": state["messages"] + [tool_message]},
graph=Command.PARENT,
)
return handoff_tool
assign_to_billing = create_handoff_tool(
agent_name="billing_agent",
description="Transfer to billing specialist for bill inquiries and payment issues.",
)
assign_to_data_plan = create_handoff_tool(
agent_name="data_plan_agent",
description="Transfer to data plan specialist for plan changes and recommendations.",
)
# Supervisor agent
supervisor_agent = create_react_agent(
model="openai:gpt-4",
tools=[assign_to_billing, assign_to_data_plan],
prompt=(
"You are a supervisor managing customer support agents.\n\n"
"AVAILABLE AGENTS:\n"
"- billing_agent: Handles bill inquiries, payment issues, charge explanations\n"
"- data_plan_agent: Handles data plan inquiries, upgrades, recommendations\n\n"
"INSTRUCTIONS:\n"
"- Route each customer query to the most appropriate specialist\n"
"- Assign work one agent at a time\n"
"- Do not attempt to handle queries yourself\n"
),
name="supervisor",
)
# Build the graph
telecom_support = (
StateGraph(MessagesState)
.add_node(supervisor_agent, destinations=("billing_agent", "data_plan_agent", END))
.add_node(billing_agent)
.add_node(data_plan_agent)
.add_edge(START, "supervisor")
.compile()
)
How a Conversation Flows
Here's what happens when a customer asks a mixed question like "I want to upgrade my plan and understand why my bill is higher this month":
The Problem
This architecture has several issues for natural conversation:
- Routing Forces Single-Topic Handling: The supervisor must choose one agent, leaving parts of the customer's question unaddressed
- Context Fragmentation: When the conversation eventually gets routed to billing, it has lost the grounding context for plan upgrades
- Prompt Bloat: As you add capabilities, each agent's prompt grows, degrading accuracy and consistency due to the curse of instructions
- Rigid Topic Separation: Real conversations don't respect domain boundaries, but the architecture forces them
This is where Parlant's architecture shines. Let's see how to migrate this to a better pattern.
The Migration Strategy: Divide and Conquer
The key insight is this: LangGraph becomes your backend retrieval layer, while Parlant becomes your conversational control layer. So instead of a complete rewrite, you're implementing a strategic separation of concerns:
What Stays in LangGraph
Keep LangGraph for what it does best:
1. Complex Multi-Step Backend Workflows
# Example: Complex RAG retrieval workflow stays in LangGraph
from langgraph.graph import StateGraph
from typing import TypedDict
class RetrievalState(TypedDict):
query: str
retrieved_docs: list
reranked_docs: list
synthesized_answer: str
def retrieve_documents(state: RetrievalState) -> RetrievalState:
# Vector search
docs = vector_store.search(state["query"])
return {"retrieved_docs": docs}
def rerank_documents(state: RetrievalState) -> RetrievalState:
# Rerank by relevance
reranked = reranker.rerank(state["query"], state["retrieved_docs"])
return {"reranked_docs": reranked}
def synthesize_answer(state: RetrievalState) -> RetrievalState:
# Generate answer from docs
answer = llm.generate(state["query"], state["reranked_docs"])
return {"synthesized_answer": answer}
# Build retrieval workflow
retrieval_graph = (
StateGraph(RetrievalState)
.add_node("retrieve", retrieve_documents)
.add_node("rerank", rerank_documents)
.add_node("synthesize", synthesize_answer)
.add_edge(START, "retrieve")
.add_edge("retrieve", "rerank")
.add_edge("rerank", "synthesize")
.compile()
)
2. Business Logic Orchestration
- Data transformations
- Multi-service coordination
- Complex decision trees for backend operations
- ETL-style workflows
3. Knowledge Base Access
However, knowledge base access needs to be transformed into a tool that Parlant can call:
import parlant.sdk as p
@p.tool
async def search_knowledge_base(
context: p.ToolContext,
query: str
) -> p.ToolResult:
"""Search the knowledge base for information to answer customer questions."""
# Call your LangGraph retrieval workflow
result = await retrieval_graph.ainvoke({"query": query})
return p.ToolResult(
data=result["synthesized_answer"],
metadata={
"sources": [doc.metadata for doc in result["reranked_docs"][:3]]
}
)
What Migrates to Parlant
Move to Parlant everything related to conversational behavior:
1. Routing Logic Instead of a supervisor deciding which agent to call, use Parlant's dynamic guideline matching to create a single, coherent, and comprehensively grounded agent:
# The supervisor's routing logic becomes contextual guidelines
await agent.create_guideline(
condition="The customer asks about their account balance",
action="Provide their account balance ",
tools=[get_account_overview],
)
await agent.create_guideline(
condition="The customer wants to change or upgrade their data plan",
action="Recommend appropriate upgraded plans based on their needs",
tools=[get_current_plan, find_available_plans],
)
2. Conversational Prompts General "job description" parts of the prompt go into agent description. Instructional sections about "how to converse" become granular guidelines:
agent = await server.create_agent(
name="Telecom Support Agent",
description="""\
A helpful customer support agent for a telecom company,
assisting with billing, data plans, and technical support"""
)
# From LangGraph prompt: "Always verify the customer's account before discussing billing details"
await agent.create_guideline(
condition="You're about to discuss sensitive billing information",
action="Ask for their PIN to verify their identity before proceeding",
)
# From LangGraph prompt: "Be clear and transparent about all charges"
await agent.create_guideline(
condition="You're explaining charges to a customer",
action="Explain each charge clearly, and proactively address common concerns",
)
3. Multi-Step Conversation Flows Sequential interaction designs become Parlant journeys:
# Plan upgrade flow
journey = await agent.create_journey(
title="Data Plan Upgrade",
triggers=["The customer wants to upgrade their data plan"],
description="Guide customer through plan selection and upgrade process"
)
t1 = await journey.initial_state.transition_to(
tool_state=get_current_plan,
)
t2 = await t1.target.transition_to(
chat_state="Present current usage and ask about their needs"
)
t3 = await t2.target.transition_to(
tool_state=find_available_plans,
condition="Customer has shared their needs"
)
t4 = await t3.target.transition_to(
chat_state="Recommend plans based on their usage pattern"
)
t5 = await t4.target.transition_to(
chat_state="Confirm selected plan and explain next steps",
condition="Customer has selected a plan"
)
t6 = await t5.target.transition_to(
tool_state=process_plan_upgrade,
condition="Customer confirms upgrade"
)
t7 = await t6.target.transition_to(
chat_state="Confirm upgrade is complete and summarize changes"
)
Migrating Tool Calls
Tool migration has two distinct patterns depending on whether the tool represents internal workflow logic or conversational triggers.
Pattern 1: Internal Workflow Tools → Wrapped LangGraph
If your LangGraph tool implements complex multi-step logic that doesn't need to be conversationally-driven, wrap it as a Parlant tool:
Before (LangGraph internal tool):
# Complex multi-step retrieval in LangGraph
from langgraph.graph import StateGraph, START
class RetrievalState(TypedDict):
query: str
documents: list
answer: str
def retrieve(state: RetrievalState) -> RetrievalState:
docs = vector_store.search(state["query"], k=20)
return {"documents": docs}
def rerank(state: RetrievalState) -> RetrievalState:
reranked = reranker.rerank(state["query"], state["documents"])
return {"documents": reranked[:5]}
def synthesize(state: RetrievalState) -> RetrievalState:
answer = llm.generate(state["query"], state["documents"])
return {"answer": answer}
retrieval_workflow = (
StateGraph(RetrievalState)
.add_node("retrieve", retrieve)
.add_node("rerank", rerank)
.add_node("synthesize", synthesize)
.add_edge(START, "retrieve")
.add_edge("retrieve", "rerank")
.add_edge("rerank", "synthesize")
.compile()
)
After (Wrapped as Parlant tool):
import parlant.sdk as p
@p.tool
async def search_policy_docs(
context: p.ToolContext,
query: str
) -> p.ToolResult:
"""Search company policy documentation for relevant information."""
# Run the LangGraph workflow
result = await retrieval_workflow.ainvoke({"query": query})
return p.ToolResult(
data=result["answer"],
metadata={
"sources": [
{"title": doc.metadata["title"], "url": doc.metadata["url"]}
for doc in result["documents"]
]
}
)
# Associate with guideline
await agent.create_guideline(
condition="Customer asks about company policies, terms of service, or coverage details",
action="Search the policy documentation and provide a clear, accurate answer based on official policy",
tools=[search_policy_docs]
)
Pattern 2: Conversational Tools → Parlant Tools with Guidelines
If your tool should be triggered based on conversation context, convert it to a Parlant tool with guideline association.
Before (LangGraph with implicit tool calling):
The problem with LangGraph's implicit tool calling is that the LLM can hallucinate or infer parameter values. We've seen production cases where the LLM guesses amounts based on previous transactions ("You sent $50 last time, I'll send $50 again"), infers recipients from context without confirmation ("You mentioned John earlier, I'll send it to John").
@tool
def send_money(sender_id: str, recipient: str, amount: float) -> dict:
"""Send money to another account."""
# Process transaction
return {
"status": "success",
"transaction_id": "TXN-12345",
"amount": amount,
"recipient": recipient
}
payment_agent = create_react_agent(
model="openai:gpt-4",
tools=[send_money],
prompt="You help customers send money. Process transfers when requested."
)
After (Parlant with explicit contextual guideline and parameter annotations):
import parlant.sdk as p
from typing import Annotated
@p.tool
async def send_money(
context: p.ToolContext,
recipient: Annotated[str, p.ToolParameterOptions(
source="customer", # Must come from customer explicitly
description="The recipient's account number or username"
)],
amount: Annotated[float, p.ToolParameterOptions(
source="customer", # Must come from customer explicitly
description="The amount to transfer in USD"
)],
) -> p.ToolResult:
"""Send money from customer's account to a recipient."""
# Process the transaction
result = await payment_api.transfer(
from_account=context.customer_id,
to_account=recipient,
amount=amount
)
return p.ToolResult(
data=f"Successfully sent ${amount} to {recipient}. Transaction ID: {result['transaction_id']}",
canned_response_fields={ # If you're using canned responses...
"amount": f"${amount}",
"recipient": recipient,
"transaction_id": result["transaction_id"]
}
)
# Explicit conditional trigger
await agent.create_guideline(
condition="Customer wants to send money or transfer funds to someone",
action="Process the transfer and confirm the transaction details",
tools=[send_money],
)
Parlant's ToolParameterOptions solves the hallucination problem by explicitly controlling where each parameter comes from. With source="customer", the Parlant agent must ask the customer directly and cannot infer or guess values. With source="context", parameters are pulled from secure session data and cannot be taken from conversation history.
Adopting Parlant's Event-Driven Conversation API
One of the most significant advantages you'll gain from migrating to Parlant is its event-driven conversational API. This is fundamentally different from the request-reply pattern most LangGraph implementations use.
The Limitation of Request-Reply
Traditional chatbot APIs (and most LangGraph deployments) work like this:
# Traditional pattern - tightly coupled request/response
@app.post("/chat")
async def chat(user_message: str, session_id: str):
# Process single message
response = langgraph_agent.invoke({"messages": [{"role": "user", "content": user_message}]})
# Return single response
return {"response": response["messages"][-1]["content"]}
This pattern has several limitations:
- No multi-message input: Users must cram everything into one message
- Rigid synchronous responses: Agent must respond to a user trigger, can't follow up later
- No interruptions: Can't handle "wait, cancel that" from users
- No proactive engagement: Agent can't initiate conversation
- No typing indicators or status: Poor UX during long-running operations
Parlant's Event-Driven Model
Parlant treats conversations as streams of events in both directions:
Benefits You Get Automatically
With this event-driven architecture, you automatically support:
1. Multi-Message Input
// Frontend (users) can send multiple messages before agent responds
await conversation.sendMessage("Hi there");
await conversation.sendMessage("I need to upgrade my plan");
await conversation.sendMessage("Oh, and check my bill too, please");
// Agent sees all three in context and responds once
2. Proactive Agent Follow-Up
@p.tool
async def purchase_flight_ticket(
context: p.ToolContext,
flight_details: str
) -> p.ToolResult:
"""Schedule a proactive follow-up message."""
# Schedule async task
asyncio.create_task(follow_up_on_flight_ticket_purchase(
context.session_id,
flight_details
)) # In a real implementation, you'd need to clean up this task in a done-callback
return p.ToolResult(data="Ticket purchase initiated. You'll receive an update shortly.")
async def follow_up_on_flight_ticket_purchase(session_id: str, flight_details: str):
await flight_api.book_ticket(flight_details)
# Emit agent message proactively
await client.sessions.createEvent(session_id, {
"kind": "message",
"source": "ai_agent",
"guidelines": [
{"action": "Confirm flight ticket was booked."},
],
})
3. Interruptions and Cancellations
# User can interrupt ongoing operations
await agent.create_guideline(
condition="Customer says 'cancel', 'stop', 'never mind', or similar",
action="Confirm the cancellation, and ask what they'd like to do instead"
)
If You Want to Keep Your Existing UI's API Contract...
If you've already built a frontend around a traditional request/response API and can't migrate immediately, you can create an adapter easily. Our docs show how to build a Custom Frontend that translates between your existing API and Parlant's event-driven model.
However, we strongly recommend migrating to the event-driven pattern for production use. It provides a significantly better user experience and is more robust for real-world conversational applications.
Architecture Recommendation: Parlant as API, LangGraph as Backend
Now that we've covered the individual migration pieces, let's look at the recommended overall architecture:
Layer Responsibilities
Conversational Control Layer (Parlant)
- Manages conversation state and context
- Dynamically loads relevant guidelines and journeys
- Handles multi-turn dialogue and topic transitions
- Ensures compliance and business rule adherence
- Composes user-facing messages
- Manages event-driven conversation API
Backend Orchestration Layer (LangGraph)
- Complex multi-step workflows (RAG, data retrieval)
- Business logic orchestration
- State management for backend operations
- Integration between multiple services
- Data transformations and processing
Data & Services Layer
- Persistent storage
- Vector databases for semantic search
- External API integrations
- Microservices for domain logic
Conclusion
Migrating from LangGraph to Parlant isn't about replacing one framework with another. Instead, it's about strategically positioning each tool where it excels.
LangGraph continues to power your backend orchestration, handling complex workflows and data processing. Parlant takes control of the conversational layer, delivering natural, coherent dialogue that scales.
What You've Gained
After migration, you have:
A cleaner architecture where conversational behavior is separated from business logic, making both easier to develop, maintain, and implement feedback from business stakeholders.
Better conversations where users can naturally discuss multiple topics, interrupt, ask follow-up questions, and receive coherent responses that maintain context.
Faster iteration where adding new conversational capabilities means writing a guideline, not refactoring graphs and prompts.
Team empowerment where business experts can refine conversational behavior without developer intervention for every change.
Production-ready AI with compliance guarantees, event-driven conversations, and enterprise features built in.
Getting Started
The best way to learn is by doing. Start with a small, contained use case:
- Pick one specialized agent from your LangGraph implementation
- Migrate its conversational logic to Parlant guidelines
- Wrap its tools as Parlant tools
- Test the conversation quality improvement
- Expand from there
Resources
- Parlant Documentation
- Parlant vs LangGraph (Conceptual)
- Parlant GitHub Repository
- LangGraph Documentation
We're Here to Help
Migrating can feel daunting, but you don't have to do it alone. Our team has helped other companies migrate from LangGraph to Parlant, and we're here to help you too.
Have you tried migrating from LangGraph to Parlant? We'd love to hear about your experience. Reach out on Discord or via our contact page.
