Slack bot: one session per thread

A Slack bot keeps one session per thread, and the bot can restart at any time. It stores three values per thread: the session id, the cursor it has read up to, and the last turn id. Every handler binds the session again from the store, so any process that Slack calls can answer.

Demonstrates: binding a session by its id, following its events from a stored cursor, answering an approval by id, and a message that starts a turn or steers the running one.

Run: put the code blocks titled slack_bot.py in one file, in order. With the SDK installed and MISTRAL_API_KEY set, run python slack_bot.py.

For a full Slack app, with a real Slack connection and issues filed in Linear, see the Slack feedback triage bot. This page keeps only the session handling, with Slack faked in the terminal.

On an agent deployed with Studio (self-hosted or Mistral-managed), any process can bind the session. In your process, only the event loop that started it can, so the demo runs in one program.

The demo keeps the store in a dict. For a bot that restarts, keep it in a database: on start, read it back and start one follower per stored thread.

The agent

The agent

The agent asks before each tool call:

import asyncio

from mistralai import agents

@agents.tool
def list_branches() -> list[str]:
    """List the repository's stale branches."""
    return ["fix/typo", "exp/old-cache"]

@agents.tool
def delete_branch(name: str) -> str:
    """Delete a branch."""
    return f"deleted {name}"

agent = agents.Agent(
    model="mistral-large-4",
    instructions="Clean up stale branches with your tools, one call at a time. Answer in one short sentence.",
    tools=[list_branches, delete_branch],
    permission_mode="ask",  # every tool call asks for approval
)
The bot's code

The bot's code

A FakeSlack stub plays Slack. It clicks Allow as soon as it shows the buttons, then clicks the same button again:

class FakeSlack:
    """Slack, played in the terminal: replace it with your Slack client's calls."""

    async def post(self, thread: str, text: str) -> None:
        print(f"[{thread}] {text}")

    async def show_buttons(self, thread: str, title: str | None, value: str) -> None:
        print(f"[{thread}] buttons: {title}")
        # In this demo, a person clicks Allow at once, then clicks the same button again.
        await bot.on_click(thread, value, allow=True)
        await bot.on_click(thread, value, allow=True)

    async def close_buttons(self, callback_id: str) -> None:
        print("buttons closed")
class SlackBot:
    """One session per Slack thread. The store maps a thread to [session id, cursor, last turn id]."""

    def __init__(self, agent: agents.Agent, slack: FakeSlack) -> None:
        self.agent = agent
        self.slack = slack
        self.store: dict[str, list[str | None]] = {}
        self.followers: list[asyncio.Task[None]] = []

    async def on_message(self, thread: str, timestamp: str, text: str) -> None:
        if thread not in self.store:
            session = await self.agent.start_session()  # the conversation outlives the bot
            self.store[thread] = [session.id, None, None]
            self.followers.append(asyncio.create_task(self.follower(thread)))
        session = self.agent.session(self.store[thread][0])
        turn = await session.send_message(text, idempotency_key=timestamp)  # starts or steers
        self.store[thread][2] = turn.id  # the turn a stop button stops

    async def on_click(self, thread: str, value: str, allow: bool) -> None:
        turn_id, callback_id = value.split("/")
        answer = agents.AllowApproval() if allow else agents.DenyApproval()
        match await self.agent.session(self.store[thread][0]).turn(turn_id).answer(callback_id, answer):
            case agents.Answered(answer=applied) if applied != answer:
                await self.slack.post(thread, "someone answered first")
            case agents.AlreadySettled():
                await self.slack.post(thread, "someone already answered that")

    async def on_stop(self, thread: str) -> None:
        session_id, _, turn_id = self.store[thread]
        if turn_id is not None:
            await self.agent.session(session_id).turn(turn_id).interrupt()  # TurnConflict: it had ended

    async def follower(self, thread: str) -> None:
        """Resumes from the stored cursor after a restart."""
        session_id, cursor, _ = self.store[thread]
        async for event in self.agent.session(session_id).iter_events(after=cursor):
            match event:
                case agents.TurnEnded(state=agents.Completed(text=text)):
                    await self.slack.post(thread, text)
                case agents.TurnEnded(state=agents.Interrupted()):
                    await self.slack.post(thread, "(stopped)")
                case agents.TurnEnded(state=agents.Failed()):
                    await self.slack.post(thread, "(that failed; ask again)")
                case agents.CallbackOpened(callback=callback):
                    await self.slack.show_buttons(thread, callback.title, value=f"{callback.turn_id}/{callback.id}")
                case agents.CallbackResolved(callback_id=callback_id):
                    await self.slack.close_buttons(callback_id)
            self.store[thread][1] = event.id
        del self.store[thread]  # the stream ends only with the session

bot = SlackBot(agent, FakeSlack())
  • on_message sends with send_message(): a message sent while a turn runs joins that turn. The Slack timestamp is the idempotency key, so a message Slack delivers twice reaches the session once.
  • follower only watches. It posts each turn's text, and shows buttons for each approval, with the turn and approval ids as the button's value. It stores each event's id, so after a restart it reads on from the last event it handled.
  • on_click answers by id, from whichever process Slack calls. When two people click at once, both get Answered, with the answer that counted.
  • on_stop interrupts the last turn.

No handle of the bot has an on_approval= handler, so every approval waits for a click.

The demo sends one message and waits for the turn to end. turn.result() returns Blocked while an approval waits, so main waits again past it with after=state:

async def main() -> None:
    thread = "C042/1730290000.1"
    await bot.on_message(thread, "1730290000.1", "Delete the branch fix/typo.")
    session_id, _, turn_id = bot.store[thread]
    # Wait for the turn's end: each approval returns Blocked, until FakeSlack's clicks answer it.
    turn = agent.session(session_id).turn(turn_id)
    state = await turn.result()
    while isinstance(state, agents.Blocked):
        state = await turn.result(after=state)
    await agent.session(session_id).cancel()
    await asyncio.gather(*bot.followers)

if __name__ == "__main__":
    asyncio.run(main())
Output
[C042/1730290000.1] buttons: Allow tools.client.delete_branch?
[C042/1730290000.1] someone already answered that
buttons closed
[C042/1730290000.1] Branch fix/typo has been deleted.

The first click allowed the deletion. The second click came after the answer, so it got AlreadySettled. Then the follower read the answer and closed the buttons.

Common issues

Common issues

  • SessionNotFoundError for a session your process started. The handle was bound in another event loop or process. Serve or deploy the agent to bind it from any process.
  • The follower posts a turn's text twice after a restart. It resumed from an older cursor. Store each event's id once you have handled it.
Next steps

Next steps

  • Sessions: bind a session again by its id.
  • Approvals: answer an approval by id from any process.
  • Serve: run the agent where every process can bind its sessions.