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.

YOUR APPa view, and a personVIEWcursor 01> review alert 4711EVENTSiter_events(after=cursor)COMMANDSsend_message, answer,steer, interrupt, cancelSESSIONon the host: runs the turns01TurnStartedreview 471102CallbackOpenedlookup_payment?03CallbackResolvedallowed04CallbackOpenedopen_case?05CallbackResolvedallowed06TurnEndedCompleted07TurnStartedwhich case?08TurnEndedCompleted09TurnStartedreview 4712–479010TurnEndedInterrupted
Step 1 of 7. The view follows the session with iter_events(). A person types, and send_message() returns a Turn as soon as the host accepts the message. The turn runs on the host.Dashed arrow: events the view reads. Solid arrow: commands. Cursor: the last event the view read (a row number here; really an opaque id).
The example agent

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

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

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

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:

Output
user: Review alert 4711.
sent: turn started=True
[Allow] Allow tools.client.lookup_payment?
allowed
[Allow] Allow tools.client.open_case?
allowed
assistant: Alert 4711 concerns a suspicious 9,800 EUR payment to a new payee (NL91 4421) at 03:12, so I opened case file CASE-4711.
Completed
user: Which case did you open?
assistant: I opened case file CASE-4711 for alert 4711.
Completed
user: Now review alerts 4712 to 4790, one at a time.
user: Skip alert 4713.
steered
Interrupted
that turn has ended: send a new message

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() and cancel() stop work. The first steer reached the running turn: its message is in the conversation. The interrupted turn then ended Interrupted, and the session takes the next message. A steer on that ended turn returned TurnConflict: send a new message instead.
Steer a turn while you follow it

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 open

follow_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:

Output
user: Review alert 4711.
[Allow] Allow tools.client.lookup_payment?
steered
allowed
user: Reply in French, please.
[Allow] Allow tools.client.open_case?
allowed
assistant: Alerte 4711 examinée : le paiement de 9 800 EUR vers un nouveau bénéficiaire (NL91 4421) à 03h12 est suspect, j'ai donc ouvert le dossier CASE-4711.
Completed
user: Now review alerts 4712 to 4790, one at a time.
[Allow] Allow tools.client.lookup_payment?
clicked Stop
Interrupted
  • 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 turn Interrupted at 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

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

Common issues

  • The turn stays Blocked: buttons stay open. A view answers nothing on its own. Something must call turn.answer(): a click, as here, or an on_approval= function given to start_session(), which answers while a call such as session.run() waits for the turn.
  • No approval ever opens. The agent's permission_mode is "auto", the default, and no permission_policy rule asks. Set permission_mode="ask", or add a rule with decision="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, as main() does, and send commands from another.