Build a session app
A session app shows what an agent does and sends a person's commands to its session. Events come from the session, each with an id, in order, so a view can continue from the last one it read. Commands go to the session: a message, an approval answer, a steer or an interrupt. Each command returns once the session accepts it, not when the turn ends. Watching never changes anything: closing a view leaves the work running.
The example agent
The app uses an alert_reviewer with the tools of Step through a session, lookup_payment and open_case, that answers in one sentence. The agent has permission_mode="ask" and the session has no on_approval=, so every tool call waits for a click:
import asyncio
from mistralai import agents
# lookup_payment and open_case: as in Step through a session
alert_reviewer = agents.Agent(
model="mistral-large-4",
instructions=(
"Review the alert: look up its payment first. If the payment looks suspicious, open a case file. "
"Answer in plain text, in one sentence."
),
tools=[lookup_payment, open_case],
permission_mode="ask",
)Watch the session
A view keeps what it shows on a screen: the open approvals as buttons, the messages as lines, how many turns ended, and the cursor, the id of the last event it read:
class Screen:
"""What one view shows: the open approvals, the conversation, and where it stopped reading."""
def __init__(self) -> None:
self.cursor: str | None = None
self.buttons: dict[str, agents.OpenCallback] = {}
self.lines: list[str] = [] # one line per message
self.ended = 0 # the turns that ended
self.changed = asyncio.Condition()
async def update(self) -> None:
async with self.changed:
self.changed.notify_all()The view reads the session's events with iter_events(), from its cursor, and draws each one:
async def view(session: agents.Session, screen: Screen) -> None:
"""Watch the session and draw it. A view that opens again continues from screen.cursor."""
async for event in session.iter_events(after=screen.cursor):
match event:
case agents.CallbackOpened(callback=callback):
screen.buttons[callback.id] = callback
print(f"[Allow] {callback.title}")
case agents.CallbackResolved(callback_id=callback_id):
screen.buttons.pop(callback_id, None)
case agents.EntryCompleted(entry=agents.MessageEntry(role="user" | "assistant") as message):
screen.lines.append(f"{message.role}: {message.text}")
print(screen.lines[-1])
case agents.TurnEnded(state=state):
screen.ended += 1
print(type(state).__name__)
screen.cursor = event.id
await screen.update()CallbackOpened carries an open approval, and the view shows a button for it. CallbackResolved says an approval is no longer open: a client answered it, this view or another, or its turn ended. The view removes the button. EntryCompleted carries each entry of the session's history once it is whole: the view draws the user's and the agent's messages. TurnEnded carries the turn's record. The view moves its cursor past every event, the ones it draws and the ones it skips. iter_events() follows the session until the session ends.
Send commands
A person's click and a person's message are commands. The click answers the approval on the turn that opened it:
async def click_allow(session: agents.Session, callback: agents.OpenCallback) -> str:
mine = agents.AllowApproval()
match await session.turn(callback.turn_id).answer(callback.id, mine):
case agents.Answered(answer=applied) if applied != mine:
return "the other view answered first"
case agents.Answered():
return "allowed"
case agents.AlreadySettled():
return "no longer open"
case agents.NotFound():
return "no such approval"
async def on_typed(session: agents.Session, text: str) -> agents.Turn:
return await session.send_message(text) # starts a turn, or adds the message to the running one
async def on_steer(turn: agents.Turn, text: str) -> str:
match await turn.steer(text):
case agents.TurnConflict():
return "that turn has ended: send a new message"
case agents.Turn():
return "steered"The arms cover the three results of answer(): Answered, AlreadySettled and NotFound. When two views answer the same approval at the same time, both get Answered, and answer is the answer that counted: compare it with yours. AlreadySettled means the approval is no longer open: someone answered it first, or its turn or session ended. send_message() starts a turn when none runs, and adds the message to the running turn otherwise. turn.steer() adds a message to that one turn, and returns TurnConflict once the turn has ended. Approvals covers on_approval= functions.
Run the app
The person in this script waits for the screen to change, then clicks the first button. The button goes as soon as it is clicked, so the next wait does not find it again. click_all clicks until one more turn has ended, so an approval the model asks for later is clicked too:
async def next_click(screen: Screen, ended: int) -> agents.OpenCallback | None:
"""Wait for a button to click, or None once more than `ended` turns have ended."""
async with screen.changed:
await screen.changed.wait_for(lambda: screen.buttons or screen.ended > ended)
return next(iter(screen.buttons.values()), None)
async def click_all(session: agents.Session, screen: Screen, ended: int) -> None:
"""Click Allow on each button, until more than `ended` turns have ended."""
while (callback := await next_click(screen, ended)) is not None:
screen.buttons.pop(callback.id) # the button goes once clicked
print(await click_allow(session, callback))main() runs the view as a task, and sends the person's commands from the main task:
async def main() -> None:
session = await alert_reviewer.start_session()
screen = Screen()
watcher = asyncio.create_task(view(session, screen))
turn = await on_typed(session, "Review alert 4711.")
print(f"sent: turn started={turn.started}")
await click_all(session, screen, ended=0)
watcher.cancel() # the view closes: the session keeps running
turn = await on_typed(session, "Which case did you open?")
await asyncio.sleep(5) # stand-in for the time the app stays closed: the turn keeps running
watcher = asyncio.create_task(view(session, screen)) # a new view, from the cursor
await click_all(session, screen, ended=1)
turn = await on_typed(session, "Now review alerts 4712 to 4790, one at a time.")
print(await on_steer(turn, "Skip alert 4713.")) # the turn runs: the message joins it
await turn.interrupt() # stops this turn; the session stays open
await click_all(session, screen, ended=2)
print(await on_steer(turn, "Skip alert 4714.")) # the turn has ended
await session.cancel() # ends the session and its running turns
await watcher # the view's loop ends with the session
asyncio.run(main())It prints:
The output shows three rules:
- Approvals reach the view as events. Each tool call opened an approval, the view showed a button, and the click let the turn continue.
- Leaving never stops the work.
asyncio.sleep(5)stands for the time the app is closed: the turn ran and ended with no view open. The new view started from the saved cursor, so it read only the question and the reply it missed. - A steer joins a running turn; only
interrupt()andcancel()stop work. The first steer reached the running turn: its message is in the conversation. The interrupted turn then endedInterrupted, and the session takes the next message. A steer on that ended turn returnedTurnConflict: send a new message instead.
Steer a turn while you follow it
A chat app can also show a turn in the call that waits for it: turn.result(on_event=show) calls show with each of the turn's events as they come. Meanwhile, a separate UI task sends the person's commands: a steer, a click on Allow, a click on Stop. The session has no on_approval= function, so the person answers each approval.
follow() shows the reply as the model writes it, and puts each approval in the UI's buttons queue:
async def follow(turn: agents.Turn, buttons: asyncio.Queue[agents.OpenCallback]) -> None:
"""Show the turn while it runs: the reply as the model writes it, and each approval as a button."""
async def show(event: agents.Event) -> None:
match event:
case agents.EntryCompleted(entry=agents.MessageEntry(role="user") as message):
print(f"user: {message.text}")
case agents.EntryAdded(entry=agents.MessageEntry(role="assistant")):
print("assistant: ", end="")
case agents.TextDelta(text=text):
print(text, end="", flush=True) # the reply, a few words at a time
case agents.EntryCompleted(entry=agents.MessageEntry(role="assistant")):
print()
case agents.CallbackOpened(callback=callback):
print(f"[Allow] {callback.title}")
buttons.put_nowait(callback) # the UI shows the button
state = await turn.result(on_event=show)
while isinstance(state, agents.Blocked): # an approval waits for the person: keep following
state = await turn.result(after=state, on_event=show)
print(type(state).__name__)EntryAdded starts the reply's line, each TextDelta adds the next words, and EntryCompleted ends the line. When an approval opens, turn.result() returns Blocked. follow() waits again with after=state, so it keeps following past that approval while the person answers it, and shows no event twice.
The person's commands come from the UI task. Two stand-ins for a person: one steers at the first button, then clicks Allow on each button; the other clicks Stop:
async def steer_then_allow(
session: agents.Session, turn: agents.Turn, buttons: asyncio.Queue[agents.OpenCallback]
) -> None:
"""The person at the UI: types a steer when the first button shows, then clicks Allow on each button."""
callback = await buttons.get()
print(await on_steer(turn, "Reply in French, please."))
while True:
print(await click_allow(session, callback))
callback = await buttons.get()
async def stop(turn: agents.Turn, buttons: asyncio.Queue[agents.OpenCallback]) -> None:
"""The person at the UI: clicks Stop when the first button shows."""
await buttons.get()
print("clicked Stop")
await turn.interrupt() # stops this turn; the session stays openfollow_and_steer() starts a UI task next to each wait:
async def follow_and_steer() -> None:
session = await alert_reviewer.start_session()
buttons: asyncio.Queue[agents.OpenCallback] = asyncio.Queue()
turn = await on_typed(session, "Review alert 4711.")
ui = asyncio.create_task(steer_then_allow(session, turn, buttons)) # the UI sends commands meanwhile
await follow(turn, buttons)
ui.cancel()
turn = await on_typed(session, "Now review alerts 4712 to 4790, one at a time.")
ui = asyncio.create_task(stop(turn, buttons))
await follow(turn, buttons)
await ui
await session.cancel()
asyncio.run(follow_and_steer())It prints:
- The steer joined the running turn. Its message came back in the events, and the reply that followed is in French.
- Each approval waited for a click, and the events kept coming. The UI task answered while
follow()waited. - Stop ended the turn, not the session.
interrupt()ended the second turnInterruptedat its first approval; the session takes the next message.
Send the commands from their own task: show runs inside the wait, so a show that waits for the person holds back the next events.
Reopen the view from its cursor
A view's cursor is all it needs to continue. Store screen.cursor with the session id. A view that opens again, in this process or another one, binds the session by its id and reads from there. It misses no event except a reply's TextDeltas, and repeats none:
session = alert_reviewer.session(session_id) # sends no request
async for event in session.iter_events(after=stored_cursor):
...Another process can bind a session this way when you deployed the agent with Studio (self-hosted or Mistral-managed): see Run modes. It can also answer an approval by its ids, with session.turn(turn_id).answer(callback_id, ...).
Common issues
- The turn stays
Blocked: buttons stay open. A view answers nothing on its own. Something must callturn.answer(): a click, as here, or anon_approval=function given tostart_session(), which answers while a call such assession.run()waits for the turn. - No approval ever opens. The agent's
permission_modeis"auto", the default, and nopermission_policyrule asks. Setpermission_mode="ask", or add a rule withdecision="ask". - The same task watches and drives. A task that loops on
iter_events()never returns to send a click. Run the view as its own task, asmain()does, and send commands from another.
Next
- Callbacks: answer client tool calls and sign-ins as well as approvals.
- Events: the event types and cursors.
- Run modes: run the same app against a deployed agent.
- API reference: Session and Turn:
Session.iter_events,Turn.steer,Turn.interruptandTurn.answer.