# Session lifecycle

A session moves through five phases, and only one of them is an end. The `open()` call rejects when
the session could not be opened, so every line after the `await` runs on a session that exists:

```js
import { BitskiffHost, isBitskiffError } from '@bitskiff/host';

try {
  const session = await new BitskiffHost({ key: 'bsk_pk_...' }).open();   // your host key
  session.on('state', (s) => console.log(s.phase, s.ended?.reason ?? ''));
} catch (err) {
  if (isBitskiffError(err)) console.log(err.code);      // `origin_not_allowed`, `invalid_key`, ...
}
```

```ts
import { BitskiffHost, isBitskiffError, type SessionState } from '@bitskiff/host';

try {
  const session = await new BitskiffHost({ key: 'bsk_pk_...' }).open();   // your host key
  session.on('state', (s: SessionState) => console.log(s.phase, s.ended?.reason ?? ''));
} catch (err) {
  if (isBitskiffError(err)) console.log(err.code);      // `origin_not_allowed`, `invalid_key`, ...
}
```

## Phases

Read the current phase on `session.state.phase`:

| `phase` | What it means |
|---|---|
| `opening` | the open is in flight |
| `open` | there is a join code and clients can join |
| `reconnecting` | the link dropped and the SDK is getting it back. The clients keep their places and see the host as away |
| `suspended` | the SDK stopped trying. The session is not known to be gone. Call `session.retry()` from your Try again button |
| `ended` | final, with a reason |

The `state` object is a frozen snapshot, replaced whole. An `on('state')` listener fires on every
change.

## Join codes

Every setting on this page is written as a
[duration string](/docs/guides/limits/#durations): `30s`, `10s`, `1h30m`.

A join code rotates. The rotation is `joinCodeRotation`, `30s` by default, and the backend accepts
the code in force plus the one either side. A code is accepted for its own step and the next. At
the default that is 30 to 60 seconds. An older code is refused, and the client is told `invalidCode`.

The `session.joinCode` object carries the current code and its URL, and an `on('joinCode')`
listener fires on every rotation, so the QR a host draws is always the current one. The code itself
is `session.joinCode.code`, 15 letters and digits, and case matters. Any grouping a page prints it in --
the spacing the sample client page shows, for one -- is display only, and a person may type it back
with spaces or dashes.

## Reloads and host grace

A reload is not an end. A browser host keeps its session for the tab it is in, so a reload resumes
the same session with its clients still in it. It has the host grace (`hostResumeWindow`, `10s` by
default) to get back.

## `open()` rejections

A rejected `open()` carries a code on `error.code`. This table is every code an `open()` can be
rejected with; a `switch` written from it has no default case that surprises you in production.

`Waits` says whether `open()` retries that code before it rejects. `open()` waits only on what a
wait alone fixes: a request that never reached the service, a service that is not ready, and the
pacing a rate limit named.

It waits `error.retryAfterMs` and asks again, for as long as its open budget lasts, and rejects with
the last refusal when the budget runs out. The budget is `openBudget`, `2m` by default.

Everything else is handed to you on the first answer, **including a project at its
concurrent-sessions ceiling**: there you get `concurrency_limit` straight away, with
`error.retryAfterMs` to count down and `error.details.scope` naming the level that filled.

Waiting for a session to end is your own loop to write, on your own page, where you can say what is
happening. The section under the table is how to watch the waits `open()` does take, and how to
bound them.

| `code` | Waits | What to do |
|---|---|---|
| `origin_not_allowed` | no | add the page's origin to the host key's allowlist with `keys update <keyId> --origin`. It replaces the list: see [Credentials](/docs/guides/credentials/#publishable-keys). Serve the page over HTTP: a page opened straight off disk has no origin to add |
| `invalid_key` | no | check that you pasted the whole key string, and that it has not been revoked or expired |
| `secret_key_in_browser` | no | replace the `bsk_sk_...` in the page with a host key or a token. A secret key never belongs in page source |
| `invalid_token` | no | ask your backend for a new token. This one is expired, revoked or unknown, and retrying with it gets the same answer |
| `restricted_mode` | no | the project takes no publishable-key opens. Open from your backend with a secret key, or with a token your backend minted |
| `forbidden` | no | the credential may not open sessions. Open with a host key, or a secret key or token that holds `sessions:open`: see [Credentials](/docs/guides/credentials/#permissions) |
| `validation` | no | the session config is malformed. `error.details` names the field |
| `join_url_missing` | no | set the project's join URL in the console. Without one there is nowhere to point a client |
| `concurrency_limit` | no | you are at the concurrent-sessions ceiling. Close a session, or raise `concurrentSessions` in the console. `error.retryAfterMs` is 10000: draw that countdown and call `open()` again, or wait for a session of yours to end |
| `rate_limited` | yes | you are opening faster than `sessionOpensPerMinute`. `open()` waits `error.retryAfterMs` and asks again |
| `idempotency_in_flight` | yes | an open with the same `Idempotency-Key` is still running. `open()` waits and asks again for its answer |
| `unavailable` | yes | the service or something under it is down. `open()` waits `error.retryAfterMs` and asks again |
| `overloaded` | yes | the service is at capacity. `open()` waits `error.retryAfterMs` and asks again |
| `network` | yes | the request never reached the service. `open()` waits and asks again; check that the device is online |
| `timeout` | yes | the request or the handshake ran out of budget. `open()` waits and asks again |
| `quota_exceeded` | no | a usage cap or the spend cap is spent. It clears at the period roll, or when you raise it; `error.retryAfterMs` is the time to the reset |
| `plan_expired` | no | your organization's plan has expired. Opens and joins are refused until it is renewed; `error.details.expiredAt` is the instant it ended |
| `protocol_unsupported` | no | the service does not speak this SDK's protocol version. Upgrade the package |
| `internal` | no | the service failed. Call `open()` again yourself; if it stays, `error.requestId` is the handle to quote |
| `protocol` | no | an answer did not match the protocol this package is pinned to. Upgrade the package |
| `engine` | no | the transport failed to come up; `error.cause` carries what it threw |
| `disposed` | no | the host was disposed while the open was in flight. Nothing to do: it was on its way out |

### The open budget

A page whose open keeps hitting a rate window or an unready service sits inside `open()` for up to
two minutes with nothing drawn on it. Two things make that visible.

Pass a logger to see each wait. The SDK writes one warn line at the first refusal it waits on,
`bitskiff: open refused, retrying`, with the refusal's `code`, its `retryAfterMs` and `nextAt`, the
instant of the next attempt:

```js
import { BitskiffHost } from '@bitskiff/host';

const bitskiff = new BitskiffHost({
  key: 'bsk_pk_...',               // your host key
  platform: { logger: console },   // the SDK's own logger; warn and above by default
});

const session = await bitskiff.open();
```

```ts
import { BitskiffHost, type Session } from '@bitskiff/host';

const bitskiff = new BitskiffHost({
  key: 'bsk_pk_...',               // your host key
  platform: { logger: console },   // the SDK's own logger; warn and above by default
});

const session: Session = await bitskiff.open();
```

Pass `openBudget: '0s'` to make one attempt. The first refusal is then the rejection, whatever its
code, which is what a page that would rather paint its own "try again" wants -- a rate window
included, which is otherwise the one thing `open()` will sit through:

```js
import { BitskiffHost, isBitskiffError } from '@bitskiff/host';

const bitskiff = new BitskiffHost({ key: 'bsk_pk_...' });   // your host key

try {
  const session = await bitskiff.open({ openBudget: '0s' });   // one attempt, no waiting
  console.log(session.joinCode.url);
} catch (err) {
  if (!isBitskiffError(err)) throw err;
  console.log(err.code, err.retryAfterMs);   // `rate_limited`, 30000
}
```

```ts
import { BitskiffHost, isBitskiffError, type Session } from '@bitskiff/host';

const bitskiff = new BitskiffHost({ key: 'bsk_pk_...' });   // your host key

try {
  const session: Session = await bitskiff.open({ openBudget: '0s' });   // one attempt, no waiting
  console.log(session.joinCode.url);
} catch (err) {
  if (!isBitskiffError(err)) throw err;
  console.log(err.code, err.retryAfterMs);   // `rate_limited`, 30000
}
```

`openBudget` on the open is this one call's budget. `reconnect: { openBudget }` on the
constructor sets the default for every `open()`, `resume()` and `retry()` this host makes.

A handle you already hold reports each wait as an event. `session.on('lifecycle')` fires
`apiRefused` once per refusal, carrying `detail.errorCode` and `detail.requestId`, and then
`retryScheduled`, carrying `nextAt`, the `session.now()` instant of the next attempt. Neither moves
`phase`, which stays `opening`, so neither raises a `state` event. When the budget runs out the
reason is `openFailed`, `phase` becomes `suspended`, and the call rejects with the refusal.

These events reach you during `session.retry()` and `bitskiff.resume(hostToken)`, which run on a
handle you hold. The first `open()` has no handle yet, so the log line above is how a page watches it.

```js
session.on('lifecycle', (e) => {
  if (e.reason === 'apiRefused') console.log('refused', e.detail?.errorCode);
  if (e.reason === 'retryScheduled') console.log('next attempt at', e.nextAt);
});
```

```ts
import type { SessionLifecycle } from '@bitskiff/host';

session.on('lifecycle', (e: SessionLifecycle) => {
  if (e.reason === 'apiRefused') console.log('refused', e.detail?.errorCode);
  if (e.reason === 'retryScheduled') console.log('next attempt at', e.nextAt);
});
```

## Session end reasons

Key on `session.state.ended.reason`. A host session ends for one of these reasons:

| `ended.reason` | What it means |
|---|---|
| `hostClosed` | Your own code called `session.close()` -- here, or in another tab holding the same session |
| `apiClosed` | Something outside the SDK closed it: the console's Close, `bitskiff sessions close`, or the REST route. Revoking the key the session was opened with ends it this way too |
| `idleTimeout` | The idle limit ran out with no client joined. Only a session opened with `idleTimeout` ends this way |
| `maxSessionTime` | The session reached `maxSessionTime`, the longest it was allowed to run |
| `releasedSessionCeiling` | The host stayed released for a day, the longest a session runs without its host on Bitskiff |
| `hostResumeExpired` | The host did not come back inside the host grace. Its clients are told `hostGone` |
| `ticketRejected` | The ticket this host connected with was invalid, expired or already used |
| `sessionNotFound` | The service has no record of this session any more |
| `replaced` | A newer connection presented this host identity. This handle is finished and never resumes itself; the newer one holds the session |
| `keyRevoked` | The key the session was opened with was revoked |
| `protocol` | The two ends do not agree on a protocol version, or this one sent envelopes the other could not read |
| `refused` | The service refused the open for a reason about the project it does not spell out to the caller. `ended.errorCode` carries the code |
| `disposed` | Your own code called `dispose()`. Nothing about it reached the service |
| `unknown` | The session ended for a reason this SDK release does not know |


Handle the reasons your host acts on, and always write a `default` arm. Without one, a session ends
and the person standing at the host is told nothing.

## Clients

The `session.clients` list is the live roster, in join order, and three events follow it. The
`clientJoined` event hands you the `Client`; `clientChanged` and `clientLeft` each hand you **one
event object**:

| Event | The object | What it carries |
|---|---|---|
| `clientJoined` | the `Client` | the client is in the roster and input may flow |
| `clientChanged` | `ClientChanged` | `{ client, change, previous, at, stopPropagation() }` |
| `clientLeft` | `ClientLeft` | `{ client, reason, at, stopPropagation() }` |

The `at` field is a `session.now()` instant, so "how long ago" is one subtraction and never a wall clock.
The `previous` field is the state the client was in before the change, which is where a `grants`
change reads what the grants came from. A client's grants are what it may do inside the session --
`sendInput`, `receiveInput`, `broadcast`, `receiveBroadcast`, `manage` -- and the host sets them.

```js
const paint = (id, live) => console.log(id, live);   // your own roster: a client id, live or not
const drop = (id, why) => console.log(id, why);      // and the reason it went

for (const client of session.clients) paint(client.id, true);   // what is already here

session.on('clientJoined', (client) => paint(client.id, true));
session.on('clientLeft', (e) => drop(e.client.id, e.reason));
session.on('clientChanged', (e) => {
  switch (e.change) {
    case 'reconnected':
    case 'present':                        // the page called setPresence('present')
      return paint(e.client.id, true);
    case 'disconnected':                   // link down, the client keeps its place
    case 'away':                           // setPresence('away'), the client keeps its place
      return paint(e.client.id, false);
    default:                               // `grants`, `channels`, `e2ee`, `network`
      return;
  }
});
```

```ts
import type { Client, ClientChanged, ClientLeft } from '@bitskiff/host';

const paint = (id: string, live: boolean): void => console.log(id, live);   // your own roster
const drop = (id: string, why: string): void => console.log(id, why);      // and why it went

for (const client of session.clients) paint(client.id, true);   // what is already here

session.on('clientJoined', (client: Client) => paint(client.id, true));
session.on('clientLeft', (e: ClientLeft) => drop(e.client.id, e.reason));
session.on('clientChanged', (e: ClientChanged) => {
  switch (e.change) {
    case 'reconnected':
    case 'present':                        // the page called setPresence('present')
      return paint(e.client.id, true);
    case 'disconnected':                   // link down, the client keeps its place
    case 'away':                           // setPresence('away'), the client keeps its place
      return paint(e.client.id, false);
    default:                               // `grants`, `channels`, `e2ee`, `network`
      return;
  }
});
```

**A client is one object for as long as it is in the session** -- the same instance in the roster,
in every message and in every callback, across a reconnect. What changes is `client.state`, an
immutable snapshot replaced whole; it is already the new one when your handler runs.

A client you hold can also be listened to on its own: `client.on('change', fn)` and
`client.on('left', fn)` are handed the **same** event object, run **before** the session-wide
handlers, and `e.stopPropagation()` in one of them stops the session-wide handler for that event.
Specific before general, as in the DOM, and the same rule a channel's `{ from }` handlers follow.

`ClientChange` is `disconnected`, `reconnected`, `away`, `present`, `grants`, `channels` (this
client declared or dropped a name), `e2ee` (its key generation moved) and `network` (this client
walked from wifi onto cellular, or the other way, without losing its connection; `previous`
carries the class it left). Every member that moves on its own has a value here; `deadlines` and
`transport` move with the change that caused them.

## Background clients

A client whose page calls `client.setPresence('away')` keeps its place. Its `presence` goes to
`away` and the host gets an `away` change. The SDK never guesses whether a person is looking: the
page says so, and `client.setPresence('present')` says the person is back.

The place is held for `backgroundBudget`, `30s` by default. A client that comes back inside that window gets a `present` change on the same object. Past it, it
ends with `backgroundBudget`.

The client's own page sees the same thing from the inside, on its `BitskiffClient`:
`client.state.presence` is `present` or `away`. `client.state.returning` is true from the moment
the page calls `setPresence('present')` until its place is confirmed still held. Take the
greyed-out look off when `returning` goes false, not at the call.

## Deliberate ends

Three calls end something on purpose. Two of them are the host's and one is the client page's, and
the handle each is called on is a different object:

| Call | The handle | What it ends |
|---|---|---|
| `await session.close()` | the host's `Session` | the session, for every client in it. Clients end with `hostClosed` |
| `await client.boot()` | a roster `Client` on the host | one client. It ends with `booted` |
| `await client.leave()` | the client page's `BitskiffClient` | this page's own place. The host sees `clientLeft` with `left` |

Two different objects are called a client, one in each SDK. A roster `Client` is the host's view of
one client, and it has `boot()` and no `leave()`. A `BitskiffClient` is the client page's own
handle, and it has `leave()` and no `boot()`. Every snippet on this site says which of the two it
is holding.

Call `close()` as soon as the session is over. Closing the tab does not close the session. To the
service it looks like a dropped link. The session stays open for the host grace, and clients show
the host as away. Then it ends with `hostResumeExpired`, and they get `hostGone`.

**What the host is told** is one word on `clientLeft`'s `e.reason`:

| `e.reason` | What it means |
|---|---|
| `left` | the client said so |
| `booted` | you, a console action or a [termination rule](/docs/guides/lifecycle/#termination-rules) removed it, or it stayed over the [message rate](/docs/guides/channels/) |
| `reconnectBudget` | the link was down too long while the client was present |
| `backgroundBudget` | it was away too long |
| `replaced` | a newer connection presented the same identity |
| `sessionEnded` | the session ended and no finer reason reached this client |
| `keyRevoked` | the host key that opened the session was revoked |
| `keyUnverified` | under end-to-end encryption, the client's encryption key failed its binding, so the host booted it |
| `rate` | the host booted it for sending over its byte rate: see [Limits](/docs/guides/limits/#rate-limits) |

`keyRevoked` is about the host key. A client whose own client key is revoked with `compromise`
loses its place within 5 seconds: see [Client keys](/docs/guides/credentials/#client-keys).

## Termination

A client can lose its place without a deliberate end. That is termination. Two ends come built
in: the two budgets. The host's inactivity window is a countdown you act on, and termination rules
add ends of your own.

### Built-in ends

Every session has two budgets. They are built in, not rules:

| Budget | Default | It ends a client that |
|---|---|---|
| `reconnectBudget` | `10s` | lost its link while present and did not come back in time |
| `backgroundBudget` | `30s` | was set `away` and did not come back in time |

Change either one on `open()`. Setting one to `0s` ends the client at once. It does not turn the
budget off. See [Other session settings](/docs/guides/limits/#other-session-settings).

### The host's inactivity window

The host can also keep its own window for each client. Pass `inactivity` to `open()`. The host SDK
starts a countdown for a client when it attaches, and `client.markActive()` restarts it. You decide
what counts as activity: a tap, a move past the joystick's dead zone, anything.

The client page sees the same countdown. It is an `inactivity` entry on
`client.deadlines`, and `on('warning')` fires `warningLead` before it runs out. When it runs out,
the SDK neither boots the client nor raises an event. Watch the entry, and call
`client.boot('idle')` if you want the client gone. The window lives in the host, so it stops when
the host does.

### Termination rules

For anything else, add rules. A session's `terminationRules` is a list, and each rule is a condition
plus a duration:

| Field | What it is |
|---|---|
| `name` | your name for the rule |
| `when` | a [CEL](https://cel.dev) condition about one client. It has no clock in it |
| `for` | how long `when` must hold before the rule fires. Optional, `1s` when omitted |

The service enforces every rule, not the host. A rule still fires while the host is away or
restarting, which is when a stuck client most needs to go. The host SDK only shows the countdown.

Rules are set when the session opens. Omit `terminationRules` and the session takes your project's
default list, which is empty until you set one. Pass `[]` for no rules at all.

### How a rule fires

These three sentences are the whole behaviour:

1. `when` is re-evaluated whenever a fact about the client changes.
2. A rule **arms** when `when` becomes true and **disarms** when it becomes false.
3. When a rule has been armed for `for`, `when` is evaluated once more: if it is still true the
   rule fires, and otherwise the rule disarms.

A fact that goes false and true again inside `for` starts the countdown from zero. There is no
sampling interval. The one delay is for the two session facts, `session.clients` and
`session.hostState`: a rule over them can arm up to 250 ms after the change.

### Examples

```js
import { BitskiffHost } from '@bitskiff/host';

const bitskiff = new BitskiffHost({ key: 'bsk_pk_...' });   // your host key

// 1. The default: no rules. The two budgets are enough.
await bitskiff.open();

// 2. End a byte hog.
await bitskiff.open({
  terminationRules: [{ name: 'byte-hog', when: 'bytesOut > 50000000' }],
});

// 3. No flapping.
await bitskiff.open({
  terminationRules: [
    { name: 'no-flapping', when: "reconnects >= 5 && connection == 'detached'", for: '10s' },
  ],
});
```

```ts
import { BitskiffHost, type TerminationRule } from '@bitskiff/host';

const bitskiff = new BitskiffHost({ key: 'bsk_pk_...' });   // your host key

// 1. The default: no rules. The two budgets are enough.
await bitskiff.open();

// 2. End a byte hog.
const byteHog: TerminationRule = { name: 'byte-hog', when: 'bytesOut > 50000000' };
await bitskiff.open({ terminationRules: [byteHog] });

// 3. No flapping.
const noFlapping: TerminationRule = {
  name: 'no-flapping',
  when: "reconnects >= 5 && connection == 'detached'",
  for: '10s',
};
await bitskiff.open({ terminationRules: [noFlapping] });
```

1. **The default** adds no rule. A client ends when a budget runs out or the host boots it.
2. **`byte-hog`** ends a client 1 second after a stats report shows it has sent more than 50 MB
   since it joined. It does nothing while the host is away.
3. **`no-flapping`** ends a client that has reconnected five times and dropped again, 10 seconds
   later. A client that gets back inside those 10 seconds keeps its place.

### What a rule can read

A `when` can name ten facts about one client, and nothing else. Any other name is refused.

| Fact | What it holds |
|---|---|
| `connection` | `'reserved'` (has a ticket, not attached yet), `'attached'` or `'detached'` |
| `presence` | `'present'` or `'away'` |
| `reconnects` | how many times the client has reconnected |
| `grants` | the client's grants. Test one with `in`: `!('sendInput' in grants)` |
| `bytesIn`, `bytesOut` | bytes delivered to the client, and bytes it sent, since it joined |
| `messagesIn`, `messagesOut` | the same, counted in messages |
| `session.clients` | how many clients the session holds now |
| `session.hostState` | `'attached'` or `'away'` |

See [`TerminationRule`](/docs/packages/host/#reference) in the host reference. Two traps: a rule
over `connection == 'reserved'` can end a client mid-join, and `session.clients == 0` never fires,
because a rule only runs for a client that is there.

The four counters are different from the other six:

- They move only when a host posts a stats report, about once a minute. With no host attached,
  they stand still.
- The client counts its own traffic and reports it to the host, which posts the figures. A client
  that stops reporting never trips a counter rule.

Use a counter rule to clear out a client that uses more than its share. Do not use it as a defence.
A per-second ceiling belongs in [Rate limits](/docs/guides/limits/#rate-limits).

### Rule limits

| Limit | Value |
|---|---|
| Rules per session | 16 |
| `name` | 1 to 32 characters of `a-z`, `0-9`, `_` and `-`, unique in the list |
| `for` | `1s` to `24h`. Your project can lower the ceiling |
| `when` | 512 bytes, and it must return `true` or `false` |

A rule outside a limit is refused, never clamped. `open()` rejects with a `BitskiffError` whose
`code` is `validation`, `details.field` names the rule, such as `terminationRules[1].when`, and
`details.reason` says what is wrong. Import `@bitskiff/host/expressions` to get that refusal before
the request goes out. Without it, the service sends the same refusal.

### When a rule fires

While a rule is armed, both ends see a `policy` countdown. The host reads it on the roster
client's `client.state.deadlines`, and the client page on its own `client.deadlines`. On both,
`on('warning')` fires `warningLead` before it, like any other ending. If the rule fires, the client is
removed:

- The host gets `clientLeft` with `e.reason` `booted`.
- The client ends with `ended.reason` `booted` and `ended.bootReason` `policy`.

The client is told `policy`, never the rule's name, because the name is your own string. The
service records the name on the project's `client.gone` event.

## Client end reasons

Key on `client.state.ended.reason` on the client:

| `ended.reason` | What it means |
|---|---|
| `invalidCode` | the code is past its window (see [Join codes](/docs/guides/lifecycle/#join-codes)). Scan the current one |
| `hostGone` | the host slept or lost its link for longer than the host grace |
| `clientsFull` | the session is at `maxClients`, 4 by default. Open the next one with a higher number: see [Session capacity](/docs/guides/limits/#session-capacity) |
| `wrongClient` | this client key is not for this session: the session is another project's, or its host key is one this client key does not serve |
| `booted` | the host removed this client, or a termination rule did. For a rule, `ended.bootReason` is `policy` |
| `left` | this page called `leave()` |
| `reconnectBudget` | the link was down too long while the client was present |
| `backgroundBudget` | it was away too long |
| `hostClosed` | the host closed the session |
| `refused` | the service refused the join. `ended.errorCode` says which refusal it was, such as `invalid_token` for a token that is expired, revoked or unknown |

Those ten are the common ones. A client can also see `sessionEnded`, `apiClosed`, `idleTimeout`,
`maxSessionTime`, `replaced`, `ticketRejected`, `sessionNotFound`, `keyRevoked`, `hostUnverified`,
`keyUnverified`, `rate`, `protocol` and `disposed`. `rate` means the host booted this client for
sending over its byte rate. A ticket, in `ticketRejected`, is the single-use
credential the service mints for one connection attempt; it expires in a minute and no SDK hands
you one.

Turn the reasons you recognise into your own words, and write a fallback message for the rest. A
reason your page has never seen still has to say something true.

## Close codes

Under every `ended.reason` is the code the connection was closed with. The SDK reads it for you:
it turns one of these into an `ended.reason`, or into a reconnect, and a page that keys on
`ended.reason` never has to look at the number.

It is here because two other places hand you one: `bitskiff sessions close` says every client is closed with 4000, and a REST refusal that also
ended something carries the code in `closeCode`.

| Close code | What it means |
|---|---|
| `1000` | This end called `leave()` or `close()` |
| `4000` | The session ended. Every client in it is closed with this code |
| `4001` | This client was removed by the host or from the console |
| `4002` | The ticket was invalid, expired or already used |
| `4003` | The reconnect or background budget is spent. The place is gone and the way back is a fresh join code |
| `4004` | No session with this id is live |
| `4005` | The caller's protocol range and the session's pinned version do not meet |
| `4006` | A newer connection presented the same identity. This one stops |
| `4007` | The host did not come back inside the host grace |
| `4008` | The key the session was opened with was revoked |
| `4009` | Malformed or oversize envelopes kept coming after the first was refused |
| `4100` | Too many attempts. The SDK waits out `retryAfterMs`, or backs off on its own -- jittered, up to 10 s -- when the close carries none, and comes back |
| `4101` | The region is shedding load. The SDK backs off and comes back |
| `4102` | A planned drain. The SDK backs off and comes back |
| `4200` | The server this connection was on is going away. The SDK mints a fresh ticket and reconnects |
| `4201` | The link was lost and could not be resumed. The SDK mints a fresh ticket and reconnects |
| `4202` | The session moved. Reserved; not sent |
| `4203` | This client released its server connection and it sent on it anyway. The place is held: the SDK does not reconnect, and rejoins only when you call `rejoin()` or the released transport is lost |

Read the range, not the individual number. 1000 and 4000 to 4099 end the session, and the SDK does
not reconnect. 4100 to 4199 mean back off, and the SDK tries again after the wait. 4200 to 4299 mean
reconnect now, and the SDK does. Anything else is treated as 4201.

## Next steps

- [Channels](/docs/guides/channels/)
- [Client pages](/docs/guides/client-page/)
- [Node hosts](/docs/guides/node-hosts/)
