# Building an AI Ouija board with Dash 4.5

**URL:** <https://community.plotly.com/t/building-an-ai-ouija-board-with-dash-4-5/97930>\
**Category:** Dash Python\
**Tags:** show-and-tell\
**Created:** [October 30, 2026, 8:49pm UTC](https://community.plotly.com/t/building-an-ai-ouija-board-with-dash-4-5/97930 "2026-10-30T20:49:14Z")\
**Posts on this page:** 1\
**Page:** 1

<div class="post-metadata">

**Author:** ![Philippe](https://sea2.discourse-cdn.com/flex024/user_avatar/community.plotly.com/philippe/32/3521_2.png) [@Philippe](https://community.plotly.com/u/Philippe)\
**Post date:** [October 30, 2026, 8:49pm UTC](https://community.plotly.com/t/building-an-ai-ouija-board-with-dash-4-5/97930/1 "2026-10-30T20:49:14Z")

</div>

Dash 4.5.0 ships two features I built. Streaming callbacks let a callback yield updates while it keeps running. Shared storage gives every user and every tab the same server-side state. To show them off, I made The Seance, a multiplayer Halloween Ouija board.

Ask the spirit a question and the planchette spells the answer, letter by letter, on every open board. Three AI guests react and ask their own questions. A bell tolls, the wood creaks, and the spirit speaks its answer out loud.

It is one Python file of about 650 lines plus 280 lines of JavaScript for sound. No websocket server, no Redis, no frontend framework.

The full source is on GitHub at [T4rk1n/dash-seance](https://github.com/T4rk1n/dash-seance).

![demo](https://us1.discourse-cdn.com/flex024/uploads/plot/original/3X/6/8/689a36836cd9c3688bf20074fb957a8601b45770.webp)

## What the app does

You type a question and hit `Ask`. The planchette glides to each letter with a glow, the board shakes when the answer is complete, and the answer lands in a running log. Everyone at the table sees the same thing at the same moment, and the header counts the living souls present.

The three AI guests each have a personality:

- **Madame Zelda** , a theatrical medium who claims to know every spirit personally
- **Skeptical Steve** , who insists someone is pushing the planchette and gets more rattled with every answer
- **Nervous Nora** , who was dragged here by friends and wants the lights on

They react out loud to each answer. When the room goes quiet for 25 seconds, one of them asks the spirit a question of their own. With an OpenRouter key, the spirit and the guests are LLMs and their words stream in token by token. Without one, they use canned lines, so the demo runs anywhere.

## One callback per tab that never ends

The core of the app is a streaming callback: an `async def` that uses `yield` instead of `return`. Each yield pushes new values to the page right away, and the callback keeps running.

Every tab runs one when it loads, and it never finishes. It subscribes to a shared topic and turns each event into a frame for the page ([full callback](https://github.com/T4rk1n/dash-seance/blob/858ae6abd45519957c5715fe36e8424bb67130f2/app.py#L486)):

```python
@callback(
    Output("planchette", "style"),
    Output("message", "children"),
    Output("log", "children"),
    # ...
    Input("join", "data"),
)
async def sit_at_the_table(_):
    async with store.subscribe(TOPIC) as seance:
        async for event in seance:
            if event["t"] == "letter":
                yield frame(style=planchette_style(event["spot"], glow=True),
                            message=event["text"])
            # ... question, done, say, said, souls

```

A few details made this pleasant:

- **Only what changed goes out.** Each yield returns `no_update` for every output it doesn’t touch, so a letter moves the planchette without re-sending the log.
- **`Patch` works mid-stream.** The log grows with `Patch().prepend(...)`, so each frame sends one new line, not the whole history.
- **Closing the tab is just cleanup code.** When a tab closes, Dash cancels the generator at its current `await`, so a `finally:` block decrements the soul count and tells everyone else.
- **Many streams don’t exhaust connections.** Dash 4.5 runs all of a page’s streams over one shared downlink, hosted in a SharedWorker so the browser’s tabs can share it. Ten open tabs don’t hit the browser’s limit of about six connections per host.

## Shared storage is the table everyone sits at

Streaming callbacks give each tab a live pipe. Shared storage is what connects the pipes. One line turns it on:

```python
app = Dash( __name__ , backend="fastapi", shared_storage=LocalSharedStorage)
store = app.shared_storage

```

The seance uses it four ways:

- **Broadcast every letter, question and chat line:** `store.publish("seance", event)` and `store.subscribe("seance")`
- **Count the living souls at the table:** `store.get("souls")` and `store.set("souls", n)`
- **Give the LLMs memory of the conversation:** `store.set("history", lines[-12:])`
- **Let only one question run at a time:** `store.set("busy", who, ttl=60)`

The last one is my favourite. The `ttl` makes the lock safe: a `finally:` releases it normally, but if an answer ever hangs, the lock expires after a minute instead of jamming the board forever.

Asking a question is an ordinary callback, and [`channel`](https://github.com/T4rk1n/dash-seance/blob/858ae6abd45519957c5715fe36e8424bb67130f2/app.py#L254) does the work. It publishes events and never touches another tab’s outputs. Each tab’s listener decides what those events mean for its own page. `LocalSharedStorage` keeps everything in one process. Redis and disk-cache backends ship in the same package behind the same interface.

embed: node/e4718360-8f36

No player ever talks to another directly. The tabs only listen, and anything that speaks, human or AI, publishes to the same topic.

## The spirit and the guests stream from an LLM

The FastAPI backend (`backend="fastapi"`) runs callbacks as plain `async` code on the server’s event loop. That matters here: the app is mostly waiting on LLM tokens, and an async `httpx` stream from OpenRouter slots straight into a callback.

The spirit’s answer is filtered to letters and digits as the tokens arrive, and each letter is published the moment it exists. The planchette starts moving before the model has finished thinking, paced at one letter every 0.55 seconds so people can read along. Guest lines stream the same way into their speech bubbles.

The guests themselves are not callbacks. They’re a background task, the [director](https://github.com/T4rk1n/dash-seance/blob/858ae6abd45519957c5715fe36e8424bb67130f2/app.py#L349), on the same event loop. It subscribes to the same topic as the tabs: after each answer it picks one or two guests to react, and after 25 quiet seconds it has one of them ask a question. Because the guests publish through shared storage like everyone else, the tabs can’t tell them apart from human players.

One lesson: start background tasks lazily, from the first callback, not at import ([`ensure_director`](https://github.com/T4rk1n/dash-seance/blob/858ae6abd45519957c5715fe36e8424bb67130f2/app.py#L380)). Running `python app.py` with the FastAPI backend re-imports the module in a uvicorn subprocess, so a director started at import would run twice and every guest would speak twice.

## Spooky sounds with zero audio files

Every sound is synthesized in the browser with the Web Audio API, from [`assets/sounds.js`](https://github.com/T4rk1n/dash-seance/blob/858ae6abd45519957c5715fe36e8424bb67130f2/assets/sounds.js), which Dash serves automatically:

- **Always:** a low drone that slowly breathes
- **A question is asked:** a distant church bell
- **The planchette moves:** filtered noise scraping across wood, then a knock as it lands
- **The answer is complete:** a rising ghostly wail over a rumble, timed with the board shake
- **A guest starts talking:** a breathy whisper
- **Someone joins the table:** a soft three-note chime

The browser’s speech synthesis also whispers each letter as the planchette lands on it.

To trigger them, the listener callback sets a cue with [`set_props`](https://github.com/T4rk1n/dash-seance/blob/858ae6abd45519957c5715fe36e8424bb67130f2/app.py#L499), and a clientside callback plays it:

```python
set_props("sfx", {"data": {"kind": "scrape", "n": cues, "say": letter}})

clientside_callback(
    ClientsideFunction("seance", "play"),
    Output("sfx", "clear_data"),
    Input("sfx", "data"),
    prevent_initial_call=True,
)

```

The server decides when a sound plays; the browser decides how it sounds. A counter in the cue makes the store change even when the same sound plays twice in a row. A speaker button mutes everything, and the choice is remembered per browser.

## Giving the spirit a voice

The browser’s built-in voice is free and instant, but it sounds like your operating system. For the final answer I wanted something better, so the spirit speaks through OpenRouter’s text-to-speech endpoint: ElevenLabs v3 with the “Callum” voice and a `[slow, sinister whisper]` tag.

The server starts the TTS request the moment the answer is complete, while the planchette finishes and the wail plays. The clip comes back in about 1.5 seconds. The browser slows it to 85% speed, which also lowers the pitch, and runs it through the same reverb as everything else. Every tab plays the same clip, and the app costs nothing extra without a key: the browser’s voice reads the answer instead.

Getting the clip to the page used two more pieces of Dash, both one-liners (see [`spirit_voice`](https://github.com/T4rk1n/dash-seance/blob/858ae6abd45519957c5715fe36e8424bb67130f2/app.py#L194) and [`serve_voice`](https://github.com/T4rk1n/dash-seance/blob/858ae6abd45519957c5715fe36e8424bb67130f2/app.py#L223)):

```python
# Keep the mp3 in shared storage for five minutes.
store.set(f"voice:{key}", base64.b64encode(mp3).decode(), ttl=300)

# app.server is a plain FastAPI app, so a route is just a route.
@server.get("/spirit-voice/{key}.mp3")
def serve_voice(key: str):
    clip = store.get(f"voice:{key}")
    if clip is None:
        return Response(status_code=404)
    return Response(base64.b64decode(clip), media_type="audio/mpeg")

```

The stream then carries only the clip’s URL, and the browser fetches and decodes it.

## Patterns for streams that never end

Most streaming callbacks finish: they stream an answer, then return. A listener that runs for as long as the tab is open is a different shape, and a few patterns keep it smooth. If you build something like The Seance, these are the ones to know.

1. **Use `set_props` for anything other callbacks react to.** Dash waits for a callback to finish before running callbacks that depend on its outputs, and a listener never finishes. `set_props` updates a component without declaring it as an output, so dependent callbacks run right away. That is how the sound cues reach the clientside callback that plays them.
2. **Yield after a `set_props`.** Its update goes out with the next frame, and a second `set_props` on the same prop before then replaces the first. When nothing else changes, an empty `yield frame()` sends it.
3. **Send references, not payloads.** Frames are for small, frequent updates like a planchette position. Large data such as an mp3 belongs in shared storage, served from a route on `app.server`, with only its URL in the frame.
4. **Start background tasks lazily.** With the FastAPI backend, `python app.py` re-imports the module in a uvicorn subprocess. Start long-lived tasks from the first callback, not at import, or they run twice.

One styling note from Dash 4 itself: `dcc.Input` puts its `className` on a wrapper with a fixed height. To make inputs taller, style `.dash-input-container` and `.dash-input-element` instead.

## Try it yourself

```bash
git clone https://github.com/T4rk1n/dash-seance.git
cd dash-seance
cp .env.example .env # add your OpenRouter key, or leave it empty for canned lines
uv run app.py

```

Open [http://127.0.0.1:8050](http://127.0.0.1:8050) in two or three tabs and ask the spirit something you shouldn’t. Click anywhere once so the browser allows sound.

That is what I wanted these features to do: take away the parts an app author shouldn’t have to build. No websocket server, no message broker, no React state, no polling. Streaming callbacks carry the live updates, shared storage connects the players, and the rest is a Halloween story told in Python. Happy haunting.
