This release is a pre-release and may not be stable for production use.
chatflow-agent
🌐 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-agent
chatflow-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.
Table of Contents
- 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
Key Features
- 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.
Installation & Requirements
- 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]"
Quickstart
Async Multi-Agent Quickstart (Recommended for Webhooks & Bots)
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())
Synchronous Quickstart (runner.run())
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}")
Our Contributors
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.
Supported Providers & Configuration
Configuring API Keys
You can supply credentials using any of the following three methods:
1. Direct in Python Code
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.",
)
2. Using a .env File
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()
3. Via Terminal Environment Variables
- Windows (PowerShell):
$env:GEMINI_API_KEY="AIzaSy..."
- Linux / macOS (Bash/Zsh):
export GEMINI_API_KEY="AIzaSy..."
Provider Reference Table
| 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 (
Engine Resolution Precedence (resolve_engine)
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).
Agents, Tools & Handoffs
A. Cloud LLM Agent with Tools
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())
B. Local Offline Agent (Google Gemma via Ollama)
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())
C. Multi-Agent Swarm with Autonomous Handoffs
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())
Tool Parameter Schemas & ToolSchemaError
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.
Runner Limits & Public Exceptions
| 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. |
Sessions, Persistence & Concurrency
chatflow-agent provides zero-dependency session stores that preserve conversation history, the active specialist agent, and session metadata across turns and process restarts.
In-Memory Storage (SessionStore)
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),
)
Persistent & In-Memory SQLite Storage (SQLiteSessionStore)
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.
Production Resilience & Concurrency
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
Tool Idempotency & Side-Effect Handling
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.
Conversational Rollback vs. External Effects
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.
Idempotent Tool Design
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.
Retry-Safe Tool Design
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.
Replay Protection
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.
External Transaction Tracking
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.
Recommended Patterns
| 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.
Available Channels
WhatsApp Channel (Meta Cloud API)
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).
Telegram Channel
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()
Interactive Terminal (CLI)
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()
Usage Examples
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.
Technical Documentation
- 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).
🤝 Contributing
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).
License
Distributed under the MIT License.
Created and maintained by Gustavo Baranda, with contributions from the community.
Metadata
Release files for chatflow-agent 0.4.0rc1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| chatflow_agent-0.4.0rc1.tar.gz | 2.5 MB | With provenance | Details for chatflow_agent-0.4.0rc1.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| chatflow_agent-0.4.0rc1-py3-none-any.whl | Python 3 | none | any | Details for chatflow_agent-0.4.0rc1-py3-none-any.whl |
Total release size: 2.6 MB
Release files / chatflow_agent-0.4.0rc1.tar.gz
| Download URL | chatflow_agent-0.4.0rc1.tar.gz |
|---|---|
| Size | 2.5 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
067f98b71bd85df42df8a23490bf08b969d8cdf761ca2e385aeb9cca00355e3d
|
|
BLAKE2b-256 checksum How to use checksums |
f626804113436b5285f63eecc010fec1e897e9bf7b2e0c992a2ac1c4a886f484
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 27, 2026.
Transparency logRelease files / chatflow_agent-0.4.0rc1-py3-none-any.whl
| Download URL | chatflow_agent-0.4.0rc1-py3-none-any.whl |
|---|---|
| Size | 93.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
3286d49301ee939cca2d47b145ba88f930d5135e8457d89e0e311009912105f2
|
|
BLAKE2b-256 checksum How to use checksums |
1b9526e4e6d4ecf0aef83891f08f711e217ada16016080bb3501f44aa81295d9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 27, 2026.
Transparency logSecurity for chatflow-agent 0.4.0rc1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for chatflow_agent-0.4.0rc1.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 27, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.