# Aiogram Scenes

> Use when using the experimental aiogram 3 Scene, SceneWizard, or ScenesManager conversation API.

- Skill: `ballisarium/aiogram-scenes` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add ballisarium/aiogram-scenes`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ballisarium/aiogram-scenes/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: ballisarium (https://skillmd.com/u/ballisarium)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ballisarium/aiogram-scenes

---


# aiogram scenes

> The official aiogram documentation states: **"This feature is experimental and may be
> changed in future versions."**

Take that seriously. Scenes are a convenience layer over FSM; anything built on them may
need edits on a minor upgrade. For code that must survive upgrades untouched, use plain
FSM (`aiogram-fsm`). Choose scenes when the wizard is long enough that the boilerplate of
manual state handling actually hurts.

## when to use this skill

- a linear or branching wizard with many steps
- you want enter/leave hooks and a built-in history (`back`)
- an existing codebase already uses `aiogram.fsm.scene`
- deciding whether to migrate a scene-based flow to plain FSM

## what scenes add over FSM

| Need | Plain FSM | Scenes |
|---|---|---|
| step state | `State` in a `StatesGroup` | one `Scene` subclass per step |
| entering | `state.set_state(...)` | `@on.message.enter()` hook |
| leaving | manual | `@on.message.leave()` / `.exit()` hooks |
| going back | you keep the order yourself | `wizard.back()` with real history |
| data | `FSMContext` | `wizard` with the same operations |

Scenes are still FSM underneath: each scene occupies a state, and the same storage,
strategy, and isolation settings apply.

## a complete scene

```python
from aiogram import Bot, Dispatcher, F, Router
from aiogram.filters import Command
from aiogram.fsm.scene import Scene, SceneRegistry, ScenesManager, on
from aiogram.types import Message


class NameScene(Scene, state="registration_name"):
    @on.message.enter()
    async def greet(self, message: Message) -> None:
        await message.answer("What is your name?")

    @on.message(F.text)
    async def got_name(self, message: Message) -> None:
        await self.wizard.update_data(name=message.text)
        await self.wizard.goto(AgeScene)

    @on.message()
    async def not_text(self, message: Message) -> None:
        await message.answer("Please send your name as text.")


class AgeScene(Scene, state="registration_age"):
    @on.message.enter()
    async def ask(self, message: Message) -> None:
        await message.answer("How old are you?")

    @on.message(F.text.regexp(r"^\d{1,3}$"))
    async def got_age(self, message: Message) -> None:
        data = await self.wizard.update_data(age=int(message.text or "0"))
        await message.answer(f"Thanks, {data['name']} ({data['age']}).")
        await self.wizard.clear_data()
        await self.wizard.exit()

    @on.message()
    async def bad(self, message: Message) -> None:
        await message.answer("Send a number.")


router = Router(name="scenes")
router.message.register(NameScene.as_handler(), Command("register"))

dispatcher = Dispatcher()
dispatcher.include_router(router)

registry = SceneRegistry(router)
registry.add(NameScene, AgeScene)

assert issubclass(NameScene, Scene)
assert ScenesManager is not None
```

Register command entry points on the same router **before** `registry.add(...)`, so
a broad active-scene text handler cannot swallow the command.

Three wiring steps, all required:

1. **Declare** the scene with a `state=` name. That string is persisted, exactly like an
   FSM state name.
2. **Register** every scene with `SceneRegistry.add(...)`. A scene you can `goto` but did
   not register will fail at run time.
3. **Provide an entry point** — `Scene.as_handler()` produces a callback you register on a
   router, usually behind a command.

`SceneRegistry(router, register_on_add=True)` attaches scenes to that router as they are
added. `registry.register(*scenes)` adds without attaching.

## the `on` marker

`on` exposes one attribute per observer: `message`, `callback_query`, `edited_message`,
`channel_post`, `edited_channel_post`, `inline_query`, `chosen_inline_result`,
`chat_member`, `my_chat_member`, `chat_join_request`, `poll`, `poll_answer`,
`pre_checkout_query`, `shipping_query`.

Each supports four forms:

| Form | Fires |
|---|---|
| `@on.message(*filters)` | while the scene is active and filters pass |
| `@on.message.enter()` | when the scene is entered |
| `@on.message.leave()` | when leaving for another scene |
| `@on.message.exit()` | when the whole scene stack is exited |

Note the observer prefix on the lifecycle hooks: `@on.message.enter()` runs when the
scene is entered *via a message*. A scene entered from a callback query needs
`@on.callback_query.enter()` as well, or the user gets no prompt.

## SceneWizard

`self.wizard` inside a scene:

| Method | Behaviour |
|---|---|
| `goto(scene, **kwargs)` | leave the current scene and enter another |
| `back(**kwargs)` | return to the previous scene in history |
| `retake(**kwargs)` | re-enter the current scene, re-running its enter hook |
| `exit(**kwargs)` | leave the scene stack entirely |
| `enter(**kwargs)` | run the enter hooks of the current scene |
| `leave(**kwargs)` | run the leave hooks |
| `set_data(data)` / `get_data()` / `update_data(...)` / `clear_data()` | scene data |
| `get_value(key, default=None)` | read one key |

`goto` accepts a `Scene` subclass, a `State`, or a raw state string.

`retake()` re-runs entry actions and also applies `reset_data_on_enter`. If that flag is
true it discards collected data. For invalid input, repeat the prompt directly when you
need to preserve data.

`exit()` clears the current state and scene history, **not FSM data**. Call
`clear_data()` explicitly when completion should discard the collected answers.

## `After` — declarative transitions

Declare navigation with the decorator's `after=` argument:

```python
from aiogram import F
from aiogram.fsm.scene import After, Scene, on
from aiogram.types import Message


class ConfirmScene(Scene, state="confirm"):
    @on.message.enter()
    async def ask(self, message: Message) -> None:
        await message.answer("Confirm? yes/no")

    @on.message(F.text.lower() == "yes", after=After.exit())
    async def yes(self, message: Message) -> None:
        await message.answer("Confirmed.")

    @on.message(F.text.lower() == "no", after=After.back())
    async def no(self, message: Message) -> None:
        await message.answer("Going back.")
```

`After.exit()`, `After.back()`, and `After.goto(SomeScene)` run after the handler body
returns.

## SceneConfig

Verified fields: `state`, `handlers`, `actions`, `reset_data_on_enter`,
`reset_history_on_enter`, `callback_query_without_state`, `attrs_resolver`.

Set them as class keyword arguments:

```python
from aiogram.fsm.scene import Scene, on
from aiogram.types import Message


class FreshScene(
    Scene,
    state="fresh",
    reset_data_on_enter=True,
    reset_history_on_enter=True,
    callback_query_without_state=True,
):
    @on.message.enter()
    async def enter(self, message: Message) -> None:
        await message.answer("clean slate")
```

- `reset_data_on_enter=True` clears scene data on every entry — right for a flow that
  should never inherit a previous attempt's answers.
- `reset_history_on_enter=True` makes this scene a history root, so `back()` cannot go
  past it.
- `callback_query_without_state=True` lets the scene's callback handlers fire even when
  the user has any state (or none): it removes the scene state filter entirely. Keep
  payload and permission filters narrow so old buttons cannot hijack another flow.

## entering from elsewhere

Outside a scene, aiogram injects a `ScenesManager` as `scenes`:

```python
from aiogram import Router
from aiogram.filters import Command
from aiogram.fsm.scene import Scene, ScenesManager, on
from aiogram.types import Message

router = Router(name="entry")


class Start(Scene, state="start"):
    @on.message.enter()
    async def enter(self, message: Message) -> None:
        await message.answer("in the scene")


@router.message(Command("go"))
async def go(message: Message, scenes: ScenesManager) -> None:
    await scenes.enter(Start)


@router.message(Command("leave"))
async def leave(message: Message, scenes: ScenesManager) -> None:
    await scenes.close()
```

`Scene.as_router(name=None)` returns a ready-made `Router` for the scene if you prefer
composing routers over registering handlers.

## limits and cautions

- **Experimental.** Pin your aiogram version and re-read the changelog before upgrading.
- Scene `state=` strings are persisted. Renaming one strands users mid-flow exactly as
  with plain FSM; keep the recovery handler described in `aiogram-fsm`.
- History uses a separate `scenes_history` destiny, with a default depth of 10. It
  shares the storage and JSON constraints. Redis keys need `with_destiny=True`.
- A scene entered from a callback query needs `@on.callback_query.enter()`; forgetting it
  is the most common scenes bug.
- Scene data is shared with normal FSM data under the same key. Do not run a hand-written
  FSM flow and a scene over the same user at the same time.
- Private prompts from a group require an explicit `bot.send_message(user_id, ...)`
  and permission to contact that user. Scene hooks do not move the FSM key to a DM.

## migrating a scene to plain FSM

If the experimental status becomes a problem: one scene becomes one `State`; `enter`
hooks become the code that runs right after `set_state`; `wizard.goto` becomes
`set_state`; `wizard.exit` clears state/history but retains data (use `state.clear()`
if full cleanup is intended); `back()` becomes an explicit step
list. The persisted state strings can be kept identical, so users mid-flow are not lost.

## checklist

- the experimental status is acceptable for this codebase, and the aiogram version is pinned
- every scene registered with `SceneRegistry.add(...)`
- at least one entry point via `Scene.as_handler()` or `ScenesManager.enter(...)`
- `@on.callback_query.enter()` present wherever a scene can be entered from a button
- invalid input repeats a prompt without unintentionally resetting collected data
- `wizard.exit()` on success, cancel, and unrecoverable failure
- scene `state=` strings treated as persisted values, not renamed casually
- only JSON-serialisable values in scene data
- a recovery handler for states left behind by a previous deployment
- no hand-written FSM flow running over the same user at the same time

## see also

- `aiogram-fsm` — the stable foundation, and the fallback if scenes change
- `aiogram-routing` — where `as_handler()` / `as_router()` attach
- `aiogram-keyboards` — driving a wizard from buttons

