Skip to main content
Unlisted page
This page is unlisted. Search engines will not index it, and only users having a direct link can access it.

One post tagged with "migration"

View All Tags

Migrating from LangGraph to Parlant

Migrating from LangGraph to Parlant
17 min read

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:

  1. Routing Forces Single-Topic Handling: The supervisor must choose one agent, leaving parts of the customer's question unaddressed
  2. Context Fragmentation: When the conversation eventually gets routed to billing, it has lost the grounding context for plan upgrades
  3. Prompt Bloat: As you add capabilities, each agent's prompt grows, degrading accuracy and consistency due to the curse of instructions
  4. 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:

  1. No multi-message input: Users must cram everything into one message
  2. Rigid synchronous responses: Agent must respond to a user trigger, can't follow up later
  3. No interruptions: Can't handle "wait, cancel that" from users
  4. No proactive engagement: Agent can't initiate conversation
  5. 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:

  1. Pick one specialized agent from your LangGraph implementation
  2. Migrate its conversational logic to Parlant guidelines
  3. Wrap its tools as Parlant tools
  4. Test the conversation quality improvement
  5. Expand from there

Resources​

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.

github Get Migration Help

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.