Skip to main content

WhatsApp bot engine with node-based flows and interactive replies

Project description

TurnStack — Developer Documentation

The WhatsApp conversation engine that gets out of your way. You define the flow. TurnStack drives it.


Table of Contents

  1. What TurnStack Is (and Isn't)
  2. Core Concepts
  3. Quick Start
  4. The Flow Tree
  5. Building Blocks — Node Reference
  6. Field Types (inside Input)
  7. Node → WhatsApp Widget Mapping
  8. The Engine
  9. Session & State
  10. Session Stores
  11. Navigation — Built-in Commands
  12. Sending Replies — Adapter Pattern
  13. Wiring to a Webhook
  14. Validation & Transformation
  15. Dynamic Content
  16. Conditional Fields — BranchField
  17. Pagination — Automatic Behaviour
  18. Custom Node Handlers
  19. Error Handling
  20. Debug Utilities
  21. Complete Example — Customer Support Bot

1. What TurnStack Is (and Isn't)

TurnStack is a conversation-flow engine. You give it a tree of nodes. It receives normalised WhatsApp messages, drives the user through the tree, manages all session state, and hands you back structured Reply objects ready to send.

What TurnStack handles for you:

  • Session lifecycle (create, persist, expire, reset)
  • Navigation state machine (current node, history stack, back/home/exit)
  • Multi-step form collection with per-field validation and transformation
  • Menu and list pagination (automatic, configurable)
  • Interactive vs plain-text rendering hints via reply.node_type
  • Unsupported message types (stickers, audio, reactions) — polite reply, no state change
  • Media file delivery followed by the next node — both sent automatically
  • Global navigation commands (back, home, exit) intercepted before dispatch

What TurnStack does NOT do:

  • Send messages — that's your adapter (REST, pywa, Twilio, or anything else)
  • Store sessions to a database — plug in your own SessionStore
  • Parse raw WhatsApp webhook payloads — your webhook handler does that (it's a one-time ~50-line setup, and we give you the exact boilerplate below)
  • Lock you into any web framework — FastAPI, Flask, Django, Lambda, raw asyncio — all fine

2. Core Concepts

Raw WA payload
      │
      ▼
  Your webhook  ──► builds IncomingMessage
      │
      ▼
  engine.process(incoming)
      │
      ▼
  List[Reply]   ──► your send adapter dispatches each reply to WA API

FlowTree — a dictionary of named nodes you build once at startup.

Node — a single step in the conversation. Each node has a type (menu, input, action, etc.) and a next key pointing to the next node.

Session — per-user state the engine manages. Contains current_node, collected form data, navigation history, and arbitrary context your code can read/write.

IncomingMessage — a normalised message object you build from the raw WA payload and pass to the engine.

Reply — a structured response object the engine returns. You read reply.node_type and reply.options to decide how to send it (interactive list, buttons, plain text, document, CTA, carousel, etc.).


3. Quick Start

pip install fastapi uvicorn httpx python-dotenv turnstack
from turnstack import BotEngine, FlowTree, IncomingMessage
from turnstack.nodes import Menu, Input, Action, Option, Field

# 1. Build the tree
tree = FlowTree(entry="welcome")

tree.add("welcome", Menu(
    text="👋 Welcome! What would you like to do?",
    options=[
        Option("📝 Book appointment", next="book_form"),
        Option("ℹ️ About us",         next="about"),
    ],
))

tree.add("book_form", Input(
    title="Booking",
    fields=[
        Field("name", "What is your full name?"),
        Field("date", "What date works for you? (YYYY-MM-DD)"),
    ],
    next="confirm_booking",
))

tree.add("confirm_booking", Action(
    fn=lambda session, collected: f"✅ Booking confirmed for {collected['name']} on {collected['date']}!",
    next="welcome",
))

tree.add("about", Action(
    fn=lambda s, c: "We are an example company. Reply anything to go back.",
    next="welcome",
))

# 2. Create the engine
engine = BotEngine(tree=tree)

# 3. In your webhook, normalise the payload and call process()
async def handle_message(user_id: str, text: str):
    incoming = IncomingMessage(user_id=user_id, type="text", text=text)
    replies  = await engine.process(incoming)
    for reply in replies:
        print(reply.body)   # send this via your WhatsApp adapter

4. The Flow Tree

from turnstack import FlowTree

tree = FlowTree(entry="welcome")
tree.add("welcome",  Menu(...))
tree.add("register", Input(...))
tree.add("done",     Action(...))

FlowTree(entry="<node_key>") — the entry key is where all new sessions start.

tree.add(key, node) — register a node. The key is a plain string; any node type is valid.

tree.validate() — called automatically when BotEngine starts. Raises if any next reference points to a missing node, or if no entry node is defined.

Special destination key: "__end__"

Use next="__end__" on any node to cleanly terminate the session. The engine sends the final message and the session is marked closed. The next message from the user starts a fresh session from the entry node.

tree.add("goodbye", Action(
    fn=lambda s, c: "👋 Thanks for using our service. Goodbye!",
    next="__end__",
))

5. Building Blocks — Node Reference

5.1 Menu

Presents the user with a list of options. Renders as a WhatsApp interactive list message (with automatic pagination when options exceed the display limit).

from turnstack.nodes import Menu, Option

tree.add("main_menu", Menu(
    text="What would you like to do?",
    options=[
        Option("🛒 Place order",   next="order_flow",  description="Create a new order"),
        Option("📦 Track order",   next="track_flow",  description="Check delivery status"),
        Option("🆘 Support",       next="support_flow"),
        Option("❌ Cancel order",  next="cancel_flow"),
    ],
    button_label="Main Menu",     # label on the interactive list button
    header="MyCo Services",       # optional header
    footer="Reply 00 for home",   # optional footer
    allow_numeric=True,           # also accept "1", "2", "3"…
))

Option fields:

Field Type Description
label str Displayed text (keep under 24 chars for WA interactive rows)
next str Node key to navigate to when selected
value str ID sent back when selected. Defaults to next if not set.
description str Optional subtitle in list-style menus (max 72 chars)

When the user selects an option, the engine navigates to the next node. No code required.


5.2 Input

A multi-step form. Walks through a list of fields one at a time, validating each response before moving on. After all fields are collected, advances to next.

from turnstack.nodes import Input, Field, MenuField, ButtonsField

tree.add("support_ticket", Input(
    title="Support Ticket",    # shown as "Support Ticket — Step 1 of 3"
    fields=[
        Field("summary",    "Briefly describe your issue:"),
        MenuField("priority", "How urgent is this?", options=[
            Option("🔴 Critical", value="critical"),
            Option("🟡 Medium",   value="medium"),
            Option("🟢 Low",      value="low"),
        ]),
        Field("contact_email", "What email should we reach you at?"),
    ],
    next="ticket_confirm",
))
Argument Type Description
fields List[Field | ...] Ordered list of field objects (any mix of types)
next str Node to go to after all fields are collected
title str Optional flow title shown on each step
The user can send back at any point to re-answer the previous field, or 0 to step back field by field within the same Input node.

5.3 Confirm

Presents a summary and asks the user to confirm before you commit a side effect. Renders as WhatsApp interactive reply buttons (max 3 options).

from turnstack.nodes import Confirm, Option

tree.add("ticket_confirm", Confirm(
    text=lambda collected: (
        f"Please confirm your ticket:\n\n"
        f"Issue: {collected['summary']}\n"
        f"Priority: {collected['priority']}\n"
        f"Email: {collected['contact_email']}"
    ),
    options=[
        Option("✅ Submit",   next="ticket_action"),
        Option("✏️ Edit",     next="support_ticket"),
        Option("❌ Cancel",   next="main_menu"),
    ],
))

text can be a plain string or a callable (collected: dict) -> str. The callable receives session.collected so you can summarise what the user entered.


5.4 Action

Runs your Python function, sends the return value as a text message, then navigates to next.

from turnstack.nodes import Action

def save_ticket(session, collected):
    ticket_id = db.create_ticket(
        user_id  = session.user_id,
        summary  = collected["summary"],
        priority = collected["priority"],
        email    = collected["contact_email"],
    )
    return f"✅ Ticket #{ticket_id} created. We'll reply to {collected['contact_email']}."

tree.add("ticket_action", Action(
    fn=save_ticket,
    next="main_menu",
))

fn signature: (session: Session, collected: dict) -> str

The string you return becomes the message body. Return None or "" to send no text (useful when you only want a side effect before navigating to the next node).

fn can also be an async coroutine:

async def async_action(session, collected):
    result = await external_api.call(collected["query"])
    return f"Result: {result}"

5.5 Router

Silently branches to a different node based on session state — no user input, no visible message. Use it as the entry point or at any junction where you need conditional routing.

from turnstack.nodes import Router, Route

tree = FlowTree(entry="entry_router")

tree.add("entry_router", Router(
    before=load_user_profile,       # optional hook run before route conditions
    routes=[
        Route(when=lambda s: not s.context.get("user"),               next="onboarding"),
        Route(when=lambda s: s.context["user"]["role"] == "admin",    next="admin_menu"),
    ],
    default="main_menu",            # fallback when no route matches
))

def load_user_profile(session):
    """before hook — populate session.context before route conditions run."""
    row = db.get_user(session.user_id)
    if row:
        session.context["user"] = dict(row)

before is called once before any when condition is evaluated. Use it to load data from your database into session.context so route conditions stay clean and declarative.

Route.when receives the full session object and must return bool. Routes are evaluated in order; the first True wins.


5.6 ListNode

Renders a dynamic list fetched at runtime with built-in pagination and optional interactive selection.

from turnstack.nodes import ListNode, Option

tree.add("product_list", ListNode(
    fetch            = fetch_products,
    item_label       = lambda p: f"{p['name']} — Ksh {p['price']:,}",
    item_description = lambda p: p.get("category", ""),
    on_select        = "product_detail",
    title            = "🛒 Our Products",
    empty_text       = "No products available right now.",
    interactive      = True,
    button_label     = "Browse",
    page_size        = 8,
    extra_options    = [
        Option("🔙 Back to menu", next="main_menu"),
    ],
))

def fetch_products(session):
    """Simple fetch — returns a flat list."""
    return db.get_all_products()

Paginated fetch (when you have thousands of records):

def fetch_products(session, page: int, page_size: int):
    """Paginated fetch — return (items_on_this_page, total_count)."""
    rows  = db.get_products(offset=page * page_size, limit=page_size)
    total = db.count_products()
    return rows, total

The engine detects which signature you use (3 params = paginated) and calls accordingly. Prev/Next navigation is added automatically.

When the user selects an item, the selected item is stored in session.context["selected_item"] and the engine navigates to on_select.

Argument Type Default Description
fetch Callable required Simple or paginated fetch function
item_label Callable[[item], str] required Display label for each item
on_select str required Node to go to on selection
title str "Select an option" Heading above the list
empty_text str "No items available." Shown when fetch returns empty
item_description Callable[[item], str] None Optional subtitle per item
extra_options List[Option] [] Static options appended on last page
interactive bool False Render as interactive list
button_label str "Options" Interactive list button label
page_size int 8 Items per page (1–10)

5.7 MediaReply

Generates a file (PDF, Excel, image, etc.), uploads it to WhatsApp, and sends it to the user. The engine then automatically navigates to next and sends the following node's reply. Your adapter receives two Reply objects — the file and the follow-up — just loop and send both.

from turnstack.nodes import MediaReply
import io, openpyxl

def build_report(session, collected) -> bytes:
    wb = openpyxl.Workbook()
    ws = wb.active
    ws.append(["Name", "Town", "Units"])
    for row in db.get_properties(session.user_id):
        ws.append([row["name"], row["town"], row["units"]])
    buf = io.BytesIO()
    wb.save(buf)
    return buf.getvalue()

tree.add("export_report", MediaReply(
    generate  = build_report,
    filename  = lambda s, c: f"report_{s.user_id}.xlsx",
    mime_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
    caption   = "📊 Here is your property report.",
    next      = "main_menu",
))

generate can be sync or async. filename and caption can be plain strings or callables (session, collected) -> str.

The send adapter handles media in two steps: upload to /{PHONE_ID}/media, then send using the returned media_id. The boilerplate below handles this for you.


5.8 CtaUrl

Sends a WhatsApp interactive CTA URL card — a tappable button that opens a URL in the user's browser. No message is sent back to your webhook when the user taps; the engine automatically advances to next after sending the card.

from turnstack.nodes import CtaUrl

# Static CTA
tree.add("view_pricing", CtaUrl(
    body   = "Check out our latest pricing plans.",
    url    = "https://example.com/pricing",
    button = "View Pricing",
    header = "Our Plans",               # plain text header
    footer = "Opens in your browser",
    next   = "main_menu",
))

Dynamic CTA (body, url, and button can be callables (session, collected) -> str):

tree.add("my_portal", CtaUrl(
    body   = lambda s, c: f"Hi {s.context['user']['first_name']}! Your portal is ready.",
    url    = lambda s, c: f"https://example.com/portal/{s.user_id}",
    button = lambda s, c: "Open My Portal",
    header = "Your Dashboard",
    footer = "Secure · Personalised",
    next   = "main_menu",
))

Rich headers (image, video, or document — pass a dict):

CtaUrl(
    body   = "Step 1: Review our pricing.",
    url    = "https://example.com/pricing",
    button = "View Pricing",
    header = {"type": "image",    "url": "https://example.com/banner.jpg"},
    # or:  {"type": "video",    "url": "https://example.com/intro.mp4"}
    # or:  {"type": "document", "url": "https://example.com/guide.pdf", "filename": "guide.pdf"}
    next   = "next_step",
)

Chaining multiple CTA cards (set next to another CtaUrl node):

tree.add("step_1", CtaUrl(body="Step 1 ...", url="...", button="Open", next="step_2"))
tree.add("step_2", CtaUrl(body="Step 2 ...", url="...", button="Open", next="done"))
tree.add("done",   Action(fn=lambda s, c: "All done!", next="main_menu"))
Argument Type Description
body `str Callable`
url `str Callable`
button `str Callable`
header `str dict
footer `str None`
next str Node to advance to after sending

5.9 Carousel

Sends a WhatsApp carousel message — a horizontally scrollable set of cards, each with a header image, body text, and quick-reply buttons. When the user taps a card button, the engine advances to that button's next node and stores the tapped button's ID in session.collected[store_as].

from turnstack.nodes import Carousel, Card, CarouselButton

tree.add("product_showcase", Carousel(
    body  = "🛒 Browse our top picks — tap a card to select:",
    cards = [
        Card(
            header_type = "image",
            header_url  = "https://example.com/product-a.jpg",
            body        = "Product A — Ksh 1,200",
            buttons     = [CarouselButton(id="prod_a", title="🛍️ Select", next="product_detail")],
        ),
        Card(
            header_type = "image",
            header_url  = "https://example.com/product-b.jpg",
            body        = "Product B — Ksh 850",
            buttons     = [CarouselButton(id="prod_b", title="🛍️ Select", next="product_detail")],
        ),
        Card(
            header_type = "image",
            header_url  = "https://example.com/product-c.jpg",
            body        = "Product C — Ksh 2,000",
            buttons     = [CarouselButton(id="prod_c", title="🛍️ Select", next="product_detail")],
        ),
    ],
    store_as = "selected_product",   # key in session.collected
))

tree.add("product_detail", Action(
    fn   = lambda s, c: f"You selected: {c.get('selected_product')}. We'll process your order!",
    next = "main_menu",
))
Argument Type Description
body str Intro text above the carousel
cards List[Card] 2–10 card objects
store_as str Key in session.collected where the tapped button ID is stored

Card fields:

Field Type Description
header_type "image" Currently only image headers are supported by WA carousels
header_url str Publicly accessible image URL
body str | None Card body text (max 1024 chars)
buttons List[CarouselButton] 1–2 quick-reply buttons per card

CarouselButton fields:

Field Type Description
id str Value stored in session.collected[store_as] when tapped
title str Button label (max 20 chars)
next str Node to navigate to when tapped

5.10 ContactReply

Sends a WhatsApp contact card (or multiple contacts) to the user, optionally with a caption text sent first. Use this to share support numbers, agent details, or any contact information.

from turnstack.nodes import ContactReply

tree.add("share_support_contact", ContactReply(
    contacts = [
        {
            "name":   {"formatted_name": "Acme Support", "first_name": "Acme"},
            "phones": [{"phone": "+254 700 000000", "type": "MOBILE", "wa_id": "254700000000"}],
        }
    ],
    caption = "📞 Here's our support number. Save it and reach out anytime!",
    next    = "main_menu",
))

The caption is sent as a separate text message before the contact card (WhatsApp contact messages do not support a body field natively). The contacts list must use the WhatsApp Cloud API contact shape. To share a contact a user sent you, the engine stores it in parsed form — the send adapter reconstructs the required WA shape automatically.

Argument Type Description
contacts List[dict] One or more contacts in WA API shape
caption str | None Text sent before the card(s)
next str Node to navigate to after sending

6. Field Types (inside Input)

6.1 Field / TextField

Plain text input. Accepts any text message from the user.

Field("full_name", "What is your full name?")
TextField("full_name", "What is your full name?")  # identical alias

With validation and transformation:

Field(
    "age",
    "How old are you?",
    validate  = lambda v: "Must be a number." if not v.isdigit() else None,
    transform = int,
)

6.2 MenuField

Interactive list selection inside a form. The user picks one option; the value is stored in session.collected.

from turnstack.nodes import MenuField, Option

MenuField(
    "town",
    "Which town are you in?",
    options = [
        Option("Nairobi",  value="nairobi"),
        Option("Mombasa",  value="mombasa"),
        Option("Kisumu",   value="kisumu"),
    ],
    button_label    = "Select Town",
    rejection_text  = "Please select a town from the list.",
)

options can be a static list or a callable (session) -> List[Option] for dynamic menus.


6.3 ButtonsField

Interactive reply buttons inside a form. Max 3 options (WhatsApp limit). The user taps a button; the value is stored in session.collected.

from turnstack.nodes import ButtonsField, Option

ButtonsField(
    "tier",
    "Which plan are you on?",
    options = [
        Option("Standard", value="standard"),
        Option("Premium",  value="premium"),
    ],
)

6.4 ImageField

Prompts the user to send an image. The collected value is a dict:

{
    "media_id":  "...",    # WhatsApp media ID (use to download via Media API)
    "mime_type": "image/jpeg",
}
from turnstack.nodes import ImageField

ImageField(
    "photo",
    "Please send a photo of your property 📷",
    rejection_text = "Please send an image (JPG or PNG).",
)

6.5 DocumentField

Prompts the user to send a document. The collected value is a dict:

{
    "media_id":  "...",
    "mime_type": "application/pdf",
    "filename":  "document.pdf",
}
from turnstack.nodes import DocumentField

DocumentField(
    "id_doc",
    "Please send a copy of your ID (PDF or image).",
    rejection_text = "Please send a document or image file.",
)

6.6 LocationField

Sends a WhatsApp location request (a native UI button prompting the user to share their location). The collected value is a dict:

{
    "latitude":  -1.286389,
    "longitude": 36.817223,
    "name":      "Nairobi CBD",    # may be None
    "address":   "Kenyatta Ave",   # may be None
}
from turnstack.nodes import LocationField

LocationField(
    "pickup_location",
    "Please share your pickup location 📍",
    rejection_text = "⚠️ Please use the 📍 button to share your location.",
)

6.7 BranchField

Conditionally injects a group of fields into the form based on earlier answers. The step counter updates dynamically — the user only sees steps relevant to their path.

Input(
    title="Loan Application",
    fields=[
        ButtonsField("employment_type", "Are you employed or self-employed?", options=[
            Option("Employed",      value="employed"),
            Option("Self-employed", value="self_employed"),
        ]),

        # Only shown for employed applicants
        BranchField(
            when=lambda s: s.collected.get("employment_type") == "employed",
            fields=[
                Field("employer_name",  "Who is your employer?"),
                Field("monthly_salary", "What is your monthly salary (KES)?",
                      validate=lambda v: None if v.isdigit() else "Enter a number."),
            ],
        ),

        # Only shown for self-employed applicants
        BranchField(
            when=lambda s: s.collected.get("employment_type") == "self_employed",
            fields=[
                Field("business_name",    "What is your business name?"),
                Field("monthly_revenue",  "What is your average monthly revenue (KES)?"),
            ],
        ),

        Field("loan_amount", "How much would you like to borrow (KES)?"),
    ],
    next="loan_confirm",
)

BranchField is not itself a field — it has no name. It's a conditional wrapper that flattens transparently at runtime. Branches can be nested.

A field's skip_if argument is an alternative for single-field conditional skipping:

Field(
    "company_name",
    "What is your company name?",
    skip_if=lambda s: s.collected.get("employment_type") == "self_employed",
)

7. Node → WhatsApp Widget Mapping

Use this table to understand exactly what WhatsApp message type each TurnStack node and reply.node_type value maps to, so you can wire your send adapter correctly.

TurnStack Node / reply.node_type WhatsApp Message Type Notes
Menu / "menu" Interactive list message Rows built from reply.options; button label from reply.meta["button_label"]
ListNode / "menu" with reply.meta["sections"] Interactive list with sections Pre-built sections in reply.meta["sections"]; use these directly
Confirm / "confirm" Interactive reply buttons Max 3 buttons; built from reply.options
Input + TextField / "input" Plain text message Simple question prompt
Input + MenuField / "input_menu" Interactive list message Same as Menu; built from reply.options
Input + ButtonsField / "input_buttons" Interactive reply buttons Max 3 buttons; built from reply.options
Input + ImageField / "input_image" Plain text message WA has no native image-request widget; send a plain prompt
Input + DocumentField / "input_document" Plain text message WA has no native document-request widget; send a plain prompt
Input + LocationField / "input_location" Interactive location request type: "location_request_message" with action.name = "send_location"
MediaReply / reply.type == "media" Document or Image send Upload file first via Media API; send using returned media_id
CtaUrl / "cta_url" Interactive CTA URL button interactive.type = "cta_url"; header can be text, image, video, or document
Carousel / "carousel" Interactive carousel Cards with image headers and quick-reply buttons
ContactReply / reply.type == "contact" Contacts message Caption sent first as plain text, then type: "contacts"
Action / "text" Plain text message Return value of fn
Router (no message) Silent branching — no WA message sent
Error / reply.type == "error" Plain text message Log the error; optionally send reply.body to the user
Session end / reply.type == "end" Plain text message Goodbye message before session closes

8. The Engine

8.1 Instantiation

from turnstack import BotEngine, FlowTree
from turnstack.stores.memory import InMemorySessionStore

engine = BotEngine(
    tree             = tree,
    session_store    = InMemorySessionStore(),  # default
    session_timeout  = 3600,                     # seconds of inactivity before expiry
    back_keywords    = {"0", "back", "go back"},
    home_keywords    = {"00", "home", "menu", "start over"},
    exit_keywords    = {"000", "exit", "quit", "reset", "goodbye", "bye"},
    unsupported_text = "⚠️ Sorry, I can't process that message. Please try again.",
)

All parameters except tree are optional. The engine validates the tree on startup and raises immediately if any node reference is broken.


8.2 process()

replies: List[Reply] = await engine.process(incoming)

The single public method you call for every inbound message. Always returns a List[Reply].

In the common case the list contains one item. When a MediaReply or ContactReply node fires, the list may contain two items — the file/contact reply and the follow-up node — sent in order. Just loop:

for reply in replies:
    await send_whatsapp(user_id, phone, reply)

The engine handles everything internally: session load/create/expire, global command interception, node dispatch, state transition, and session save. You never touch the session store or call internal engine methods directly.


8.3 IncomingMessage

Build this from the raw WhatsApp webhook payload and pass it to process().

from turnstack import IncomingMessage

# Text message
IncomingMessage(user_id="2547XXXXXXXX", type="text", text="Hello", raw=raw_payload)

# Interactive selection (button or list reply)
IncomingMessage(user_id="2547XXXXXXXX", type="interactive", interactive_id="option_value")

# Image
IncomingMessage(user_id="2547XXXXXXXX", type="image",
                media_id=msg["image"]["id"], media_mime=msg["image"].get("mime_type"))

# Document
IncomingMessage(user_id="2547XXXXXXXX", type="document",
                media_id=msg["document"]["id"],
                media_mime=msg["document"].get("mime_type"),
                media_name=msg["document"].get("filename"))

# Location
IncomingMessage(user_id="2547XXXXXXXX", type="location",
                location={"latitude": loc["latitude"], "longitude": loc["longitude"],
                          "name": loc.get("name"), "address": loc.get("address")})

# Contacts (shared by user)
IncomingMessage(user_id="2547XXXXXXXX", type="contacts", contacts=parsed_contacts)

# Unsupported type (sticker, audio, reaction…) — engine replies politely, holds state
IncomingMessage(user_id="2547XXXXXXXX", type="sticker")
Field Type Description
user_id str Unique user identifier (WA phone number or user ID)
type str "text", "interactive", "image", "document", "location", "contacts", or any other
text str | None Text body (type="text")
interactive_id str | None Selected option ID (type="interactive")
media_id str | None WhatsApp media ID (type="image" or type="document")
media_mime str | None MIME type of the media
media_name str | None Original filename (documents)
location dict | None Location dict with latitude/longitude/name/address
contacts list | None List of parsed contact dicts (type="contacts")
raw Any Original raw payload — stored for your reference; engine ignores it

8.4 Reply

The object returned by process(). Read its fields to decide how to send the message.

@dataclass
class Reply:
    type:              Literal["text", "media", "contact", "end", "error"]
    body:              str                 # message text / caption for media
    phone:             str                 # recipient (same as user_id by default)

    # media
    file_bytes:        Optional[bytes]
    filename:          Optional[str]
    mime_type:         Optional[str]

    # interactive hints
    options:           List[ReplyOption]   # populated for menu / confirm / buttons nodes
    node_type:         Optional[str]       # see table in §7 for all values
    suggested_replies: List[str]           # option labels for quick-reply chips

    # meta — extra hints specific to the node type
    meta:              Dict[str, Any]

    # navigation
    current_node:      Optional[str]
    session_state:     Optional[str]       # "new" | "active" | "expired"

ReplyOption:

@dataclass
class ReplyOption:
    label:       str    # display text
    value:       str    # the id to send back when selected
    description: str    # optional subtitle (list menus)

Notable meta keys by node type:

node_type meta keys available
"menu" / "input_menu" button_label, sections (ListNode pre-built sections)
"cta_url" url, button_label, header, footer
"carousel" cards (list of card dicts ready for WA API)
"contact" contacts (list of WA-shaped contact dicts)

9. Session & State

9.1 Session object

The engine manages this for you. You interact with it inside fn, when, before, fetch, validate, transform, and dynamic text callables.

session.user_id          # str  — the user's identifier
session.current_node     # str  — which node the user is currently on
session.collected        # dict — all form values collected so far
session.context          # dict — your arbitrary data (not cleared between nodes)
session.nav_stack        # list — navigation history (for back/go home)
session.lifecycle_state  # "new" | "active" | "expired"

9.2 session.collected

Form data collected by Input nodes. Keys are the name values of your fields.

def confirm_order(session, collected):
    return (
        f"Order summary:\n"
        f"Item:     {collected['item_name']}\n"
        f"Quantity: {collected['quantity']}\n"
        f"Address:  {collected['delivery_address']['address']}"
    )

collected is cleared when an Input node is entered fresh (not on back-navigation within it). Data from previous Input nodes persists until explicitly cleared or the session expires.


9.3 session.context

A free-form dict for your own data. The engine does not read or write it except:

  • ListNode writes context["selected_item"] on item selection.
  • Carousel writes collected[store_as] (not context) when a card button is tapped.

Persists for the lifetime of the session.

# In a Router before hook
def load_user(session):
    session.context["user"] = db.get_user(session.user_id)

# In a Menu text callable
Menu(text=lambda s: f"Hello {s.context['user']['first_name']}! What can I do?", ...)

# In an Action
def process_order(session, collected):
    user = session.context["user"]
    ...

9.4 session.pagination

Stores page indices for menu and list pagination. Managed entirely by the engine — do not write to this directly. Readable for debugging.


10. Session Stores

10.1 InMemorySessionStore

The default. Fast, zero-config, but sessions are lost on server restart. Good for development.

from turnstack.stores.memory import InMemorySessionStore

engine = BotEngine(tree=tree, session_store=InMemorySessionStore(session_timeout=3600))

10.2 Custom Stores

Implement the SessionStore interface to persist sessions to Redis, a database, or anywhere:

from turnstack.session import SessionStore, Session
import json

class RedisSessionStore(SessionStore):

    def __init__(self, redis_client, timeout: int = 3600):
        self.redis   = redis_client
        self.timeout = timeout

    async def get(self, user_id: str) -> Session | None:
        data = await self.redis.get(f"session:{user_id}")
        if not data:
            return None
        return Session.from_dict(json.loads(data))

    async def save(self, session: Session) -> None:
        await self.redis.setex(
            f"session:{session.user_id}",
            self.timeout,
            json.dumps(session.to_dict()),
        )

    async def delete(self, user_id: str) -> None:
        await self.redis.delete(f"session:{user_id}")

engine = BotEngine(tree=tree, session_store=RedisSessionStore(redis, timeout=3600))

11. Navigation — Built-in Commands

The engine intercepts these plain-text messages before dispatching to any node handler. They work anywhere in the flow without any node configuration.

Keyword(s) Action
0, back, go back Step back — previous field inside an Input, or previous node
00, home, menu, start over Jump to the entry node, clearing the navigation stack
000, exit, quit, reset, goodbye, bye End the session; next message starts a fresh one

All keyword sets are configurable on BotEngine:

engine = BotEngine(
    tree          = tree,
    back_keywords = {"b", "back"},
    home_keywords = {"h", "home"},
    exit_keywords = {"x", "exit"},
)

Back within an Input node is field-aware: pressing back steps to the previous field (clearing its collected value) rather than leaving the Input node entirely. Once at field 0, pressing back leaves the node and goes to the previous node in the stack.


12. Sending Replies — Adapter Pattern

TurnStack is send-agnostic. You read reply.node_type (and reply.type for media/contact/error) to decide how to format the outgoing WA message, then send it however you like.

The pattern is always:

replies = await engine.process(incoming)
for reply in replies:
    await send_whatsapp(user_id, phone, reply)

12.1 The boilerplate send helper

Copy this into your app and adjust as needed. It covers every node type TurnStack can produce, using the WhatsApp Cloud API directly via httpx.

import os
import httpx

WA_TOKEN    = os.getenv("WA_TOKEN", "")
WA_PHONE_ID = os.getenv("WA_PHONE_ID", "")

async def send_whatsapp(user_id: str, phone: str, reply) -> None:
    headers = {"Authorization": f"Bearer {WA_TOKEN}", "Content-Type": "application/json"}
    url     = f"https://graph.facebook.com/v25.0/{WA_PHONE_ID}/messages"

    # ── error / end ───────────────────────────────────────────────────
    if reply.type in ("error", "end"):
        body = reply.body or ("⚠️ Something went wrong." if reply.type == "error" else "👋 Goodbye!")
        payload = {
            "messaging_product": "whatsapp", "recipient_type": "individual",
            "to": phone, "type": "text", "text": {"body": body},
        }
        async with httpx.AsyncClient() as client:
            await client.post(url, json=payload, headers=headers)
        return

    # ── media file (MediaReply) ───────────────────────────────────────
    if reply.type == "media" and reply.file_bytes:
        upload_url = f"https://graph.facebook.com/v25.0/{WA_PHONE_ID}/media"
        async with httpx.AsyncClient() as client:
            upload_resp = await client.post(
                upload_url,
                headers={"Authorization": f"Bearer {WA_TOKEN}"},
                files={"file": (reply.filename, reply.file_bytes, reply.mime_type)},
                data={"messaging_product": "whatsapp"},
            )
        if upload_resp.status_code != 200:
            print(f"❌ Media upload failed: {upload_resp.text}")
            return
        media_id = upload_resp.json().get("id")
        mime = (reply.mime_type or "").lower()
        if mime.startswith("image/"):
            wa_type = "image"
            media_body: dict = {"id": media_id}
            if reply.body:
                media_body["caption"] = reply.body[:1024]
        else:
            wa_type = "document"
            media_body = {"id": media_id, "filename": reply.filename}
            if reply.body:
                media_body["caption"] = reply.body[:1024]
        payload = {
            "messaging_product": "whatsapp", "recipient_type": "individual",
            "to": phone, "type": wa_type, wa_type: media_body,
        }
        async with httpx.AsyncClient() as client:
            await client.post(url, json=payload, headers=headers)
        return

    # ── CTA URL button (CtaUrl) ───────────────────────────────────────
    if reply.node_type == "cta_url":
        meta         = reply.meta or {}
        button_label = meta.get("button_label", "Open")[:20]
        link_url     = meta.get("url", "")
        footer_text  = meta.get("footer", "")
        interactive: dict = {
            "type": "cta_url",
            "body": {"text": reply.body[:1024] or " "},
            "action": {
                "name": "cta_url",
                "parameters": {"display_text": button_label, "url": link_url},
            },
        }
        header = meta.get("header", "")
        if isinstance(header, dict):
            h_type = header.get("type", "image")
            if h_type == "text" and header.get("text"):
                interactive["header"] = {"type": "text", "text": str(header["text"])[:60]}
            elif h_type in ("image", "video"):
                interactive["header"] = {"type": h_type, h_type: {"link": header.get("url", "")}}
            elif h_type == "document":
                doc: dict = {"link": header.get("url", "")}
                if header.get("filename"):
                    doc["filename"] = header["filename"]
                interactive["header"] = {"type": "document", "document": doc}
        elif isinstance(header, str) and header:
            interactive["header"] = {"type": "text", "text": header[:60]}
        if footer_text:
            interactive["footer"] = {"text": footer_text[:60]}
        payload = {
            "messaging_product": "whatsapp", "recipient_type": "individual",
            "to": phone, "type": "interactive", "interactive": interactive,
        }

    # ── carousel (Carousel) ───────────────────────────────────────────
    elif reply.node_type == "carousel":
        cards_data = (reply.meta or {}).get("cards", [])
        wa_cards = []
        for idx, card in enumerate(cards_data):
            card_obj: dict = {
                "card_index": idx,
                "type": "cta_url",
                "header": {
                    "type": card["header_type"],
                    card["header_type"]: {"link": card["header_url"]},
                },
                "action": {
                    "buttons": [
                        {"type": "quick_reply", "quick_reply": {"id": btn["id"], "title": btn["title"][:20]}}
                        for btn in card["buttons"]
                    ]
                },
            }
            if card.get("body"):
                card_obj["body"] = {"text": card["body"][:1024]}
            wa_cards.append(card_obj)
        payload = {
            "messaging_product": "whatsapp", "recipient_type": "individual",
            "to": phone, "type": "interactive",
            "interactive": {
                "type": "carousel",
                "body": {"text": reply.body[:1024]},
                "action": {"cards": wa_cards},
            },
        }

    # ── interactive list with named sections (ListNode / MenuField) ───
    elif reply.node_type in ("menu", "input_menu") and reply.meta and "sections" in reply.meta:
        payload = {
            "messaging_product": "whatsapp", "recipient_type": "individual",
            "to": phone, "type": "interactive",
            "interactive": {
                "type": "list",
                "body": {"text": reply.body[:1024] or "Select an option"},
                "action": {
                    "button": reply.meta.get("button_label", "Options"),
                    "sections": reply.meta["sections"],
                },
            },
        }

    # ── interactive list — flat (Menu / MenuField without sections) ───
    elif reply.node_type in ("menu", "input_menu") and reply.options and len(reply.options) >= 2:
        rows = [
            {"id": opt.value[:200], "title": opt.label[:24], "description": (opt.description or "")[:72]}
            for opt in reply.options
        ]
        payload = {
            "messaging_product": "whatsapp", "recipient_type": "individual",
            "to": phone, "type": "interactive",
            "interactive": {
                "type": "list",
                "body": {"text": reply.body[:1024] or "Choose an option"},
                "action": {
                    "button": (reply.meta.get("button_label", "Options") if reply.meta else "Options"),
                    "sections": [{"title": " ", "rows": rows}],
                },
            },
        }

    # ── interactive reply buttons (Confirm / ButtonsField) ───────────
    elif reply.node_type in ("confirm", "input_buttons") and reply.options:
        buttons = [
            {"type": "reply", "reply": {"id": opt.value[:256], "title": opt.label[:20]}}
            for opt in reply.options[:3]
        ]
        payload = {
            "messaging_product": "whatsapp", "recipient_type": "individual",
            "to": phone, "type": "interactive",
            "interactive": {
                "type": "button",
                "body": {"text": reply.body[:1024] or "Choose:"},
                "action": {"buttons": buttons},
            },
        }

    # ── location request (LocationField) ─────────────────────────────
    elif reply.node_type == "input_location":
        payload = {
            "messaging_product": "whatsapp", "recipient_type": "individual",
            "to": phone, "type": "interactive",
            "interactive": {
                "type": "location_request_message",
                "body": {"text": reply.body[:1024] or "Please share your location."},
                "action": {"name": "send_location"},
            },
        }

    # ── contact card (ContactReply) ───────────────────────────────────
    elif reply.type == "contact":
        wa_contacts = (reply.meta or {}).get("contacts", [])

        def _to_wa_contact(c: dict) -> dict:
            if "name" in c:
                return c  # already in WA API shape
            wa: dict = {"name": {"formatted_name": c.get("formatted_name", "Contact")}}
            for k in ("first_name", "last_name", "middle_name"):
                if c.get(k):
                    wa["name"][k] = c[k]
            if c.get("phones"):
                wa["phones"] = c["phones"]
            for extra in ("emails", "org", "addresses", "urls"):
                if c.get(extra):
                    wa[extra] = c[extra]
            return wa

        # Send caption text first if present
        if reply.body:
            caption_payload = {
                "messaging_product": "whatsapp", "recipient_type": "individual",
                "to": phone, "type": "text", "text": {"body": reply.body},
            }
            async with httpx.AsyncClient() as client:
                await client.post(url, json=caption_payload, headers=headers)

        payload = {
            "messaging_product": "whatsapp", "recipient_type": "individual",
            "to": phone, "type": "contacts",
            "contacts": [_to_wa_contact(c) for c in wa_contacts],
        }

    # ── plain text (Action / TextField / error fallback) ─────────────
    else:
        payload = {
            "messaging_product": "whatsapp", "recipient_type": "individual",
            "to": phone, "type": "text", "text": {"body": reply.body},
        }

    async with httpx.AsyncClient() as client:
        resp   = await client.post(url, json=payload, headers=headers)
        status = "✅" if resp.status_code == 200 else "❌"
        print(f"{status} WA → {user_id} [{reply.node_type}] {resp.status_code}")

You own this function. Customise payloads, swap httpx for another HTTP client, add retry logic, or route to a different send library — TurnStack has no opinion about what happens after Reply is returned.


12.2 Sending via pywa / any library

If you use pywa or another WhatsApp SDK, adapt the same reply.node_type switch to your library's API:

from pywa import WhatsApp
from pywa.types import Button, SectionList, Section, SectionRow

wa = WhatsApp(phone_id=WA_PHONE_ID, token=WA_TOKEN)

async def send(reply):
    if reply.node_type in ("menu", "input_menu"):
        rows = [SectionRow(id=o.value, title=o.label) for o in reply.options]
        await wa.send_message(
            to=reply.phone,
            text=reply.body,
            buttons=SectionList(
                button_title=reply.meta.get("button_label", "Options"),
                sections=[Section(title="Options", rows=rows)],
            ),
        )
    elif reply.node_type in ("confirm", "input_buttons"):
        btns = [Button(id=o.value, title=o.label) for o in reply.options]
        await wa.send_message(to=reply.phone, text=reply.body, buttons=btns)
    else:
        await wa.send_message(to=reply.phone, text=reply.body)

The engine's output is always the same structured Reply — the send layer is fully swappable.


13. Wiring to a Webhook

Copy this boilerplate into your app. It handles all message types WhatsApp can send — text, interactive, image, document, location, contacts, and quick-reply buttons — and feeds each into the engine.

import os
import traceback
import httpx
from fastapi import FastAPI, Request, Response, HTTPException
from dotenv import load_dotenv
from turnstack import BotEngine, IncomingMessage

load_dotenv()
WA_VERIFY_TOKEN = os.getenv("WA_VERIFY_TOKEN", "")
WA_TOKEN        = os.getenv("WA_TOKEN", "")
WA_PHONE_ID     = os.getenv("WA_PHONE_ID", "")

app = FastAPI()

# ── webhook verification ──────────────────────────────────────────────────────

@app.get("/api/v1/webhooks/whatsapp")
async def verify(request: Request):
    p = request.query_params
    if p.get("hub.mode") == "subscribe" and p.get("hub.verify_token") == WA_VERIFY_TOKEN:
        return Response(content=p.get("hub.challenge"), media_type="text/plain")
    raise HTTPException(403)


# ── webhook handler ───────────────────────────────────────────────────────────

@app.post("/api/v1/webhooks/whatsapp")
async def webhook(request: Request):
    raw = await request.json()

    # ── Step 1: parse the WA envelope ─────────────────────────────────
    try:
        value = raw["entry"][0]["changes"][0]["value"]
        if "messages" not in value:
            return {"status": "no_messages"}
        msg      = value["messages"][0]
        phone    = msg.get("from", "")
        user_id  = msg.get("from_user_id", phone)
        msg_type = msg.get("type", "")
    except Exception:
        traceback.print_exc()
        return {"status": "parse_error"}

    # ── Step 2: build IncomingMessage ──────────────────────────────────
    try:
        if msg_type == "text":
            incoming = IncomingMessage(
                user_id=user_id, type="text",
                text=msg["text"]["body"], raw=raw,
            )
        elif msg_type == "interactive":
            itype = msg["interactive"]["type"]
            iid   = (msg["interactive"]["button_reply"]["id"]
                     if itype == "button_reply"
                     else msg["interactive"]["list_reply"]["id"])
            incoming = IncomingMessage(
                user_id=user_id, type="interactive", interactive_id=iid, raw=raw,
            )
        elif msg_type == "button":
            # Quick-reply button payload (sent back from Carousel / template buttons)
            incoming = IncomingMessage(
                user_id=user_id, type="interactive",
                interactive_id=msg["button"]["payload"], raw=raw,
            )
        elif msg_type == "image":
            incoming = IncomingMessage(
                user_id=user_id, type="image",
                media_id=msg["image"]["id"],
                media_mime=msg["image"].get("mime_type"), raw=raw,
            )
        elif msg_type == "document":
            incoming = IncomingMessage(
                user_id=user_id, type="document",
                media_id=msg["document"]["id"],
                media_mime=msg["document"].get("mime_type"),
                media_name=msg["document"].get("filename"), raw=raw,
            )
        elif msg_type == "location":
            loc = msg["location"]
            incoming = IncomingMessage(
                user_id=user_id, type="location",
                location={
                    "latitude":  loc.get("latitude"),
                    "longitude": loc.get("longitude"),
                    "name":      loc.get("name"),
                    "address":   loc.get("address"),
                }, raw=raw,
            )
        elif msg_type == "contacts":
            raw_contacts = msg.get("contacts", [])
            parsed = []
            for c in raw_contacts:
                name_block = c.get("name", {})
                parsed.append({
                    "formatted_name": name_block.get("formatted_name", ""),
                    "first_name":     name_block.get("first_name", ""),
                    "last_name":      name_block.get("last_name", ""),
                    "phones":         c.get("phones", []),
                    "_raw":           c,
                })
            incoming = IncomingMessage(
                user_id=user_id, type="contacts", contacts=parsed, raw=raw,
            )
        else:
            # Sticker, audio, reaction, etc. — engine replies politely, holds state
            incoming = IncomingMessage(user_id=user_id, type=msg_type, raw=raw)
    except Exception:
        traceback.print_exc()
        return {"status": "message_parse_error"}

    # ── Step 3: process + send ─────────────────────────────────────────
    try:
        replies = await engine.process(incoming)
    except Exception:
        traceback.print_exc()
        # Engine crashed — send a plain fallback so the user isn't stuck
        try:
            async with httpx.AsyncClient() as client:
                await client.post(
                    f"https://graph.facebook.com/v25.0/{WA_PHONE_ID}/messages",
                    headers={"Authorization": f"Bearer {WA_TOKEN}"},
                    json={
                        "messaging_product": "whatsapp", "recipient_type": "individual",
                        "to": phone, "type": "text",
                        "text": {"body": "⚠️ Something went wrong. Please try again."},
                    },
                )
        except Exception:
            traceback.print_exc()
        return {"status": "engine_error"}

    for reply in replies:
        try:
            await send_whatsapp(user_id, phone, reply)
        except Exception:
            traceback.print_exc()
            # Log the failure but continue — one bad reply shouldn't block the rest

    return {"status": "ok"}

Note on Graph API version. The boilerplate uses v25.0. Meta recommends using the latest stable version your app has been tested against. Update the version string as needed.


14. Validation & Transformation

Every field type (Field, MenuField, ButtonsField, ImageField, DocumentField, LocationField) supports two optional hooks:

validate(value) -> str | None — return an error message to reject the input; return None to accept.

import re

def validate_email(v: str):
    if not re.match(r"^[^@]+@[^@]+\.[^@]+$", v):
        return "⚠️ That doesn't look like a valid email address."
    return None

def validate_positive_integer(v: str):
    if not v.isdigit() or int(v) <= 0:
        return "⚠️ Please enter a positive whole number."
    return None

Field("email",    "Your email address?",   validate=validate_email)
Field("quantity", "How many units?",       validate=validate_positive_integer)

When validation fails the engine re-asks the same question with the error message prepended. No state change occurs.

transform(value) -> Any — applied after validation passes, before storing in session.collected.

Field("units",         "How many units?",
      validate=lambda v: None if v.isdigit() else "Enter a number.",
      transform=int)   # stored as int, not string

Field("full_name",     "Your full name?",
      transform=str.strip)

Field("date_of_birth", "Date of birth (YYYY-MM-DD)?",
      validate=lambda v: None if re.match(r"\d{4}-\d{2}-\d{2}", v) else "Format: YYYY-MM-DD",
      transform=lambda v: datetime.strptime(v, "%Y-%m-%d").date())

15. Dynamic Content

Most text-bearing arguments accept a callable so you can personalise the UI at runtime.

Menu text — callable receives (session):

Menu(
    text=lambda s: f"Hi {s.context.get('user', {}).get('name', 'there')}! What can I do for you?",
    options=[...],
)

Confirm text — callable receives (collected):

Confirm(
    text=lambda c: f"Confirm order for {c['item_name']} × {c['quantity']}?",
    options=[...],
)

Dynamic options from a database:

MenuField(
    "branch",
    "Select your nearest branch:",
    options=lambda session: [
        Option(b["name"], value=str(b["id"]), description=b["address"])
        for b in db.get_branches(session.context.get("city"))
    ],
)

Dynamic CtaUrl body, url, and button:

CtaUrl(
    body   = lambda s, c: f"Hi {s.context['user']['first_name']}! Your portal is ready.",
    url    = lambda s, c: f"https://example.com/portal/{s.user_id}",
    button = lambda s, c: "Open My Portal",
    next   = "main_menu",
)

Dynamic filename and caption on MediaReply:

MediaReply(
    generate  = build_statement,
    filename  = lambda s, c: f"statement_{s.context['user']['account_no']}.pdf",
    caption   = lambda s, c: f"📄 Statement for {c['period']}",
    mime_type = "application/pdf",
    next      = "main_menu",
)

16. Conditional Fields — BranchField

See Section 6.7 for the full reference. Quick pattern:

Input(
    fields=[
        ButtonsField("type", "What are you reporting?", options=[
            Option("Bug",     value="bug"),
            Option("Feature", value="feature"),
        ]),
        BranchField(
            when=lambda s: s.collected.get("type") == "bug",
            fields=[
                Field("steps_to_reproduce", "How do you reproduce it?"),
                Field("expected_behaviour", "What did you expect to happen?"),
            ],
        ),
        BranchField(
            when=lambda s: s.collected.get("type") == "feature",
            fields=[
                Field("feature_description", "Describe the feature you'd like:"),
                Field("business_value",       "Why would this be valuable?"),
            ],
        ),
        Field("contact_email", "Your email for follow-up?"),
    ],
    next="submit_ticket",
)

The step counter shown to the user (Step N of M) reflects only the active fields for their path.


17. Pagination — Automatic Behaviour

Menu pagination kicks in automatically when a Menu or MenuField has more options than WhatsApp can show in a single interactive list. The engine:

  1. Splits options into pages (max 8 real options per page, with Prev/Next controls)
  2. Tracks the current page in session.pagination
  3. Sends the correct page on each interaction

You do nothing — just define as many options as you need.

ListNode pagination works the same way. For large datasets use the paginated fetch signature (session, page, page_size) -> (items, total) to avoid loading all records into memory.

Page size on ListNode is configurable (1–10, default 8):

ListNode(fetch=..., ..., page_size=5)

18. Custom Node Handlers

If you need a node type that doesn't exist in TurnStack, register a custom handler:

from turnstack.handlers.base import NodeHandler
from turnstack.reply import Reply
from turnstack.session import Session
from turnstack.message import IncomingMessage
from turnstack.tree import FlowTree

class PaymentPromptHandler(NodeHandler):
    async def handle(
        self,
        node:    dict,
        session: Session,
        message: IncomingMessage,
        tree:    FlowTree,
    ) -> Reply:
        ref = payment_gateway.create_link(session.user_id, node["amount"])
        session.context["payment_ref"] = ref
        self._transition_to(session, node.get("next", "main_menu"))
        return Reply(
            type         = "text",
            body         = f"Please complete payment here: {ref['url']}",
            phone        = session.user_id,
            node_type    = "text",
            current_node = session.current_node,
        )

# Register with the engine
engine.register_handler("payment_prompt", PaymentPromptHandler())

# Use in the tree
tree.add("pay_now", {"type": "payment_prompt", "amount": 500, "next": "payment_confirm"})

19. Error Handling

The engine never raises exceptions to the caller. All internal errors produce a Reply(type="error", ...) with a descriptive body.

for reply in replies:
    if reply.type == "error":
        logger.error(f"Engine error | node={reply.current_node} | {reply.body}")
        await send_plain_text(phone, "⚠️ Something went wrong. Please try again.")
        continue
    await send_whatsapp(user_id, phone, reply)

Common error causes:

  • A next key references a node that doesn't exist (caught at startup by tree.validate())
  • A generate function in MediaReply raises an exception
  • A fetch function in ListNode raises
  • No handler registered for a node type (only with custom types)

Exceptions in Action.fn are caught and surfaced as error replies. Catch expected exceptions yourself for user-friendly messages:

def save_order(session, collected):
    try:
        order_id = db.create_order(session.user_id, collected)
        return f"✅ Order #{order_id} placed!"
    except db.OutOfStockError:
        return "⚠️ Sorry, that item is out of stock. Please choose another."
    except Exception:
        logger.exception("Unexpected error saving order")
        return "⚠️ Something went wrong. Please try again later."

20. Debug Utilities

Inspect all active sessions:

for user_id, session in engine.session_store.all().items():
    print(user_id, session.current_node, session.collected)

Reset a single session:

await engine.session_store.delete("2547XXXXXXXX")

Add debug endpoints to your API:

@app.get("/debug/sessions")
async def debug_sessions():
    return {
        uid: {
            "node":      s.current_node,
            "state":     s.lifecycle_state,
            "collected": s.collected,
            "context":   s.context,
        }
        for uid, s in engine.session_store.all().items()
    }

@app.delete("/debug/sessions/{user_id}")
async def reset_session(user_id: str):
    await engine.session_store.delete(user_id)
    return {"reset": user_id}

@app.get("/debug/db")
async def debug_db():
    """Show all database records (add your own queries)."""
    ...

Log reply metadata in your send function:

print(f"[{reply.session_state}] node={reply.current_node} type={reply.node_type}{reply.body[:60]}")

21. Complete Example — Customer Support Bot

A complete, runnable example showing the majority of TurnStack features together.

"""
support_bot.py
==============
Customer support bot using TurnStack.
"""

import io
import asyncio
import traceback
import httpx
from fastapi import FastAPI, Request, Response, HTTPException
from turnstack import BotEngine, FlowTree, IncomingMessage
from turnstack.nodes import (
    Menu, Input, Confirm, Action, Router, ListNode, MediaReply, CtaUrl,
    Option, Field, MenuField, ButtonsField, ImageField, BranchField, Route,
)

# ── database (stub — replace with your real DB) ───────────────────────────────

users   = {}   # user_id -> {name, tier}
tickets = []   # list of ticket dicts

def get_user(user_id):   return users.get(user_id)
def save_user(user_id, name, tier):
    users[user_id] = {"name": name, "tier": tier}
def create_ticket(user_id, data):
    tid = len(tickets) + 1
    tickets.append({"id": tid, "user_id": user_id, **data})
    return tid
def get_tickets(user_id):
    return [t for t in tickets if t["user_id"] == user_id]


# ── router hooks ──────────────────────────────────────────────────────────────

def load_profile(session):
    user = get_user(session.user_id)
    if user:
        session.context["user"] = user


# ── action functions ──────────────────────────────────────────────────────────

def do_register(session, collected):
    save_user(session.user_id, collected["name"], collected["tier"])
    session.context["user"] = {"name": collected["name"], "tier": collected["tier"]}
    return f"✅ Welcome, {collected['name']}! Your account is set up."

def do_submit_ticket(session, collected):
    tid = create_ticket(session.user_id, {
        "type":     collected["ticket_type"],
        "summary":  collected["summary"],
        "detail":   collected.get("detail"),
        "image_id": collected.get("screenshot", {}).get("media_id"),
    })
    return f"✅ Ticket #{tid} submitted. Our team will respond within 24 hours."

def do_learn_more(session, collected):
    tier = session.context.get("user", {}).get("tier", "standard")
    if tier == "premium":
        return "⭐ As a Premium member you get 24/7 live support and dedicated SLAs."
    return "📋 Standard support includes email responses within 24 hours."

def build_report(session, collected) -> bytes:
    import openpyxl
    wb = openpyxl.Workbook()
    ws = wb.active
    ws.append(["#", "Type", "Summary"])
    for t in get_tickets(session.user_id):
        ws.append([t["id"], t["type"], t.get("summary", "")])
    buf = io.BytesIO()
    wb.save(buf)
    return buf.getvalue()


# ── flow tree ─────────────────────────────────────────────────────────────────

tree = FlowTree(entry="entry")

tree.add("entry", Router(
    before  = load_profile,
    routes  = [Route(when=lambda s: s.context.get("user") is None, next="welcome_new")],
    default = "main_menu",
))

tree.add("welcome_new", Menu(
    text    = "👋 Welcome to SupportBot! Looks like you're new here.",
    options = [
        Option("Get started", next="register"),
        Option("Learn more",  next="about_action"),
    ],
))

tree.add("about_action", Action(
    fn   = lambda s, c: "SupportBot lets you raise and track tickets, download reports, and manage your account — all on WhatsApp.",
    next = "welcome_new",
))

tree.add("register", Input(
    title  = "Registration",
    fields = [
        Field("name", "What is your name?",
              validate=lambda v: "At least 2 characters." if len(v.strip()) < 2 else None,
              transform=str.strip),
        ButtonsField("tier", "Which plan are you on?", options=[
            Option("Standard", value="standard"),
            Option("Premium",  value="premium"),
        ]),
    ],
    next = "register_action",
))

tree.add("register_action", Action(fn=do_register, next="main_menu"))

tree.add("main_menu", Menu(
    text    = lambda s: f"Hi {s.context.get('user', {}).get('name', 'there')} 👋 How can I help?",
    options = [
        Option("🎫 New ticket",      next="new_ticket",     description="Report an issue or request a feature"),
        Option("📋 My tickets",      next="my_tickets",     description="View your open tickets"),
        Option("📊 Download report", next="report_media",   description="Get an Excel report"),
        Option("ℹ️ My plan",          next="plan_action",   description="View plan details"),
        Option("🔗 API docs",        next="docs_cta",       description="Read the developer docs"),
    ],
    button_label = "Main Menu",
))

# New ticket with conditional fields
tree.add("new_ticket", Input(
    title  = "New Ticket",
    fields = [
        ButtonsField("ticket_type", "What type of issue is this?", options=[
            Option("🐛 Bug",      value="bug"),
            Option("💡 Feature",  value="feature"),
            Option("❓ Question", value="question"),
        ]),
        Field("summary", "Describe your issue in one sentence:"),
        BranchField(
            when=lambda s: s.collected.get("ticket_type") == "bug",
            fields=[
                Field("detail", "What steps reproduce the bug?"),
                ImageField("screenshot", "Attach a screenshot (optional — send any text to skip):"),
            ],
        ),
        BranchField(
            when=lambda s: s.collected.get("ticket_type") == "feature",
            fields=[Field("detail", "Describe the feature you'd like in more detail:")],
        ),
    ],
    next = "confirm_ticket",
))

tree.add("confirm_ticket", Confirm(
    text    = lambda c: (
        f"📋 Ticket summary:\n\n"
        f"Type: {c['ticket_type']}\nIssue: {c['summary']}\n"
        f"Details: {c.get('detail', '—')}\n\nSubmit this ticket?"
    ),
    options = [
        Option("✅ Submit",  next="submit_ticket_action"),
        Option("✏️ Edit",    next="new_ticket"),
        Option("❌ Cancel",  next="main_menu"),
    ],
))

tree.add("submit_ticket_action", Action(fn=do_submit_ticket, next="main_menu"))

# My tickets — dynamic list
tree.add("my_tickets", ListNode(
    fetch            = lambda session: get_tickets(session.user_id),
    item_label       = lambda t: f"#{t['id']}{t['type']}",
    item_description = lambda t: t.get("summary", "")[:60],
    on_select        = "main_menu",
    title            = "📋 Your Tickets",
    empty_text       = "You haven't raised any tickets yet.",
    interactive      = True,
    button_label     = "My Tickets",
    extra_options    = [Option("🔙 Back", next="main_menu")],
))

# Report download
tree.add("report_media", MediaReply(
    generate  = build_report,
    filename  = lambda s, c: f"tickets_{s.user_id}.xlsx",
    mime_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
    caption   = "📊 Here is your ticket report.",
    next      = "main_menu",
))

# Plan info
tree.add("plan_action", Action(fn=do_learn_more, next="main_menu"))

# CTA — link to API docs
tree.add("docs_cta", CtaUrl(
    body   = "Read the full TurnStack developer documentation.",
    url    = "https://github.com/your-org/turnstack",
    button = "Open Docs",
    header = "TurnStack Docs",
    footer = "Opens in your browser",
    next   = "main_menu",
))

# ── engine ────────────────────────────────────────────────────────────────────

engine = BotEngine(tree=tree, session_timeout=3600)

# ── send helper + webhook — copy from §12 and §13 ────────────────────────────
# (paste send_whatsapp() and the FastAPI webhook handler here)

TurnStack — build the conversation, not the plumbing.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

turnstack-0.1.3.tar.gz (104.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

turnstack-0.1.3-py3-none-any.whl (74.3 kB view details)

Uploaded Python 3

File details

Details for the file turnstack-0.1.3.tar.gz.

File metadata

  • Download URL: turnstack-0.1.3.tar.gz
  • Upload date:
  • Size: 104.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.5

File hashes

Hashes for turnstack-0.1.3.tar.gz
Algorithm Hash digest
SHA256 3f6767c5cc7c672142d3cbad733eae8cfa0adab54cd34227489196153a8eb0bf
MD5 4f536c886d6c5249e9fa9033f830aa09
BLAKE2b-256 b2e2b93707a274b8cee97797e9bbe70c566eb166a29e1fa606bccf09728a64c0

See more details on using hashes here.

File details

Details for the file turnstack-0.1.3-py3-none-any.whl.

File metadata

  • Download URL: turnstack-0.1.3-py3-none-any.whl
  • Upload date:
  • Size: 74.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.5

File hashes

Hashes for turnstack-0.1.3-py3-none-any.whl
Algorithm Hash digest
SHA256 fb93e91cc5d73917e58d510e5b61312e0677d32d412ba7a90ec91feb9d68551d
MD5 9c1ba584d0f8692471d37218c763b731
BLAKE2b-256 c47aefd7b2468994bda16fe5a08e486fa1c7248094238329235112b0493b11bb

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page