> ## Documentation Index
> Fetch the complete documentation index at: https://docs.reactor.inc/llms.txt
> Use this file to discover all available pages before exploring further.

# Sessions & Clients

> Session hooks, connection hooks, and serving more than one client.

A model's life has two nested scopes. The **session** is the model serving traffic: it starts before
anyone is watching and ends when the runtime tears it down. A **connection** is one client inside
that session, and a session can hold several at once.

Reactor gives you a hook for each scope, so a model can tell "a new viewer joined" apart from "the
session began".

## The four hooks

```python theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
from reactor_runtime import ClientInfo, connected, disconnected, session_ended, session_started


class MyModel(ReactorModel):
    @session_started
    async def on_session_start(self) -> None:
        self._history = []

    @connected
    async def on_connect(self, client: ClientInfo) -> None:
        self._viewers += 1

    @disconnected
    async def on_disconnect(self, client: ClientInfo) -> None:
        self._viewers -= 1

    @session_ended
    async def on_session_end(self) -> None:
        self._history.clear()
```

| Hook               | Scope      | Runs                                  |
| ------------------ | ---------- | ------------------------------------- |
| `@session_started` | Session    | Once, before any client has connected |
| `@connected`       | Connection | Once per client, each time one joins  |
| `@disconnected`    | Connection | Once per client, each time one leaves |
| `@session_ended`   | Session    | Once, when the session itself ends    |

Each hook can be `async def` or plain `def`, and a model declares at most one of each.

## Ordering

For a session serving two clients in sequence, the hooks fire in this order:

```
session_started          ← nobody is connected yet
  connected     (A)      ← self.connected becomes set
  disconnected  (A)      ← self.connected becomes clear
  connected     (B)
  disconnected  (B)
session_ended
```

`self.connected` is set before the first `@connected` hook runs and cleared before the
`@disconnected` hook for the last client to leave, so a `run()` loop gating on it always agrees with
the hooks.

<Note>
  When a session ends, its connections are torn down wholesale. `@session_ended` fires, but the
  per-client `@disconnected` hooks do not. Anything that must happen for every client, however the
  session ends, belongs in `@session_ended`.
</Note>

## Choosing a hook

Put work in the scope whose lifetime it matches.

**`@session_started` / `@session_ended`** for anything shared across everyone the session serves: a
scene the viewers all observe, a scoreboard, a conversation history, a cache you want warm before
the first client arrives.

**`@connected` / `@disconnected`** for anything belonging to one viewer: their camera pose, their
cursor, their entry in a roster.

Anything that costs real money or memory — loading a checkpoint, allocating GPU buffers — belongs in
`load()`, which runs once at startup, well before either hook.

## The client handle

Add a `client: ClientInfo` parameter to any hook or `@event` handler and the runtime injects the
client it concerns. It never appears in your schema, and clients never send it.

```python theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
@connected
async def on_connect(self, client: ClientInfo) -> None:
    print(client.id, client.joined_at)
```

`ClientInfo` carries the connection `id`, the `joined_at` timestamp, and a `send()` coroutine that
messages that one client:

```python theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
await client.send(Welcome(message="hello"))
```

The handle stays valid for as long as the client is connected, including inside its own
`@disconnected` hook, so you can say goodbye before it goes.

## Serving several clients

`self.connected` tracks occupancy, not identity — it is set while **any** client is connected, and
clears only when the last one leaves. A single `run()` loop produces one stream that every connected
client receives.

That makes the common multi-client pattern a counter:

```python theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
class MyModel(ReactorModel):
    def load(self, config_path: Path | None) -> None:
        self.pipe = load_checkpoint()
        self._viewers = 0

    @connected
    async def on_connect(self) -> None:
        # ✅ Reset shared state for the first arrival only
        if self._viewers == 0:
            self.prompt = "a sunny meadow"
            self._step = 0
        self._viewers += 1

    @disconnected
    async def on_disconnect(self) -> None:
        self._viewers = max(0, self._viewers - 1)
```

Resetting unconditionally in `@connected` would yank the scene out from under everyone already
watching whenever somebody new joined. Guarding on the counter keeps the second viewer from
disturbing the first.

If you need to address clients individually, keep the handles:

```python theme={"theme":{"light":"github-light","dark":"github-dark-high-contrast"}}
@connected
async def on_connect(self, client: ClientInfo) -> None:
    self._clients[client.id] = client

@disconnected
async def on_disconnect(self, client: ClientInfo) -> None:
    self._clients.pop(client.id, None)
```

Then `self.send()` broadcasts to everyone and `client.send()` reaches exactly one. See
[Per-client messages](/deploy/development/reactor-model/events-and-messages#per-client-messages).

## Next

<CardGroup cols={2}>
  <Card title="Events & Messages" icon="bolt" href="/deploy/development/reactor-model/events-and-messages">
    Commands, replies, and outbound messages.
  </Card>

  <Card title="Managing State" icon="sliders-horizontal" href="/deploy/development/reactor-model/state">
    One event loop, shared by your loop and your handlers.
  </Card>
</CardGroup>
