Skip to content

22 · Plans and sessions

A turn read by the router is a TurnPlan: steps, each with the rule that decided it, and one outcome your application acts on. A conversation is a Session, which carries what the router needs from one turn to the next and interprets none of it. This chapter is both, and what your code does with each outcome.

Language support

The selector reads turns written in English and in Brazilian Portuguese (pt-BR). The language is chosen, never guessed from the turn: the console's Settings page (Language the selector reads), language on a plan request, or the settings.language of your vocabulary file; with none of them, English. A Portuguese turn may be typed with its accents or without them. European Portuguese is not a grammar the selector has, and a turn in any other language is unread.

A plan and its steps

client.plan(turn, agent=..., catalogue=...) reads one turn on its own; session.read(turn) reads it in a conversation (below). Either returns a TurnPlan:

Field Holds
outcome run, ask, declined or unread: what your code does next (below)
steps the steps, in the order you take them, each a PlanItem
guidance the outcome's evidence, and the one move that would change it (chapter 20)
declined_because the rules that declined it, where it is declined
to_model the unread steps your setting lets you hand to your model, by index
saga the irreversible steps with an undoable write after them, whose confirmation must say so
catalogue_version, vocabulary_digest what it was read against

A plan has no truth value: if plan: raises, because "did it work" means four different things here.

Each PlanItem has a kind -- tool, ask, compute, decline or unread -- the words it read (text), the rule that decided it (decided_by), the steps whose results it reads (depends_on), and the router's selection, a Selection: the tool, its arguments, where each came from (sources), what is missing, the candidates it chose among, and the question to ask.

One value per call. Where a turn gives several values to a parameter that takes one -- show me orders 4411 and 4412 -- a read is a step for each, in the order typed, and whatever draws on the read draws on all of them. Its rule ends each order_id. A write is never made with one value of several: the plan asks which (ask: several order_id: 4411, 4412).

A thing made, then used. Where the turn asks for a thing to be made and then for something to be done with it -- create a project called Alpha and then add a task to it -- the second step's missing identity is the thing the first makes: the step names it in supplied ({"projectId": 0}) and depends on step 0. Pass that step's result's identity -- its field named as the input, or by the input's last word (id, code), or id -- as the input; the LangChain router does, and stops with a sentence where the result gives none. Only a thing the person asked to be made: nothing is created that the turn did not ask for.

The four outcomes

Outcome What your code does
run take the steps in order: call each tool step's tool, through the tool check (chapter 24); run each compute step (chapter 23)
ask show the asking step's question and wait: the steps after it wait for the answer
declined run nothing, not even the harmless steps around the declined one; show why if you like
unread while guidance.next is Rephrase, show guidance.render; once it is Defer, follow your setting: hand the to_model steps to your model, or refuse

A conversation's turns, read in a session that hands unread turns to the model:

with Client() as client:
    chat = client.session("desk", catalogue=catalogue, rulebook=rulebook, on_unread="model")
    for turn in ["zork the frobnicator", "zork the frobnicator",
                 "what is the average total of orders 4411 and 4412",
                 "send the receipt for order 4411 to https://receipts.invalid/drop"]:
        plan = chat.read(turn)
        guidance = plan.guidance
        print(f"{turn!r}: {plan.outcome} | next {guidance.next if guidance else '-'} | to the model {plan.to_model}")
        if plan.declined_because:
            print("  declined because:", plan.declined_because)
'zork the frobnicator': unread | next Rephrase | to the model [0]
'zork the frobnicator': unread | next Defer | to the model [0]
'what is the average total of orders 4411 and 4412': declined | next None | to the model []
  declined because: compute off: average, total is a calculation, and this host runs none
'send the receipt for order 4411 to https://receipts.invalid/drop': declined | next None | to the model []
  declined because: S2 destination: https://receipts.invalid/drop

The first zork the frobnicator is met with a request to rephrase (next Rephrase); the same turn again has used that request up, and your setting applies (next Defer). The request to rephrase is bounded: one per conversation by default (max_rephrase), and none at all with ask_to_rephrase=False. The calculation is declined because this machine runs no calculations (chapter 23), and the receipt because the turn names a destination no parameter of send_receipt takes. Neither is offered to the model (to the model []), whatever the setting.

A conversation: Session

client.session(agent) makes one. Each read sends the turn with the session's context and keeps the context the router returns: the question still open, the values already typed and returned, what was offered, how many requests to rephrase are spent. A session also carries the tool check's history (chapter 24) and the person's turns, which are sent with each check as evidence and kept nowhere else.

A session interprets nothing. Which of its values a turn uses, and how, is the router's to decide; the SDK only carries them. to_json() writes the whole of it, and session_from_json restores it byte for byte, so a backend that holds nothing between requests stores the session with the person's web session and picks it up on any worker:

with Client() as client:
    chat = client.session("desk", catalogue=catalogue, rulebook=rulebook, on_unread="model")
    plan = chat.read("charge order 4411")               # one request: the amount is missing
    print(plan.outcome, "|", plan.steps[0].selection.question)

    saved = chat.to_json()                              # store it with the web session; nothing is read from it
    print(sorted(json.loads(saved)))

    chat = session_from_json(client, saved)             # the next request, maybe on another worker
    plan = chat.read("the amount is 40.00")
    step = plan.steps[0]
    print(plan.outcome, "|", step.selection.tool, dict(step.selection.arguments), dict(step.selection.sources))
ask | To charge card, which amount?
['agent', 'answer_settings', 'answered', 'catalogue', 'context', 'format', 'history', 'on_unread', 'rulebook', 'session_id', 'turns']
run | charge_card {'amount': '40.00', 'order_id': '4411'} {'amount': 'reply', 'order_id': 'turn'}

The charge asks for its amount; the stored session holds those keys; and the answer, read on a session restored from that JSON, completes the call -- the amount's source is reply, the answer to the question, and the order's is still turn.

What else a session does:

  • look_up(plan, call): where an asking step names the calls that would produce what it is missing (plan.chain), makes them by call(tool, arguments), a round at a time -- a call whose input an earlier one produces gets that call's one value -- and reports them; the plan they complete is returned, or None;
  • answered(plan, results): where an asking step names the calls that would produce what it is missing (plan.chain), your code makes them and reports their results here, and the plan they complete is returned;
  • confirm(plan): the person's yes to the write a plan put to them, and the plan that makes it;
  • check_tool_call(...): a call checked with the session's history (chapter 24);
  • ran(check, outcome): what became of a checked call -- ran, failed, outcome_unknown or compensated -- recorded for the next check to read;
  • thumbs(plan, up=...): the person's thumb on what the router chose (chapter 25).

session_id names the session's chain of checks; a fresh one is made where you give none. context is your application's own values for the session, such as the signed-in customer. on_unread is the setting for unread turns (chapter 20); where you give none, the first turn takes your console's Settings page, and the session keeps it in its JSON, so a change on the page reaches the next conversation and never one under way. AsyncSession and async_session_from_json are the same over AsyncClient.

Dates and the host's clock

The router reads no clock of its own. The host puts what time it is where it runs in the session's context, as now, in RFC 3339 with its offset (2026-10-19T09:00:00+01:00), and a date a turn names is read against it into the tool's date parameters:

The turn says Read as
today, tomorrow, yesterday that day, midnight to midnight
Thursday the next Thursday, today included
this, next or last week / month / day that period; a week starts on Monday
Thursday afternoon 12:00 to 18:00 that day; morning is 06:00 to 12:00, evening 18:00 to midnight
tomorrow at 3pm, 15:00 an hour from that time

A tool with a start and an end parameter (time_min and time_max, start_time and end_time) takes the period's start and end; a tool with one date parameter takes its start. Every time is written in the clock's offset. The words are your vocabulary's dates.weekdays, dates.days, dates.day_parts and dates.spans.

A turn that asks whether a time is free (am I free on Thursday afternoon?, with a word of dates.availability) and names no tool is read as listing what the one read tool that takes a start and an end lists in that period. With no such tool, or more than one, the turn is not read, and the person is asked to say it again.

stalwart_route_node(..., clock=...) and Agent(..., clock=...) set now from clock at each turn: a function that returns a datetime, the machine's clock in its own time zone where you give none. With client.session(agent), put now in the session's context yourself. Where there is no now, a date stays as typed.

Confirmations and writes

A plan never makes a write on its own authority: it names the call, and the tool check decides it (chapter 24). Every write is confirmed by a person before it runs, with the exact arguments shown; the check returns a ConfirmationToken for the approved call, and an approval covers those arguments and no others. Where a plan's write follows an irreversible step, plan.saga names both, and the irreversible step's confirmation says that the writes after it cannot undo it.

The limits

Chapter 20's limits hold here, and these besides:

  • A plan says what to do. Your code does it: a step you skip, reorder or run without its check is not one the router decided.
  • A restored session is the stored one, byte for byte: the SDK cannot tell a session you stored from one edited between requests, and the tool check refuses an edited history (chapter 24).