Skip to main content

Journeys

Journeys let you define multi-turn conversational flows.

They are the SOPs (Standard Operating Procedures) that your agent follows when guiding a customer through a process like booking a trip, troubleshooting an issue, or processing a return.

Unlike rigid flow frameworks where each state and transition must be followed to the letter, Parlant journeys are adaptive: the agent strives to follow the flow you've defined, but may jump multiple states (if the conditions for doing so apply), revisit previous ones, or adjust its pace based on how the customer interacts.

The result is a structured process that still feels like a natural conversation.

Each journey has four components:

  1. Title: A short name to differentiate it from other journeys.
  2. Description: Orientating notes about the journey's purpose.
  3. Conditions: Contextual queries that determine when the journey should activate.
  4. States & Transitions: An optional, but often useful state diagram that defines the ideal flow.
Journey Activation

Condition matching only affects initial activation of a journey. Once a journey is active, it stays active until the next-step selection process decides otherwise. A journey's conditions are not re-evaluated on every turn once it's already in-progress.

A Worked Example​

Consider a journey for a travel booking agent:

  • Title: Book Flight
  • Conditions: The customer requested to book a flight
  • Description: This journey guides the customer through the flight booking process.

This journey activates when the customer asks to book a flight. The agent follows the flow while adapting to the customer's pace, ensuring all necessary information is collected.

Implementing the Journey​

async def create_book_flight_journey(agent: p.Agent):
journey = await agent.create_journey(
title="Book Flight",
conditions=["The customer requested to book a flight"],
description="This journey guides the customer through the flight booking process.",
)

t1 = await journey.initial_state.transition_to(
chat_state="Ask if they have a destination in mind",
)

# Branch out based on the customer's response
t2 = await t1.target.transition_to(condition="They do", chat_state="Get dates of travel")

t3a = await t1.target.transition_to(condition="They don't", tool_state=load_popular_destinations)
t3b = await t3a.target.transition_to(chat_state="Recommend a destination")

# Merge back to the main path after choosing a destination.
# This is done by transitioning into an existing state node.
await t3b.target.transition_to(state=t2.target, condition="Destination selected")

t4 = await t2.target.transition_to(chat_state="Confirm details")

t5a = await t4.target.transition_to(tool_state=book_flight)
t5b = await t5a.target.transition_to(chat_state="Provide ticket details")

States and Transitions​

A journey's state diagram is a directed graph where each node is a state and each edge is a transition (optionally with a condition).


State Types​

  1. Chat States: In this state, the agent converses with the customer while guided by the state's instruction. Note that it may spend multiple turns in a chat state before transitioning.
t = await state.transition_to(chat_state=CONVERSATIONAL_INSTRUCTION)
  1. Tool States: The agent calls a tool and loads its result into context. A tool state by itself does not produce a message and must be followed by a chat state to present the result.
t = await state.transition_to(tool_state=TOOL)
t = await state.transition_to(tool_state=TOOL, tool_instruction=OPTIONAL_HINT_ON_HOW_TO_USE_TOOL)
  1. Fork States: A pass-through routing state that evaluates conditions and branches the flow. Fork states never produce messages—they only direct flow. See Fork States (detailed) below.
fork = await state.fork()
Transitioning from Tool to Chat

When transitioning from a tool state to a chat state, the agent will automatically load the tool's result into the context, so you can use it in the chat state's action.

Note that, while technically allowed, we do not recommend that a tool state transition to another tool state; ideally, it should always be followed by a chat state, because tool usage can incur noticeable latency in agentic applications.

Instead of using sequential tool states, you should try to use a single tool state (with a dedicated tool for this purpose) to perform all necessary actions, and then follow it with a chat state to present the results to the customer.

Transitions​

  1. Direct Transitions: These transitions should always be taken. They move the conversation forward without branching.
  2. Conditional Transitions: These transitions are only taken if/when their associated condition is met.
t = await state.transition_to(chat_state=CONVERSATIONAL_INSTRUCTION, condition=CONDITION)
t = await state.transition_to(tool_state=TOOL, condition=CONDITION)

Most transitions create a new target state via chat_state or tool_state. To transition to an existing state (for example, to merge existing paths), use the state argument:

t = await state.transition_to(state=EXISTING_STATE)
t = await state.transition_to(state=EXISTING_STATE, condition=CONDITION)
Combining Conditional and Direct Transitions

If a state has a conditional transition to another state, it cannot also have a direct transition coming out of it. This is because the engine would not be able to logically determine which transition to take when the condition is met. The SDK enforces this rule.

Fork States​

Fork states are pass-through routing nodes—they evaluate conditions and direct the flow without producing any message. Here's an example of a tour package agent that branches based on customer location and age:

# Collect customer information
t1 = await journey.initial_state.transition_to(
chat_state="Ask the customer where they're from",
)

t2 = await t1.target.transition_to(
chat_state="Ask the customer for their age",
)

fork = await t2.target.fork()

t3 = await fork.target.transition_to(
chat_state="Offer premium alcohol tasting package",
condition="Customer is over 21 and from the US or Canada",
)
t4 = await fork.target.transition_to(
chat_state="Offer international experience package",
condition="Customer is over 21 but not from US/Canada",
)
t5 = await fork.target.transition_to(
chat_state="Offer family-friendly activity package",
condition="Customer is under 21",
)
When to Use Fork States

Conditional transitions from chat states often achieve the same result with less complexity.

In the example above, you could place the three conditional transitions directly on the "Ask for age" chat state and skip the fork entirely. Fork states are most useful when you need to branch after a tool state, or when you need multiple states to fan out of multiple states in the same way—in which case you could transition all of them to the same re-usable fork node rather than duplicating the same conditional transitions on each of them.

Visualizing Your Journey

Building a state diagram in code can sometimes be a bit confusing. It's often useful to visualize the journey as you build it, to ensure that the flow is clear, logical, and as you intend. Here's how it's done:

  1. Visit http://localhost:8800/journeys in your browser.
  2. Copy the ID of the journey you want to visualize.
  3. Visit http://localhost:8800/journeys/<JOURNEY_ID>/mermaid in your browser, replacing <JOURNEY_ID> with the ID you copied.
  4. Copy the generated Mermaid diagram code.
  5. Paste it into a Mermaid live editor to visualize the journey.

State Descriptions​

When a chat state's instruction needs elaboration—or when trial and error reveals the agent isn't interpreting it as intended—you can provide a more elaborate description:

t = await state.transition_to(
chat_state="Confirm the booking details",
description="""At this point, summarize all collected information: destination,
"dates, number of travelers, and selected flight options. Ask the customer
to verify everything is correct before proceeding to payment."""
)

The description is included in the agent's context when the state is active, helping it understand the action better.

Linking Journeys​

You can link journeys together by transitioning to another journey. This allows you to create modular, reusable journeys that can be composed into larger flows. Transitions can be either direct or conditional.

t = await state.transition_to(journey=EXISTING_JOURNEY)
t = await state.transition_to(condition=CONDITION, journey=EXISTING_JOURNEY)

This returns a p.JourneyTransition that lets you continue building the flow after the sub-journey completes.

Journey-Scoped Guidelines​

Guidelines scoped to a journey are only active when that journey is active. At all other times, these guidelines are ignored. This is the recommended way to handle edge cases and digressions within a specific flow:

Journey-scoped guidelines also help you maintain a clean and organized conversation model, ensuring that certain behavioral rules are only evaluated in their intended context (i.e., when the journey is active).

await journey.create_guideline(
condition="The patient says their visit is urgent",
action="Tell them to call the office immediately",
)

This guideline won't interfere with other journeys or general agent behavior — it only fires in the context of the scheduling journey.

You can also attach tools to journey-scoped guidelines:

@p.tool
async def transfer_to_human_agent(context: p.ToolContext) -> p.ToolResult:
...

guideline = await journey.create_guideline(
condition="the customer says they're unable to pay"
action="connect them with a human agent",
tools=[transfer_to_human_agent],
),
Instruction Precedence

Please note that, in general, Parlant agents give more weight to guidelines than to journey states, as guidelines are treated as more specific behavioral overrides. This means that, if a guideline is matched, it will tend to take precedence over the active journey states.

Learn More

To learn more about guidelines, check out the Guidelines page.

Match Handlers​

You can register handlers on journeys or individual states to trigger side effects like logging, analytics, or external integrations. Each level supports two hooks: on_match (before the response) and on_message (after the response).

Journey-Level Handlers​

These fire for the journey as a whole, every time it is matched, regardless of which state is active:

async def on_journey_activated(ctx: p.EngineContext, match: p.JourneyMatch) -> None:
await analytics.track("journey_hit", {
"journey": match.journey_id,
"customer": ctx.customer.id,
})

journey = await agent.create_journey(
title="Book Flight",
conditions=["The customer requested to book a flight"],
description="This journey guides the customer through the flight booking process.",
on_match=on_journey_activated,
)
  • on_match fires when the journey is activated, before the agent generates its response.
  • on_message fires after the agent sends a message while the journey is active.

State-Level Handlers​

Fire for specific states, giving you finer-grained hooks at individual points in the flow.

on_match — called when the agent transitions to a state, before generating its response:

async def my_handler(ctx: p.EngineContext, match: p.JourneyStateMatch) -> None:
...
await state.transition_to(chat_state=CHAT_STATE, on_match=my_handler)

on_message — called after the agent has generated and sent a message while in that state:

await state.transition_to(chat_state=CHAT_STATE, on_message=my_handler)
github Questions? Reach out!

Common Patterns​

Marking Terminal States​

You can explicitly mark the end of a journey by transitioning to p.END_JOURNEY:

await state.transition_to(state=p.END_JOURNEY)

This signals that the journey is complete at this point in the flow. A journey can have multiple endpoints — for example, ending after a successful booking or after the customer cancels:

# Success path
t5 = await t4.target.transition_to(chat_state="Confirm the appointment has been scheduled")
await t5.target.transition_to(state=p.END_JOURNEY)

# Cancellation path
t8 = await t7.target.transition_to(chat_state="Ask the patient to call the office")
await t8.target.transition_to(state=p.END_JOURNEY)

Note that states with no follow-up transitions are inherently terminal — the journey ends when there's nowhere left to go. p.END_JOURNEY is most useful when a state might continue to another state or end the journey depending on conditions, making the intent explicit to both the engine and other developers reading the code.

Branching and Merging Paths​

Real-world journeys often branch into alternative paths and later merge back into a shared flow. You achieve this by transitioning into an existing state node rather than creating a new one:

# Branch: alternative path
t6 = await t2.target.transition_to(tool_state=get_later_slots, condition="None work")
t7 = await t6.target.transition_to(chat_state="List later times")

# Merge: reconnect to the happy path's confirmation state
await t7.target.transition_to(state=t3.target, condition="The patient picks a time")

This keeps the journey DRY — the confirmation and scheduling logic is defined once and reused by both paths.

github Questions? Reach out!

Best Practices​

1. Transition Completeness​

When a state has multiple conditional transitions, the conditions should be non-overlapping and exhaustive — they should cover all possible continuations without ambiguity.

DON'T
# Bad: overlapping conditions — "over 21" is a subset of "over 18"
t1 = await age_state.transition_to(
chat_state="Offer standard plan",
condition="The customer is over 18",
)
t2 = await age_state.transition_to(
chat_state="Offer premium plan",
condition="The customer is over 21",
)
DO
# Good: non-overlapping, exhaustive conditions
t1 = await age_state.transition_to(
chat_state="Explain they are not eligible",
condition="The customer is under 18",
)
t2 = await age_state.transition_to(
chat_state="Offer standard plan",
condition="The customer is 18-20",
)
t3 = await age_state.transition_to(
chat_state="Offer premium plan",
condition="The customer is 21 or older",
)
info

This is a best practice, not an SDK-enforced constraint. The engine will do its best to pick the right transition even with imperfect conditions, but clear, non-overlapping conditions significantly improve reliability.

2. Avoid Forks When Possible​

Conditional transitions from chat states work just as well as forks and are less cumbersome. During evaluation, fork states get "squished" — their conditions are folded into the conditions of their follow-up states — which adds evaluation cost without adding expressiveness. Prefer direct conditional transitions from chat states whenever the preceding state is already a chat state.

3. Consecutive Forks Are Not Allowed​

Two consecutive fork states are always redundant — any journey with back-to-back forks can be simplified into a single fork with more conditions. The SDK enforces this constraint and will raise an error if you attempt it.

4. Verify Before Important Tool Calls​

Always add a confirmation chat state before tool states that perform significant actions (e.g., scheduling appointments, processing payments, placing orders). This gives the customer a chance to review and correct details before the action is executed — as demonstrated in the healthcare example's "Confirm details" state before schedule_appointment.

5. Use Journey-Scoped Guidelines When Possible​

Prefer journey-scoped guidelines over global guidelines for behavior that only applies within a specific flow. Journey-scoped guidelines are only evaluated when their parent journey is active, which prevents redundant guideline checks and reduces the number of LLM calls needed per response.

Journey vs. Task Automation​

Journeys guide conversations, not complex task automations. Complex business logic belongs in tools, while journeys define how the agent interacts with the customer.

DON'T

This is not a valid journey—it's a task automation flow with no customer interaction:

DO

This is a valid journey—it guides the customer through a conversational process:

How Journey Matching Works​

Before each response, Parlant determines which journeys and states are relevant through a multi-phase process:

  1. Journey condition matching: Journey conditions are evaluated by the GuidelineMatcher, matching the conversation context against each journey's activation conditions.
  2. State loading: For matched journeys, the engine loads the relevant next states based on the last executed state, along with their transitions.
  3. Node selection: The engine determines which state the agent should be in (see below).
  4. Message composition: The selected state's instruction, along with any matched guidelines, feeds into the message composer.

When a journey matches, the engine determines the agent's position through two parallel evaluations:

  1. Backtrack Check: Has the customer changed a previous decision, or do they want to redo part of the journey? If so, the engine traverses the graph to find the right step to return to.

  2. Next Step Selection: Has the current step been completed? If so, which transition should be taken? This uses reachable follow-ups—pre-computed multi-step paths from the current node—so the engine can fast-forward through multiple nodes in a single evaluation when the customer provides several pieces of information at once.

If backtracking is needed, the backtrack result takes priority and the next-step result is discarded. Otherwise, the next-step result advances the journey forward.