---
title: Flow
url: 'https://wiki.teletypegames.org/engines/rubbs/flow'
markdown: 'https://wiki.teletypegames.org/engines/rubbs/flow.md'
lang: en
date: '2026-10-27'
description: 'The Flow layer of rubbs: a line-oriented, declarative dialogue that every board starts with. This page lists every step of BBS::Flow with its signature, defaults and an example, explains how steps share data (the context), how a flow stops, how ERB screens and banners work, and ends with a complet…'
taxonomy:
  tag:
    - reference
---

# Flow

> The Flow layer of [rubbs](https://wiki.teletypegames.org/engines/rubbs): a line-oriented, declarative dialogue that every board starts with. This page lists every step of `BBS::Flow` with its signature, defaults and an example, explains how steps share data (the context), how a flow stops, how ERB screens and banners work, and ends with a complete login-and-menu board and the real Impostor BBS. Written against rubbs 1.0.0.

# Overview

```ruby
require 'bbs'

DEMO = BBS::Flow.define do
  banner 'WELCOME'
  ask :name, prompt: 'Your name'
  text { |ctx| "Hello, #{ctx[:name]}!" }
end

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

`BBS::Flow.define(screens_dir: nil) { … }` evaluates the block on a new `Flow` and returns it frozen. Each DSL call in the block appends one **step** to `flow.steps`, a small object of `BBS::Flow::Steps` with a `run(runner)` method; nothing is sent to anybody yet. A flow is a template, built once at load time and shared by every caller.

When a caller connects, `BBS::FlowRunner` runs the steps from top to bottom on that caller's line. Steps that print write ANSI text, steps that ask read a line from the caller. Text scrolls like on a teletype; nothing is redrawn in place. For a full screen there is the [Application](https://wiki.teletypegames.org/engines/rubbs/application) (windows, menus, mouse) and, for a line-by-line board, the 40×25 `BBS::Petscii::Screen` with its `BBS::Selector` menus (see [Commodore 64](https://wiki.teletypegames.org/engines/rubbs/commodore)).

Most boards use a flow as glue only: the terminal question, perhaps a login, then a `call` step that runs the board and one for the goodbye. The steps below are all there is; anything else is Ruby in a `call`.

Every example on this page is a constant like `DEMO` above; to try one, put it in `c.flow` and start the server as in [Getting started](https://wiki.teletypegames.org/engines/rubbs/getting-started).

## The context

Each run of a flow has its own **context**, a Hash with Symbol keys, called `ctx` in blocks. `ask` and your `call` blocks write it, blocks read it, `persist` saves fields from it, `screen` hands it to the ERB template.

| Key | Set by | Value |
|---|---|---|
| `:session_id` | the runner, always | the caller's random 16-hex-digit session id |
| any name | `ask NAME`, your `call` blocks | whatever you put there |

Sub-flows (menu options, `confirm` blocks) share the same context. A flow started with `BBS::FlowRunner.new(session, session_id, flow, context: {...})` begins with those keys (this is how a door passes the caller's name in); `session_id` is always set over them.

## How a flow ends

The runner executes steps in order until one of these:

- **The steps run out.** Back in the caller of this flow; for `config.flow` that means the server hangs up.
- **A step returns `:halt`.** The whole flow stops, including every enclosing menu and sub-flow. `:halt` comes from: the caller hanging up during any read, a failed `ask` validation, a failed `gate`, a "no" to a `confirm` without a block, a `call` block returning `:halt`.
- **`exit_menu`** leaves the innermost `menu` only, and the flow continues after it.

Errors: a `call` block's error is logged through `BBS.log` (`flow call: …`), the caller sees "Something went wrong.", and the flow goes on; a dropped line inside it halts. Any other step's error (for example in a `text` or `gate` block, an unknown banner font, a missing ERB file) ends the session.

# Step reference

| Step | Signature | Does |
|---|---|---|
| [`say`](#say) | `say(text, style: :muted)` | prints one line between blank lines |
| [`text`](#text) | `text(content = nil, style: :muted) { … }` | prints one line, fixed or computed |
| [`banner`](#banner) | `banner(text, style: :success)` | a double-line framed title |
| [`big_banner`](#big-banner) | `big_banner(text, style: :success, font: 'slant')` | a FIGlet title |
| [`screen`](#screen) | `screen(name, **vars)` | renders an ERB file |
| [`ask`](#ask) | `ask(name, prompt:, transform: nil, validate: nil)` | reads a line into the context |
| [`pause`](#pause) | `pause(message, seconds: 1.0)` | prints and sleeps |
| [`confirm`](#confirm) | `confirm(message, denied: nil)` or with a block | a yes/no question |
| [`gate`](#gate) | `gate(denied:) { … }` | stops the flow unless the block is truthy |
| [`menu`](#menu) | `menu(prompt, loop: false) { option … }` | a numbered `BBS::Selector` of sub-flows |
| [`exit_menu`](#exit-menu) | `exit_menu` | leaves the innermost menu |
| [`persist`](#persist) | `persist(*fields, store:)` | saves context fields into a `BBS::Store` |
| [`call`](#call) | `call { … }` | runs any Ruby code with the line |
| [`app`](#app) | `app(definition)` | runs a `BBS::Application` |

The steps `set`, `line`, `section`, `rows`, `table`, `wait_enter`, `fetch`, `pick`, `body`, `output`, `os` and `tui` are gone since 1.0: each is a few lines in a `call` (the runner's `line`, `ask` and `select` cover most of them), and a `BBS::FakeOS` machine runs as a `BBS::Door` (see [Doors](https://wiki.teletypegames.org/engines/rubbs/doors)).

## Styles

`style:` takes a name from `BBS::FlowRunner::STYLES`, colours of the one palette (`BBS::Ansi`):

| Name | Colour |
|---|---|
| `:success` | bright green |
| `:muted` | grey (plain text) |
| `:error` | bright red |
| `:info` | yellow |
| `:prompt` | white |

Any other value (an unknown Symbol, a raw ANSI string) prints in `:muted`. The colour names of 0.x (`:confirm`, `:cyan`, `:magenta`, `:blue`, `:yellow`, `:white`, `:green`, `:red`) are gone.

# Output steps

## say

`say(text, style: :muted)` prints `text` indented by two spaces, with a blank line before and after.

```ruby
DEMO = BBS::Flow.define do
  say 'Welcome to the board.'
  say 'Login successful.', style: :success
  say 'The file area is closed tonight.', style: :error
end
```

## text

`text(content = nil, style: :muted)` prints one indented line with no blank lines around it. With a block, the block gets the context and its return value is printed; a `nil` result prints nothing.

```ruby
DEMO = BBS::Flow.define do
  ask :name, prompt: 'Name'
  text 'Your account:', style: :prompt
  text { |ctx| "  name:    #{ctx[:name]}" }
  text { |ctx| "  session: #{ctx[:session_id]}" }
  text(style: :info) { |ctx| 'sysop!' if ctx[:name] == 'sysop' }
end
```

An exception in the block is not caught and ends the session; compute risky values in a `call` first.

## banner

`banner(text, style: :success)` prints `text` in a double-line box (`╔═╗ ║ ╚═╝`) with two spaces of padding.

```ruby
DEMO = BBS::Flow.define do
  banner 'IDENTITY VERIFICATION', style: :info
end
```

```text
  ╔═════════════════════════╗
  ║  IDENTITY VERIFICATION  ║
  ╚═════════════════════════╝
```

## big\_banner

`big_banner(text, style: :success, font: 'slant')` renders `text` with FIGlet through the `artii` gem. Any font name artii knows works (`slant`, `standard`, `small`, `big`, `doom`, `banner`, …; `bundle exec artii --list` prints them all). An unknown font raises and ends the session, so try new fonts locally first.

```ruby
DEMO = BBS::Flow.define do
  big_banner 'HARBOR'
  big_banner 'BBS', font: 'doom', style: :info
end
```

## screen

`screen(name, **vars)` renders the ERB file `NAME.erb` and writes it. See Screens (ERB) below.

```ruby
DEMO = BBS::Flow.define(screens_dir: File.join(__dir__, 'screens')) do
  screen :welcome, year: Time.now.year, node: :session_id
end
```

# Input steps

## ask

`ask(name, prompt:, transform: nil, validate: nil)` prints `prompt:` in `:prompt` style, reads one line, strips surrounding whitespace and stores the result in `ctx[name]`. On a modern terminal the server echoes the characters; on a PETSCII line the answer is typed in a `BBS::Field`, which edits with the cursor keys.

| Option | Values | Effect |
|---|---|---|
| `prompt:` | String | the text before the colon; required |
| `transform:` | `:upcase`, `:downcase`, a Proc | applied to the stripped answer before validation; a Proc gets the String and returns the new value |
| `validate:` | `:non_empty`, `:email`, a Proc or Method | checked after the transform; a Proc or Method gets the (transformed) value, truthy means valid |

A failed validation prints a message and **halts the whole flow**: `:email` says "That doesn't look like a valid email address. Farewell.", the others "Invalid input. Farewell.". It does not ask again. Any other value given to `validate:` is never checked (everything passes). To ask again, or to print your own message, use `call` (see [Asking again and passwords](#asking-again-and-passwords)).

```ruby
DEMO = BBS::Flow.define do
  ask :handle, prompt: 'Handle', transform: :downcase, validate: :non_empty
  ask :email,  prompt: 'Email',  validate: :email
  ask :age,    prompt: 'Age',    transform: ->(answer) { answer.to_i }
  text { |ctx| "#{ctx[:handle]}, #{ctx[:age]}, #{ctx[:email]}" }
end
```

Your own rule as a lambda; `abc` becomes 0 by the transform and fails:

```ruby
ADULTS = BBS::Flow.define do
  ask :age, prompt: 'Age', transform: ->(answer) { answer.to_i }, validate: ->(age) { age >= 18 }
  say 'Welcome in.'
end
```

There is no `mask:` on `ask`; a password prompt is a `call` step with `runner.readline(mask: '*')`.

## pause

`pause(message, seconds: 1.0)` prints `message` in `:info` style and sleeps. The caller cannot skip it; keep it short.

```ruby
DEMO = BBS::Flow.define do
  pause 'Verifying identity...', seconds: 1.2
  say 'Verified.', style: :success
end
```

## confirm

A yes/no question with a `[yes/no]` prompt; `yes` or `y` (any case) is yes, anything else is no.

**Without a block**, `confirm(message, denied: nil)` is a checkpoint: on yes the flow continues, on no it prints `denied` (if given) and **halts**.

**With a block**, `confirm(message, denied: nil) { … }` runs the block as a sub-flow on yes; on no it prints `denied` (if given) and the flow **continues** after the block.

```ruby
DEMO = BBS::Flow.define do
  confirm 'Are you over 18?', denied: 'Come back later.'
  say 'Welcome to the adult area.'

  confirm 'Subscribe to the newsletter?', denied: 'Maybe next time.' do
    ask :email, prompt: 'Email', validate: :email
    say 'Subscribed.', style: :success
  end

  say 'Main menu next.'
end
```

# Control steps

## gate

`gate(denied:) { |ctx| … }` calls the block with the context; if the result is falsy it prints `denied` in `:error` and halts the flow.

```ruby
BANNED = %w[spammer troll].freeze

DEMO = BBS::Flow.define do
  ask :handle, prompt: 'Handle', transform: :downcase
  gate(denied: 'This handle is banned.') { |ctx| !BANNED.include?(ctx[:handle]) }
  say 'Come in.'
end
```

## menu

`menu(prompt, loop: false) { option … }` is a numbered `BBS::Selector` of the options, 38 columns wide so it fits a C64, with `prompt` on its status row. The cursor keys and the digits light an option, RETURN takes it, and Esc (on a C64 RUN/STOP or ←) leaves the menu without one. Each `option(label) { … }` is a sub-flow, run with the shared context when it is taken. An option without a block does nothing.

| `loop:` | After the option's steps |
|---|---|
| `false` | the menu is done; the flow continues after it |
| `true` | the menu is shown again, until an option runs `exit_menu` or the caller presses Esc |

```ruby
DEMO = BBS::Flow.define do
  menu 'Main menu', loop: true do
    option 'Bulletins' do
      say 'Nothing new.'
    end
    option 'Settings' do
      menu 'Settings' do
        option('Colours on')  { say 'Colours on.' }
        option('Colours off') { say 'Colours off.' }
      end
    end
    option 'Log off' do
      exit_menu
    end
  end
  say 'Goodbye!'
end
```

`:halt` inside an option (a failed gate, a `call` returning `:halt`, a hang-up) stops the whole flow, not just the menu.

## exit\_menu

`exit_menu` leaves the innermost enclosing `menu`; the flow continues after it. It works from nested sub-flows too (a `confirm` block inside an option). Outside any menu it ends the flow. A `call` block cannot return it (only `:halt` passes through a `call`).

```ruby
DEMO = BBS::Flow.define do
  menu 'Pick', loop: true do
    option('Again') { say 'Once more.' }
    option('Done') do
      confirm 'Really leave the menu?' do
        exit_menu
      end
    end
  end
  say 'Out of the menu.'
end
```

Note the difference: answering "no" to that `confirm` (with a block) just continues and the menu loops again.

## persist

`persist(*fields, store:)` saves the named context fields into a `BBS::Store`, keyed by the session id: the first `persist` of a call adds a row, later ones update it. Fields missing from the context are skipped; if none are present, nothing is written. List only fields that are columns (`headers:`) of the store. A `timestamp` column, if the store has one, is filled with the time the row was created.

Since 0.33 the store is a table in a SQLite file: a `.csv` path means the `.sqlite3` file beside it (`data/guestbook.sqlite3`), and a CSV file of an earlier version is imported once, when the table is empty. See [Data and network](https://wiki.teletypegames.org/engines/rubbs/data-and-network) for the store itself.

```ruby
GUESTBOOK = BBS::Store.new(path: File.join(__dir__, 'data/guestbook.csv'),
                           headers: %w[session_id timestamp name city message])

DEMO = BBS::Flow.define do
  ask :name, prompt: 'Name'
  ask :city, prompt: 'City'
  persist :name, :city, store: GUESTBOOK
  ask :message, prompt: 'Leave a message'
  persist :message, store: GUESTBOOK
  say 'Thanks for signing the guestbook.'
end

GUESTBOOK.all
# => [{ "session_id" => "3f9c2a1be07d4c11", "timestamp" => "2026-10-27T20:15:02Z",
#       "name" => "Zsolt", "city" => "Budapest", "message" => "Hello from the Harbor" }]
```

# Code steps

## call

`call { |ctx, runner| … }` runs any Ruby code. The block gets the context (read and write it freely) and the `FlowRunner`, which gives you the line:

| `runner.` |  |
|---|---|
| `write(text)` | writes to the caller (use `\r\n` line endings) |
| `line(text, style: :muted)` | writes one line indented by two spaces, in a style's colour |
| `ask(prompt)` | writes `prompt` and reads the answer, stripped (in a `BBS::Field` on a C64); `nil` when the caller hung up |
| `readline(mask: nil)` | reads one line; `mask: '*'` echoes `*` instead of the characters; `nil` when the caller hung up |
| `select(options, hint: nil)` | a numbered `BBS::Selector` of `[label, value]` pairs; returns the value taken, `nil` for Esc |
| `context` | the context Hash (the block's `ctx`) |
| `session` | the `BBS::Session`: `readkey`, `term_cols`, `term_rows`, `petscii?`, `enable_mouse`, … (see [Telnet and Store](https://wiki.teletypegames.org/engines/rubbs/telnet-and-store)) |
| `session_id` | the session id |

Return `:halt` to stop the flow; any other return value continues it. Inside the block use `next` (not `return`) to leave early. An exception is logged, the caller sees "Something went wrong.", and the flow continues; a lost connection halts.

```ruby
DEMO = BBS::Flow.define do
  call do |ctx, runner|
    runner.line "Your terminal is #{runner.session.term_cols}x#{runner.session.term_rows}."
    ctx[:wide] = runner.session.term_cols >= 100
    nil
  end
  text { |ctx| ctx[:wide] ? 'Wide screen layout.' : 'Narrow screen layout.' }
end
```

What the removed steps did is a few lines here. A list to choose from, a wait for RETURN, a value loaded with a line on screen:

```ruby
Post = Struct.new(:title, :body)
POSTS = [Post.new('Welcome', 'Hello all.'), Post.new('Rules', 'Be nice.')].freeze

DEMO = BBS::Flow.define do
  call do |_ctx, runner|
    post = runner.select(POSTS.map { |p| [p.title, p] }, hint: 'Read which post?')
    next nil unless post

    runner.line post.title, style: :prompt
    runner.line post.body
    runner.ask('Press RETURN')
    nil
  end
end
```

`call` is also how a flow runs things that are not steps: an `Application` with code around it (`APP.run(...)`, see [app](#app)), another flow (`BBS::FlowRunner.new(runner.session, ctx[:session_id], OTHER, context: ctx).run`), a door (`BBS::Door.new(:machine, MACHINE).run(runner.session, ctx)`, a FakeOS machine too), a ZMODEM download, a board's own `BBS::Petscii::Screen`.

### Asking again and passwords

`ask` halts on a bad answer; a `call` loop asks until it gets a good one, and can mask the input:

```ruby
PINS = { 'sysop' => '1234' }.freeze

DEMO = BBS::Flow.define do
  call do |ctx, runner|
    loop do
      answer = runner.ask('  Handle (3-12 letters): ')
      break :halt if answer.nil?

      answer = answer.downcase
      if answer.match?(/\A[a-z]{3,12}\z/)
        ctx[:handle] = answer
        break nil
      end
      runner.line 'Letters only, 3 to 12 of them.', style: :error
    end
  end

  call do |ctx, runner|
    next nil unless PINS.key?(ctx[:handle])

    result = :halt
    3.times do
      runner.write '  PIN: '
      pin = runner.readline(mask: '*')
      break if pin.nil?

      if pin.strip == PINS[ctx[:handle]]
        ctx[:sysop] = true
        result = nil
        break
      end
      runner.line 'Wrong PIN.', style: :error
    end
    result
  end

  text { |ctx| "Welcome, #{ctx[:handle]}#{' (sysop)' if ctx[:sysop]}." }
end
```

Inside `loop`, `times` or `each` blocks, `next` only skips to the next iteration; leave them with `break VALUE`, and make that value (or the block's last expression) the `call`'s result. A hang-up (`ask` or `readline` returning `nil`) must end the loop, otherwise it spins forever.

## app

`app(definition)` hands the line to a `BBS::Application` (see [Application](https://wiki.teletypegames.org/engines/rubbs/application)): `definition.run(session:, session_id:, context: ctx)`, until it quits, through its doors too (the chosen door runs and the Application is rebuilt after it). The Application's `context` is this same Hash, so changes are visible to later steps; a dropped line halts the flow.

```ruby
DESKTOP = BBS::Application.define do
  status_hint('F10', 'Back') { :halt }
end

DEMO = BBS::Flow.define do
  ask :handle, prompt: 'Handle'
  app DESKTOP
  say 'Back in the flow.'
end
```

The same from a `call` step, the form every board in our network uses, leaves room for code around the run:

```ruby
DEMO = BBS::Flow.define do
  ask :handle, prompt: 'Handle'
  call do |ctx, runner|
    DESKTOP.run(session: runner.session, session_id: ctx[:session_id], context: ctx)
    nil
  end
  say 'Thanks for calling.'
end
```

# Screens (ERB)

A `screen` step renders an ERB template and writes it. Screens are where hand-drawn ANSI title pages live.

**Where the file is.** `screen :welcome` reads `welcome.erb` from the flow's own folder, `BBS::Flow.define(screens_dir: DIR)`, or, if the flow has none, from `BBS.config.screens_dir`. If neither is set, the step raises (`No screens_dir: …`) and the session ends. A flow shipped in a gem names its own folder, so it renders its screens inside any board.

**Variables.** Inside the template every context key is an instance variable (`@session_id`, `@name`, …), plus the step's `vars`. A `vars` value that is a Symbol is looked up in the context (`node: :session_id` gives `@node` the session id); anything else is used as is.

**Helpers** available in a template:

| Helper | Gives |
|---|---|
| `clr` | clear screen and home the cursor (`\e[2J\e[H`) |
| `reset` | `\e[0m` |
| `green`, `yellow`, `cyan`, `white`, `red` | bright colours of the palette (`BBS::Ansi`) |
| `gray`, `dim_green` | plain grey and normal green |
| `banner(text, padding: 2)` | a green double-line box, as the `banner` step |
| `big_banner(text, font: 'slant')` | green FIGlet art, as the `big_banner` step |

ERB runs with `trim_mode: '-'`, so `-%>` swallows the line break after a tag; every `\n` of the result is sent as `\r\n`.

`screens/welcome.erb`:

```erb
<%= clr -%>
<%= big_banner('Harbor') %>

  <%= cyan %>The Harbor BBS<%= reset %>  <%= gray %>node <%= @node %>, <%= @year %><%= reset %>
<% if @name -%>
  <%= white %>Welcome back, <%= @name %>.<%= reset %>
<% end -%>

```

The flow:

```ruby
DEMO = BBS::Flow.define(screens_dir: File.join(__dir__, 'screens')) do
  screen :welcome, year: Time.now.year, node: :session_id
  ask :name, prompt: 'Name'
  screen :welcome, year: Time.now.year, node: :session_id
end
```

The same renderer is callable directly, e.g. from a `call` or a test: `BBS::Renderer.render('welcome', { year: 2026 }, dir: 'screens')` returns the String.

# Banners

The `banner` and `big_banner` steps and the template helpers all come from `BBS::Banner`, which you can call yourself (in a `call`, a door):

| Method | Returns |
|---|---|
| `BBS::Banner.render(text, color: BBS::Ansi::BRIGHT_GREEN, padding: 2)` | a double-line box around `text`, indented by two spaces, `\r\n` line endings, a blank line before and after |
| `BBS::Banner.render_big(text, color: BBS::Ansi::BRIGHT_GREEN, font: 'slant')` | FIGlet art of `text` (artii font `font`), each line indented by two spaces and coloured, `\r\n` line endings |

`color` is an ANSI SGR sequence, usually a colour of the palette (`BBS::Ansi::CYAN`); `BBS::FlowRunner::STYLES[:info]` gives a style's colour.

```ruby
require 'bbs'

puts BBS::Banner.render('HELLO', color: BBS::FlowRunner::STYLES[:info])
puts BBS::Banner.render_big('BBS', font: 'small')

DEMO = BBS::Flow.define do
  call { |_ctx, runner| runner.write BBS::Banner.render_big('NEWS', color: BBS::Ansi::BRIGHT_CYAN, font: 'standard') }
end
```

```text
  ╔═════════╗
  ║  HELLO  ║
  ╚═════════╝

   ___ ___ ___
  | _ ) _ ) __|
  | _ \ _ \__ \
  |___/___/___/
```

# A complete board: login and menu

A board with a welcome screen, a login with a sysop PIN, a user store, a message reader and an account page. Folder:

```text
harbor/
├── bbs.rb
├── screens/welcome.erb   # the template from "Screens (ERB)" above
└── data/                 # users.sqlite3 appears here
```

`bbs.rb`:

```ruby
require 'bbs'

USERS = BBS::Store.new(path: File.join(__dir__, 'data/users.csv'),
                       headers: %w[session_id timestamp handle email])

Post = Struct.new(:title, :author, :body)
POSTS = [
  Post.new('Welcome aboard', 'sysop', 'This is the Harbor. Be nice, keep it short.'),
  Post.new('Door games', 'sysop', 'Three doors are coming next month.')
].freeze

PINS = { 'sysop' => '1234' }.freeze

READ_POSTS = lambda do |_ctx, runner|
  loop do
    post = runner.select(POSTS.map { |p| ["#{p.title} (#{p.author})", p] }, hint: 'Read which post?')
    break nil unless post

    runner.line post.title, style: :prompt
    runner.line post.body
    break :halt if runner.ask('  RETURN for the list: ').nil?
  end
end

MAIN = BBS::Flow.define(screens_dir: File.join(__dir__, 'screens')) do
  screen :welcome, year: Time.now.year, node: :session_id

  banner 'LOGIN', style: :info
  ask :handle, prompt: 'Handle', transform: :downcase, validate: :non_empty

  call do |ctx, runner|
    next nil unless PINS.key?(ctx[:handle])

    runner.write '  PIN: '
    pin = runner.readline(mask: '*')
    next :halt if pin.nil?

    ctx[:sysop] = pin.strip == PINS[ctx[:handle]]
    nil
  end

  gate(denied: 'Wrong PIN.') { |ctx| !PINS.key?(ctx[:handle]) || ctx[:sysop] }
  persist :handle, store: USERS
  banner 'ACCESS GRANTED'

  menu 'Main menu', loop: true do
    option('Read posts') { call(&READ_POSTS) }

    option 'Your account' do
      text { |ctx| "Handle:  #{ctx[:handle]}" }
      text { |ctx| "Session: #{ctx[:session_id]}" }
      text { |ctx| "Sysop:   #{ctx[:sysop] ? 'yes' : 'no'}" }
      confirm 'Subscribe to the newsletter?', denied: 'Maybe next time.' do
        ask :email, prompt: 'Email', validate: :email
        persist :email, store: USERS
        say 'Subscribed.', style: :success
      end
    end

    option 'Log off' do
      exit_menu
    end
  end

  say 'Fair winds!', style: :info
end

BBS.configure do |c|
  c.flow = MAIN
end

BBS.start
```

After a caller subscribed, `USERS.find(session_id)` gives `{ "session_id" => "3f9c2a1be07d4c11", "timestamp" => "2026-10-27T22:17:56Z", "handle" => "sysop", "email" => "a@b.hu" }`; the row is in `data/users.sqlite3`.

# A real board: Impostor

The Impostor BBS is the debrief terminal of the game *Definitely not an Impostor*. Its whole dialogue is one Flow in a gem (`lib/impostor.rb`), with its own `screens/` folder, so the same Flow runs as a standalone board and as a door in another board (`door :impostor, Impostor::Modern.flow(sessions: store), label: '&Impostor'`). The two faces are two modules, `Impostor::Modern` and `Impostor::Petscii`, each with its screens (`screens/modern/`, `screens/petscii/`, the latter drawn for 40 columns).

```ruby
require 'bbs'
require_relative 'impostor/code_generator'

module Impostor
  SCREENS = File.expand_path('../screens', __dir__)

  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)

  # The flow both faces share; +screens_dir+ holds the face's screens.
  def self.debrief(sessions:, screens_dir:)
    BBS::Flow.define(screens_dir: screens_dir) do
      screen :welcome

      banner "IDENTITY VERIFICATION", style: :info

      ask :player_name, prompt: "Player Name", transform: :upcase
      ask :code,        prompt: "Code"
      persist :player_name, :code, store: sessions

      pause "Verifying identity...", seconds: 1.2

      gate denied: "Access denied. Code mismatch." do |ctx|
        CodeGenerator.decrypt(ctx[:code]) == ctx[:player_name][0, CodeGenerator::NAME_LEN]
      end

      confirm "Norman, is that you?", denied: "Very well. Goodbye." do
        screen :congrats

        menu ">", loop: true do
          option "Leave feedback" do
            ask :feedback, prompt: "Feedback"
            persist :feedback, store: sessions
            say "Feedback recorded. Thank you.", style: :success
          end

          option "Subscribe to updates" do
            ask :email, prompt: "Email address", validate: :email
            persist :email, store: sessions
            say "Perfect. We'll be in touch.", style: :success
          end

          option "Exit" do
            exit_menu
          end
        end
      end
    end
  end

  module Modern
    def self.flow(sessions:) = Impostor.debrief(sessions: sessions, screens_dir: File.join(SCREENS, 'modern'))
  end

  module Petscii
    def self.flow(sessions:) = Impostor.debrief(sessions: sessions, screens_dir: File.join(SCREENS, 'petscii'))
  end
end
```

Things to notice: the flow is built by a method, so the store and the face are parameters, not globals; the code is persisted before the gate, so failed attempts are on record too; the `confirm` block holds the rest of the board, so a "no" ends it politely; and the board's `bbs.rb` only wires it up:

```ruby
require 'bundler/setup'
require_relative 'lib/impostor'

sessions = Impostor.sessions_store(File.expand_path('data/sessions.csv', __dir__))

BBS.configure do |c|
  c.flow = BBS::Terminal.flow(title: 'Impostor BBS',
                              modern: Impostor::Modern.flow(sessions: sessions),
                              petscii: Impostor::Petscii.flow(sessions: sessions))
end

BBS.start
```

# Running a flow yourself

`BBS::FlowRunner.new(session, session_id, flow, context: {}).run` runs any flow on a session and returns when it ends; the return value is `:halt` if it halted, otherwise `nil`. This is how `BBS::Terminal.flow` runs the face the caller picked, and how a flow door runs inside an Application. From a `call` step:

```ruby
RULES = BBS::Flow.define do
  banner 'RULES', style: :info
  text '1. Be nice.'
  text '2. No spam.'
  pause 'Back to the board...', seconds: 2
end

DEMO = BBS::Flow.define do
  ask :handle, prompt: 'Handle'
  call do |ctx, runner|
    BBS::FlowRunner.new(runner.session, ctx[:session_id], RULES, context: ctx).run
  end
  say 'Rules read.'
end
```

Returning the runner's result from the `call` passes a `:halt` of the inner flow on to the outer one. The inner flow gets its own context, seeded from `ctx` (a copy of the keys: what the inner flow `ask`s does not appear in the outer `ctx`).

# See also

- [rubbs](https://wiki.teletypegames.org/engines/rubbs) — the main page
- [Getting started](https://wiki.teletypegames.org/engines/rubbs/getting-started) — installation, configuration, project layout
- [Application](https://wiki.teletypegames.org/engines/rubbs/application) — the widget framework, entered with `app` or `APP.run`
- [Doors](https://wiki.teletypegames.org/engines/rubbs/doors) — flows as doors, `BBS::FakeOS` machines
- [Commodore](https://wiki.teletypegames.org/engines/rubbs/commodore) — `BBS::Terminal.flow`, PETSCII faces, `BBS::Petscii::Screen` and `BBS::Selector`
- [Data and network](https://wiki.teletypegames.org/engines/rubbs/data-and-network) — `BBS::Store`, `BBS::SQLite`, `BBS.log`
- [Telnet and Store](https://wiki.teletypegames.org/engines/rubbs/telnet-and-store) — `runner.session`, keys

'; 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: [Drawing and theming](https://wiki.teletypegames.org/engines/rubbs/drawing.md)
- Next: [Getting started](https://wiki.teletypegames.org/engines/rubbs/getting-started.md)
