ussd-interpreter-x
v1.1.9
Published
Stateless USSD session interpreter with async pipeline support
Readme
ussd-interpreter
A stateless USSD session interpreter. Turns a raw input string like "1*0724000001*500*1" into a resolved session — current state, captured data, and the screen to render — with zero dependencies.
npm install ussd-interpreterHandholding Guide
Start with How it works to understand the replay model, then Quick start to see a working flow in 30 lines. State types covers the six building blocks you compose flows from. Async data explains how to fetch external data, Session store how to persist it across requests,. Pipeline, Pagination, and Advisories covers production gotchas every deployment will encounter. Session stories cover advanced features. Reference has the full option tables and execution model. FAQ answers the questions that come up in production.
Contents
- How it works
- Quick start
- State types
- Multiple flows
- Nested flows
- Async data
- Escape key
- Session store
- Pipeline
- Pagination
- Advisories
- Session stories
- Chaos Monkey
- Reference
- FAQ
How it works
USSD gateways send the full history of what a user has typed on every request, as a single *-separated string. The interpreter replays that string through your state graph on each request to reconstruct exactly where the user is and what they have entered — no session store, no database, no cache.
"1*0724000001*500*1"
│ │ │ └─ confirmed
│ │ └───── entered amount
│ └───────────────── entered account number
└──────────────────── selected TransferQuick start
import { UssdInterpreter } from 'ussd-interpreter'
// Define states bottom-up — leaf states first, menus last
const success = UssdInterpreter.terminalState({
id: 'success',
render: (data) => `KES ${data.amount} sent to ${data.accountNumber}.`
})
const enterAmount = UssdInterpreter.dataState({
id: 'enter_amount',
captureKey: 'amount',
validate: (token) => {
const n = Number(token)
if (isNaN(n) || n <= 0) return 'Enter a valid amount.'
if (n > 150000) return 'Exceeds daily limit.'
return true
},
transform: (token) => Number(token),
render: () => 'Enter amount (KES):',
next: success
})
const enterAccount = UssdInterpreter.dataState({
id: 'enter_account',
captureKey: 'accountNumber',
validate: (token) => /^\d{10}$/.test(token) ? true : 'Must be 10 digits.',
render: () => 'Enter recipient account number:',
next: enterAmount
})
const mainMenu = UssdInterpreter.navigationState({
id: 'main_menu',
render: () => 'Welcome\n1. Send Money',
transitions: { '1': enterAccount },
allowBack: false
})
const interpreter = new UssdInterpreter(mainMenu)
app.post('/ussd', async (req, res) => {
const { sessionId, phoneNumber, text } = req.body
const session = await interpreter.resolveAsync(text, { sessionId, phoneNumber })
res.send(`${session.isEnded ? 'END' : 'CON'} ${session.render()}`)
})Africa's Talking note: The
CON/ENDprefix is your responsibility — the interpreter exposessession.isEndedso your handler can decide. This keeps the library gateway-agnostic.
State types
navigationState — menu screens
Handles option selection. "0" triggers back, "00" jumps to root (both configurable via allowBack and allowRoot).
const mainMenu = UssdInterpreter.navigationState({
id: 'main_menu',
onArrive: async ({ gateway, session }) => ({ ... }), // optional — see Async data
render: () => 'Welcome\n1. Transfer\n2. Balance',
transitions: { '1': transferFlow, '2': balanceFlow },
allowBack: false, // root menu — disable back
allowRoot: false,
label: () => 'User opened the main menu.' // optional — see Session stories
})transitions can also be a lazy function (token, data) => state | null. See Circular references and lazy transitions.
dataState — free-text input
Captures raw user input. "0" is never treated as back here — it is always data. This is intentional: data like phone numbers, amounts, and ID numbers can legitimately start with zero. There is no built-in back from a data capture screen — use escapeKey if you need one.
const enterPin = UssdInterpreter.dataState({
id: 'enter_pin',
captureKey: 'pin', // stored in session.data.pin
validate: (token) => /^\d{4}$/.test(token) ? true : 'PIN must be 4 digits.',
transform: (token) => token, // optional — runs before storing
onArrive: async ({ gateway, session }) => ({ ... }), // optional — see Async data
render: () => 'Enter your 4-digit PIN:',
next: confirmState, // or a function: (value, data) => state
label: (data) => 'PIN entered.' // optional — see Session stories
})How a submission flows through dataState
When a user submits input on a dataState, execution follows this sequence:
user types token
→ escape check if token matches escapeKey → redirect, stop here
→ validate(token, data) return true to pass, string to reject
→ transform(token, data) only runs if validate passed — return stored value
→ data[captureKey] = value stored value saved into data bag (not raw token)
→ next(value, data) determines next state — value is already transformedIf validate returns a string, the same screen is re-rendered with the error prepended and captureKey is not stored. transform never runs on a failed validation.
confirmationState — yes/no prompt
const confirm = UssdInterpreter.confirmationState({
id: 'confirm_transfer',
onArrive: async ({ gateway, session }) => ({ ... }), // optional
render: (data) => `Send KES ${data.amount} to ${data.accountNumber}?\n1. Confirm\n2. Cancel`,
on: { confirm: successState, cancel: cancelledState },
confirmToken: '1', // default
cancelToken: '2', // default
label: (data) => `Reviewed transfer of KES ${data.amount}.` // optional
})terminalState — end of session
The session ends here. session.isEnded is true, your handler sends END. onArrive() is especially useful on terminal states for fetching receipt data before the final screen.
const transferSuccess = UssdInterpreter.terminalState({
id: 'transfer_success',
onArrive: async ({ gateway, session }) => ({
ref: await generateTransactionRef(session.accountNumber, session.amount)
}),
render: (data) => `Done! Ref: ${data.ref}`,
label: (data) => `Transfer of KES ${data.amount} completed.` // optional
})routerState — silent branching
Routes to a different state based on captured data without consuming a user token. Resolved automatically whenever it reaches the top of the navigation stack — the user sees nothing and presses nothing. Does not support label or onArrive.
Never use a
routerStateas your root state. The user would see a blank screen and be immediately redirected with no explanation. Routers are always thenextof adataStateor the destination of a transition — never a starting point.
const accountTypeRouter = UssdInterpreter.routerState({
id: 'account_type_router',
route: (data) => data.accountType === 'savings' ? savingsMenu : currentMenu
})paginationState — long content
Splits long text across multiple screens with automatic page indicators and navigation. See Pagination for full documentation.
const terms = UssdInterpreter.paginationState({
id: 'terms',
content: 'Full terms of service text here...',
pageSize: 160,
on: { accept: nextState, cancel: cancelledState },
label: () => 'User read the terms and conditions.' // optional
})Multiple flows
A USSD service typically has several independent flows — signup, transfer, balance check, KYC — all accessible from a shared root menu. Each flow is just a subgraph of states. You compose them by connecting them to the root menu as transitions.
root menu
├── 1 → signup flow
│ ├── under 18 → END (age_blocked)
│ └── success → END (signup_success)
├── 2 → transfer flow
│ ├── no balance → END (insufficient_funds)
│ └── success → END (transfer_success)
└── 3 → kyc flow
├── fail → END (kyc_failed)
└── success → END (kyc_success)Every early exit is a terminalState. The interpreter treats a rejection message identically to a success message — session.isEnded is true and your handler sends END either way.
Example — three flows, one interpreter
import { UssdInterpreter } from 'ussd-interpreter'
// ── KYC flow ──────────────────────────────────────────────────────────────────
const kycFailed = UssdInterpreter.terminalState({
id: 'kyc_failed',
render: () => 'Verification failed. Please visit a branch.'
})
const kycSuccess = UssdInterpreter.terminalState({
id: 'kyc_success',
render: () => 'Identity verified successfully.'
})
const enterIdNumber = UssdInterpreter.dataState({
id: 'enter_id_number',
captureKey: 'idNumber',
validate: (token) => /^\d{8}$/.test(token) ? true : 'ID must be 8 digits.',
onArrive: async ({ gateway, session }) => {
const result = await verifyKyc(session.idNumber)
return { kycPassed: result.passed }
},
render: () => 'Enter your ID number:',
next: UssdInterpreter.routerState({
id: 'kyc_router',
route: (data) => data.kycPassed ? kycSuccess : kycFailed
})
})
// ── Transfer flow ─────────────────────────────────────────────────────────────
const insufficientFunds = UssdInterpreter.terminalState({
id: 'insufficient_funds',
render: (data) => `Insufficient balance. Available: KES ${data.balance}.`
})
const transferSuccess = UssdInterpreter.terminalState({
id: 'transfer_success',
render: (data) => `KES ${data.amount} sent to ${data.accountNumber}.`
})
const confirmTransfer = UssdInterpreter.confirmationState({
id: 'confirm_transfer',
render: (data) => `Send KES ${data.amount} to ${data.accountNumber}?\n1. Confirm\n2. Cancel`,
on: { confirm: transferSuccess, cancel: mainMenu } // mainMenu defined below — use lazy if in same file
})
const enterAmount = UssdInterpreter.dataState({
id: 'enter_amount',
captureKey: 'amount',
validate: (token, data) => {
const n = Number(token)
if (isNaN(n) || n <= 0) return 'Enter a valid amount.'
if (n > data.balance) return 'Amount exceeds your balance.'
return true
},
transform: (token) => Number(token),
render: (data) => `Available: KES ${data.balance}\nEnter amount:`,
next: confirmTransfer
})
const enterAccount = UssdInterpreter.dataState({
id: 'enter_account',
captureKey: 'accountNumber',
validate: (token) => /^\d{10}$/.test(token) ? true : 'Must be 10 digits.',
onArrive: async ({ gateway, session }) => {
const { balance, hasBalance } = await fetchBalance(gateway.phoneNumber)
return { balance, insufficientFunds: !hasBalance }
},
render: () => 'Enter recipient account number:',
next: UssdInterpreter.routerState({
id: 'balance_router',
route: (data) => data.insufficientFunds ? insufficientFunds : enterAmount
})
})
// ── Signup flow ───────────────────────────────────────────────────────────────
const ageBlocked = UssdInterpreter.terminalState({
id: 'age_blocked',
render: () => 'Sorry, you must be 18 or older to register.'
})
const signupSuccess = UssdInterpreter.terminalState({
id: 'signup_success',
render: (data) => `Welcome, ${data.name}! Your account is ready.`
})
const enterName = UssdInterpreter.dataState({
id: 'enter_name',
captureKey: 'name',
validate: (token) => token.trim().length >= 3 ? true : 'Name too short.',
render: () => 'Enter your full name:',
next: signupSuccess
})
const enterDob = UssdInterpreter.dataState({
id: 'enter_dob',
captureKey: 'dob',
validate: (token) => /^\d{8}$/.test(token) ? true : 'Use format DDMMYYYY.',
render: () => 'Enter date of birth (DDMMYYYY):',
next: UssdInterpreter.routerState({
id: 'age_router',
route: (data) => {
const year = parseInt(data.dob.slice(4, 8))
const age = new Date().getFullYear() - year
return age < 18 ? ageBlocked : enterName
}
})
})
// ── Root menu — one entry point for all flows ─────────────────────────────────
const mainMenu = UssdInterpreter.navigationState({
id: 'main_menu',
render: () => 'Welcome\n1. Sign Up\n2. Transfer Money\n3. Verify Identity',
transitions: {
'1': enterDob, // → signup flow
'2': enterAccount, // → transfer flow
'3': enterIdNumber // → KYC flow
},
allowBack: false
})
const interpreter = new UssdInterpreter(mainMenu)
app.post('/ussd', async (req, res) => {
const { sessionId, phoneNumber, text } = req.body
const session = await interpreter.resolveAsync(text, { sessionId, phoneNumber })
res.send(`${session.isEnded ? 'END' : 'CON'} ${session.render()}`)
})Key points
Every early exit is just a terminalState. There is no special "abort" or "early end" mechanism. A rejection message and a success message are both terminal states — session.isEnded is true and your handler sends END either way.
A routerState is the idiomatic way to branch on dynamic conditions. After onArrive() populates the data bag with a result like kycPassed or insufficientFunds, a router immediately redirects to the appropriate state — without the user pressing anything.
Flows are isolated by default. Data captured in the transfer flow is scoped to that session's frame stack. It does not leak into the signup or KYC flows.
One interpreter, one root. You always create a single UssdInterpreter instance per service. The root menu's transitions are the entry points into each flow.
Nested flows
A USSD service with multiple layers of menus uses the same three building blocks at every level. A navigationState is a menu. A dataState is a step. A terminalState is an exit. The pattern is identical whether you are one level deep or four.
root menu
├── 1 → accounts flow
│ ├── 1 → savings sub-flow
│ │ ├── 1 → deposit → END
│ │ └── 2 → withdraw → END
│ └── 2 → loans sub-flow
│ ├── 1 → apply → END
│ └── 2 → repay → END
├── 2 → payments flow
│ ├── 1 → send money sub-flow
│ │ ├── 1 → mobile → END
│ │ └── 2 → bank → END
│ └── 2 → buy goods sub-flow
│ ├── 1 → till → END
│ └── 2 → paybill → END
└── 3 → services flow
├── 1 → KYC sub-flow → END
└── 2 → statements → ENDCode structure
Build bottom-up — leaf states first, menus last. Each level is a navigationState whose transitions point to either another menu or the first state of a sub-flow:
// ── Leaf states (defined first) ───────────────────────────────────────────────
const depositSuccess = UssdInterpreter.terminalState({ id: 'deposit_success', render: () => 'Deposit successful.' })
const withdrawSuccess = UssdInterpreter.terminalState({ id: 'withdraw_success', render: () => 'Withdrawal successful.' })
const loanApplied = UssdInterpreter.terminalState({ id: 'loan_applied', render: () => 'Application submitted.' })
// ... data states for each sub-flow (enterDepositAmount, enterWithdrawAmount, etc.)
// ── Sub-flow menus ────────────────────────────────────────────────────────────
const savingsMenu = UssdInterpreter.navigationState({
id: 'savings_menu',
render: () => 'Savings\n1. Deposit\n2. Withdraw\n0. Back',
transitions: { '1': enterDepositAmount, '2': enterWithdrawAmount }
})
const loansMenu = UssdInterpreter.navigationState({
id: 'loans_menu',
render: () => 'Loans\n1. Apply\n2. Repay\n0. Back',
transitions: { '1': enterLoanAmount, '2': enterRepayAmount }
})
// ── Root flow menus ───────────────────────────────────────────────────────────
const accountsMenu = UssdInterpreter.navigationState({
id: 'accounts_menu',
render: () => 'Accounts\n1. Savings\n2. Loans\n0. Back',
transitions: { '1': savingsMenu, '2': loansMenu }
})
const paymentsMenu = UssdInterpreter.navigationState({
id: 'payments_menu',
render: () => 'Payments\n1. Send Money\n2. Buy Goods\n0. Back',
transitions: { '1': sendMoneyMenu, '2': buyGoodsMenu }
})
const servicesMenu = UssdInterpreter.navigationState({
id: 'services_menu',
render: () => 'Services\n1. KYC\n2. Statements\n0. Back',
transitions: { '1': enterIdNumber, '2': enterStatementPeriod }
})
// ── Root menu ─────────────────────────────────────────────────────────────────
const mainMenu = UssdInterpreter.navigationState({
id: 'main_menu',
render: () => 'Welcome\n1. Accounts\n2. Payments\n3. Services',
allowBack: false,
transitions: { '1': accountsMenu, '2': paymentsMenu, '3': servicesMenu }
})
const interpreter = new UssdInterpreter(mainMenu)Back navigation works automatically at every level. A user inside savingsMenu pressing "0" returns to accountsMenu. A user inside accountsMenu pressing "0" returns to mainMenu. The interpreter manages this through the frame stack — no special handling needed per level.
Organising the code
With nested flows, keeping everything in one file becomes unwieldy. The natural structure is one file per root flow, with each flow exporting only its entry state:
src/
flows/
accounts/
index.js ← exports accountsMenu
savings.js ← savingsMenu + its states
loans.js ← loansMenu + its states
payments/
index.js ← exports paymentsMenu
send-money.js
buy-goods.js
services/
index.js ← exports servicesMenu
kyc.js
statements.js
menu.js ← mainMenu — imports from each flow's index.js
interpreter.js ← creates and exports the UssdInterpreter instancemenu.js only knows about entry states — not internals:
// menu.js
import { accountsMenu } from './flows/accounts/index.js'
import { paymentsMenu } from './flows/payments/index.js'
import { servicesMenu } from './flows/services/index.js'
export const mainMenu = UssdInterpreter.navigationState({
id: 'main_menu',
render: () => 'Welcome\n1. Accounts\n2. Payments\n3. Services',
allowBack: false,
transitions: { '1': accountsMenu, '2': paymentsMenu, '3': servicesMenu }
})Circular references and lazy transitions
When you build a flow bottom-up, you sometimes need a state that was defined after the one referencing it. This is a circular reference problem — JavaScript evaluates files top to bottom, so a variable that hasn't been defined yet is undefined at the point it is referenced.
Consider this:
const savingsMenu = UssdInterpreter.navigationState({
id: 'savings_menu',
render: () => 'Savings\n1. Deposit\n2. Withdraw',
transitions: {
'1': enterDepositAmount, // ❌ undefined — declared below
'2': enterWithdrawAmount // ❌ undefined — declared below
}
})
const enterDepositAmount = UssdInterpreter.dataState({ ... })
const enterWithdrawAmount = UssdInterpreter.dataState({ ... })When JavaScript evaluates transitions: { '1': enterDepositAmount }, it reads the current value of enterDepositAmount — which is undefined because the const declaration hasn't been reached yet. The object is built immediately with undefined baked in. By the time a user presses "1", it is too late — the transitions map was already frozen with undefined values.
The fix: a lazy transition function.
Instead of a plain object — evaluated immediately at definition time — pass a function. A function is not called until the moment a user presses a key. By that time, every variable in the file has been defined:
const savingsMenu = UssdInterpreter.navigationState({
id: 'savings_menu',
render: () => 'Savings\n1. Deposit\n2. Withdraw',
// Called at navigation time — all variables defined by then
transitions: (token) => {
if (token === '1') return enterDepositAmount // ✓ defined by now
if (token === '2') return enterWithdrawAmount // ✓ defined by now
return null
}
})
const enterDepositAmount = UssdInterpreter.dataState({ ... })
const enterWithdrawAmount = UssdInterpreter.dataState({ ... })Why "lazy"? A plain object is eager — it resolves all its values the moment it is created. A function is lazy — it resolves nothing until it is called. Passing a function defers the lookup to the moment it is actually needed.
When you need it. You only need lazy transitions when a menu references states defined below it in the same file. The cleanest way to avoid it entirely is to build strictly bottom-up: leaf states first, menus last, and each menu only referencing things already defined above it.
The function form also enables dynamic routing — choosing which state to go to based on the current data bag:
// Route to different flows based on account type fetched by onArrive()
const accountMenu = UssdInterpreter.navigationState({
id: 'account_menu',
render: () => '1. Transact\n2. Statement',
transitions: (token, data) => {
if (token === '1') return data.accountType === 'savings' ? savingsTransact : currentTransact
if (token === '2') return statementFlow
return null
}
})Dynamic exits
Some exits are not known at definition time. A sub-flow may terminate differently depending on data fetched mid-flow — a balance check, an eligibility result, an API response. Use onArrive() to fetch the condition and a routerState to branch on it:
const withdrawBlocked = UssdInterpreter.terminalState({
id: 'withdraw_blocked',
render: (data) => `Insufficient funds. Available: KES ${data.balance}.`
})
const withdrawSuccess = UssdInterpreter.terminalState({
id: 'withdraw_success',
render: (data) => `KES ${data.amount} withdrawn successfully.`
})
const enterWithdrawAmount = UssdInterpreter.dataState({
id: 'enter_withdraw_amount',
captureKey: 'amount',
validate: (token, data) => {
const n = Number(token)
if (isNaN(n) || n <= 0) return 'Enter a valid amount.'
if (n > data.balance) return 'Amount exceeds your balance.'
return true
},
transform: (token) => Number(token),
onArrive: async ({ gateway, session }) => {
const { balance } = await fetchBalance(gateway.phoneNumber)
return { balance }
},
render: (data) => `Available: KES ${data.balance}\nEnter amount:`,
next: UssdInterpreter.routerState({
id: 'withdraw_router',
route: (data) => data.balance <= 0 ? withdrawBlocked : withdrawConfirm
})
})The exit — withdrawBlocked or withdrawConfirm — is determined at runtime from live data, not hardcoded into the flow definition. This pattern scales to any condition: eligibility checks, KYC results, loan limits, account status.
Advisory: As flows grow deeper and exits become more dynamic, the single biggest risk is losing track of which states can reach which terminals. Keep each sub-flow's entry state and all its internal states in the same file. Document the possible exits at the top of each flow file as a comment. A flow that can end in five different ways with no documentation becomes very hard to reason about — especially when
onArrive()results change what those exits are at runtime.
Async data — onArrive() and resolveAsync()
When a state needs external data before it can render — a live balance, a transaction reference, a KYC result — declare an onArrive() async function on the state. It receives { gateway, session } and must return a plain object. Its keys are merged into the session data available to render().
onArrive() runs when the user arrives at a screen — before they have typed anything on that screen. Data captured on the previous screen is in session. Data the user is about to enter on the current screen is not yet available.
This is the most common source of confusion. If you put onArrive() on enterAmount, it runs when the user sees the "Enter amount" prompt — at that point session.amount is undefined. session.accountNumber from the previous screen is available. This is correct and intentional — use onArrive() to pre-fetch data the current screen needs to render, not to process what the user has just typed.
gateway contains whatever your USSD gateway sent — phoneNumber, sessionId, or any other fields. For all other state functions (render, validate, label), the data bag is flat — gateway and session keys merged together.
const showBalance = UssdInterpreter.terminalState({
id: 'show_balance',
onArrive: async ({ gateway, session }) => {
const { balance } = await fetchBalance(gateway.phoneNumber)
return { balance }
},
render: (data) => `Your balance is KES ${data.balance}`
})Call resolveAsync() instead of resolve() in your handler, passing whatever your gateway sends:
app.post('/ussd', async (req, res) => {
const { sessionId, phoneNumber, text } = req.body
const session = await interpreter.resolveAsync(text, { sessionId, phoneNumber })
res.send(`${session.isEnded ? 'END' : 'CON'} ${session.render()}`)
})Priority rule: user-captured data always wins over onArrive() on key conflict. onArrive() can only fill in keys that don't already exist in the data bag.
Why validate and transform are synchronous
validate and transform run during the synchronous replay of the input string — before onArrive(). Making them async would mean re-running them on every request for every historical token, turning a microsecond operation into potentially dozens of API calls per request.
If you need external data to validate something — checking a recipient exists, verifying a balance — fetch it in onArrive() on the previous screen and store it in the data bag. validate can then check data.balance or data.recipientFound without any async work.
// Fetch balance in onArrive() on enterAccount
const enterAccount = UssdInterpreter.dataState({
id: 'enter_account',
captureKey: 'accountNumber',
validate: (token) => /^\d{10}$/.test(token) ? true : 'Must be 10 digits.',
onArrive: async ({ gateway }) => {
const { balance } = await fetchBalance(gateway.phoneNumber)
return { balance } // stored in data bag
},
render: () => 'Enter account number:',
next: enterAmount
})
// Use it synchronously in validate on enterAmount
const enterAmount = UssdInterpreter.dataState({
id: 'enter_amount',
captureKey: 'amount',
validate: (token, data) => {
const n = Number(token)
if (n > data.balance) return `Exceeds balance of KES ${data.balance}.`
return true
},
render: () => 'Enter amount (KES):',
next: confirmState
})See The fetch guard pattern to avoid re-fetching on every request when a store is configured.
Escape key — escaping dataStates and confirmationStates
On a dataState, "0" is always raw input — the user cannot press back. On a
confirmationState, on.cancel may not route far enough back. With a deep
flow, a user mid-transfer who wants to cancel everything and return to the main
menu has no clean way out.
Configure escapeKey and escapeTarget on the interpreter to give users an
exit from any data capture or confirmation screen:
const interpreter = new UssdInterpreter(mainMenu, {
escapeKey: '00', // token that triggers escape
escapeTarget: mainMenu // where to go — state object, not a string id
})When the user presses "00" on any dataState or confirmationState,
validate() and transform() are skipped entirely. The current state is
replaced with escapeTarget — stack depth stays the same, so back navigation
still works correctly from the escape destination.
Disabling escape on a specific state
Some states should not have an escape route. A PIN entry screen may use "00"
as valid input. Use escapeKey: null to disable the global escape on that state:
const enterPin = UssdInterpreter.dataState({
id: 'enter_pin',
captureKey: 'pin',
validate: (token) => /^\d{4}$/.test(token) ? true : 'PIN must be 4 digits.',
render: () => 'Enter your 4-digit PIN:',
next: confirmState,
escapeKey: null // disable — "00" is treated as raw input here
})Per-state escape target
A specific state can escape to a different destination than the global target:
const enterLoanAmount = UssdInterpreter.dataState({
id: 'enter_loan_amount',
captureKey: 'loanAmount',
validate: (token) => Number(token) > 0 ? true : 'Enter a valid amount.',
render: () => 'Enter loan amount:\n00. Back to loans menu',
next: confirmLoan,
escapeKey: '00',
escapeTarget: loansMenu // escape to loans menu, not root
})Escape on confirmationState
on.confirm and on.cancel continue to work normally. Escape is an additional
exit route, not a replacement for either:
const confirmTransfer = UssdInterpreter.confirmationState({
id: 'confirm_transfer',
render: (data) => `Send KES ${data.amount}?\n1. Confirm 2. Cancel 00. Main menu`,
on: { confirm: transferSuccess, cancel: enterAmount },
// escapeKey inherited from interpreter — "00" goes to mainMenu
})Precedence
| Situation | Behaviour |
|---|---|
| Per-state escapeKey: null | Escape disabled — global escape ignored |
| Per-state escapeKey set | Uses per-state key and per-state target (if set), else global target |
| No per-state override | Uses global escapeKey and escapeTarget |
| No global escapeKey | No escape — existing behaviour preserved |
Options
| Option | Where | Description |
|---|---|---|
| escapeKey | Interpreter | Global escape token. Requires escapeTarget. |
| escapeTarget | Interpreter | State object to escape to. Required if escapeKey is set. |
| escapeKey | dataState, confirmationState | Per-state override. null disables global escape. |
| escapeTarget | dataState, confirmationState | Per-state escape destination. |
Session store — persisting data across requests
By default the interpreter is stateless — data fetched by onArrive() exists for one request only and is re-fetched on the next. If re-fetching is expensive, configure a store. The library manages the full store lifecycle — your handler writes nothing extra.
const interpreter = new UssdInterpreter(root, {
storeTtl: 120, // seconds — match your gateway session timeout
store: {
async get (sessionId) {
const raw = await redis.get(`session:${sessionId}`)
return raw ? JSON.parse(raw) : {}
},
async set (sessionId, data, ttl) {
await redis.set(`session:${sessionId}`, JSON.stringify(data), { EX: ttl })
},
async del (sessionId) {
await redis.del(`session:${sessionId}`)
}
},
onStoreError: (err) => logger.warn({ err }, 'Store error — failing open')
})Pass gateway metadata to resolveAsync() — the library uses sessionId to key store operations automatically:
app.post('/ussd', async (req, res) => {
const { sessionId, phoneNumber, serviceCode, text } = req.body
const session = await interpreter.resolveAsync(text, { sessionId, phoneNumber, serviceCode })
res.send(`${session.isEnded ? 'END' : 'CON'} ${session.render()}`)
})How it works
Per request the library does:
1. store.get(sessionId) → restore previously fetched session data
2. replay input → user-captured data always wins on conflict
3. onArrive({ gateway, session }) → enrich session with external data
4. session.isEnded
? store.del(sessionId) → clean up on session end
: store.set(sessionId, data, ttl)
5. return sessionReplay-first: if a user goes back and corrects their input, replay produces the new value — the stored value is ignored for that key. Stale data never shadows corrected user input.
Fail-open: store errors never surface as USSD errors. If store.get() throws, the library proceeds without restored data. If store.set() throws, the session is returned normally. onStoreError is called in either case for observability.
The fetch guard pattern
With a store, onArrive() can skip re-fetching when data is already present. Store a fingerprint alongside fetched data to detect stale results on back-navigation:
onArrive: async ({ gateway, session }) => {
// Already fetched and input hasn't changed — store provided it
if (
session.customerData &&
session.customerData._source === session.passportNumber
) return {}
const result = await verifyKYC({
passportNumber: session.passportNumber,
phoneNumber: gateway.phoneNumber // gateway data — certified, read-only
})
return {
customerData: {
...result.customer,
_source: session.passportNumber // fingerprint — detects stale data on back-nav
},
isVerified: result.verified
}
}The _source prefix (prefixed _ to signal internal) mirrors the _page convention used by paginationState.
Store options
| Option | Default | Description |
|---|---|---|
| store | null | Store adapter { get, set, del }. If omitted, stateless default. |
| storeTtl | 120 | Seconds before store entries expire. |
| onStoreError | null | (err) => void — called on any store error. If omitted, fails silently. |
Session resumption — app layer pattern
USSD sessions drop. Carriers enforce hard timeouts — typically around 3 minutes. For long flows, resumption is a real usability concern.
Session resumption is not a library feature — it is an application-level pattern. The library is stateless and unaware of sessions across dials. The handler owns the resume store, the consent screen, and the gateway text reconciliation.
See the session resumption proposal in the project documentation for a full
implementation including resumption.js, stripToRoot, and handler integration.
Pipeline — multiple async sources
When a state needs data from more than one source, use Pipeline inside onArrive(). It handles single, parallel, and chained fetches — each step with its own timeout and failure behaviour.
import { UssdInterpreter, Pipeline } from 'ussd-interpreter'Single fetch
const p = new Pipeline([
{ name: 'balance', fetch: (data) => fetchBalance(data.accountNumber), timeout: 3000 }
])Parallel — independent sources run concurrently
const p = new Pipeline([
{ name: 'equifax', fetch: (data) => fetchEquifax(data.id), timeout: 2000, parallel: true, onFail: 'continue' },
{ name: 'transunion', fetch: (data) => fetchTransUnion(data.id), timeout: 2000, parallel: true, onFail: 'continue' },
{ name: 'registry', fetch: (data) => fetchRegistry(data.id), timeout: 2000, parallel: true, onFail: 'continue' }
])Chained — each step receives the previous output
Every step's fetch function receives two arguments: data (the session data bag) and prev (the merged results of all steps that have settled so far, keyed by step name). This lets each step use the output of the one before it:
const p = new Pipeline([
{ name: 'auth', fetch: (data) => fetchAuth(data.idNumber), timeout: 2000, onFail: 'abort' },
{ name: 'kyc', fetch: (data, prev) => fetchKyc(data.idNumber, prev.auth.token), timeout: 3000, onFail: 'abort' }
// ^^^^^^^^^^^^^^^^
// output of 'auth' step
])Mixed — combine freely
Sequential steps, parallel groups, and chained steps can be combined in any order. A sequential step after a parallel group receives all parallel results in prev:
const kycPipeline = new Pipeline([
{ name: 'auth', fetch: (data) => fetchAuth(data.idNumber), timeout: 2000, onFail: 'abort' },
{ name: 'equifax', fetch: (data, prev) => fetchEquifax(data.idNumber, prev.auth.token), timeout: 2000, onFail: 'continue', parallel: true },
{ name: 'transunion', fetch: (data, prev) => fetchTransUnion(data.idNumber, prev.auth.token), timeout: 2000, onFail: 'continue', parallel: true },
{ name: 'score', fetch: (data, prev) => computeScore(prev.equifax, prev.transunion), timeout: 500, onFail: 'abort' }
// ^^^^^^^^^^^^^^^^^^^^^^^^^^
// both parallel results available here
])
const kycState = UssdInterpreter.confirmationState({
id: 'kyc_verify',
render: (data) => `Score: ${data.score?.value}\n1. Continue\n2. Cancel`,
on: { confirm: createAccount, cancel: cancelled },
onArrive: ({ gateway, session }) => kycPipeline.run(session)
})pipeline.run(data) always returns a plain object:
{
pipelinePassed: true,
failedAt: null,
auth: { token: 'T001' },
equifax: { passed: true, score: 88 },
transunion: { failed: true, reason: 'timeout' }, // onFail: 'continue' — recorded, not thrown
score: { value: 85, passed: true }
}Calculations work exactly the same as fetches — fetch can return a plain value without Promise.resolve():
{ name: 'band', fetch: (d, prev) => ({ band: prev.score.value >= 80 ? 'LOW' : 'HIGH' }) }Step options
| Option | Default | Description |
|---|---|---|
| name | required | Unique key. Result stored under this name in prev and in the output. |
| fetch | required | (data, prev) => value | Promise |
| timeout | 5000 | ms before this step is considered failed. |
| onFail | 'abort' | 'abort' stops the whole pipeline. 'continue' records the failure and moves on. |
| parallel | false | Group with adjacent parallel: true steps and run via Promise.all. |
Pagination — long content across multiple screens
When content is too long to fit on a single USSD screen, use paginationState. It splits the text automatically, appends page indicators and navigation options, and handles all forward/back/accept/cancel navigation.
const termsAndConditions = UssdInterpreter.paginationState({
id: 'terms',
content: 'By registering you agree to our terms of service. ' +
'We may use your data for service improvement. ' +
'You can opt out at any time by contacting support. ' +
'Charges apply as per your tariff plan.',
pageSize: 160,
on: {
accept: signupFlow,
cancel: cancelledState
}
})What the screens look like
Page 1 of 3 — Next and Cancel, no Back:
By registering you agree to
our terms of service. We may
use your data for improvement.
(1/3)
1.Next 0.CancelPage 2 of 3 — Next, Back, and Cancel:
You can opt out at any time
by contacting support. Charges
apply as per your tariff plan.
(2/3)
1.Next 2.Prev 0.CancelPage 3 of 3 — Accept replaces Next:
Data is processed lawfully and
securely per our privacy policy.
(3/3)
1.Accept 2.Prev 0.CancelHiding the page indicator
Set showPageIndicator: false to omit the (n/total) counter. Useful when
the content is self-contained and the counter adds visual clutter — T&C screens,
welcome messages, or any content where page position is not meaningful to the user:
const termsAndConditions = UssdInterpreter.paginationState({
id: 'terms',
content: tandc,
pageSize: 160,
showPageIndicator: false,
on: { accept: signupFlow, cancel: cancelledState }
})Pages then render without the counter:
By registering you agree to
our terms of service. We may
use your data for improvement.
1.Next 0.CancelCustomising token labels
The display labels for Next and Back can be customised independently of the tokens. Cancel and Accept are not generated by the library — write them directly in your content string so you have full control over their wording and position. This lets you match your product's language or
brand voice without changing the token values:
const termsAndConditions = UssdInterpreter.paginationState({
id: 'terms',
content: tandc, // write your own cancel/accept options in the content
pageSize: 160,
showPageIndicator: false,
nextToken: '3', nextLabel: 'More',
prevToken: '4', prevLabel: 'Back',
cancelToken: '0',
acceptToken: '1',
on: { accept: signupFlow, cancel: cancelledState }
})Middle pages:
...terms of service content...
3.More 4.PrevLast page — Cancel and Accept are in the content, not the footer:
...final terms content...
1. Yes
2. No
4.PrevNavigation behaviour
| Token | Page 1 | Middle page | Last page |
|---|---|---|---|
| "1" | Next page | Next page | Accept — advances to on.accept |
| "2" | Exits pagination | Previous page | Previous page |
| "0" | Exits pagination | Previous page | Previous page |
Note on cancel:
"0"uses the standard back action — it pops one frame per press. From page 1 this exits to the previous flow state. From deeper pages it walks back page by page.
Dynamic content
content can be a function that receives the current data bag:
const transactionHistory = UssdInterpreter.paginationState({
id: 'tx_history',
onArrive: async ({ gateway, session }) => ({
transactions: await fetchTransactions(gateway.phoneNumber)
}),
content: (data) => data.transactions
.map((tx, i) => `${i + 1}. KES ${tx.amount} — ${tx.date}`)
.join('\n'),
pageSize: 160,
on: { accept: mainMenu, cancel: mainMenu }
})Options
| Option | Default | Description |
|---|---|---|
| id | required | Unique state identifier |
| onArrive | — | async ({ gateway, session }) => plainObject — same as other state types |
| content | required | Full text to paginate, or (data) => string for dynamic content |
| pageSize | 160 | Max characters per page including the footer |
| showPageIndicator | true | Show (n/total) on each page. Set to false to hide. |
| on | required | { accept: state, cancel: state } |
| nextToken | '1' | Token to advance to the next page |
| prevToken | '2' | Token to go to the previous page (content navigation) |
| cancelToken | '0' | Token to exit pagination (back action) |
| acceptToken | '1' | Token to accept on the last page |
| nextLabel | 'Next' | Display label for the next token |
| prevLabel | 'Prev' | Display label for the prev token |
| label | — | (data) => string — optional, for session stories |
Advisories
This section documents architectural constraints and production gotchas that are not bugs but will affect real deployments. Each advisory explains the problem, who it affects, and what to do about it.
- Side effects and stateless replay
- Deep flows and the dynamic exits problem
- Session drops and user abandonment
onArrive()performance and the fetch guard pattern
Advisory: Side effects and stateless replay
Affects: any flow where onArrive() creates, updates, or deletes something external and a routing condition reads that state.
The interpreter replays the full input string on every request. This is what makes the library stateless and horizontally scalable. But replay carries an implicit assumption: the world does not change between requests in a way that affects routing decisions.
When a side effect inside onArrive() permanently changes external state — creating an account, disbursing a loan, completing KYC — and a routing condition somewhere in the graph reads that state, replay may produce a different branch than the original pass did.
The classic failure: a user completes an account opening flow. Their account is created mid-session. They press 00 to return to the main menu. The library replays the full input string from root. The root router calls hasAccount() — which now returns { found: true } because the account was just created. The tokens that followed the account opening flow are now replayed against the existing customer menu. The user lands in the wrong state.
Design checklist. Before shipping any flow where onArrive() has a side effect, ask three questions:
- Does this
onArrive()create, update, or delete something external? - Is there a routing condition anywhere in the graph that reads what was just changed?
- Can the user navigate back or to root after this
onArrive()runs?
If all three are yes, apply Fix A if the user can navigate to root, Fix B if the user can navigate back one step, or both if both are possible.
Fix A — 00 (root navigation): strip the input string to everything after the last 00 before passing it to resolveAsync(). The root branch condition re-evaluates with current world state and the user correctly lands on the right menu:
function stripToRoot (text) {
if (!text) return ''
const tokens = text.split('*')
const rootIndex = tokens.lastIndexOf('00')
if (rootIndex === -1) return text
return tokens.slice(rootIndex + 1).join('*')
}
app.post('/ussd', async (req, res) => {
const { sessionId, phoneNumber, text } = req.body
const session = await interpreter.resolveAsync(stripToRoot(text ?? ''), {
phoneNumber,
sessionId
})
res.send(`${session.isEnded ? 'END' : 'CON'} ${session.render()}`)
})Fix B — 0 (back one step): set allowBack: false on any state whose onArrive() performs an irreversible side effect. The user cannot navigate back past a point of no return:
UssdInterpreter.confirmationState({
id: 'open_account_confirm',
allowBack: false, // side effect occurs here — back navigation disabled
onArrive: async ({ gateway, session }) => {
const result = await bankingApi.openAccount({ ... })
return { accountNumber: result.accountNumber }
},
render: (data) => `Account ${data.accountNumber} created.\n1. Continue`,
on: { confirm: welcomeScreen, cancel: welcomeScreen }
})As a general rule: any state whose onArrive() performs an irreversible side effect should set allowBack: false. This applies to dataState, navigationState, confirmationState, and paginationState. Declare the point of no return at the state level, where the side effect lives — not in the handler.
Advisory: Deep flows and the dynamic exits problem
Affects: flows with more than 5–6 steps where routing conditions change at runtime.
As flows grow deeper and exits become more dynamic, the single biggest risk is losing track of which states can reach which terminals. A flow that can end in five different ways — depending on balance, KYC status, account type, and fraud score — with no documentation becomes very hard to reason about, especially when onArrive() results change what those exits are at runtime.
Recommended practice: document the possible exits at the top of each flow file as a comment, before any code:
/**
* Transfer flow — possible exits:
* recipient_not_found — invalid account number
* insufficient_funds — balance check failed
* fraud_blocked — fraud check failed at confirmation
* transfer_cancelled — user cancelled at confirmation
* transfer_success — completed
*/Keep each sub-flow's entry state and all its internal states in the same file. A state that appears in two flow files is a sign the flow boundary is wrong.
Advisory: Session drops and user abandonment
Affects: any flow longer than 3–4 steps deployed on a mobile carrier network.
USSD sessions drop. Carriers enforce hard session timeouts — typically around 3 minutes. A user who loses signal mid-flow dials again and starts from scratch. For short flows this is a minor inconvenience. For long flows — onboarding, identity verification, loan applications — it is a real abandonment driver.
Session resumption is an application-level pattern — not a library feature. For flows longer than 4 steps in a production deployment, implement resumption in your handler. See Session resumption for the pattern.
Advisory: onArrive() performance and the fetch guard pattern
Affects: any flow using onArrive() without a store configured.
onArrive() runs on the landing state on every request. Without a store, every request replays the full input string from scratch and the landing state's onArrive() re-fetches from external APIs every time. In a 6-step flow, the 6th request triggers one onArrive() call — but without a store, so did the 5th, and the 4th. Every request fetches fresh.
This is correct for freshness but expensive at scale. Configure a store to cache onArrive() results across requests in the same session. See Session store.
If you cannot use a store, use the fetch guard pattern inside onArrive() to skip re-fetching when data is already present:
onArrive: async ({ gateway, session }) => {
if (session.balance !== undefined) return {}
const { balance } = await fetchBalance(gateway.phoneNumber)
return { balance }
}This reduces redundant fetches without a store, at the cost of potentially serving stale data within a session.
Session stories — session.buildStory()
After a session ends, buildStory() produces a structured account of what happened — the path the user took, all captured data, and a plain-English narrative of each step. Use it to power reports, audit logs, and analytics.
app.post('/ussd', async (req, res) => {
const { sessionId, phoneNumber, text } = req.body
const session = await interpreter.resolveAsync(text, { sessionId, phoneNumber })
if (session.isEnded) {
const story = {
sessionId,
phoneNumber,
timestamp: new Date().toISOString(),
...session.buildStory()
}
await reports.save(story)
}
res.send(`${session.isEnded ? 'END' : 'CON'} ${session.render()}`)
})A completed transfer produces:
{
"sessionId": "ATUid_abc123",
"phoneNumber": "+254724000001",
"timestamp": "2025-04-27T10:32:11.000Z",
"outcome": "completed",
"exitState": "transfer_success",
"path": ["main_menu", "enter_account", "enter_amount", "confirm_transfer", "transfer_success"],
"data": { "accountNumber": "0724000001", "amount": 500 },
"steps": [
{ "state": "main_menu", "label": "User opened the service." },
{ "state": "enter_account", "label": "Entered account number 0724000001." },
{ "state": "enter_amount", "label": "Entered amount KES 500." },
{ "state": "confirm_transfer", "label": "Reviewed transfer of KES 500 to 0724000001." },
{ "state": "transfer_success", "label": "Transfer of KES 500 to 0724000001 completed." }
],
"narrative": "User opened the service. Entered account number 0724000001. Entered amount KES 500. Reviewed transfer of KES 500 to 0724000001. Transfer of KES 500 to 0724000001 completed."
}Declaring labels
Add a label function to any state. It receives the final session data bag and returns a plain string. Supported on all state types except routerState.
const transferSuccess = UssdInterpreter.terminalState({
id: 'transfer_success',
render: (data) => `KES ${data.amount} sent to ${data.accountNumber}.`,
label: (data) => `Transfer of KES ${data.amount} to ${data.accountNumber} completed.`
})
const confirmTransfer = UssdInterpreter.confirmationState({
id: 'confirm_transfer',
render: (data) => `Send KES ${data.amount} to ${data.accountNumber}?\n1. Confirm\n2. Cancel`,
on: { confirm: transferSuccess, cancel: cancelledState },
label: (data) => `Reviewed transfer of KES ${data.amount} to ${data.accountNumber}.`
})
const enterAmount = UssdInterpreter.dataState({
id: 'enter_amount',
captureKey: 'amount',
validate: (token) => Number(token) > 0 ? true : 'Enter a valid amount.',
transform: (token) => Number(token),
render: () => 'Enter amount (KES):',
next: confirmTransfer,
label: (data) => `Entered amount KES ${data.amount}.`
})Labels are opt-in. States without a label still appear in path and steps with label: null but contribute nothing to narrative.
Note:
label(data)receives the final session data, not the per-frame snapshot. All captured values are available to every label function regardless of when in the flow they were entered.
Deduplication and raw mode
If a user backed out and re-entered a state, buildStory() keeps only the last visit — the narrative reflects the user's final committed path. For the full undeduped stack:
const story = session.buildStory({ raw: true })Redacting sensitive data
Pass a redact array to replace sensitive keys with '[REDACTED]' in both the data output and in any label() calls — so no label function can accidentally leak a sensitive value. session.data itself is never mutated.
const story = session.buildStory({ redact: ['pin', 'password', 'cvv'] })Resumption in stories
When a session was resumed, buildStory() includes a resumption field and prepends a plain-English note to narrative. See Session resumption for the full output format.
What the caller adds
buildStory() returns no gateway metadata. Merge sessionId, phoneNumber, and timestamp in yourself, as shown in the example above.
Chaos Monkey — stress-test your flow
import { ChaosMonkey } from 'ussd-interpreter/chaos'The chaos monkey generates thousands of random sessions against your flow and verifies that your invariants always hold — regardless of what users enter, what your onArrive() hooks return, or how your flow branches.
const monkey = new ChaosMonkey(interpreter, {
iterations: 5000,
verbose: true
})
monkey
.dataHints({
'enter_account': { valid: () => '0724000001', invalid: ['abc', '123'] },
'enter_amount': { valid: () => '500', invalid: ['-1', 'abc'] }
})
.needsHints({
'show_balance': { happy: { balance: 3200 }, chaos: ['happy', 'null', 'timeout', 'throw'] },
'transfer_success': { happy: { ref: 'TXN001' }, chaos: ['happy', 'null', 'wrong_type'] }
})
.invariant('accountNumber is always 10 digits when present', (session) => {
if (!session.data.accountNumber) return true
return /^\d{10}$/.test(session.data.accountNumber)
})
const report = await monkey.run()
report.print()Sample report
════════════════════════════════════════════════════════════
CHAOS MONKEY REPORT
════════════════════════════════════════════════════════════
Seed: a3f8c1d2
Iterations: 5,000
Violations: 1
Duration: 3.41s
Result: ✗ FAILED
════════════════════════════════════════════════════════════
VIOLATION 1 — CRASH
────────────────────────────────────────────────────────
resolveAsync() threw an error: Cannot read property 'toFixed' of null
Input: "2"
State: show_balance
Chaos: null
Error: Cannot read property 'toFixed' of null
Reproduce: seed=a3f8c1d2, iteration=1847
════════════════════════════════════════════════════════════Reproduce any failure by passing the seed back in, or run the specific input string directly:
new ChaosMonkey(interpreter, { seed: 'a3f8c1d2', iterations: 5000 })
const session = await interpreter.resolveAsync('2', { sessionId: 'test' })
console.log(session.render())Chaos modes
| Mode | What it does |
|---|---|
| happy | Calls the real function or returns the hint |
| null | Returns null |
| empty | Returns {} |
| wrong_type | Returns an array, string, number, or boolean |
| missing_keys | Returns the happy result with random keys dropped |
| extra_keys | Returns the happy result with extra unexpected keys added |
| throw | Throws a generic Error |
| throw_bad | Throws a non-Error (string, null, number) |
| timeout | Returns a Promise that never resolves — triggers onArriveTimeout |
Options
| Option | Default | Description |
|---|---|---|
| iterations | 1000 | Number of random sessions to run |
| seed | auto-generated | Seed for reproducibility |
| maxViolations | 20 | Stop collecting after this many |
| maxDepth | 10 | Max flow depth to explore |
| maxStates | 100 | Max states to discover |
| verbose | false | Print progress to console |
Reference
UssdSession
| Property | Type | Description |
|---|---|---|
| state | object | Current state definition |
| data | object | Frozen captured data for the current frame |
| error | string | null | Last error message shown to user |
| retryCount | number | Consecutive failures on current state |
| isDead | boolean | Session forcibly terminated |
| isTerminal | boolean | Flow has reached a terminal state |
| isEnded | boolean | isTerminal \|\| isDead — use this for CON/END |
| depth | number | Navigation stack depth |
| render() | string | Screen text to send to the user |
| buildStory(options?) | object | Structured session story for reports and audit logs |
buildStory options:
| Option | Default | Description |
|---|---|---|
| raw | false | Skip deduplication — return the full undeduped frame stack |
| redact | [] | Keys to replace with '[REDACTED]' in data output and in label() calls |
buildStory() always returns a resumption field — null when no resumption occurred, a structured object when the session was resumed. See Session resumption.
UssdInterpreter options
| Option | Default | Description |
|---|---|---|
| separator | '*' | Token separator |
| maxTokens | 20 | Max tokens per input string (DoS guard) |
| maxTokenLength | 64 | Max characters per token (DoS guard) |
| maxRetries | 3 | Max consecutive failures before session is killed |
| consumeBudget | 200 | Max internal operations per resolve() |
| onArriveTimeout | 5000 | ms before onArrive() is killed |
| escapeKey | null | Token that triggers escape on dataState and confirmationState. Requires escapeTarget. |
| escapeTarget | null | State object to escape to. Required if escapeKey is set. |
| store | null | Store adapter { get, set, del }. If omitted, stateless default. |
| storeTtl | 120 | Seconds before store entries expire |
| onStoreError | null | (err) => void — called on any store error. If omitted, fails silently. |
Order of execution
Every call to resolveAsync() follows this sequence:
resolveAsync(inputString, gatewayOptions)
│
├─ 1. store.get(sessionId)
│ Restore data from previous requests.
│ If store throws → continue without it (fail open).
│
├─ 2. replay input string token by token
│ For each token on a dataState or confirmationState:
│ a. escape check if token matches escapeKey → replace, continue
│ b. validate(token, data) only runs if escape did not fire
│ c. transform(token, data) only runs if validate passed
│ d. data[captureKey] = result stored value, not raw token
│ e. drain routerState if next is a router, resolve it now
│ Produces a session at the landing state.
│
├─ 3. onArrive({ gateway, session }) landing state only, once
│ Runs after replay, before render.
│ Result merged into data — captured data wins on conflict.
│ If throws or times out → dead session, stop here.
│
├─ 4. store.set(sessionId, data, ttl) if session is alive
│ or store.del(sessionId) if session ended
│ If store throws → return session normally (fail open).
│
└─ 5. return session
Your handler calls session.render()What each function can see
| Function | What it can see |
|---|---|
| escape check | Fires before anything else on dataState and confirmationState. Skips validate and transform entirely when it fires. |
| validate(token, data) | Data from all previous screens. It cannot see the current token — that is what is being validated. It cannot see onArrive() data — that runs after replay. |
| transform(token, data) | Same as validate. The token has already passed validation. |
| route(data) | Data captured by all screens the user passed through to reach this router. If the user typed "1*0724000001" and the router sits after enterAccount, it sees data.accountNumber — but not data.amount, captured on a later screen. |
| onArrive({ gateway, session }) | All captured data from every previous screen. Gateway options. Store-restored data from prior requests. It cannot see the current screen's own captureKey — the user has not submitted it yet. |
| render(data) | Everything — replay data plus onArrive() result. |
| label(data) | Final session data at the time buildStory() is called. All captured values from all screens. |
FAQ
Does the interpreter know about sessionId?
Only as a store key. When a store is configured, the library reads sessionId from the gateway options passed to resolveAsync() and uses it to key store operations. That is the only thing the library does with it. Everything else — failure tracking, idempotency of side effects, logging — belongs in your request handler.
Does the interpreter know about CON and END?
No. Use session.isEnded in your handler to decide which prefix to prepend.
Is concurrency a problem?
No. You create one interpreter instance at startup and share it across all requests — it holds no mutable state. Each call to resolve() or resolveAsync() is entirely self-contained: it takes an input string, replays it, and returns a session. Two simultaneous calls for the same user produce the same deterministic result independently. Concurrency is only a concern in your handler if you trigger non-idempotent side effects.
Is it framework, server, and gateway agnostic? Yes. Zero runtime dependencies. Works identically on Express, Fastify, AWS Lambda, Cloudflare Workers, and any JavaScript runtime.
Can Pipeline handle calculations, not just network calls?
Yes. A step's fetch function can return a plain value, a Promise, or the result of a synchronous calculation — the pipeline treats all three identically.
Can a user bypass the flow and jump to any state they want?
No — but understanding what that means in practice matters. A flow is the sequence of states a user must pass through to reach an outcome: to reach transfer_success, they must pass through enter_account, then enter_amount, then confirm_transfer, in that order. The interpreter replays the full input string on every request, so there is no way to skip ahead — every state's validate() must return true before the flow advances
