joiinflow developer docs
unreleased · pinned to commit
joiinflow sdk

WhatsApp transport and LLM flow logic, extracted once.

A private, pip-installable Python package for Meta Cloud API and Twilio BYON WhatsApp messaging, Anthropic chat, and the generic flow-orchestration engine that used to be copy-pasted into every Joiin backend.

# uv
uv add "joiinflow[redis] @ git+https://github.com/kagisano/joiinflow-sdk.git@<ref>"

Why this exists

Two FastAPI backends — joiinflow-backend (Meta + Twilio WhatsApp for real-estate agencies: Property24 search, calendar booking, hot-lead alerts) and joiinai-li/backend (the Liftclub WhatsApp support bot: wallet, OTP, lead qualification) — shared literal file lineage in a joiin_flow_handler.py and drifted apart every time one product changed without the other. Neither repo was structured as an installable library, so every new product meant re-implementing WhatsApp send/receive, signature verification, and session handling from scratch, and re-diverging.

joiinflow is the plumbing pulled out of both: transport, signature verification, and the generic parts of the flow engine. Each app still writes its own domain logic — Property24 tools, lead-qual scripts, wallet flows — by subclassing one base class.

Architecture

Meta Cloud APIWebhook POSTs, Graph API sends
TwilioBYON WhatsApp, subaccounts
AnthropicClaude messages API
↑ used by ↑
joiinflowwhatsapp/ · llm/ · flow/ — transport, signatures, FlowHandler, SessionStore
↑ subclassed & configured by ↑
joiinflow-backendJoiinAgencyFlowHandler — Property24, calendar
joiinai-li/backendLiftclubFlowHandler — lead-qual, wallet
your new appYourFlowHandler — whatever you're building

What's in the box

whatsapp/

MetaChannel, TwilioChannel, Meta + Twilio signature verification, webhook payload parsing into a normalized InboundMessage.

llm/

AnthropicChatClient — plain chat, sync tool-use loop, and an async tool-use loop for FlowHandler.

flow/

FlowHandler orchestration base class, the SessionStore protocol with in-memory and Redis implementations, ToolSpec/ToolRegistry.

Public-later design

Everything takes an explicit JoiinflowConfig — there's no global settings singleton, no hardcoded Supabase/Vercel assumptions in the public API. DB persistence stays app-side behind hooks. That's deliberate: internal-only today, but nothing structural needs to change to open this to external developers later.


start here

Install & versioning

joiinflow-sdk is a private repository, distributed as a Git dependency — not published to PyPI (public or private index) yet. Both consuming apps pin it in pyproject.toml:

dependencies = [
  "joiinflow[redis] @ git+https://github.com/kagisano/joiinflow-sdk.git@d8307db682e2b20073d6958d76402bed51eea3a8",
]

Extras

ExtraPulls inNeeded for
redisupstash-redisRedisSessionStore
devpytest, pytest-asyncio, mypy, ruffWorking on the SDK itself, not for consuming apps

Pinning: tag vs. commit SHA

The intended pattern is a SemVer tag — @v0.1.0 — so upgrades are a one-line diff and reproducible. As of this writing, no tag has been pushed yet (the tag push was rejected — see the callout below), so both apps currently pin a full commit SHA instead. Functionally identical, just less readable in a diff. Re-pin to a tag once one exists.

Known gap

Nobody has cut a v0.1.0 tag on joiinflow-sdk yet — that needs someone with normal push permissions on the repo (an automated session hit a 403 pushing tags through its git proxy). Until then, treat the commit SHA above as "v0.1.0-equivalent" and update every consuming app's pin together when a real tag lands.

Authenticating to a private repo

Because it's private, both uv/pip locally and your CI/Vercel build need Git credentials that can clone it:

  • Local machine: if you can already git clone the repo in a terminal, uv sync will just work — it shells out to the same git binary with the same credential helper (macOS Keychain, gh auth login, or an SSH key all work).
  • Vercel build: Vercel's build environment does not inherit your local credentials. It needs its own — typically a fine-grained GitHub PAT (read-only, scoped to kagisano/joiinflow-sdk) set as a build-time environment variable, or a deploy key. This has not yet been verified end-to-end against a real Vercel build — see Deploy to Vercel.

Reproducible installs

Both apps commit uv.lock. Run uv sync (not uv add/pip install ad hoc) to install from the lock file so your local environment matches what CI resolves.


Quickstart

The shortest path from an empty folder to a WhatsApp bot that replies via Claude. This mirrors exactly what joiinflow-backend and joiinai-li/backend do — see Build a new app for the full version with routing, deployment config, and persistence hooks.

from joiinflow.config import JoiinflowConfig
from joiinflow.whatsapp import MetaChannel
from joiinflow.flow import FlowHandler, InMemorySessionStore, ToolSpec
from joiinflow.whatsapp.types import Channel, InboundMessage

config = JoiinflowConfig(anthropic_api_key="sk-ant-...")

class MyFlowHandler(FlowHandler):
    async def build_system_prompt(self, context: dict) -> str:
        return "You are a helpful WhatsApp assistant."

    def build_tools(self, context: dict) -> list[ToolSpec]:
        return []

    async def on_tool_call(self, name: str, inputs: dict, context: dict) -> str:
        return "Unknown tool."

handler = MyFlowHandler(config, InMemorySessionStore(), meta_channel=MetaChannel(config))

# somewhere in your webhook route, after verifying the signature and parsing the payload:
msg = InboundMessage(channel=Channel.META, number_id="NUMBER_ID", from_number="27821234567", text="Hi")
context = {"session_key": "27821234567", "channel": Channel.META, "number_id": "NUMBER_ID", "access_token": "..."}
await handler.handle_inbound(msg, context)

That's the entire reusable mechanics: history lookup, prompt build, LLM call (with or without tools), send, history save. Everything else is hooks you fill in.


core concepts

JoiinflowConfig

joiinflow.config.JoiinflowConfig is a frozen dataclass. Every joiinflow component takes it as an explicit constructor argument — there is no module-level settings singleton to import, unlike the old app.config.settings pattern both apps used to share.

FieldTypeDefaultUsed by
meta_api_versionstr"v22.0"MetaChannel — Graph API URL
meta_app_secretstr""App-side, when calling verify_meta_signature
meta_verify_tokenstr""App-side, the GET handshake check
anthropic_api_keystr""AnthropicChatClient
anthropic_modelstr"claude-sonnet-4-6"AnthropicChatClient
redis_urlstr""Not read by the SDK — a convenience slot for your app's own config, see note below
redis_tokenstr""Same as above
redis_ttl_secondsint86400App-side, when constructing RedisSessionStore
Note

redis_url/redis_token on the config are not read internally by RedisSessionStore — you construct your own Redis client (e.g. upstash_redis.Redis(url=..., token=...)) and pass the client instance in. This is the "protocols only" principle: joiinflow never builds a network connection on your behalf, it just accepts one that satisfies RedisLike. The fields exist so your app has one config object to read from, not two.

Fields you don't use can stay at their defaults — a Twilio-only app never touches the meta_* fields, for example.

Channel & InboundMessage

Defined in joiinflow.whatsapp.types, re-exported from joiinflow.whatsapp.

Channel

class Channel(str, Enum):
    META = "meta"
    TWILIO = "twilio"

Which transport a message arrived on / a reply should go out on. Replaces the old agency.get("_twilio") dict-sniffing hack with something typed and switchable.

InboundMessage

A normalized inbound WhatsApp message, regardless of transport — the output of webhook parsing and the input to FlowHandler.handle_inbound().

FieldTypeNotes
channelChannel
number_idstrMeta phone_number_id for META messages; the receiving E.164 number for TWILIO messages
from_numberstrCustomer's number
textstrMessage body
message_idstrdefault "" — provider message ID, for status logging
profile_namestrdefault "" — Meta only, from the contacts block

Design principles

These aren't style preferences — each one closes a specific problem the two duplicated handlers had.

Explicit config, no globals

Every class takes JoiinflowConfig (and, for storage, a store/client instance) in its constructor. Nothing reaches for an environment variable or a cached singleton behind your back. You can run two differently-configured FlowHandlers in the same process if you need to.

Protocols, not implementations, for persistence

SessionStore is a typing.Protocol. joiinflow ships two implementations (InMemorySessionStore, RedisSessionStore), but it never touches Supabase, Postgres, or any app-specific table — that stays behind FlowHandler's on_inbound_received/on_reply_sent hooks, which you implement.

The four-hook subclassing boundary

FlowHandler owns the send/receive/history/tool-loop/error-handling sequence. You implement build_system_prompt, build_tools, on_tool_call, and optionally on_inbound_received/on_reply_sent — that's the entire surface where domain logic plugs in. See FlowHandler.

Typed for autocomplete

Ships py.typed (PEP 561) with full annotations. Once installed, Pylance/mypy give real completions on every joiinflow.* import — no stub package needed.


whatsapp transport

MetaChannel

joiinflow.whatsapp.meta.MetaChannel — a thin async client for the WhatsApp Cloud API (Meta Graph API). Lifted from the original app/services/whatsapp.py in both apps, de-globalized.

from joiinflow.whatsapp import MetaChannel
channel = MetaChannel(config)  # config: JoiinflowConfig
asyncsend_message(number_id, to, text, access_token) -> dict

Sends a plain text message. Raises httpx.HTTPStatusError on a non-2xx Graph API response (after logging the response body).

asyncsend_template_message(number_id, to, template_name, language_code, components, access_token) -> dict

Sends an approved WhatsApp template (e.g. OTP codes, session-reopening messages outside the 24h window). components is the raw Graph API components list.

asyncsend_read_receipt(number_id, message_id, access_token) -> None

Marks an inbound message as read (triggers the double-blue-tick + typing indicator). Failures are swallowed and logged — a missed read receipt shouldn't break message handling.

asyncsend_agent_alert(number_id, agent_number, customer_number, customer_name, summary, access_token) -> dict

Convenience wrapper over send_message that formats a [HOT LEAD] alert string.

staticis_hot_lead(text: str) -> bool

Case-insensitive substring match against a fixed trigger list — "make an offer", "ready to buy", "speak to an agent", "frustrated", and 13 others. Also importable directly as joiinflow.whatsapp.meta.is_hot_lead and HOT_LEAD_TRIGGERS if you want to extend the list yourself.

TwilioChannel

joiinflow.whatsapp.twilio.TwilioChannel — Twilio BYON (Bring Your Own Number) WhatsApp: sending, subaccount management, and OTP-based number registration. This is the transport half only; DB-touching persistence (which subaccount belongs to which agency, usage-log rows) is deliberately left to your app.

from joiinflow.whatsapp import TwilioChannel
channel = TwilioChannel(master_account_sid, master_auth_token)  # needed for subaccount mgmt only
Master vs. subaccount credentials

The constructor's account_sid/auth_token are your master Twilio account — used only by create_subaccount/suspend_subaccount. Every other method takes the relevant subaccount's SID/token explicitly as arguments, since each customer/agency has its own subaccount.

asynccreate_subaccount(friendly_name) -> dict

Creates a Twilio subaccount under the master account. Returns {"sid", "auth_token", "friendly_name"}.

asyncsuspend_subaccount(subaccount_sid) -> None

Suspends a subaccount (churn, non-payment).

asyncinitiate_byon_registration(subaccount_sid, subaccount_token, phone_number, waba_id, sender_name="") -> dict

Starts WhatsApp BYON registration — Twilio sends an OTP to the number via SMS/voice. Returns {"sid", "status", "phone_number"}.

Raises ByonUserError for customer-fixable problems (number still linked to another WhatsApp account, ineligible number, invalid number, rate-limited, trial-account restriction) with a human-readable message ready to show the customer — and plain RuntimeError for everything else (infra failure).

asyncverify_byon_registration(subaccount_sid, subaccount_token, request_sid, code) -> dict

Submits the OTP. Returns {"status"}"approved" means the number is live.

asyncget_byon_request_status(subaccount_sid, subaccount_token, request_sid) -> dict

Polls the current status of a pending registration.

asyncconfigure_webhook(account_sid, auth_token, phone_number, webhook_url) -> bool

Sets the incoming-message webhook URL on a Twilio phone number.

asyncget_subaccount_usage(subaccount_sid, subaccount_token, start_date, end_date) -> list[dict]

Fetches raw usage records (sms, sms-inbound, sms-outbound categories) for billing reconciliation.

asyncsend(account_sid, auth_token, from_number, to_number, body) -> dict

Sends a WhatsApp message. Auto-prefixes whatsapp: on both numbers if missing. Returns the raw Twilio response JSON — read response["sid"] yourself if you need it for a usage log; joiinflow doesn't log usage on your behalf.

asyncvalidate_credentials(account_sid, auth_token) -> bool

Simple credential sanity check against the Twilio API.

Signature verification

joiinflow.whatsapp.signatures. Verifying inbound webhook POSTs is not optional — anyone who discovers your webhook URL can otherwise post forged messages that get treated as real customer conversations.

What this fixes

Before this SDK existed, both apps checked the Meta hub.verify_token only on the one-time GET handshake. The actual POST payload — every real inbound message — was never verified. verify_meta_signature closes that gap.

verify_meta_signature(app_secret: str, signature_header: str, raw_body: bytes) -> bool

Validates the X-Hub-Signature-256 header (HMAC-SHA256 over the raw body, using your Meta app secret).

async def receive_webhook(request: Request) -> dict:
    raw_body = await request.body()  # read raw bytes BEFORE .json()
    signature = request.headers.get("X-Hub-Signature-256", "")
    if not verify_meta_signature(settings.meta_app_secret, signature, raw_body):
        return {"received": False}
    payload = await request.json()
    ...
Common mistake

You must hash the exact raw bytes Meta sent. If you call await request.json() first and re-serialize it to compute the signature, whitespace/key-ordering differences will make every signature check fail. Always call request.body() first.

verify_twilio_signature(auth_token: str, signature: str, url: str, params: dict) -> bool

Validates X-Twilio-Signature (HMAC-SHA1 over the URL + sorted form params, base64-encoded). url must be the exact URL Twilio signed, including any query string — get it with str(request.url) in FastAPI. params is the parsed form body, e.g. dict(await request.form()).

Webhook parsing

joiinflow.whatsapp.webhook. Normalizes each transport's webhook payload shape into InboundMessage — route registration itself stays app-side.

parse_meta_webhook_payload(payload: dict) -> list[InboundMessage]

Walks entry[].changes[].value.messages[]. Only type == "text" messages are extracted — a single payload can contain several, so this always returns a list (commonly length 0 or 1). Status callbacks and location messages are intentionally skipped: read the raw payload yourself if your app needs them (see joiinai-li/backend's own richer _extract_events, which handles both, as an example of an app extending past the generic parser).

parse_twilio_webhook_form(form: dict) -> InboundMessage | None

Reads Body, From, To, MessageSid; strips the whatsapp: prefix from numbers. Returns None if Body is empty (e.g. a delivery-status callback with no message text).


llm client

AnthropicChatClient

joiinflow.llm.anthropic.AnthropicChatClient — wraps the Anthropic Messages API. Lazily constructs the underlying anthropic.Anthropic client on first use.

from joiinflow.llm.anthropic import AnthropicChatClient
client = AnthropicChatClient(config)
chat(system_prompt, history, max_tokens=400) -> str

One-shot chat, no tools. history is a list of {"role", "content"} messages in Anthropic's wire format. Returns concatenated text blocks from the response, stripped.

chat_with_tools(system_prompt, history, tools, tool_handler, max_tokens=1024) -> str

Synchronous tool-use loop. tool_handler: Callable[[str, dict], str] is invoked for each tool call the model makes, synchronously, and its return value is fed back as the tool result. Loops until the model stops calling tools.

asyncachat_with_tools(system_prompt, history, tools, tool_handler, max_tokens=1024) -> str

Same loop, but tool_handler: Callable[[str, dict], Awaitable[str]] is awaited — used internally by FlowHandler, whose on_tool_call hook is a coroutine. The underlying Anthropic call itself still runs in a worker thread (asyncio.to_thread) so it doesn't block your event loop.

Prefer chat/chat_with_tools for standalone, synchronous use (e.g. a one-off classification call); use FlowHandler, which drives achat_with_tools for you, for anything that's part of an inbound-message turn.


flow engine

FlowHandler

joiinflow.flow.engine.FlowHandler — an abstract base class. It owns the reusable mechanics of one inbound-message turn; you subclass it and implement the parts that are specific to your product.

Lifecycle

on_inbound_received() build_system_prompt() build_tools() Claude (chat or tool loop) on_tool_call()
(0+ times)
send via channel on_reply_sent()

Steps in bold-bordered boxes above are the hooks you implement. Everything else — session-history lookup/append/save, choosing between a plain chat call and a tool loop, dispatching the send to the right channel, catching and falling back on LLM/send errors — is handled for you inside handle_inbound().

Constructor

FlowHandler(
    config: JoiinflowConfig,
    session_store: SessionStore,
    *,
    llm_client: AnthropicChatClient | None = None,
    meta_channel: MetaChannel | None = None,
    twilio_channel: TwilioChannel | None = None,
    max_history: int = 20,
)

Only wire up the channel(s) you actually use — a Meta-only app can leave twilio_channel as None; calling handle_inbound on a Twilio-channel message without one configured raises RuntimeError immediately.

The four hooks

abstract · asyncbuild_system_prompt(context: dict) -> str

Build this turn's system prompt from whatever app-loaded context you put in context — business profile, enabled flows, knowledge base, etc.

abstractbuild_tools(context: dict) -> list[ToolSpec]

Return this turn's available tools. Called immediately after build_system_prompt, so you can stash things onto context in that hook and read them back here (see the Property24 example below). An empty list means a plain chat call — no tool loop.

abstract · asyncon_tool_call(name: str, inputs: dict, context: dict) -> str

Dispatch one tool call to its implementation; return the tool result text fed back to the model. This is your entire tool-dispatch table — a chain of if name == "...": is completely normal here.

default: returns Falseon_inbound_received(msg: InboundMessage, context: dict) -> bool

Called first, before any history/prompt/LLM work. Use it to persist the inbound message, upsert a thread record, fire a push notification — whatever your app needs to record on every inbound turn. Return True to short-circuit the rest of handle_inbound entirely (no LLM call, no send, no history write) — e.g. a human agent has taken over the thread, or a deterministic state machine (like a scripted lead-qual flow) already sent its own reply directly via self._send(...) and there's nothing left for the LLM to do this turn.

default: no-opon_reply_sent(msg: InboundMessage, reply: str, context: dict) -> None

Called after a reply has sent successfully. Use it to persist the outbound message.

Other public methods

asynchandle_inbound(msg: InboundMessage, context: dict) -> str | None

Runs one full turn. Returns the sent reply text, or None if the turn was short-circuited by on_inbound_received or the send failed.

asyncgenerate_reply(context: dict, history: list[dict]) -> str

Runs system-prompt-building + tool-loop-selection + the LLM call — without sending or persisting anything. Built for a "test this flow" surface (a dashboard preview panel, like joiinflow-backend's /test-chat endpoint) that should exercise the exact same prompt/tool logic as production without touching WhatsApp or session storage.

The context dict contract

context is a plain dict your app builds per request. joiinflow reads specific keys out of it; everything else is yours to use freely (and hooks can add to it mid-turn — see the note on build_tools above).

KeyRequired whenPurpose
session_keyalwaysOpaque string used to look up/save history in your SessionStore — commonly f"{agency_id}:{phone}"
channelalwaysChannel.META or Channel.TWILIO — selects which client sends the reply
number_id, access_tokenchannel == Channel.METAPassed straight through to MetaChannel.send_message
account_sid, auth_token, from_numberchannel == Channel.TWILIOPassed straight through to TwilioChannel.send

Error handling & introspection

  • fallback_reply — class attribute, the string sent if the LLM call raises. Defaults to "Hi! Thanks for your message. Someone from our team will get back to you shortly." — override it per subclass for your own tone.
  • last_error — instance attribute, set to a short description any time the LLM call or the send fails. Read it from a debug endpoint instead of a module-level global (the old pattern in both apps had one process-wide _last_error variable, which is subtly wrong under any concurrency).

Session storage

joiinflow.flow.session. Replaces the module-level _history_store: dict global both apps used — a plain in-process dict silently loses history on every Vercel cold start, since there's no guarantee two requests for the same conversation hit the same warm process.

SessionStore protocol

class SessionStore(Protocol):
    def get_history(self, key: str) -> list[dict]: ...
    def save_history(self, key: str, history: list[dict]) -> None: ...
    def clear_history(self, key: str) -> None: ...

Structural — anything with these three methods works, no inheritance required.

InMemorySessionStore(max_history=20)

A plain dict under the hood. Fine for local dev and tests. Not recommended in production on Vercel — same cold-start problem as before.

RedisSessionStore(redis_client, ttl_seconds=86400, max_history=20, key_prefix="conv")

The recommended default. Survives cold starts because history lives in Redis, not process memory. redis_client must satisfy RedisLike:

class RedisLike(Protocol):
    def get(self, key: str): ...
    def set(self, key: str, value: str, ex: int | None = None): ...
    def delete(self, key: str): ...

Both upstash_redis.Redis and redis-py's redis.Redis satisfy this out of the box — construct whichever your app already uses and pass the instance in. joiinflow never builds the connection itself.

from upstash_redis import Redis
from joiinflow.flow import RedisSessionStore

store = RedisSessionStore(Redis(url=settings.upstash_redis_url, token=settings.upstash_redis_token),
                           ttl_seconds=settings.redis_ttl_seconds)

Keys are stored as {key_prefix}:{key}; ttl_seconds is passed as Redis's ex on every save, so a conversation's history simply expires if nobody replies for that long — no separate staleness bookkeeping needed.

ToolSpec & ToolRegistry

joiinflow.flow.tools. Generalizes the ad-hoc _P24_TOOL/_CALENDAR_TOOLS dict-literal pattern from the original handlers into a typed shape.

ToolSpec

ToolSpec(name: str, description: str, input_schema: dict)

input_schema is a standard JSON Schema object, exactly as Anthropic's tool-use API expects. .to_anthropic_schema() returns {"name", "description", "input_schema"} — Anthropic's wire format.

SEARCH_TOOL = ToolSpec(
    name="search_property24",
    description="Search the agency's Property24 listings...",
    input_schema={
        "type": "object",
        "properties": {
            "area": {"type": "string"},
            "budget": {"type": "string"},
        },
        "required": ["area", "budget"],
    },
)

ToolRegistry(tools=None)

An ordered collection you rarely construct directly — FlowHandler.handle_inbound wraps whatever list your build_tools hook returns in one internally. bool(registry) is False for an empty registry (that's the "empty list ⇒ plain chat" check), .to_anthropic_tools() returns the list achat_with_tools needs, .names() lists registered tool names.


guide

Build a new app

Full walkthrough for a brand-new WhatsApp backend, from an empty folder to something deployable. Follows exactly the shape of joiinflow-backend and joiinai-li/backend — same deploy target, same file layout.

1 · Project setup

mkdir my-new-whatsapp-app && cd my-new-whatsapp-app
git init
uv init --python 3.11
uv add fastapi "uvicorn[standard]" python-multipart
uv add "joiinflow[redis] @ git+https://github.com/kagisano/joiinflow-sdk.git@<ref>"
uv add upstash-redis

2 · Layout

my-new-whatsapp-app/
  app/
    __init__.py
    config.py        # builds JoiinflowConfig from env vars
    main.py           # FastAPI app
    flow_handler.py    # your FlowHandler subclass — the actual product logic
    routes/
      webhook.py        # Meta (and/or Twilio) webhook routes
  api/
    index.py           # from app.main import app
  vercel.json
  .env.example
  pyproject.toml

3 · app/config.py

from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
from joiinflow.config import JoiinflowConfig

class Settings(BaseSettings):
    meta_verify_token: str = ""
    meta_app_secret: str = ""
    meta_api_version: str = "v22.0"
    anthropic_api_key: str = ""
    anthropic_model: str = "claude-sonnet-4-6"
    upstash_redis_url: str = ""
    upstash_redis_token: str = ""
    model_config = SettingsConfigDict(env_file=".env", extra="ignore")

@lru_cache
def get_settings() -> Settings:
    return Settings()

settings = get_settings()

def build_joiinflow_config() -> JoiinflowConfig:
    return JoiinflowConfig(
        meta_verify_token=settings.meta_verify_token,
        meta_app_secret=settings.meta_app_secret,
        meta_api_version=settings.meta_api_version,
        anthropic_api_key=settings.anthropic_api_key,
        anthropic_model=settings.anthropic_model,
    )

4 · app/flow_handler.py

from joiinflow.flow import FlowHandler, RedisSessionStore, ToolSpec
from joiinflow.llm.anthropic import AnthropicChatClient
from joiinflow.whatsapp import MetaChannel
from joiinflow.whatsapp.types import InboundMessage
from upstash_redis import Redis

from app.config import settings, build_joiinflow_config

class MyFlowHandler(FlowHandler):
    async def build_system_prompt(self, context: dict) -> str:
        return "You are a helpful assistant for My New App."

    def build_tools(self, context: dict) -> list[ToolSpec]:
        return []   # add ToolSpecs once you need tool calls

    async def on_tool_call(self, name: str, inputs: dict, context: dict) -> str:
        return "Unknown tool."

    async def on_inbound_received(self, msg: InboundMessage, context: dict) -> bool:
        return False   # e.g. persist to your own DB here

    async def on_reply_sent(self, msg: InboundMessage, reply: str, context: dict) -> None:
        pass           # e.g. persist the outbound message here

config = build_joiinflow_config()
flow_handler = MyFlowHandler(
    config,
    RedisSessionStore(Redis(url=settings.upstash_redis_url, token=settings.upstash_redis_token)),
    llm_client=AnthropicChatClient(config),
    meta_channel=MetaChannel(config),
)

5 · app/routes/webhook.py

from fastapi import APIRouter, BackgroundTasks, Query, Request
from fastapi.responses import PlainTextResponse
from joiinflow.whatsapp.signatures import verify_meta_signature
from joiinflow.whatsapp.webhook import parse_meta_webhook_payload
from joiinflow.whatsapp.types import Channel

from app.config import settings
from app.flow_handler import flow_handler

router = APIRouter(tags=["webhook"])

@router.get("/webhook", response_class=PlainTextResponse)
async def verify(hub_mode: str = Query(None, alias="hub.mode"),
                  hub_verify_token: str = Query(None, alias="hub.verify_token"),
                  hub_challenge: str = Query(None, alias="hub.challenge")) -> str:
    if hub_mode == "subscribe" and hub_verify_token == settings.meta_verify_token:
        return hub_challenge or ""
    return "Forbidden"

@router.post("/webhook")
async def receive(request: Request, background_tasks: BackgroundTasks) -> dict:
    raw_body = await request.body()
    signature = request.headers.get("X-Hub-Signature-256", "")
    if not verify_meta_signature(settings.meta_app_secret, signature, raw_body):
        return {"received": False}

    payload = await request.json()
    for msg in parse_meta_webhook_payload(payload):
        context = {
            "session_key": f"{msg.number_id}:{msg.from_number}",
            "channel": Channel.META,
            "number_id": msg.number_id,
            "access_token": settings.whatsapp_access_token,  # wherever you store this
        }
        background_tasks.add_task(flow_handler.handle_inbound, msg, context)
    return {"received": True}

6 · Boilerplate — identical across every app

These three files never change shape between apps; that's the point of the Vercel deploy pattern.

# app/main.py
from fastapi import FastAPI
from app.routes.webhook import router as webhook_router
app = FastAPI()
app.include_router(webhook_router)
# api/index.py
from app.main import app
// vercel.json
{"$schema":"https://openapi.vercel.sh/vercel.json","framework":"fastapi","buildCommand":null,"rewrites":[{"source":"/(.*)","destination":"/api/index.py"}]}

Deploy to Vercel

Deployment is unchanged by using joiinflow — it's a library swap, not a deploy-shape change. The one new piece is that Vercel's build now needs to clone a private dependency.

Checklist

  1. Connect the repo to a Vercel project as usual (framework auto-detects as fastapi from vercel.json).
  2. Set every env var your Settings class reads (Meta/Twilio/Anthropic/Supabase/Redis credentials) in the Vercel project's Environment Variables.
  3. Give the Vercel build a credential that can clone kagisano/joiinflow-sdk — a fine-grained GitHub PAT (read-only, scoped to that one repo) is the simplest option. Vercel doesn't have a built-in "private Python dependency" credential slot the way it does for private npm registries, so this typically means baking the PAT into the git URL via a build-time env var, e.g. rewriting the dependency to git+https://${GITHUB_PAT}@github.com/kagisano/joiinflow-sdk.git@<ref> at build time, or configuring a global git credential helper in a custom build command.
  4. Deploy a preview build and check the build logs for the uv sync / pip install step specifically — confirm it actually resolves joiinflow rather than silently failing and falling back to a cached wheel from a previous successful build.
Unverified

This exact flow — a real Vercel build successfully cloning a private GitHub dependency — has not yet been confirmed end-to-end on either joiinflow-backend or joiinai-li/backend. It's flagged as a spike to run before merging either app's migration PR. If you're the one running it: once it works, replace this callout with the actual working build-command / env-var setup, since "a PAT baked into the git URL" is the standard approach but Vercel-specific quirks sometimes require a variant.

uv.lock

Both apps commit uv.lock so Vercel's build resolves exactly the dependency graph you tested locally, rather than re-resolving (and potentially picking up a newer, untested joiinflow commit if you're pinned loosely, or a newer transitive dependency).

Test locally & in VS Code

Set up the interpreter

uv sync
cp .env.example .env   # fill in real values

In VS Code: Cmd/Ctrl+Shift+PPython: Select Interpreter → pick ./.venv/bin/python. Because joiinflow ships py.typed, Pylance gives full autocomplete and type-checking on every joiinflow.* import immediately — no separate stub package needed.

Run the server

uv run uvicorn app.main:app --reload --port 8000

Open http://localhost:8000/docs — FastAPI's Swagger UI lists every route and lets you fire test requests without needing WhatsApp at all. Endpoints that don't touch your database (e.g. a GET /webhook handshake, a GET /debug/... route) work with zero external credentials configured.

Testing real inbound messages before deploying

ngrok http 8000

Register the resulting https://....ngrok-free.app/webhook URL as your Meta app's webhook callback URL, message your WhatsApp test number from a phone, and watch it flow through your local server with breakpoints available.

Testing flow logic without hitting WhatsApp at all

Build a small test-only endpoint (or script) that calls flow_handler.generate_reply(context, history) directly — it runs your prompt/tool logic and returns the text, with no send and no session-store write. This is exactly what joiinflow-backend's POST /test-chat does for its dashboard's "Test Flow" panel.

Iterating on the SDK itself alongside an app

Instead of pushing to joiinflow-sdk and re-pinning a commit SHA on every change while you're iterating:

uv add --editable ../joiinflow-sdk

This points the app's dependency at your local checkout — edits show up immediately, no reinstall. Revert to the git URL before committing so CI and Vercel still resolve the real, pushed package.

Debug config (.vscode/launch.json)

{
  "version": "0.2.0",
  "configurations": [{
    "name": "FastAPI",
    "type": "debugpy",
    "request": "launch",
    "module": "uvicorn",
    "args": ["app.main:app", "--reload", "--port", "8000"],
    "envFile": "${workspaceFolder}/.env",
    "console": "integratedTerminal"
  }]
}

Migrate an existing app

Two real apps have already gone through this. Both are worth reading as case studies before migrating a third.

JoiinAgencyFlowHandler

joiinflow-backend. Both Meta and Twilio channels, full tool loop (Property24 search + calendar booking as ToolSpecs), Supabase-backed thread/message persistence in the hooks, push notifications from on_inbound_received. The reference example for a tool-heavy handler.

LiftclubFlowHandler

joiinai-li/backend. Meta only, no tools at all — instead, on_inbound_received runs a deterministic question/answer state machine and returns True to short-circuit the LLM entirely for every turn except the final verdict evaluation. The reference example for the short-circuit hook.

Migration checklist

  1. Add the git dependency to pyproject.toml, run uv sync.
  2. Turn your existing transport files (whatsapp.py, anthropic_service.py) into thin adapters: construct the joiinflow class from your app's config, re-export the same function names your call sites already use. This keeps every existing caller working unchanged — you're swapping the implementation underneath, not the call sites.
  3. Split your existing flow handler: identify what's generic orchestration (delete it, FlowHandler replaces it) vs. domain-specific (loaders, prompt builder, tool dispatch — move into your FlowHandler subclass's methods, logic unchanged).
  4. Swap your session store from whatever in-process dict it was to RedisSessionStore.
  5. Add signature verification to your webhook routes if it wasn't already there.
  6. Keep vercel.json and api/index.py untouched — this is a library swap, not a deploy change.
  7. Smoke-test: does the app still import? Does /docs load? Does a scripted "mock the DB, real Claude call" test (see joiinai-li/backend/test_flow_handler.py for the pattern) still produce the same prompt/reply shape as before the migration?

reference

API index

Every public symbol, by import path. Click through to the full description above.

ImportKindSection
joiinflow.config.JoiinflowConfigdataclassJoiinflowConfig
joiinflow.whatsapp.types.ChannelenumTypes
joiinflow.whatsapp.types.InboundMessagedataclassTypes
joiinflow.whatsapp.MetaChannelclassMetaChannel
joiinflow.whatsapp.meta.is_hot_lead, HOT_LEAD_TRIGGERSfunction, listMetaChannel
joiinflow.whatsapp.TwilioChannelclassTwilioChannel
joiinflow.whatsapp.twilio.ByonUserErrorexceptionTwilioChannel
joiinflow.whatsapp.signatures.verify_meta_signaturefunctionSignatures
joiinflow.whatsapp.signatures.verify_twilio_signaturefunctionSignatures
joiinflow.whatsapp.webhook.parse_meta_webhook_payloadfunctionWebhook parsing
joiinflow.whatsapp.webhook.parse_twilio_webhook_formfunctionWebhook parsing
joiinflow.llm.anthropic.AnthropicChatClientclassAnthropicChatClient
joiinflow.flow.FlowHandlerabstract classFlowHandler
joiinflow.flow.SessionStoreprotocolSession storage
joiinflow.flow.InMemorySessionStoreclassSession storage
joiinflow.flow.RedisSessionStoreclassSession storage
joiinflow.flow.session.RedisLikeprotocolSession storage
joiinflow.flow.ToolSpecdataclassToolSpec & ToolRegistry
joiinflow.flow.ToolRegistryclassToolSpec & ToolRegistry

Troubleshooting & FAQ

uv sync fails to fetch joiinflow

Check that git clone https://github.com/kagisano/joiinflow-sdk.git works in a plain terminal first, outside of uv entirely. If that fails too, it's a Git credential problem, not a uv problem — see Authenticating to a private repo. If it succeeds but uv sync still fails, run with -v for the underlying git error.

“Signature verification always fails”

By far the most common cause: you called await request.json() before computing the signature, or otherwise hashed something other than the exact raw bytes Meta/Twilio sent. Call request.body() first, verify, then parse. See the callout in Signature verification.

RedisSessionStore isn't persisting between messages”

Confirm session_key is actually stable across turns from the same customer — a common bug is building it from something that changes per-request (a message ID, a timestamp) instead of a stable pair like agency_id:phone_number.

“Should I use chat_with_tools or achat_with_tools?”

If you're driving a turn through FlowHandler, you never call either directly — handle_inbound calls achat_with_tools for you via your on_tool_call hook. Call AnthropicChatClient methods directly only for standalone use outside the flow engine (e.g. a one-off classification call) — use chat_with_tools there if you're in synchronous code, achat_with_tools if you're already in an async function.

“How do I test the flow logic without real WhatsApp/Twilio/Supabase credentials?”

Mock the DB-touching functions in your handler module (unittest.mock.patch) and call flow_handler.generate_reply(context, history) or drive handle_inbound with a fake SessionStore/channel object satisfying the relevant protocol. See joiinai-li/backend/test_flow_handler.py for a working example that patches DB loaders with fixture data and only needs ANTHROPIC_API_KEY to exercise a real Claude call.

on_inbound_received returned True but I still see a reply sent”

If your hook sends its own reply (e.g. a scripted state-machine message) before returning True, make sure you're calling self._send(context, to, text) — not accidentally also relying on handle_inbound's normal send path, which only runs when the hook returns False.

“I need a package version tag, not a commit SHA”

See the callout in Install & versioning — no tag has been cut yet. Ask whoever has push access to kagisano/joiinflow-sdk to run git tag v0.1.0 <sha> && git push origin v0.1.0, which will also trigger the release workflow (lint/type-check/test → build sdist+wheel → GitHub Release).

“Where do I add a new WhatsApp transport (e.g. a third provider)?”

Add a new module under joiinflow/whatsapp/ alongside meta.py/twilio.py, add a matching value to the Channel enum, and extend FlowHandler._send's dispatch (currently an if channel == Channel.TWILIO: ... elif channel == Channel.META: ... chain) to route to it. This is a change to the SDK itself, not something app code should work around.

Versioning & releases

SemVer git tags (vX.Y.Z) on joiinflow-sdk. The package version is derived automatically from the tag via hatch-vcs — nobody hand-edits a version number in pyproject.toml.

Release workflow

Pushing a tag matching v*.*.* triggers GitHub Actions: lint (ruff) → type-check (mypy) → test (pytest) → build sdist + wheel → publish a GitHub Release with those artifacts attached. A separate CI workflow runs the same lint/type-check/test steps on every push to main and every pull request, independent of tagging.

Consuming a new version

Bump the @<ref> in each consuming app's pyproject.toml dependency line to the new tag, run uv sync to update uv.lock, and commit both files together in one PR. Since FlowHandler subclasses only touch the four public hooks and a small, deliberately-stable constructor signature, most SDK releases should be non-breaking for existing subclasses — treat any change to hook signatures or the context dict contract as a breaking (major) change.