The Flow layer of rubbs: a line-oriented, declarative dialogue that every board starts with. This page lists every step of
BBS::Flowwith 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.
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 (windows, menus, mouse) and, for a line-by-line board, the 40×25 BBS::Petscii::Screen with its BBS::Selector menus (see Commodore 64).
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.
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.
The runner executes steps in order until one of these:
config.flow that means the server hangs up.: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 | Signature | Does |
|---|---|---|
say |
say(text, style: :muted) |
prints one line between blank lines |
text |
text(content = nil, style: :muted) { … } |
prints one line, fixed or computed |
banner |
banner(text, style: :success) |
a double-line framed title |
big_banner |
big_banner(text, style: :success, font: 'slant') |
a FIGlet title |
screen |
screen(name, **vars) |
renders an ERB file |
ask |
ask(name, prompt:, transform: nil, validate: nil) |
reads a line into the context |
pause |
pause(message, seconds: 1.0) |
prints and sleeps |
confirm |
confirm(message, denied: nil) or with a block |
a yes/no question |
gate |
gate(denied:) { … } |
stops the flow unless the block is truthy |
menu |
menu(prompt, loop: false) { option … } |
a numbered BBS::Selector of sub-flows |
exit_menu |
exit_menu |
leaves the innermost menu |
persist |
persist(*fields, store:) |
saves context fields into a BBS::Store |
call |
call { … } |
runs any Ruby code with the line |
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).
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.
say(text, style: :muted) prints text indented by two spaces, with a blank line before and after.
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(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.
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(text, style: :success) prints text in a double-line box (╔═╗ ║ ╚═╝) with two spaces of padding.
DEMO = BBS::Flow.define do
banner 'IDENTITY VERIFICATION', style: :info
end
╔═════════════════════════╗
║ IDENTITY VERIFICATION ║
╚═════════════════════════╝
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.
DEMO = BBS::Flow.define do
big_banner 'HARBOR'
big_banner 'BBS', font: 'doom', style: :info
end
screen(name, **vars) renders the ERB file NAME.erb and writes it. See Screens (ERB) below.
DEMO = BBS::Flow.define(screens_dir: File.join(__dir__, 'screens')) do
screen :welcome, year: Time.now.year, node: :session_id
end
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).
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:
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(message, seconds: 1.0) prints message in :info style and sleeps. The caller cannot skip it; keep it short.
DEMO = BBS::Flow.define do
pause 'Verifying identity...', seconds: 1.2
say 'Verified.', style: :success
end
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.
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
gate(denied:) { |ctx| … } calls the block with the context; if the result is falsy it prints denied in :error and halts the flow.
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(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 |
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 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).
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(*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 for the store itself.
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" }]
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) |
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.
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:
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), 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.
ask halts on a bad answer; a call loop asks until it gets a good one, and can mask the input:
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(definition) hands the line to a BBS::Application (see 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.
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:
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
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:
<%= clr -%>
<%= big_banner('Harbor') %>
<%= cyan %>The Harbor BBS<%= reset %> <%= gray %>node <%= @node %>, <%= @year %><%= reset %>
<% if @name -%>
<%= white %>Welcome back, <%= @name %>.<%= reset %>
<% end -%>
The flow:
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.
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.
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
╔═════════╗
║ HELLO ║
╚═════════╝
___ ___ ___
| _ ) _ ) __|
| _ \ _ \__ \
|___/___/___/
A board with a welcome screen, a login with a sysop PIN, a user store, a message reader and an account page. Folder:
harbor/
├── bbs.rb
├── screens/welcome.erb # the template from "Screens (ERB)" above
└── data/ # users.sqlite3 appears here
bbs.rb:
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.
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).
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:
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
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:
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 asks does not appear in the outer ctx).
app or APP.runBBS::FakeOS machinesBBS::Terminal.flow, PETSCII faces, BBS::Petscii::Screen and BBS::SelectorBBS::Store, BBS::SQLite, BBS.logrunner.session, keys