# Unattended hosts

A host with nobody standing at it -- signage in a lobby, a bot, a dedicated game host -- has one
job: keep a session open. The `openForever` loop is what does it, and `loop.health` is what the
host's display paints from.

## The open-forever loop

A session ends for several reasons: a link that drops for longer than the grace, somebody closing
it in the console, a revoked key. A program that called `open` once has nothing to show after that
session ends. The `openForever` loop opens the next session, on a backoff table, for as long as the
process runs.

```js
import { BitskiffHost, qrTerminal } from '@bitskiff/host-node';
import { openForever } from '@bitskiff/host-node/unattended';

// Your own function: do something with a message from a client.
function play(m) { /* ... */ }

const bitskiff = new BitskiffHost();                    // the key comes from BITSKIFF_SECRET_KEY
bitskiff.channel('press').on('message', (m) => play(m));   // declared once, heard in every session

const loop = openForever(bitskiff, {
  session: { label: 'lobby-north', hostResumeWindow: '5m' },
});

loop.on('open', (session) => {                          // every session, the first one included
  console.log(qrTerminal(session.joinCode.url));        // scannable out of the terminal
  session.on('joinCode', (code) => {                    // the code rotates: draw the new one
    console.log(qrTerminal(code.url));
  });
});

loop.on('reopen', (_session, { attempt, reason }) => {  // the second session on
  console.log(`reopened after ${reason} (attempt ${attempt})`);
});
```

```ts
import { BitskiffHost, qrTerminal } from '@bitskiff/host-node';
import type { JoinCode, Message, Session } from '@bitskiff/host-node';
import { openForever } from '@bitskiff/host-node/unattended';
import type { ReopenInfo } from '@bitskiff/host-node/unattended';

function play(m: Message): void { /* ... */ }

const bitskiff = new BitskiffHost();                    // the key comes from BITSKIFF_SECRET_KEY
bitskiff.channel('press').on('message', (m: Message) => play(m));   // declared once, heard in every session

const loop = openForever(bitskiff, {
  session: { label: 'lobby-north', hostResumeWindow: '5m' },
});

loop.on('open', (session: Session) => {                 // every session, the first one included
  console.log(qrTerminal(session.joinCode.url));        // scannable out of the terminal
  session.on('joinCode', (code: JoinCode) => {          // the code rotates: draw the new one
    console.log(qrTerminal(code.url));
  });
});

loop.on('reopen', (_session: Session, { attempt, reason }: ReopenInfo) => {   // the second session on
  console.log(`reopened after ${reason} (attempt ${attempt})`);
});
```

The blocks here are a Node host reading its secret key from the environment, which is what a
permanent install runs. [Node hosts](/docs/guides/node-hosts/) has the install and the key.

A browser or Electron host runs the same loop from `@bitskiff/host/unattended`, and the last
section has that import. A host whose usage you bill to a customer opens with a token instead of a
key: [Usage-based billing](/docs/guides/usage-based-billing/#hosts-you-bill).

The `open` event fires for every session the loop opens, the first one included, so the wiring goes
in one place and runs for every session. The `reopen` event fires for the ones after it and says
which end it is recovering from.

That block is a whole program. The blocks after it are snippets: each shows the lines its section is
about, on the `loop` and the `bitskiff` opened here.

**Set `hostResumeWindow` to the longest drop a host should ride through.** Inside it the session
survives, its clients keep their places, and the host redraws the same session's code when it comes
back. Past it the session ends and the next one is a new session with a new code.

`5m` is the number to ship; the `10s` default covers a page reload and not a venue's wifi. It is a
[duration string](/docs/guides/limits/#durations), like every duration you write here.

**Leave `idleTimeout` alone.** The platform default is `0s`, no limit, until you set one. Setting it
on a host with nobody at it ends a good session on every quiet stretch and puts a new join code on
the host's display each time.

**Draw the code on the event as well as on `open`.** The code rotates every 30 seconds, so a code a
client sees stays accepted for at least one full step and at most two -- 30 to 60 seconds at the
default. A host that draws it once shows a code that has stopped working while nobody is watching.

## Labels

Give every host a `label`. It is that installation's identity across every session the loop opens,
so the console, the session list and every webhook read as one host instead of a stream of
unrelated sessions, and a refusal names which installation was turned away.

```js
import { BitskiffHost } from '@bitskiff/host-node';
import { openForever } from '@bitskiff/host-node/unattended';

const loop = openForever(new BitskiffHost(), {
  session: {
    label: 'lobby-north',                                // this installation, in every session
    metadata: { venue: 'north-building', floor: '2' },   // your own keys, carried with it
  },
});

loop.on('open', (session) => console.log(session.joinCode.code));
```

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

const loop = openForever(new BitskiffHost(), {
  session: {
    label: 'lobby-north',                                // this installation, in every session
    metadata: { venue: 'north-building', floor: '2' },   // your own keys, carried with it
  },
});

loop.on('open', (session: Session) => console.log(session.joinCode.code));
```

The `metadata` map is yours: whatever your own tools need to place the host without looking it up.

Which of your hosts is up is one read: `GET /v1/sessions?label=lobby-north` matches the label
exactly and pages like any other list. A label with no row is a host that is not serving. The row
goes when the host grace expires, so the read lags a cut-off host by the grace.

It is still the only signal there is for a host cut off by its own network -- Bitskiff sees no event for it.

## Fallback content

What a host shows when a session is not available is yours, and the SDK renders none of it. Bind it
to `health.status` and give "at capacity" its own branch.

```js
// Your own display.
function showJoinCode() { /* ... */ }
function showFallback() { /* ... */ }
function showAtCapacity() { /* ... */ }

loop.on('health', (health) => {
  if (health.status === 'serving') showJoinCode();       // back at once; nothing to debounce
  else if (health.lastRefusal?.limit) showAtCapacity();  // a cap turned the last open away
  else if (health.status === 'down') showFallback();
});

loop.on('open', (session) => {
  session.on('capacity', (capacity) => {                 // the same branch, session still live
    if (capacity.status !== 'ok') showAtCapacity();
  });
});
```

```ts
import type { CapacityNotice, Session } from '@bitskiff/host-node';
import type { HostHealth } from '@bitskiff/host-node/unattended';

// Your own display.
function showJoinCode(): void { /* ... */ }
function showFallback(): void { /* ... */ }
function showAtCapacity(): void { /* ... */ }

loop.on('health', (health: HostHealth) => {
  if (health.status === 'serving') showJoinCode();       // back at once; nothing to debounce
  else if (health.lastRefusal?.limit) showAtCapacity();  // a cap turned the last open away
  else if (health.status === 'down') showFallback();
});

loop.on('open', (session: Session) => {
  session.on('capacity', (capacity: CapacityNotice) => {  // the same branch, session still live
    if (capacity.status !== 'ok') showAtCapacity();
  });
});
```

A status of `down` means not serving and not about to be: 30 seconds without serving. It ends only
when a session is open and connected again, so a page that follows it needs no grace of its own and
no debounce, and it does not flicker. A blip that comes back inside those 30 seconds never reaches
`down` at all.

A status of `recovering` is the state in between, and a host that has just booted starts there.

The `health.changed` field names what moved, so a page switches on one value instead of comparing
snapshots. The `health.phase` field says whether a session is still in hand: `reconnecting` while
the transport comes back. The `health.lastDrop` field carries the last drop, which is what the host's
display shows during the 30 seconds before `down`.

The capacity branch has two halves because a cap is met from two sides. While a session is live,
`session.capacity` carries the project's standing and `on('capacity')` fires when it moves. Once
the session has ended and the next `open` is refused by a cap, `health.lastRefusal.limit` names it.
Nothing clears `lastRefusal`, so read it beside `status`, as the block above does.

The `health.sessions` and `health.reconnects` fields are counters, never a rate. They are what a
host prints in a corner for a technician.

Two fields carry the clock behind `status`. `health.servingSince` is the instant the serving run in
hand began, and it is `undefined` whenever nothing is being served. `health.lastServingAt` is the
instant serving last ended, and it is `undefined` until a host has served once.

`recovering` is measured from `lastServingAt`, or from the loop's boot for a host that has never served, which is
why a host that has just booted starts there.

## Signs a host needs a person

Two things say a host needs a person: a refusal that waiting will not clear, still standing on a
host that is not serving; and how long it has been down.

```js
import { isBitskiffError } from '@bitskiff/host-node';

// Your own display, for whoever comes to look. `text` is one line to put on it.
function showAttention(text) { /* ... */ }

loop.on('openRefused', (error, { attempt, nextAt }) => {
  if (!isBitskiffError(error)) return;
  const inS = nextAt === undefined ? 0 : Math.round((nextAt - bitskiff.platform.now()) / 1000);
  console.error(`${error.code} from ${error.source}, attempt ${attempt}, next try in ${inS}s`);
});

loop.on('health', (health) => {
  const refusal = health.lastRefusal;
  const downForMs = bitskiff.platform.now() - health.since;
  if (health.status !== 'serving' && refusal?.clears === 'never') showAttention(refusal.code);
  else if (health.status === 'down' && downForMs > 900_000) showAttention('down for 15 minutes');
});
```

```ts
import { isBitskiffError } from '@bitskiff/host-node';
import type { HostHealth, OpenRefusedInfo } from '@bitskiff/host-node/unattended';

// Your own display, for whoever comes to look. `text` is one line to put on it.
function showAttention(text: string): void { /* ... */ }

loop.on('openRefused', (error: unknown, { attempt, nextAt }: OpenRefusedInfo) => {
  if (!isBitskiffError(error)) return;
  const inS = nextAt === undefined ? 0 : Math.round((nextAt - bitskiff.platform.now()) / 1000);
  console.error(`${error.code} from ${error.source}, attempt ${attempt}, next try in ${inS}s`);
});

loop.on('health', (health: HostHealth) => {
  const refusal = health.lastRefusal;
  const downForMs = bitskiff.platform.now() - health.since;
  if (health.status !== 'serving' && refusal?.clears === 'never') showAttention(refusal.code);
  else if (health.status === 'down' && downForMs > 900_000) showAttention('down for 15 minutes');
});
```

**Read `lastRefusal` beside `status`.** Nothing clears the field, so it stands where the last
refusal left it through the whole next healthy session. Without the `status` gate, a host refused
at 09:00, handed a new key and serving all day still lights the marker.

The `lastEnd.reason` field is not that marker either. Somebody closing a session in the console is
not a fault, and the field is sticky the same way.

There is no `needsAttention` flag. How long is too long is your policy, and `health.since` is the
instant the current status began, so `platform.now() - since` is the whole of the arithmetic.

**The same facts reach your server as the `open.refused` webhook, which is how a fleet owner sees a
host that cannot open.** A host whose every `open` is refused has no session to move anything in
the event stream. The `open.refused` event carries the `label`, the `code`, what clears it and the
cap it hit, once per episode rather than once per retry.
[Webhooks](/docs/guides/webhooks/#refused-opens) has the endpoint and the fields.

## Usage limits

Caps refuse new work; they never cut a live session or a live client. A host at a project's ceiling
keeps serving everyone already in its session and is refused at its next `open`.

The `session.capacity` object is the project's standing, and `capacity.limits` carries a row only
for a cap that is not `ok` -- so a healthy project has an empty list.

```js
loop.on('open', (session) => {
  session.on('capacity', (capacity) => {                 // fires when the standing moves
    for (const limit of capacity.limits) {               // a row for each cap that is not ok
      console.log(`${limit.limit} ${limit.status}: ${limit.value} of ${limit.ceiling}`);
    }
  });
});
```

```ts
import type { CapacityNotice, Session } from '@bitskiff/host-node';

loop.on('open', (session: Session) => {
  session.on('capacity', (capacity: CapacityNotice) => {  // fires when the standing moves
    for (const limit of capacity.limits) {               // a row for each cap that is not ok
      console.log(`${limit.limit} ${limit.status}: ${limit.value} of ${limit.ceiling}`);
    }
  });
});
```

| Cap | What it refuses | What the live session sees | What clears it |
|---|---|---|---|
| `concurrentSessions`, 5 | the next `open`, never the one in hand | nothing | another session ending, or a raise |
| `concurrentClients`, 40 | the next join | nothing; the client's join page counts down | a client leaving, or a raise |
| `clientMinutesPerDay` | new opens and new joins | a `capacity` row, `warning` at 80% and `exceeded` at 100% | 00:00 UTC, or a raise |
| `monthlySpendCap` | new opens and new joins | the same row | the month rolling, or a raise |
| `samplePageSessions`, 100 warn and 1000 refuse | joins through the sample client page, past 1000 sessions counted for the life of the project. It refuses a client's join and never a host's `open`; at 100 the console and the CLI only warn | a `capacity` row with no reset, on the one `samplePageSessions` name | pointing the project's `joinUrl` at a page of your own |
| `samplePageJoinsPerDay`, 100 | joins through the sample client page, past 100 joins in one UTC day | nothing; the host learns of it only through a client's refused join | 00:00 UTC, or pointing the project's `joinUrl` at a page of your own |
| `turnBytesPerMonth` | nothing: relay bytes are counted and warned on, and no join is refused for them | a `capacity` row from 80% | the month rolling, or a raise |
| `sessionOpensPerMinute`, `ticketMintsPerMinute` | the call | nothing; the host reads `lastRefusal` | the window rolling |
| session ceilings | nothing: the values are clamped | what you actually got, on `session.state.config` | asking for a value inside the ceiling |

A host that asked for `reconnectBudget: '10m'` and got `2m`, the ceiling on that setting, is serving
and healthy, and `health` says nothing about the clamp. The `session.state.config` object is where a host reads what it was given. [Limits and
capacity](/docs/guides/limits/) has every session setting and every project number, each with its
default and its ceiling.

A revoked key is the one credential change that does cut a host: the session ends, every `open`
after it is refused, and the fix is a new key. Pass `key` as a function and the loop reads it on
every attempt, so a pushed credential brings the host back with nobody at the machine.

## Retries

The loop retries every refusal. A refusal that waiting cannot clear is retried too, slowly. The
loop is not the judge of what a person has fixed. A replaced key, an origin added to an allowlist
or a `joinUrl` that was set changes nothing the host can see from where it stands. Asking again is
the only way it learns.

| What happened | What the loop waits | The setting behind it |
|---|---|---|
| a session ended -- an outage, a revoked key, an idle limit | 1 s plus a jittered step. The step starts at 2 s and doubles each attempt up to 300 s | `initialBackoff`, `2s`, doubling to `maxBackoff`, `5m` |
| a refusal that clears by waiting and names no wait | the same 1 s plus a doubling step | the same two |
| a rate limit, which names the window it is pacing | up to twice that window, and the attempt count stays where it was | the window the refusal named is the floor, never longer than `maxRetryAfter`, `5m`; the jitter on top spans the whole window |
| a cap that names a wait -- a daily or a monthly quota | 300 to 600 s, and the attempt counts | `maxRetryAfter`, `5m`, then a jitter up to `maxBackoff`, `5m` |
| a refusal waiting will not clear -- a bad key, a blocked origin | 300 to 600 s, forever | `attentionBackoff`, `5m` |
| a session somebody closed in the console, or your own `close()` | 60 to 62 s, so the concurrency this host gives back stays free for somebody else | `reopenAfterClose`, `1m` |
| a ban from the edge, which turns away one address at a time | the whole ban it named, never shortened: 600 to 1200 s | no setting: the ban's own wait, which `maxRetryAfter` does not cut |

Every one of those settings is a [duration string](/docs/guides/limits/#durations) on the policy you
pass, and the defaults above are what a host runs with when you pass nothing.

Every wait is jittered, so a fleet refused in the same second does not come back in the same
millisecond.

A wait is not a sleep either. When nothing answered at all -- a dead link, a
blackholed one -- a Node host probes for reachability once `health` reads `down`, and opens on the
first answer after a probe that failed. A browser host cuts any wait short on its page's own
`online`, `pageshow` and `visible` events, whatever refused it, every wait but the one after a
close.

`Close` in the console is not a stop. Nothing on the platform stops a loop running on somebody's
machine, so the host is back about a minute later -- **with a new join code**, because a new
session is a new code and anybody holding the old one has to scan again.

The loop has three ends and three only: you called `stop()` or `detach()`, your own `reopenOn`
returned `false` for the reason a session ended, or the host or the open session was disposed.
The `health.stopped` field and the `stopped` event carry which.

The `reopenOn` predicate reopens after every end by default, which is what a host with nobody at it
wants. The reasons it is asked about are `hostClosed`, `apiClosed`, `idleTimeout`,
`maxSessionTime`, `hostResumeExpired`, `ticketRejected`, `sessionNotFound`, `replaced`, `keyRevoked`,
`protocol`, `refused` and `unknown`.

Every reason your predicate refuses ends the loop for good, and nothing
restarts it. A `disposed` end is never asked about: the host is gone, so the loop ends without
calling `reopenOn`.

```js
import { BitskiffHost } from '@bitskiff/host-node';
import { openForever } from '@bitskiff/host-node/unattended';

const loop = openForever(new BitskiffHost(), {
  policy: { attentionBackoff: '2m' },        // every other field keeps its default
});

loop.on('stopped', ({ reason }) => console.log(`loop ended: ${reason}`));
```

```ts
import { BitskiffHost } from '@bitskiff/host-node';
import { openForever } from '@bitskiff/host-node/unattended';
import type { LoopStopped } from '@bitskiff/host-node/unattended';

const loop = openForever(new BitskiffHost(), {
  policy: { attentionBackoff: '2m' },        // every other field keeps its default
});

loop.on('stopped', ({ reason }: LoopStopped) => console.log(`loop ended: ${reason}`));
```

`DEFAULT_UNATTENDED_POLICY` is exported beside `openForever` when you want to read a default rather
than set one. Pass only the fields you are changing: the rest fall back on their own.

## Fleet settings

Every default here is sized for one host on a laptop. A thousand hosts meet several of them on the
first day, so set these before the vans go out. The first two are per session; the rest are project
and key settings you change in the [console](https://console.dev.bitskiff.com).

| Setting | Set it to | Why |
|---|---|---|
| `hostResumeWindow` | `5m`, against a `30m` ceiling | The host grace. Inside it a drop costs nothing; past it every affected host shows a new join code |
| `maxSessionTime` | `0s`, unless something really must end a session on a clock | `0s` is no limit, which is what an unattended host wants. Any other value puts a new code on every host in the fleet on that clock |
| `concurrentSessions` | at least the fleet size, with headroom for an overlapping rollout | Every host holds one session. Below the fleet size, some hosts cannot open at all and take turns being down |
| `sessionOpensPerMinute` | fleet size divided by the minutes you will accept for a full re-entry, on the project and on the key | It paces re-entry after an outage. At the default 10, ten thousand hosts take about 17 hours; at 1000 they take ten minutes |
| `clientMinutesPerDay` | expected traffic, with headroom to grow | The default 2000 is an anomaly cap, not a plan. A fleet with real client traffic spends it before lunch and every host is refused until 00:00 UTC |
| `joinUrl` | [a client page of your own](/docs/guides/client-page/) | The sample client page is counted for the life of the project and nothing resets it: the console warns past 100 sessions and joins are refused past 1000, while every host keeps serving a code nobody can join |
| `sessionOpensPerMinuteIp` | the number of hosts behind the busiest address, up to 300 | Hosts behind one NAT are one address to this limit, and the default is 30 |
| `ticketMintsPerMinute` | fleet size divided by the grace's minutes, on the key the fleet uses | Every resume attempt mints a ticket, and the grace is all the time a resume has. At the default 120, most of a large fleet's sessions end instead of resuming |

The `hostTicketsPerMinuteIp` row is 30 ticket mints a minute per address, and a project can raise
it to 300. [Raises](/docs/guides/limits/#raises) lists every limit a project may raise. Raise it
to your venue's host count, or put no more than 30 hosts behind one address, or stagger a venue's
rollout so it does not come back all at once.

```js
import { BitskiffHost } from '@bitskiff/host-node';
import { openForever } from '@bitskiff/host-node/unattended';

const loop = openForever(new BitskiffHost(), {
  session: {
    label: 'lobby-north',
    hostResumeWindow: '5m',        // ride a five-minute drop without a new join code
    maxSessionTime: '0s',          // no clock ends a session this host is serving
  },
});
```

```ts
import { BitskiffHost } from '@bitskiff/host-node';
import { openForever, type UnattendedLoop } from '@bitskiff/host-node/unattended';

const loop: UnattendedLoop = openForever(new BitskiffHost(), {
  session: {
    label: 'lobby-north',
    hostResumeWindow: '5m',        // ride a five-minute drop without a new join code
    maxSessionTime: '0s',          // no clock ends a session this host is serving
  },
});
```

A venue also has an edge to stay under. The edge rule is 600 requests a minute per address, and an
address past it is banned for ten minutes. The ban takes everything behind that address, hosts and
clients alike, so plan for the number rather than measuring it.

What counts is the hosts' opens
plus two requests per client arrival, so 300 hosts and their clients rejoining at once is the case
to stagger.

## Restarts and shutdown

Two verbs end the loop, and which one a signal means is yours to decide.
Calling `detach()` ends the loop and leaves the session running out
its host grace with its token in place, so the restarted process resumes the same session on the
same join code. Calling `stop()` ends the loop and closes the session, which gives its place under `concurrentSessions` back at once.

On Node, `createDrain` is the recipe. It sorts each signal into one of the two verbs and, on a
stop, waits a bounded time for the end to land before the process leaves.

```js
import { BitskiffHost } from '@bitskiff/host-node';
import { createDrain, processSignals } from '@bitskiff/host-node/operator';
import { openForever } from '@bitskiff/host-node/unattended';

const loop = openForever(new BitskiffHost(), {
  session: { hostResumeWindow: '5m' },             // long enough for the deploy to finish
});

const drain = createDrain({
  signals: processSignals(process, ['SIGTERM', 'SIGINT', 'SIGHUP']),
  detach: () => loop.detach(),                     // the session rides the restart
  close: () => loop.stop(),                        // the session closes and its place is given back
  err: (line) => process.stderr.write(`${line}\n`),
});

await drain.done;                                  // resolves once a signal has been handled
```

```ts
import { BitskiffHost } from '@bitskiff/host-node';
import { createDrain, processSignals } from '@bitskiff/host-node/operator';
import { openForever } from '@bitskiff/host-node/unattended';

const loop = openForever(new BitskiffHost(), {
  session: { hostResumeWindow: '5m' },             // long enough for the deploy to finish
});

const drain = createDrain({
  signals: processSignals(process, ['SIGTERM', 'SIGINT', 'SIGHUP']),
  detach: () => loop.detach(),                     // the session rides the restart
  close: () => loop.stop(),                        // the session closes and its place is given back
  err: (line: string) => process.stderr.write(`${line}\n`),
});

await drain.done;                                  // resolves once a signal has been handled
```

`SIGTERM` and `SIGHUP` detach, `SIGINT` closes; pass `restartOn` to sort them another way. A fleet
that closes on `SIGTERM` changes the join code on every host at every deploy, because a closed
session cannot be resumed. The SDK installs no signal handler of its own; the operator helper does,
when you ask it to.

Both verbs are idempotent, and `stop()` returns a promise that never rejects -- ignore it like
`void`, or await it while shutting down. Nothing restarts a stopped loop; the way on is a fresh
`openForever`.

If the replacement process starts before the old one exits, its `open` is refused rather than
taking the session, and the loop tries again a second or two later. That clears itself the moment
the old process is gone.

A host that runs on your own server, on a Kubernetes rollout or with its state in your own store,
is under [Server hosts](/docs/guides/server-hosts/).

## Supervisors

Run the program under a supervisor -- systemd, a container runtime, whatever the install already
has -- and let it handle the process dying. Keep the loop's own retries for the network: restarting
a process that is waiting out a five-minute backoff buys nothing and gives up the session its host
grace was holding.

Keep the secret key out of pages. A secret key presented from a browser is refused, and that
refusal is one waiting never clears, so the same block copied into a browser host parks it on the
slow branch for good. A browser or Electron host runs the identical loop from
`@bitskiff/host/unattended` with the project's publishable key from an allowed origin:

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

const loop = openForever(new BitskiffHost({ key: 'bsk_pk_...' }), {
  session: { label: 'lobby-north', hostResumeWindow: '5m' },
});

loop.on('open', (session) => console.log(session.joinCode.url));
```

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

const loop = openForever(new BitskiffHost({ key: 'bsk_pk_...' }), {
  session: { label: 'lobby-north', hostResumeWindow: '5m' },
});

loop.on('open', (session: Session) => console.log(session.joinCode.url));
```

That host has no environment to read a key from and no reachability probe, so it opens earlier on
its page's own network and visibility events instead. Everything else on this page is the same.

## Next steps

- [Session lifecycle](/docs/guides/lifecycle/)
- [Node hosts](/docs/guides/node-hosts/)
- [Webhooks](/docs/guides/webhooks/)
- [Limits and capacity](/docs/guides/limits/)
