---
title: 'Telnet, session and store'
url: 'https://wiki.teletypegames.org/engines/rubbs/telnet-and-store'
markdown: 'https://wiki.teletypegames.org/engines/rubbs/telnet-and-store.md'
lang: en
date: '2026-10-27'
description: 'The bottom of rubbs: how a connection becomes a BBS::Session, what the telnet negotiation does, the window size (NAWS), every key readkey can return, readline, writing, mouse reporting, idle limits and hang-ups (lib/bbs/server.rb, session.rb, telnet.rb); and BBS::Store, the table a board keeps its…'
taxonomy:
  tag:
    - reference
---

# Telnet, session and store

> The bottom of [rubbs](https://wiki.teletypegames.org/engines/rubbs): how a connection becomes a `BBS::Session`, what the telnet negotiation does, the window size (NAWS), every key `readkey` can return, `readline`, writing, mouse reporting, idle limits and hang-ups (`lib/bbs/server.rb`, `session.rb`, `telnet.rb`); and `BBS::Store`, the table a board keeps its callers' data in, an SQLite file since 0.33.0 (`lib/bbs/store.rb`). The other blocks a content board needs (SQLite, HTTP, cache, rate limits, logging) are on [Data and network](https://wiki.teletypegames.org/engines/rubbs/data-and-network). Most boards only meet these through a Flow or an Application; you need them directly in a Flow `call` step, a door, or a C64 face.

# From connection to session

## `BBS.start` and `BBS::Server`

```ruby
BBS.start                 # port from BBS.config.port (ENV['BBS_PORT'] or 2323)
BBS.start(port: 2400)
```

`BBS.start(port: config.port)` runs `BBS::Server.new(port:).run`, which blocks:

1. listens on `0.0.0.0:PORT` (prints `BBS listening on port …`);
2. for each connection checks the limits: with `max_sessions` callers already on, it writes `Too many callers right now, try again later.`, logs `BBS busy: …` and closes; with `max_sessions_per_ip` calls already coming from that address, it writes `Too many calls from your address, try again later.` and logs `BBS crowded: …`;
3. starts a thread, sets the socket's read/write timeout to `idle_seconds` (900 s when `nil`; needs Ruby 3.2's `IO#timeout=`), and runs a new `BBS::Session`;
4. `Session#run`: `negotiate`, `enable_mouse` when `config.mouse` and `config.mouse_on_connect` are both on, then `FlowRunner.new(session, session_id, config.flow).run`; afterwards it always switches the mouse off and closes the socket;
5. when the session ends, however it ends, the thread decrements the count and calls `config.on_session_end`.

Every line goes through `BBS.log` and starts with `config.log_tag` (see [Data and network](https://wiki.teletypegames.org/engines/rubbs/data-and-network)). A hang-up or a silent line (`IOError`, `EPIPE`, `ECONNRESET`, `ETIMEDOUT`, `IO::TimeoutError`) is logged as `feedia-bbs: session: dropped by the client (…)`, not as an error. Any other exception is logged under `session` with its class, message and three lines of backtrace. Errors of `accept` itself (`ECONNABORTED`, `EMFILE`, `ENFILE`) are logged and the loop waits half a second instead of spinning. Ctrl-C and SIGTERM (`docker stop`) print `Server stopped.`.

## The config fields of this layer

| Field | Default | Meaning |
|---|---|---|
| `port` | `ENV['BBS_PORT']` or `2323` | The TCP port of `BBS.start`. |
| `idle_seconds` | `nil` | The socket's read/write timeout (900 s when `nil`) and the idle disconnect of an Application. See *Idle limits*. |
| `max_sessions` | `ENV['BBS_MAX_SESSIONS']` or `100` | Callers served at once. |
| `max_sessions_per_ip` | `ENV['BBS_MAX_SESSIONS_PER_IP']` or `6` | Calls served at once from one address. Private, loopback and link-local peers (bbs-web, proxying many callers from the Docker network) are not limited. Since 0.33.0. |
| `log_tag` | `ENV['BBS_LOG_TAG']` or `bbs` | What every log line starts with: the board's technical name, `feedia-bbs`. Since 1.0.0. |
| `mouse` | `false` | Whether the board uses the mouse at all. |
| `mouse_on_connect` | `true` | With `mouse`, switch reporting on at connect. A two-faced board sets it `false` (see [Commodore 64](https://wiki.teletypegames.org/engines/rubbs/commodore)). |
| `on_session_end` | `nil` | `->(session) { … }`, called from the server thread after each session, also after errors. |

```ruby
require 'bbs'

BBS.configure do |c|
  c.port           = 2400
  c.idle_seconds   = 600
  c.max_sessions   = 20
  c.on_session_end = ->(session) { puts "#{session.session_id} left (#{session.term_cols}x#{session.term_rows})" }
  c.flow = BBS::Flow.define do
    say 'Hello!'
  end
end

BBS.start
```

# Telnet negotiation

Right after the connection the session sends four telnet commands and assumes an 80×24 window until told otherwise:

| Bytes | Command | Why |
|---|---|---|
| `ff fb 03` | `IAC WILL SGA` | Suppress go-ahead: character at a time, not line at a time. |
| `ff fb 01` | `IAC WILL ECHO` | The server echoes, so `readline` decides what appears (e.g. a mask). |
| `ff fd 03` | `IAC DO SGA` | Asks the client to suppress go-ahead too. |
| `ff fd 1f` | `IAC DO NAWS` | Asks the client to report its window size. |

The client's answers (`WILL`/`WONT`/`DO`/`DONT` + option) are read and dropped wherever they arrive: in `readkey`, `readline`, `settle`. There is no other option handling (no `BINARY`, no `TTYPE`). All of it goes through one IAC parser, `BBS::Telnet::Parser` (since 1.0.0), the same one a gateway uses in both directions. A client that does not speak telnet at all (a C64 behind a WiFi modem, `nc`) simply never answers.

## NAWS: the window size

When the client reports its size (`IAC SB NAWS w1 w2 h1 h2 IAC SE`) the session updates `term_cols` and `term_rows`:

- columns are clamped to **20–255**, rows to **8–100** (since 0.33.0); a zero is ignored (the old value stays);
- an escaped 255 (`IAC IAC`) inside the report is read as 255;
- a subnegotiation is read up to 64 bytes, the rest dropped (a 65535×65535 report cannot make every render allocate gigabytes);
- a report can come at any time: a resized window is noticed on the next read, and an Application re-lays out its windows;
- on a PETSCII line (after `petscii!`) the size stays 40×25, but `naws_reported?` still becomes true.

| Method | Returns | Meaning |
|---|---|---|
| `term_cols` | Integer | Columns: 80 until reported, then the clamped report; 40 on a PETSCII line. |
| `term_rows` | Integer | Rows: 24 until reported; 25 on a PETSCII line. |
| `naws_reported?` | Boolean | True once the client has reported its window size. Real terminals do; port scanners and password-guessing bots usually do not. |
| `settle(timeout: 1.5)` | the session or `nil` | Waits up to `timeout` seconds for the window size, reading and **dropping** whatever else arrives (a bot's `root` typed before any prompt goes nowhere). Returns the session when the size came, `nil` when it did not (until 1.0 true or false). Returns at once when the size is already known. |
| `resize!(cols, rows)` | — | Sets the size as a NAWS report would: clamped the same way, a zero ignored, nothing changes on a PETSCII line, and `naws_reported?` becomes true. The telnet NAWS handling calls it, and so does `BBS::Gateway` when the caller resizes during a crossing (since 0.17.1). |

`settle` is what `BBS::Terminal.guess` uses to tell a modern terminal from a C64. It also makes a cheap bot filter:

```ruby
BOT_FILTER = BBS::Flow.define do
  call do |_ctx, runner|
    session = runner.session
    next nil if session.settle(timeout: 2)

    session.write "This board needs a telnet client that reports its window size.\r\n"
    :halt
  end
  say 'Welcome, human.'
end
```

A `call` step that returns `:halt` ends the flow; `next` leaves the block with a value.

# Reading keys: `readkey`

```ruby
key = session.readkey                # blocks until a key (or the socket timeout)
key = session.readkey(timeout: 1.0)  # :idle when nothing came in 1 s
```

Returns one of:

- a **one-character String** — a printable character, UTF-8 included (`"a"`, `" "`, `"é"`, `"ő"`);
- a **Symbol** — a named key (table below);
- a **`BBS::Telnet::MouseEvent`** — a mouse report (only when reporting is on);
- **`:idle`** — `timeout` passed without a key;
- **`nil`** — the caller hung up.

Telnet commands and NAWS reports in between are handled and never returned. Without `timeout`, a read waits until the socket's own timeout (`idle_seconds`, 900 s by default) raises `IO::TimeoutError`, an `IOError`.

## Key symbols

| Key | Symbol | Bytes it comes from |
|---|---|---|
| Enter / Return | `:enter` | CR, LF, CR LF, CR NUL (each one Enter) |
| Tab | `:tab` | `09` |
| Shift-Tab | `:shift_tab` | `ESC [ Z` |
| Backspace | `:backspace` | `08`, `7f` |
| Esc | `:escape` | a lone `ESC` (nothing within 0.15 s), `ESC ESC`, or any escape sequence rubbs does not know |
| Ctrl-C | `:interrupt` | `03` |
| Ctrl-A … Ctrl-Z | `:ctrl_a` … `:ctrl_z` | `01`–`1a`, except Ctrl-C, Ctrl-H, Ctrl-I, Ctrl-J and Ctrl-M, which are the keys above |
| Arrows | `:up`, `:down`, `:right`, `:left` | `ESC [ A`…`D`, `ESC O A`…`D` |
| Home / End | `:home`, `:end` | `ESC [ H` / `F`, `ESC O H` / `F`, `ESC [ 1 ~` / `7 ~`, `ESC [ 4 ~` / `8 ~` |
| Insert / Delete | `:insert`, `:delete` | `ESC [ 2 ~`, `ESC [ 3 ~` |
| Page Up / Down | `:page_up`, `:page_down` | `ESC [ 5 ~`, `ESC [ 6 ~` |
| F1 – F4 | `:f1` … `:f4` | `ESC O P`…`S`, `ESC [ 11 ~`…`14 ~` |
| F5 – F12 | `:f5` … `:f12` | `ESC [ 15 ~`, `17 ~`, `18 ~`, `19 ~`, `20 ~`, `21 ~`, `23 ~`, `24 ~` |
| Alt + Enter | `:alt_enter` | `ESC CR` |
| Alt + printable | `:alt_a`, `:alt_x`, `:alt_1`, … | `ESC` + the character, lowercased (Alt-Shift-A is also `:alt_a`) |
| Modified keys | `:shift_up`, `:ctrl_f5`, `:alt_home`, `:ctrl_shift_right`, … | xterm's second parameter, see below |
| Nothing in time | `:idle` | `timeout:` passed |

**Modifiers.** xterm reports Shift, Alt and Ctrl as a second CSI parameter: `ESC [ 1 ; 2 A` is Shift-Up, `ESC [ 15 ; 5 ~` is Ctrl-F5. rubbs puts a prefix on the key's symbol:

| Parameter | Prefix | Example |
|---|---|---|
| 2 | `shift_` | `:shift_up`, `:shift_f6` |
| 3 | `alt_` | `:alt_home`, `:alt_page_down` |
| 5 | `ctrl_` | `:ctrl_right`, `:ctrl_f5` |
| 6 | `ctrl_shift_` | `:ctrl_shift_left` |
| 4, 7, 8 | none | the plain key (`ESC [ 1 ; 4 A` is `:up`) |

It applies to the CSI forms: arrows, Home/End, Insert/Delete, Page Up/Down and the `~` function keys. A modified F1–F4 in xterm's `ESC [ 1 ; 5 P` form is not decoded and reads as `:escape`.

**Other bytes** (NUL, `1c`–`1f`, a broken UTF-8 sequence) are skipped. On a PETSCII line the keys are the C64's: see [Commodore 64](https://wiki.teletypegames.org/engines/rubbs/commodore).

## Mouse events

With mouse reporting on (`enable_mouse`), clicks, drags and the wheel come back as a `BBS::Telnet::MouseEvent`, a Struct:

| Field | Values |
|---|---|
| `kind` | `:press`, `:release`, `:move` (motion while a button is held), `:wheel` |
| `button` | `:left`, `:middle`, `:right`, `:none`; for `:wheel` `:up` or `:down` |
| `x`, `y` | 1-based column and row |
| `mods` | Array of `:shift`, `:meta`, `:ctrl` |

Both report formats are read: SGR (`ESC [ < b ; x ; y M` press, `… m` release) and the old X10 (`ESC [ M b x y`, each plus 32).

| Method | What it does |
|---|---|
| `enable_mouse` | Writes `\e[?1002h\e[?1006h`: xterm button-event tracking (presses, releases, motion while a button is held) in SGR format. Does nothing when already on, or on a PETSCII line. |
| `disable_mouse` | Writes `\e[?1006l\e[?1002l\e[?1000l` when it was on. `Session#run` calls it at the end of every session. |

An Application turns MouseEvents into `BBS::Event`s for widgets; a Door switches reporting off for its program. In a raw `call` step you get them as they are.

## Example: a raw key loop

A `call` step owns the line while it runs. This one shows every key it reads, stops on Esc or after 30 idle seconds, and survives a hang-up:

```ruby
require 'bbs'

KEYS = BBS::Flow.define do
  call do |_ctx, runner|
    s = runner.session
    s.settle                       # wait for NAWS, or term_cols is still the default 80
    s.enable_mouse
    s.write "#{s.term_cols}x#{s.term_rows}, NAWS: #{s.naws_reported?}. Press keys, Esc ends.\r\n"
    idle = 0
    loop do
      key = s.readkey(timeout: 1)
      break if key.nil? || key == :escape

      if key == :idle
        idle += 1
        break if idle >= 30

        next
      end
      idle = 0
      text = key.is_a?(BBS::Telnet::MouseEvent) ? "#{key.kind} #{key.button} at #{key.x},#{key.y}" : key.inspect
      s.write "\e[1;33m#{text}\e[0m\r\n"
    end
    s.disable_mouse
    nil
  end
  say 'Bye.'
end

BBS.configure { |c| c.flow = KEYS }
BBS.start
```

The first step of a call runs right after the negotiation is sent, before the client's answers have arrived: without `settle`, `term_cols` and `term_rows` would still be 80×24 and `naws_reported?` false. Use `break`, not `return`, to leave the loop: `return` inside a block raises `LocalJumpError` (the FlowRunner prints it as an error and goes on).

What it printed for a few inputs sent by a test script (with NAWS 100×30):

```text
a                  "a"
ESC [ A            :up
ESC [ 1 ; 2 A      :shift_up
ESC [ 15 ; 5 ~     :ctrl_f5
ESC O P            :f1
ESC [ Z            :shift_tab
ESC x              :alt_x
ESC CR             :alt_enter
01                 :ctrl_a
03                 :interrupt
7f                 :backspace
c3 a1              "á"
ESC [ < 0;10;5 M   press left at 10,5
ESC [ < 64;3;4 M   wheel up at 3,4
ESC [ < 2;7;8 m    release right at 7,8
ESC [ 1 ; 6 C      :ctrl_shift_right
ESC [ 24 ~         :f12
```

## Example: an arrow-key menu

```ruby
require 'bbs'

MENU = BBS::Flow.define do
  call do |ctx, runner|
    s     = runner.session
    items = %w[News Files Chat Logoff]
    pick  = 0
    loop do
      s.write "\e[2J\e[H"
      items.each_with_index do |item, i|
        s.write(i == pick ? "\e[7m > #{item} \e[0m\r\n" : "   #{item}\r\n")
      end
      key = s.readkey
      case key
      when nil then break
      when :up then pick = (pick - 1) % items.size
      when :down, :tab then pick = (pick + 1) % items.size
      when :enter
        ctx[:choice] = items[pick]
        break
      when String
        hit  = items.index { |i| i.downcase.start_with?(key.downcase) }
        pick = hit if hit
      end
    end
    nil
  end
  say 'Done.'
end

BBS.configure { |c| c.flow = MENU }
BBS.start
```

# Reading a line: `readline`

```ruby
name     = session.readline               # echoes what is typed
password = session.readline(mask: '*')    # echoes a * for each character
```

| Behaviour |  |
|---|---|
| Returns | The line as a UTF-8 String, without the line end; invalid bytes dropped. `nil` when the caller hangs up. |
| Ends on | CR, LF, CR LF or CR NUL (one line end). Writes `\r\n` when it ends. |
| Echo | Each character as typed, or `mask` per character (a UTF-8 character gets one mask). |
| Backspace | `08` or `7f` removes one whole character and writes `\b \b`. |
| Limits | At most 1024 bytes (`Telnet::MAX_LINE`); further keys are ignored. Control bytes are ignored. |
| Timeout | None of its own: it waits until the socket timeout (`idle_seconds`). |
| PETSCII | On a C64 line it reads the C64's keys and echoes in PETSCII. |

A Flow's `ask` step uses it; in a `call` step:

```ruby
LOGIN = BBS::Flow.define do
  call do |ctx, runner|
    s = runner.session
    s.write 'Name: '
    ctx[:name] = s.readline or next :halt
    s.write 'Password: '
    password = s.readline(mask: '*') or next :halt
    next :halt unless password == 'secret'

    nil
  end
  say 'Logged in.'
end
```

Tested: typing `Zsé`, Backspace, `o`, Enter returns `"Zso"` (UTF-8); a masked `abc` echoes `***`.

# Writing

| Method | What it does |
|---|---|
| `write(data)` | Writes a String to the caller. On a PETSCII line it goes through the Encoder first. Never raises: an error (a closed socket) returns `nil`, and the hang-up shows up on the next read. |
| `line` | The caller's socket itself, for a door that hands the line on as it is (`BBS::Gateway`), or for bytes that must skip the PETSCII Encoder. |
| `read_byte(timeout = nil)` | One raw byte as a String, `:timeout`, or `nil`; no telnet handling. |
| `read_raw_byte(timeout)` | One byte as an Integer for binary protocols (ZMODEM): telnet commands taken out, a doubled `IAC` returned as 255; `:timeout` or `nil` on hang-up. |

`send_raw` exists but is private (it is how the negotiation and `petscii!` bypass the Encoder); from outside, write to `session.line`.

```ruby
session.write "\e[1;32mOK\e[0m\r\n"
session.line.write "\x07".b          # a bell, past any encoder
```

# Session identity and state

| Method | Meaning |
|---|---|
| `session_id` | 16 hex characters (`SecureRandom.hex(8)`), new for each connection. A FlowRunner puts it into the context as `ctx[:session_id]`, and `persist` uses it as the Store key. |
| `term_cols`, `term_rows` | See NAWS. |
| `idle_seconds`, `idle_seconds=` | This caller's idle limit, copied from `config.idle_seconds` at connect. The Application loop reads it; setting it also sets the socket timeout (900 s for `nil`), so `session.idle_seconds = 3600` gives one caller (a sysop) an hour. Since 0.19.0. |
| `petscii!`, `petscii?` | The C64 line, see [Commodore 64](https://wiki.teletypegames.org/engines/rubbs/commodore). |

# Idle limits, session limits, hang-ups

**Idle.** `config.idle_seconds` works in two places:

| Where | What happens |
|---|---|
| The socket | Read/write timeout (900 s when `nil`). A line silent that long, at a prompt, in a `call` step or inside a door, raises `IO::TimeoutError`: the session is dropped. |
| `BBS::Application` | Counts idle seconds between keys; at the limit it shows `Disconnected after N s idle.` and ends. |

**Max sessions.** `config.max_sessions` (`BBS_MAX_SESSIONS`, else 100) callers at once, and `config.max_sessions_per_ip` (`BBS_MAX_SESSIONS_PER_IP`, else 6) from one public address; the next one gets one line and is closed, and the server logs `BBS busy` or `BBS crowded` with both limits and the address (to stderr, unbuffered, so it reaches `docker logs` at once). Size `max_sessions` to the container's memory: a session costs roughly 0.5–1.7 MB on top of the board's own 20–40 MB.

**Hang-up.** When the caller drops:

- `readkey` and `readline` return `nil`; `write` returns `nil` silently;
- a Flow `call` step that raises `IOError`, `EPIPE` or `ECONNRESET` halts the flow quietly; check for `nil` and `break`, or raise `IOError` yourself (as the C64 screen classes do);
- `Session#run` switches the mouse off and closes the socket; the server logs `session dropped` and calls `on_session_end`.

# Persistence: `BBS::Store`

`BBS::Store` is a small table with one row per key (usually the session id): thread-safe, and robust against the bytes a terminal can send. Since 0.33.0 it lives in an SQLite file (a `BBS::SQLite`, see [Data and network](https://wiki.teletypegames.org/engines/rubbs/data-and-network)); until then it was a CSV file, and the CSV file of an older board is imported on first use.

```ruby
SESSIONS = BBS::Store.new(path: 'data/sessions.csv', headers: %w[session_id timestamp player_name code])
```

## `BBS::Store.new(path:, headers:)`

| Argument | Meaning |
|---|---|
| `path:` | The file. A `.csv` path means the `.sqlite3` file beside it (`data/sessions.csv` → `data/sessions.sqlite3`), so an older board keeps its path; any other path is the SQLite file itself. The directory is created on first use. |
| `headers:` | Array of column names (Strings). `session_id` is the key column; a `timestamp` column, when present, is filled automatically. A header added later becomes a new column. |

`store.path`, `store.database_path` and `store.headers` read them back.

## Methods

| Method | Returns | What it does |
|---|---|---|
| `upsert(session_id:, **fields)` | the store | Inserts a row for `session_id`, or updates the existing one, in one transaction. |
| `find(session_id)` | Hash or `nil` | The row as a Hash with String keys, the headers only. |
| `all` | Array of Hash | Every row, oldest first. |
| `delete(session_id)` | `nil` | Removes the row; nothing happens when there is none. |
| `open!` | the store | Opens the file at once (and imports the old CSV), so a board does it at boot, not in its first caller's session. |

**Insert** (no row with that key yet): `session_id` is the key, `timestamp` the current UTC time in ISO 8601 (`2026-10-27T22:15:45Z`), other columns the fields of the same name (`username:` → `username`), empty (`nil`) when not given.

**Update** (the row exists): only the given fields change. `timestamp` keeps the time of the insert. A field set to `nil` becomes an empty string.

**Fields that are not headers are ignored**, on insert and on update alike.

## Example: a round trip

```ruby
require 'bbs'

USERS = BBS::Store.new(path: 'data/users.sqlite3', headers: %w[session_id timestamp username email])

USERS.upsert(session_id: 'a1', username: 'joe', email: 'joe@example.com')
USERS.upsert(session_id: 'b2', username: 'ann')
USERS.upsert(session_id: 'a1', email: 'joe@new.example')

USERS.find('a1')
# => {"session_id"=>"a1", "timestamp"=>"2026-10-27T22:15:45Z", "username"=>"joe", "email"=>"joe@new.example"}
USERS.find('zz')            # => nil
USERS.all.size              # => 2
USERS.delete('b2')
USERS.all.map { |r| r['username'] }   # => ["joe"]
```

The rows are in the `store` table of `data/users.sqlite3`; `sqlite3 data/users.sqlite3 'SELECT * FROM store'` shows them while the board runs.

## The CSV of an older board

When the table is still empty and the CSV file of the path exists, the Store imports it in one transaction and renames it to `users.csv.imported`. A row that is not CSV, has more fields than the header or has no `session_id` does not stop the import: it is set aside, under the header, in `users.csv.rejected` for the sysop, and the log line counts it (`store: 412 rows of data/users.csv imported into data/users.sqlite3, 2 set aside in data/users.csv.rejected`). Call `open!` at boot so this happens before the first caller.

## Clean values

Callers type into these tables, and a terminal can send anything. Every value is turned into a String and cleaned with `BBS::Text.clean`: invalid UTF-8 bytes are removed, and so are the C0 control characters (tab and newline stay), DEL, the C1 controls and the bidirectional override characters. Only the ESC byte of an ANSI sequence goes: `"ann\e[31m"` is stored as `ann[31m`.

```ruby
s = BBS::Store.new(path: 'data/log.sqlite3', headers: %w[session_id name note])
s.upsert(session_id: 's1', name: "ann\e[31m\x00 bé", note: "two\nlines")
s.find('s1')
# => {"session_id"=>"s1", "name"=>"ann[31m bé", "note"=>"two\nlines"}
```

## Threads

Every call goes to the SQLite file through one shared connection behind a lock, so one Store object can be shared by all session threads. The file is in WAL mode: a command-line tool of the board can read it (and write it) while the board runs, and the board sees the change at its next call. There is no cache in memory any more.

## With a Flow: `persist`

A Flow's `persist` step writes context keys into a Store, keyed by the session id:

```ruby
persist :player_name, :code, store: SESSIONS
```

It takes the named keys that are in the context (those not set yet are left out) and calls `store.upsert(session_id: ctx's session id, **those)`; with none of them set it does nothing. Calling `persist` again later updates the same row. See [Flow](https://wiki.teletypegames.org/engines/rubbs/flow).

The Impostor BBS (`bbs/impostor-bbs/lib/impostor.rb`) keeps every debrief in one store this way:

```ruby
module Impostor
  SESSION_HEADERS = %w[session_id timestamp player_name code feedback email].freeze

  def self.sessions_store(path) = BBS::Store.new(path: path, headers: SESSION_HEADERS)

  def self.debrief(sessions:, screens_dir:)
    BBS::Flow.define(screens_dir: screens_dir) do
      ask :player_name, prompt: 'Player Name', transform: :upcase
      ask :code,        prompt: 'Code'
      persist :player_name, :code, store: sessions
      # ...
      menu '>', loop: true do
        option 'Leave feedback' do
          ask :feedback, prompt: 'Feedback'
          persist :feedback, store: sessions
        end
        option 'Subscribe to updates' do
          ask :email, prompt: 'Email address', validate: :email
          persist :email, store: sessions
        end
        option('Exit') { exit_menu }
      end
    end
  end
end

# bbs.rb: one store, shared by both faces and every session
sessions = Impostor.sessions_store(File.expand_path('data/sessions.csv', __dir__)).open!
```

## Outside a Flow

The key is just a string: a Store keyed by the caller's name keeps data across calls.

```ruby
PROFILES = BBS::Store.new(path: 'data/profiles.sqlite3', headers: %w[session_id timestamp calls last_seen])

def count_call(name)
  calls = PROFILES.find(name)&.fetch('calls').to_i
  PROFILES.upsert(session_id: name, calls: calls + 1, last_seen: Time.now.utc.iso8601)
end
```

In an Application handler or a `call` step, the session id is `ctx[:session_id]` (or `context[:session_id]` inside an Application).

## When to outgrow it

The Store is one table of text columns keyed by one string. A board with posts, threads or several tables uses `BBS::SQLite` directly, with its own schema and numbered migrations: see [Data and network](https://wiki.teletypegames.org/engines/rubbs/data-and-network).

# See also

- [rubbs](https://wiki.teletypegames.org/engines/rubbs) — the overview
- [Getting started](https://wiki.teletypegames.org/engines/rubbs/getting-started) — `BBS.configure` and a first board
- [Flow](https://wiki.teletypegames.org/engines/rubbs/flow) — `ask`, `persist`, `call`
- [Application](https://wiki.teletypegames.org/engines/rubbs/application) — events built from these keys
- [Commodore 64](https://wiki.teletypegames.org/engines/rubbs/commodore) — the PETSCII line and its keys
- [Doors](https://wiki.teletypegames.org/engines/rubbs/doors) — handing the line on
- [Data and network](https://wiki.teletypegames.org/engines/rubbs/data-and-network) — SQLite, HTTP, cache, rate limits, logging

'; var prevLevel = 2; for (var i = 0; i < headings.length; i++) { var h = headings\[i\]; var text = h.textContent.trim(); var slug = text.toLowerCase() .replace(/\[áàâä\]/g,'a').replace(/\[éèêë\]/g,'e').replace(/\[íìîï\]/g,'i') .replace(/\[óòôöő\]/g,'o').replace(/\[úùûüű\]/g,'u') .replace(/\[^a-z0-9\]+/g, '-').replace(/^-|-$/g, ''); if (!slug) slug = 'heading-' + i; if (used\[slug\]) { slug += '-' + i; } used\[slug\] = true; h.id = slug; var level = parseInt(h.tagName.charAt(1)); var cls = level > 2 ? ' class="toc-child"' : ''; html += '- [' + text + '](#'%20+%20slug%20+%20')
'; } html += '
'; toc.innerHTML = html; // Scroll-spy: highlight current heading var links = toc.querySelectorAll('a'); var ids = \[\]; for (var j = 0; j < links.length; j++) ids.push(links\[j\].getAttribute('href').slice(1)); function updateActive() { var current = ''; for (var k = 0; k < ids.length; k++) { var el = document.getElementById(ids\[k\]); if (el && el.getBoundingClientRect().top <= 80) current = ids\[k\]; } for (var m = 0; m < links.length; m++) { links\[m\].parentNode.classList.toggle('active', links\[m\].getAttribute('href') === '#' + current); } } window.addEventListener('scroll', updateActive, { passive: true }); updateActive(); })();

---

## Navigation

- Parent: [RuBBS](https://wiki.teletypegames.org/engines/rubbs.md)
- Previous: [Minimal project](https://wiki.teletypegames.org/engines/rubbs/minimal-project.md)
- Next: [Widgets](https://wiki.teletypegames.org/engines/rubbs/widgets.md)
