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
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.
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.
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
| Extra | Pulls in | Needed for |
|---|---|---|
redis | upstash-redis | RedisSessionStore |
dev | pytest, pytest-asyncio, mypy, ruff | Working 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.
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 clonethe repo in a terminal,uv syncwill just work — it shells out to the samegitbinary 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.
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.
| Field | Type | Default | Used by |
|---|---|---|---|
meta_api_version | str | "v22.0" | MetaChannel — Graph API URL |
meta_app_secret | str | "" | App-side, when calling verify_meta_signature |
meta_verify_token | str | "" | App-side, the GET handshake check |
anthropic_api_key | str | "" | AnthropicChatClient |
anthropic_model | str | "claude-sonnet-4-6" | AnthropicChatClient |
redis_url | str | "" | Not read by the SDK — a convenience slot for your app's own config, see note below |
redis_token | str | "" | Same as above |
redis_ttl_seconds | int | 86400 | App-side, when constructing RedisSessionStore |
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().
| Field | Type | Notes |
|---|---|---|
channel | Channel | |
number_id | str | Meta phone_number_id for META messages; the receiving E.164 number for TWILIO messages |
from_number | str | Customer's number |
text | str | Message body |
message_id | str | default "" — provider message ID, for status logging |
profile_name | str | default "" — 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.
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
Sends a plain text message. Raises httpx.HTTPStatusError on a non-2xx Graph API response (after logging the response body).
Sends an approved WhatsApp template (e.g. OTP codes, session-reopening messages outside the 24h window). components is the raw Graph API components list.
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.
Convenience wrapper over send_message that formats a [HOT LEAD] alert string.
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
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.
Creates a Twilio subaccount under the master account. Returns {"sid", "auth_token", "friendly_name"}.
Suspends a subaccount (churn, non-payment).
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).
Submits the OTP. Returns {"status"} — "approved" means the number is live.
Polls the current status of a pending registration.
Sets the incoming-message webhook URL on a Twilio phone number.
Fetches raw usage records (sms, sms-inbound, sms-outbound categories) for billing reconciliation.
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.
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.
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.
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()
...
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.
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.
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).
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).
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)
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.
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.
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.
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
(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
Build this turn's system prompt from whatever app-loaded context you put in context — business profile, enabled flows, knowledge base, etc.
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.
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.
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.
Called after a reply has sent successfully. Use it to persist the outbound message.
Other public methods
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.
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).
| Key | Required when | Purpose |
|---|---|---|
session_key | always | Opaque string used to look up/save history in your SessionStore — commonly f"{agency_id}:{phone}" |
channel | always | Channel.META or Channel.TWILIO — selects which client sends the reply |
number_id, access_token | channel == Channel.META | Passed straight through to MetaChannel.send_message |
account_sid, auth_token, from_number | channel == Channel.TWILIO | Passed 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_errorvariable, 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.
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
- Connect the repo to a Vercel project as usual (framework auto-detects as
fastapifromvercel.json). - Set every env var your
Settingsclass reads (Meta/Twilio/Anthropic/Supabase/Redis credentials) in the Vercel project's Environment Variables. - 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 togit+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. - Deploy a preview build and check the build logs for the
uv sync/pip installstep specifically — confirm it actually resolvesjoiinflowrather than silently failing and falling back to a cached wheel from a previous successful build.
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+P → Python: 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
- Add the git dependency to
pyproject.toml, runuv sync. - 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. - Split your existing flow handler: identify what's generic orchestration (delete it,
FlowHandlerreplaces it) vs. domain-specific (loaders, prompt builder, tool dispatch — move into yourFlowHandlersubclass's methods, logic unchanged). - Swap your session store from whatever in-process dict it was to
RedisSessionStore. - Add signature verification to your webhook routes if it wasn't already there.
- Keep
vercel.jsonandapi/index.pyuntouched — this is a library swap, not a deploy change. - Smoke-test: does the app still import? Does
/docsload? Does a scripted "mock the DB, real Claude call" test (seejoiinai-li/backend/test_flow_handler.pyfor the pattern) still produce the same prompt/reply shape as before the migration?
API index
Every public symbol, by import path. Click through to the full description above.
| Import | Kind | Section |
|---|---|---|
joiinflow.config.JoiinflowConfig | dataclass | JoiinflowConfig |
joiinflow.whatsapp.types.Channel | enum | Types |
joiinflow.whatsapp.types.InboundMessage | dataclass | Types |
joiinflow.whatsapp.MetaChannel | class | MetaChannel |
joiinflow.whatsapp.meta.is_hot_lead, HOT_LEAD_TRIGGERS | function, list | MetaChannel |
joiinflow.whatsapp.TwilioChannel | class | TwilioChannel |
joiinflow.whatsapp.twilio.ByonUserError | exception | TwilioChannel |
joiinflow.whatsapp.signatures.verify_meta_signature | function | Signatures |
joiinflow.whatsapp.signatures.verify_twilio_signature | function | Signatures |
joiinflow.whatsapp.webhook.parse_meta_webhook_payload | function | Webhook parsing |
joiinflow.whatsapp.webhook.parse_twilio_webhook_form | function | Webhook parsing |
joiinflow.llm.anthropic.AnthropicChatClient | class | AnthropicChatClient |
joiinflow.flow.FlowHandler | abstract class | FlowHandler |
joiinflow.flow.SessionStore | protocol | Session storage |
joiinflow.flow.InMemorySessionStore | class | Session storage |
joiinflow.flow.RedisSessionStore | class | Session storage |
joiinflow.flow.session.RedisLike | protocol | Session storage |
joiinflow.flow.ToolSpec | dataclass | ToolSpec & ToolRegistry |
joiinflow.flow.ToolRegistry | class | ToolSpec & 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.