🌐 Language: English | Español · Architecture · Changelog · Contributing · PyPI Package · Report Issue
Build autonomous multi-agent swarms for WhatsApp, Telegram, Webhooks, and CLI in Python with a lightweight, provider-agnostic core.
pip install chatflow-agentchatflow-agent is an async-first Python framework for orchestrating multi-agent conversational workflows across real-world messaging channels. Inspired by lightweight agent handoff patterns, it combines provider-agnostic LLM engines with built-in concurrency locks, transactional turn rollbacks, SQLite persistence, and turnkey channel adapters for WhatsApp (Meta Cloud API), Telegram, FastAPI Webhooks, and CLI.
- Key Features
- Installation & Requirements
- Quickstart
- Our Contributors
- Supported Providers & Configuration
- Agents, Tools & Handoffs
- Sessions, Persistence & Concurrency
- Tool Idempotency & Side-Effect Handling
- Available Channels
- Usage Examples
- Technical Documentation
- Contributing
- License
- Ultra-Lightweight Core: Only 2 core runtime dependencies (
pydantic>=2.6.0,httpx>=0.27.0). All LLM engines (Google Gemini, OpenAI, Anthropic Claude, xAI Grok, DeepSeek, Groq, Ollama, and vLLM) work out of the box over nativehttpxREST without heavy SDK chains; optional channel dependencies are installed only when needed. - Omnichannel Connectors: Production-ready adapters for WhatsApp (Meta Cloud API & generic webhooks), Telegram, FastAPI Webhooks, and interactive CLI.
- Multi-Provider Freedom: Run agents on Google Gemini, OpenAI, Anthropic Claude, xAI Grok, DeepSeek, Groq, or local models via Ollama (Google Gemma, Llama, Mistral, Qwen, Phi) and vLLM—even mixing different providers within the same multi-agent swarm.
- Autonomous Peer Handoffs: Agents delegate conversations to specialist peers dynamically based on user intent, persisting the active specialist and session metadata across turns and server restarts.
- Production-Hardened Resilience: Per-session
asyncio.Lockserialization, non-blocking worker-thread offloading for file-backed SQLite stores, automatic history/agent/metadata rollback on mid-turn failures or timeouts, and an anti-500 webhook shield for Meta Cloud API.
- Python:
3.10,3.11,3.12, or3.13 - Core dependencies:
pydantic>=2.6.0,httpx>=0.27.0
Install only the extras your deployment requires:
# Core framework (all LLM engines via HTTPX, SQLite & in-memory stores, CLI channel)
pip install chatflow-agent
# With WhatsApp Webhook connector (FastAPI + Uvicorn)
pip install "chatflow-agent[whatsapp]"
# With Telegram Bot connector (python-telegram-bot)
pip install "chatflow-agent[telegram]"
# With custom FastAPI Webhook service dependencies (FastAPI + Uvicorn)
pip install "chatflow-agent[webhook]"
# Complete channel bundle (telegram, whatsapp, and webhook extras)
pip install "chatflow-agent[all]"import asyncio
from chatflow_agent import Agent, Runner
# 1. Define specialist agents
tech_agent = Agent(
name="TechSupport",
model="gemini-2.5-flash",
instructions="Help customers troubleshoot technical issues.",
)
billing_agent = Agent(
name="Billing",
model="gemini-2.5-flash",
instructions="Help customers with invoices, subscriptions, and payments.",
)
# 2. Frontline agent with autonomous peer handoffs
receptionist = Agent(
name="Receptionist",
model="gemini-2.5-flash",
instructions="Greet the customer and transfer them to TechSupport or Billing.",
handoffs=[tech_agent, billing_agent],
)
async def main() -> None:
# Using 'async with Runner(...)' pools HTTP connections across turns for GeminiEngine
async with Runner(starting_agent=receptionist) as runner:
result = await runner.run_async(
session_id="user_whatsapp_1",
user_message="Hi! I need help with an invoice error on my account.",
)
print(f"[{result.active_agent_name}]: {result.content}")
# Output: [Billing]: I would be glad to help check your invoice. Could you share your account ID?
if __name__ == "__main__":
asyncio.run(main())For CLI scripts, cron jobs, or synchronous applications where no event loop is running, call runner.run() directly:
from chatflow_agent import Agent, Runner
agent = Agent(
name="Assistant",
model="gemini-2.5-flash",
instructions="Answer concisely.",
)
runner = Runner(starting_agent=agent)
response = runner.run(session_id="sync_user_1", user_message="Hello!")
print(f"[{response.active_agent_name}]: {response.content}")Note
runner.run() manages its own event loop via asyncio.run(). Calling runner.run() from inside an already running event loop (such as a FastAPI endpoint or an async def function) raises a RuntimeError directing you to await runner.run_async(...) instead.
chatflow-agent is built with the help of an international community of developers who contribute code, bug fixes, security hardening, test coverage, and documentation improvements. Every contribution—large or small—helps keep the framework reliable and accessible.
See the full contributor graph on GitHub or check the Contributing section to join the project.
You can supply credentials using any of the following three methods:
Pass api_key to Runner (shared with the default engine) or directly to an individual Agent:
# Pass to Runner (used by the default GeminiEngine)
runner = Runner(starting_agent=my_agent, api_key="AIzaSyYourGeminiKey")
# Or pass directly to a specific Agent
grok_agent = Agent(
name="GrokSpecialist",
provider="grok",
model="grok-2-latest",
api_key="xai-your-key-here",
instructions="You are a research specialist.",
)Create a .env file in your project root and load it with python-dotenv (optional external package: pip install python-dotenv):
GEMINI_API_KEY="AIzaSy..."
OPENAI_API_KEY="sk-..."
ANTHROPIC_API_KEY="sk-ant-..."
XAI_API_KEY="xai-..."
DEEPSEEK_API_KEY="sk-..."
GROQ_API_KEY="gsk_..."from dotenv import load_dotenv
load_dotenv()- Windows (PowerShell):
$env:GEMINI_API_KEY="AIzaSy..."
- Linux / macOS (Bash/Zsh):
export GEMINI_API_KEY="AIzaSy..."
| Provider | Engine | Environment Variable | Agent Configuration |
|---|---|---|---|
| Google Gemini | GeminiEngine |
GEMINI_API_KEY |
model="gemini-2.5-flash" |
| OpenAI | OpenAIEngine |
OPENAI_API_KEY |
provider="openai", model="gpt-4o-mini" |
| Anthropic Claude | AnthropicEngine |
ANTHROPIC_API_KEY |
provider="anthropic", model="claude-3-5-sonnet-latest" |
| xAI Grok | OpenAIEngine |
XAI_API_KEY |
provider="grok", model="grok-2-latest" |
| DeepSeek | OpenAIEngine |
DEEPSEEK_API_KEY |
provider="deepseek", model="deepseek-chat" |
| Groq | OpenAIEngine |
GROQ_API_KEY |
provider="groq", model="llama-3.3-70b-versatile" |
| Ollama (Local) | OpenAIEngine |
None required | provider="ollama", model="gemma2:9b" |
| vLLM / Local Server | OpenAIEngine |
None on localhost |
base_url="http://localhost:8000/v1", model="..." |
- Built-in HTTPX Engines: All engines (
GeminiEngine,OpenAIEngine, andAnthropicEngine) are included out of the box withpip install chatflow-agent—no extra provider SDKs required. - Additional Environment Variables:
GeminiEnginealso acceptsGOOGLE_API_KEYas a fallback andGOOGLE_GEMINI_BASE_URLfor custom endpoints.Ollamaoptionally readsOLLAMA_API_KEY, and anyOpenAIEnginepreset honors<PROVIDER>_BASE_URL(e.g.,OPENAI_BASE_URL,OLLAMA_BASE_URL). - Model Prefix Auto-Detection: When
provideris omitted onAgent,resolve_engine()automatically selects the engine fromagent.model:- Google Gemini (
GeminiEngine):gemini-*(or default fallback) - OpenAI (
OpenAIEngine):gpt-*,o1*,o3*,chatgpt-* - Anthropic Claude (
AnthropicEngine):claude-* - xAI Grok (
OpenAIEngine):grok* - DeepSeek (
OpenAIEngine):deepseek-* - Ollama Local (
OpenAIEngine):gemma*,llama*,mistral*,qwen*,phi* - Groq & vLLM / Local Servers (
OpenAIEngine): Selected explicitly viaprovider="groq"orbase_url="http://localhost:8000/v1".
- Google Gemini (
When executing a turn for an Agent, the Runner resolves the LLM engine in this exact order:
- Agent-level engine (
agent.engine): If an explicitBaseEngineinstance was passed toAgent(engine=...), it is always used. - Explicit Agent provider (
agent.provider): Resolved whenagent.provideris set (gemini/google,anthropic/claude,openai,grok/xai,ollama/gemma,deepseek, orgroq). - Custom Agent endpoint (
agent.base_url): Ifagent.base_urlis set without an explicitprovider, routes toOpenAIEngine(matching the official provider preset if the URL matches an official endpoint, or requiring an explicitapi_keyunless targetinglocalhost). - Explicit Runner engine (
Runner(engine=...)): If a customenginewas explicitly passed toRunner, it takes precedence over model-name prefix heuristics. - Agent model prefix heuristics (
agent.model): Auto-detectsclaude-*,gpt-*/o1*/o3*/chatgpt-*,grok*,deepseek-*, andgemma*/llama*/mistral*/qwen*/phi*. - Default Runner engine / Gemini fallback: Uses the runner's default
GeminiEngine(configured withRunner(api_key=...)) or falls back toGeminiEngine(api_key=agent.api_key).
Important
Host-Scoped Environment Credentials: For security, environment variables (OPENAI_API_KEY, ANTHROPIC_API_KEY, XAI_API_KEY, DEEPSEEK_API_KEY, GROQ_API_KEY) are only sent when connecting to their official HTTPS provider endpoints (api.openai.com, api.anthropic.com, api.x.ai, api.deepseek.com, api.groq.com). They are never forwarded to custom base_url endpoints or local Ollama/vLLM servers. To authenticate against a custom OpenAI-compatible proxy, pass api_key="..." explicitly on the Agent or OpenAIEngine (or api_key="" if no authentication is required).
Warning
Vertex AI is not supported. GeminiEngine calls only the Gemini Developer API (Google AI Studio) with an API key. Application Default Credentials (ADC) and the GOOGLE_GENAI_USE_VERTEXAI, GOOGLE_CLOUD_PROJECT, and GOOGLE_CLOUD_LOCATION variables are not used, so setups that relied on them fail with ProviderError("Gemini API client is not configured. ..."). Use a Gemini API key, or provide your own engine through Runner(engine=...).
GeminiEngine.client was removed in v0.4.0. It held the google-genai client, which is no longer a dependency. Assigning a fake to it no longer mocks anything. To call the SDK directly, install google-genai and create your own client. To mock Gemini in tests or customize HTTP, pass an httpx.AsyncClient with GeminiEngine(http_client=...); it stays owned by you and must be used on one event loop only. See the CHANGELOG for the migration steps.
Register synchronous or asynchronous Python functions (including callable class instances and functools.partial objects) using @agent.tool, the standalone @tool decorator, or agent.add_tool():
import asyncio
from chatflow_agent import Agent, Runner
support_agent = Agent(
name="SupportBot",
model="gemini-2.5-flash", # Or provider="openai", model="gpt-4o-mini"
instructions="You are a helpful customer support agent for a retail store.",
)
@support_agent.tool
def get_order_status(order_id: str, include_items: bool = False) -> dict:
"""Look up shipping and tracking status for an order."""
return {
"order_id": order_id,
"status": "Out for delivery",
"carrier": "FedEx",
"eta": "Today before 6:00 PM",
"include_items": include_items,
}
async def main() -> None:
async with Runner(starting_agent=support_agent) as runner:
response = await runner.run_async(
session_id="user_session_101",
user_message="Hi, can you check the status of my order #FDX-8821?",
)
print(f"[{response.active_agent_name}]: {response.content}")
if __name__ == "__main__":
asyncio.run(main())Run 100% locally on your machine with zero API costs and full data privacy using Ollama:
- Start Ollama with Gemma:
ollama run gemma2:9b - Run the script below (works out of the box with
pip install chatflow-agent):
import asyncio
from chatflow_agent import Agent, Runner
# Connects to http://localhost:11434/v1 with no API key required
local_agent = Agent(
name="LocalAnalyst",
provider="ollama",
model="gemma2:9b",
instructions="You analyze confidential financial reports locally.",
)
@local_agent.tool
def calculate_vat(subtotal: float, rate_percentage: float = 21.0) -> dict:
"""Calculate VAT tax and total amount."""
vat = subtotal * (rate_percentage / 100.0)
return {"subtotal": subtotal, "vat": round(vat, 2), "total": round(subtotal + vat, 2)}
async def main() -> None:
runner = Runner(starting_agent=local_agent)
response = await runner.run_async(
session_id="local_user",
user_message="Calculate VAT for a $450 invoice.",
)
print(f"[{response.active_agent_name}]: {response.content}")
if __name__ == "__main__":
asyncio.run(main())Agents can delegate conversations to peer specialists either through the handoffs=[...] declaration (which synthesizes transfer_to_<agent> tools automatically) or by returning a Handoff object with context_updates from a custom tool:
import asyncio
from chatflow_agent import Agent, Handoff, Runner
tech_agent = Agent(
name="TechSupport",
model="gemini-2.5-flash",
instructions="You diagnose hardware and software issues.",
)
@tech_agent.tool
def run_diagnostics(device_id: str) -> str:
"""Checks device telemetry."""
return f"Device {device_id}: All sensors nominal. Firmware v2.1 up to date."
billing_agent = Agent(
name="Billing",
model="gemini-2.5-flash",
instructions="You handle invoices, subscriptions, and refund requests.",
)
concierge = Agent(
name="Reception",
model="gemini-2.5-flash",
instructions="Greet customers and transfer to TechSupport or Billing as required.",
handoffs=[tech_agent, billing_agent],
)
# Optional: custom tool returning an explicit Handoff with metadata updates
@concierge.tool
def escalate_vip_billing(account_id: str) -> Handoff:
"""Escalate a VIP customer to Billing and attach their account ID to session metadata."""
return Handoff(
target_agent_name="Billing",
reason="VIP billing inquiry",
context_updates={"vip_account_id": account_id},
)
async def main() -> None:
async with Runner(starting_agent=concierge) as runner:
resp = await runner.run_async(
session_id="client_77",
user_message="My device DEV-42 is blinking red. Can you run diagnostics?",
)
print(f"Active Agent: {resp.active_agent_name}") # Output: TechSupport
print(f"Response: {resp.content}")
if __name__ == "__main__":
asyncio.run(main())Note
- Dynamic Instructions:
Agent(instructions=...)accepts either astror a zero-argument callableCallable[[], str]evaluated before every LLM call. - Unique Agent Names: Every distinct
Agentinstance in a swarm (or registered later viaagent.add_handoff()/runner.register_agent()) must have a unique, non-emptyname. Registering two differentAgentinstances with the samenameraisesValueErrorimmediately.
Tool inspects Python type annotations and builds provider-compatible schemas automatically:
- Primitives:
str,int,float,bool - Containers:
list[T],tuple[T, ...],set[T],frozenset[T],Sequence[T](mapped toARRAYwithitemsschema),dict[K, V],Mapping[K, V](mapped toOBJECT) - Nullability & Unions:
Optional[T]/T | Nonepreservesnullable: True;int | floatwidens toNUMBER; multi-type unions (int | str) emitanyOf. - Advanced Annotations: Supports
typing.Annotated[T, ...], postponed annotations (from __future__ import annotations), and variadic*args/**kwargs(excluded from the schema). - Fail-Fast Validation (
ToolSchemaError): If a union or optional annotation contains a custom type that has no concrete JSON schema mapping (e.g.,CustomClass | None), tool registration raisesToolSchemaErrorimmediately instead of silently degrading toSTRING.
| Parameter / Exception | Default / Base | Description |
|---|---|---|
max_tool_iterations (Runner) |
10 |
Maximum tool-execution loops allowed in a single turn. |
max_handoffs_per_turn (Runner) |
5 |
Maximum agent-to-agent transfers allowed within a single turn; when exceeded, records an error ToolResult (is_error=True) and skips remaining tool calls in that batch. |
turn_timeout (run_async) |
None |
Optional per-turn execution limit in seconds (starts after acquiring the session lock). |
ChatFlowError |
Exception |
Base class for all custom exceptions in chatflow_agent. |
ProviderError |
ChatFlowError |
Raised on LLM API errors, blocked responses, or malformed payloads (error excerpts capped at 1,000 chars). |
ToolSchemaError |
ChatFlowError, TypeError |
Raised at tool definition time when a union member cannot be mapped to a JSON schema type. |
ToolExecutionError |
ChatFlowError |
Raised when a tool call fails directly via Tool.execute_async(). |
AgentHandoffError |
ChatFlowError |
Raised when a handoff targets an unknown or unauthorized agent (not declared in current_agent.handoffs). |
ReentrantTurnError |
ChatFlowError, RuntimeError |
Raised when a turn attempts to start another turn for the same session inline. |
SessionDeletedError |
ChatFlowError, RuntimeError |
Raised when a running turn's session is deleted concurrently. |
DependencyError |
ChatFlowError |
Raised when an optional provider or channel package is not installed. |
chatflow-agent provides zero-dependency session stores that preserve conversation history, the active specialist agent, and session metadata across turns and process restarts.
Ideal for local scripting, unit tests, and ephemeral sessions:
from chatflow_agent import Runner, SessionStore
runner = Runner(
starting_agent=receptionist,
session_store=SessionStore(default_max_turns=40),
)Built on Python's standard sqlite3 module with Write-Ahead Logging (WAL), atomic transactions, sliding-window turn pruning, and persistent webhook message deduplication (dedup_ttl_hours=192):
from chatflow_agent import Runner, SQLiteSessionStore
# File-backed SQLite store (persists across server restarts)
with SQLiteSessionStore(db_path="sessions.db", default_max_turns=30) as session_store:
runner = Runner(starting_agent=receptionist, session_store=session_store)
# ... run your application ...
# Or shared in-memory SQLite store for fast, isolated integration tests:
memory_sqlite = SQLiteSessionStore(":memory:")
try:
runner = Runner(starting_agent=receptionist, session_store=memory_sqlite)
finally:
memory_sqlite.close()Key Persistence Features:
- Autonomous Handoff Survival: When a user is transferred to
Billing, the active agent name andmetadataare stored in SQLite. After a server restart, subsequent messages continue directly withBilling. - Sliding Window FIFO Pruning: Retains the latest
default_max_turnscomplete user turns while pruning older messages so LLM context windows stay bounded. - Explicit Lifecycle Management: Use
with SQLiteSessionStore(...) as store:or callstore.close()on shutdown to release shared:memory:connections and shut down the background worker pool cleanly.
chatflow-agent includes built-in safeguards engineered for real-world messaging traffic:
-
Per-Session Concurrency Lock (
asyncio.Lock): When a user sends multiple messages in rapid succession (e.g., three WhatsApp voice transcriptions or quick texts in 2 seconds), turns of the same session run one at a time, preventing race conditions, overlapping tool executions, or corrupted conversation histories. They run in the order they start waiting for the session (asyncio.Lockis fair); that is the order in which the channel starts the turns, which is usually, but not necessarily, the order the messages were sent. WithSQLiteSessionStore, each queued turn reloads the history, active agent and metadata committed by the previous turn before it starts.- Scope: the guarantee holds within one process, one store instance and one running event loop (for example, one Uvicorn worker, a Telegram bot, or successive
Runner.run()calls). Turns for the same session coming from several store instances on the same file, several processes, or several threads with their own event loops are not serialized. - Turns started from inside a turn: code started by a turn (a tool, or a task or thread the tool starts) cannot start another turn of the same session while that turn is running:
runner.run_async()/runner.run()raiseReentrantTurnError(aRuntimeError) instead of waiting for a lock the current turn may be waiting on. To queue a follow-up turn for the same session, callrunner.spawn_turn(session_id, message): it returns anasyncio.Taskthat runs after the current turn ends. Never await that task inside the turn that spawned it; await it (or read its exception) afterwards. Turns of other sessions can be awaited from a tool normally. - Deleting a session:
delete()andclear()do not wait for running turns. A turn running when its session is deleted fails withSessionDeletedErrorand leaves nothing behind; a turn waiting for that session starts a new, empty one. Deleting erases a session's data; it does not block its id, so a new message creates the session again. - SQLite and the event loop: with a file-backed
SQLiteSessionStore, session reads and writes made by theRunner, the WhatsApp webhook and Telegram's/resetrun in a pool of 4 worker threads owned by the store, so a slow disk or a database locked by another writer does not stop other requests, sessions or timeouts. A SQLite call that has started cannot be interrupted: a cancelled or timed-out turn waits for its running write, rolls back, and only then raises (the timeout is detected on time but reported once the rollback is stored, up tobusy_timeout, 10 s, per write while the database is locked). The WhatsApp webhook still acknowledges after its deduplication write, and answers HTTP 500 if the lock outlastsbusy_timeout(Meta then retries).SessionStore,:memory:SQLite stores, subclasses and custom stores keep running on the event loop. This does not make one SQLite file safe for several workers or processes. - Turn time limit:
await runner.run_async(session_id, message, turn_timeout=30)limits the turn itself (keyword-only, seconds). The limit starts once the turn holds the session, so time spent waiting behind earlier turns of the same session does not count. When it expires the turn is cancelled, the session is restored to its state before the turn, andTimeoutErroris raised. It must be a positive finite number; the default,None, sets no limit. - Synchronous tools after a timeout: a synchronous (
def) tool runs in a worker thread, and Python cannot stop a callable that has already started. The timeout and conversational rollback are still reported without waiting for that callable indefinitely, but the session remains fenced until the callable really returns or raises. Later turns of that session do not start while it is running; for channels, this residual-work wait counts towardqueue_timeout_seconds. Other sessions continue normally. If the callable never finishes, the affected session remains fenced (bounded channel requests receive their configured queue-timeout fallback); use timeouts in the tool's own I/O and design side effects to be idempotent. Async tools remain cancellable normally. - WhatsApp: each message has two separate limits.
queue_timeout_seconds(default 120) bounds the wait behind earlier turns of the same session: if it expires, the user receivesfallback_messageand the turn is never run (the message is not added to the history and, as before, a Meta retry of the samewamidis discarded).llm_timeout_seconds(default 120) bounds the turn itself and starts once the turn holds the session: when it expires the turn is cancelled and rolled back and the user receivesfallback_message. A reply can therefore take up toqueue_timeout_seconds + llm_timeout_seconds. The logs tell the cases apart:Session busy ...(queue limit),Turn timed out ...(turn limit),Dispatch backstop expired ...(adispatch_async()override that never reachedRunner.run_async()), andTimeoutError raised while dispatching ...(aTimeoutErrorfrom the provider or other code, not a channel limit).llm_timeout_seconds=Noneorinfsets no turn limit.
- Scope: the guarantee holds within one process, one store instance and one running event loop (for example, one Uvicorn worker, a Telegram bot, or successive
-
Meta Anti-500 Error Shield: Meta's webhook infrastructure retries delivery aggressively if your endpoint returns HTTP 500.
WhatsAppChannelcatches any upstream LLM outages or rate limits, returns HTTP 200 to Meta to prevent retry storms, dispatches the configuredfallback_messageto the user, and logs clean diagnostic details. -
Unsupported Media & Ignored Events Handling: Audio voice notes, photos, and PDF files are safely intercepted with
unsupported_media_message, while WhatsApp reactions (reaction) and system notifications (system) are ignored cleanly with HTTP 200 without invoking theRunneror sending outbound messages. -
Turn Failure Rollback: If an LLM request times out, rate limits, or crashes mid-turn, ChatFlow automatically rolls back dialogue history to the last consistent turn in memory and SQLite (note: rollback reverts conversational history; external side effects of already executed tools are not rolled back), ensuring clean context without orphaned partial turns. See Tool Idempotency & Side-Effect Handling for how to design tools that are safe to retry.
- With
SQLiteSessionStore, the rollback restores the history, active agent and metadata the turn started from, including messages pruned by the sliding window during the turn. If anything else changed the session during the turn (another context updating its metadata or agent, a reset, a delete, or a writer outside the supported scope above), only the failed turn's own changes are reverted, other writers' messages, agent and metadata are kept, and a warning is logged.
- With
Tools that interact with external systems (process payments, create database records, send emails or messages, call external APIs, create remote resources, or trigger persistent business operations) may be executed again after a failed turn or a user retry. This section explains how to design them so a repeated call does not repeat the real-world effect.
When a turn fails, ChatFlow restores the following to the last consistent turn, in memory and in SQLite:
- session history;
- the active agent;
- session metadata.
This does not undo operations performed outside chatflow-agent. A tool may succeed and produce an external side effect, and the same turn may then fail because of a provider or network error. Since the history is rolled back, the retry no longer contains evidence that the tool already executed, so the same business operation may be requested again.
This is expected behavior in distributed systems. It is handled through idempotent tool design, not through automatic deduplication in the Runner.
Identify each business operation with a stable business identifier, such as:
order_id
payment_id
request_id
operation_id
Use it to build an idempotency key (a business operation ID) that stays the same across retries:
operation_id = f"refund:{order_id}"An idempotency key identifies one business operation. Reuse it for retries of that same operation, with the same semantic parameters (for example, the same order and amount); a different operation needs a different key.
A tool only receives the arguments the model supplies, so do not let the model generate the key. Use an identifier that already exists in your system.
Claim the key atomically, run the side effect, then record the result. payment_gateway is a placeholder for your external system.
import json
import sqlite3
DB_PATH = "operations.db"
with sqlite3.connect(DB_PATH) as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS operations (
operation_id TEXT PRIMARY KEY,
status TEXT NOT NULL, -- 'pending' | 'done'
result TEXT
)
"""
)
@billing_agent.tool
def create_refund(order_id: str, amount: float) -> dict:
"""Refund an order. Safe to call more than once for the same order."""
operation_id = f"refund:{order_id}"
with sqlite3.connect(DB_PATH) as conn:
try:
# 1. Claim the key atomically.
conn.execute(
"INSERT INTO operations (operation_id, status) VALUES (?, 'pending')",
(operation_id,),
)
except sqlite3.IntegrityError:
# Already seen: return the stored outcome instead of repeating the effect.
status, result = conn.execute(
"SELECT status, result FROM operations WHERE operation_id = ?",
(operation_id,),
).fetchone()
if status == "done":
return {**json.loads(result), "already_processed": True}
return {"status": "in_progress", "operation_id": operation_id}
# 2. Execute the side effect, forwarding the key downstream.
result = payment_gateway.refund(
order_id=order_id, amount=amount, idempotency_key=operation_id
)
# 3. Record the outcome.
with sqlite3.connect(DB_PATH) as conn:
conn.execute(
"UPDATE operations SET status = 'done', result = ? WHERE operation_id = ?",
(json.dumps(result), operation_id),
)
return result- Return the stored result when the operation already ran, so the model can answer consistently instead of repeating the action.
- Forward the key to the external system when it supports one.
- This example does not recover pending operations. A
pendingrow means either that the operation is still running or that execution was interrupted before it completed, and the example cannot tell which. Calling the tool again does not retry a pending operation; it only returnsin_progress. Your application should reconcile or retry pending operations deliberately, using the same idempotency key.
A tool can be called again because a turn is retried after a rollback, the user resends a message, or a channel redelivers a webhook. Idempotent tools make all of these safe: the second call finds the recorded key and returns the stored result.
Record what each tool did outside the conversation history (the operations table above is a minimal example): the idempotency key, the external system's ID (payment ID, message ID, remote resource ID), the status, and the result returned to the model. A history rollback does not affect this record.
| Tool type | Recommended approach |
|---|---|
| Payments | Key on payment_id or order_id plus the operation; forward the key to the payment provider. |
| Database records | Use UNIQUE constraints or upserts (INSERT ... ON CONFLICT) on a business key. |
| Emails / messages | Reserve a pending delivery per (recipient, message_kind, business_id), send it, then confirm the result (see the note below). |
| External API calls | Send the provider's idempotency key when available; otherwise look up existing state first. |
| Remote resources | Use deterministic names or tags derived from a business ID, and look them up before creating. |
| Persistent business operations | Assign an operation_id per business action and record it before executing. |
Emails and messages. Treat delivery as three separate steps: reserve a pending delivery, send the message, then confirm the result. Recording a "sent" marker before sending can lose a message if the process stops before delivery. If delivery succeeds but recording the result fails, a local marker alone cannot guarantee both no duplicates and guaranteed delivery; that requires an external provider that supports idempotency or reconciliation.
Run a production webhook server compatible with the official Meta WhatsApp Business Cloud API (requires pip install "chatflow-agent[whatsapp]"):
from chatflow_agent import Agent, Runner, WhatsAppChannel
agent = Agent(name="WhatsAppConcierge", instructions="Answer customer inquiries.")
runner = Runner(starting_agent=agent)
channel = WhatsAppChannel(
verify_token="my_custom_webhook_secret", # Verification token configured in Meta App
access_token="EAA...", # Meta Permanent/System User Token
app_secret="a1b2c3d4...", # Meta App Secret (required when verify_signature=True)
phone_number_id="109876543210987", # WhatsApp Phone Number ID from Meta Dashboard
fallback_message="We are experiencing a temporary delay. Please try again shortly.",
unsupported_media_message="Currently I can only process text messages.",
llm_timeout_seconds=120.0,
queue_timeout_seconds=120.0,
)
channel.attach(runner)
if __name__ == "__main__":
# Exposes GET /webhook (verification), POST /webhook (messages), and GET /health
channel.run(host="0.0.0.0", port=8000)- Fail-Closed HMAC Verification (
verify_signature=Trueby default): ValidatesX-Hub-Signature-256usingapp_secreton everyPOST /webhookrequest before parsing JSON. - Payload Guards: Enforces
max_body_size(default 1 MB, returning HTTP 413 when exceeded or HTTP 411 whenContent-Lengthis missing) and returns HTTP 400 for malformed or non-object JSON payloads. - Automatic Message Chunking & Outbound Retries: Outbound replies exceeding 4,096 characters are split cleanly with
split_message(), and transient network/rate-limit delivery errors are retried with exponential backoff (max_outbound_retries=3).
Testing locally? Use a tunnel like ngrok (
ngrok http 8000) or Cloudflare Tunnels to provide Meta with a public HTTPS URL (https://your-domain.ngrok-free.app/webhook).
Note
API Documentation in Development: For security, FastAPI interactive documentation (/docs, /redoc, and /openapi.json) is disabled by default in production. To enable Swagger UI and OpenAPI schemas during local development or debugging, pass enable_docs=True when instantiating WhatsAppChannel:
channel = WhatsAppChannel(
...,
enable_docs=True, # Enables /docs, /redoc, and /openapi.json for local development
)Tip
Production Rate Limiting & Reverse Proxy: To protect /webhook and /health endpoints against flood attacks, denial of service (DoS), and CPU exhaustion from unauthenticated requests without risking dropping legitimate Meta webhook bursts at the application level, always place Uvicorn behind a reverse proxy in production (such as Nginx with limit_req_zone or Cloudflare Rate Limiting):
limit_req_zone $binary_remote_addr zone=whatsapp_limit:10m rate=10r/s;
location /webhook {
limit_req zone=whatsapp_limit burst=20 nodelay;
proxy_pass http://127.0.0.1:8000;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}Connect your agent swarm to Telegram with async polling, typing indicators, automatic 4,096-character message splitting, and built-in /start and /reset commands (requires pip install "chatflow-agent[telegram]"):
from chatflow_agent import Agent, Runner, TelegramChannel
agent = Agent(name="TelegramBot", instructions="You assist Telegram users.")
runner = Runner(starting_agent=agent)
channel = TelegramChannel(
token="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", # From @BotFather
fallback_message="Sorry, a temporary issue occurred. Please retry in a few moments.",
)
channel.attach(runner)
if __name__ == "__main__":
# Runs async polling; in group chats, /reset is restricted to group administrators
channel.run()Test and debug multi-agent swarms interactively in your terminal:
from chatflow_agent import Agent, CLIChannel, Runner
agent = Agent(name="TerminalAssistant", instructions="Answer user questions concisely.")
runner = Runner(starting_agent=agent)
channel = CLIChannel(session_id="dev_test")
channel.attach(runner)
if __name__ == "__main__":
# Supports /reset to clear session memory, and 'exit' / 'quit' / 'salir' or Ctrl+C to quit
channel.run()Ready-to-run reference scripts are available in the examples/ directory:
examples/whatsapp_persistent_bot.py: Meta Cloud API WhatsApp bot with SQLite persistence, per-session concurrency locks, and a 3-agent handoff hierarchy.examples/whatsapp_service.py: Minimal WhatsApp Business Cloud API webhook starter with custom tools and specialist handoffs.examples/telegram_persistent_bot.py: Telegram bot with SQLite state persistence across polling restarts.examples/telegram_triage.py: Multi-agent Telegram triage bot with order lookup and technical support tools.examples/webhook_api_service.py: Custom FastAPI REST service exposing/api/chat,/api/sessions/{session_id}, and/healthwith SQLite persistence.examples/local_gemma_cli.py: 100% offline CLI assistant powered by Google Gemma 2 via Ollama with persistent SQLite sessions.examples/multi_provider_swarm.py: Multi-provider swarm coordinating Gemini, local Gemma, Grok, and Claude in a single conversation.examples/cli_multiagent.py: Interactive CLI swarm demonstrating autonomous handoffs between a receptionist, technical support, and billing agent.
- Architecture Guide: Deep dive into the internal execution loop, message schemas, engine adapters, and session persistence.
- Migration Guide (0.3.4 → 0.4.0): Upgrade instructions, breaking changes, and new features in
0.4.0. - Changelog: Release history, new features, security advisories, and breaking changes.
- Security Policy: Supported versions and vulnerability reporting instructions.
- Code of Conduct: Community standards and expectations.
- Guía en Español: Complete Spanish documentation (
README.es.md).
Contributions are welcome! Whether you are fixing a bug, improving documentation, adding tests, or proposing new channel connectors, your help is appreciated.
If you would like to help improve chatflow-agent, check out our open issues:
- 🟢 Good First Issues — Well-scoped tasks ideal for first-time contributors.
- 🟡 Help Wanted — Intermediate and advanced tasks looking for assistance.
- 📋 All Open Issues — Browse all open bug reports, enhancements, and contribution opportunities.
Please read our Contributing Guide before submitting a Pull Request for setup instructions, code style (ruff, mypy --strict), and test requirements (pytest).
Distributed under the MIT License.
Created and maintained by Gustavo Baranda, with contributions from the community.



























