Integrate Agent Mesh with FastAPI
Add resumable application event streams to a FastAPI application.
Use the Agent Mesh software development kit (SDK) to run a producer independently of its client connection. FastAPI clients reconnect with a saved cursor to replay retained server-sent events (SSE), then follow new events from the same run.
Start with the FastAPI browser demo. This guide covers application setup, authorization, execution and recovery. For an existing agent, use the agent SDK recipes.
Install the integration
Use Python 3.11 or later and Redis 8.6.2 or later with native producer idempotency
(XADD IDMP). Redis 8.6.6 or later is recommended by the upstream streaming guide.
pip install 'redis-iris[fastapi-sse]'
The independent A2A integration is optional. Install both with
redis-iris[a2a,fastapi-sse]; their shared redis-py range is >=8.0.1,<9.
See the database setup
for local Redis or Redis Cloud connections. Deployment qualification is separate
from configuring an endpoint.
Configure the application
The facade re-exports the upstream FastAPI Redis SDK objects unchanged. Configure its standard lifespan and streaming feature before application startup:
from fastapi import FastAPI
from redis_iris.agent_mesh.fastapi_sse import FastAPIRedis
app = FastAPI()
FastAPIRedis(app).lifespan().streams()
Set REDIS_URL for the connection, including any Transport Layer Security (TLS)
parameters. Set REDIS_PREFIX for the namespace. The SDK creates and closes its
Redis pools and stops its in-process producers when the application shuts down.
Use its REDIS_* configuration and .env sources; settings are cached globally.
The integration does not accept injected clients, per-app settings or a separate
blocking-client argument.
Keep REDIS_SOCKET_TIMEOUT unset, or longer than
REDIS_STREAM_HEARTBEAT_INTERVAL (15 seconds by default), so blocking reads can
complete. Each live reader holds one connection from the shared pool. If you set
REDIS_MAX_CONNECTIONS, reserve capacity for readers plus writes and other
commands. There are no relay buckets or subscriber queues.
Authorize scoped runs
A run is identified by a scope and stream_id. Choose a scope for the resource
you authorize, such as a tenant's chat or job. Multiple runs can share a scope;
your application tracks the current run and enforces any one-run-at-a-time policy.
Authenticate and authorize before passing the scope and run ID to the backend.
Neither StreamBackendDep nor backend.sse() performs application access checks.
The application dependency in this example verifies access to the job and returns
its authorized tenant and run identifiers:
from fastapi import Depends, Request
from fastapi.responses import StreamingResponse
from redis_iris.agent_mesh.fastapi_sse import StreamBackendDep
from myapp.auth import require_job_access
@app.get(
"/jobs/{job_id}/events",
response_class=StreamingResponse,
responses={200: {"content": {"text/event-stream": {}}}},
)
async def job_events(
request: Request,
streams: StreamBackendDep,
job=Depends(require_job_access),
):
scope = f"tenant:{job.tenant_id}:job:{job.id}"
return await streams.sse(job.run_id, request=request, scope=scope)
Authorize every reconnect. IDs and cursors grant no access. Scopes accept letters, digits, underscores, periods, colons and hyphens; run IDs accept letters, digits, underscores and hyphens. Keep credentials out of URLs.
Same-origin cookie authentication works with native EventSource. Bearer tokens
need a fetch-based SSE client or another client that accepts authorization
headers. Pass a timezone-aware verified credential deadline as expires_at to
backend.sse() to bound the open connection. This does not provide immediate
revocation.
Start a producer and publish events
Supply an async producer that receives a StreamWriter:
from redis_iris.agent_mesh.fastapi_sse import StreamWriter
async def run_job(writer: StreamWriter):
result = await perform_job()
await writer.append("job.progress", {"percent": 100})
await writer.finish("job.completed", {"result": result})
# In your authorized start endpoint, with a StreamBackendDep named streams:
# scope and run_id come from the application's job record.
writer = await streams.start(run_job, scope=scope, stream_id=run_id)
start() opens the run and starts the producer only when it was newly created.
A repeated start with the same scope and retained ID returns a retry handle
without spawning a second producer. writer.append(type, data) and
writer.finish(type, data) return Redis entry IDs. Event types are application
names; the stream. prefix is reserved for SDK events.
For additional work between opening and spawning, use await streams.open(...)
and call streams.spawn(writer, producer) only when writer.created is true.
To read without HTTP, use
async with await streams.follow(run_id, scope=scope, after=cursor).
Events expose id, type, data and final fields.
The SDK owns the producer task and generates its write-retry identities.
Applications cannot supply producer/event idempotency keys. Disconnecting a
reader leaves the task running. cancel() ends the run for readers and cancels
a producer in the same process. A producer in another process keeps running;
its later events are unread. Runs cannot be handed to another process or restart
after a process exit. Publisher-only external workers are not supported.
Agent SDK recipes
Connect an existing agent to resumable server-sent events (SSE) by translating
its asynchronous events into StreamWriter calls. Choose one recipe for the
OpenAI Agents SDK, PydanticAI, Google Agent Development Kit (ADK), or LangGraph.
The agent framework stays responsible for tools, model credentials, and
conversation state. Iris carries one turn's events.
Install redis-iris[fastapi-sse] and your chosen framework with its model-provider
dependencies. These recipes adapt the
upstream agent recipes,
which target openai-agents 0.24, pydantic-ai-slim 2.54, google-adk 2.7,
and langgraph 1.2 with langchain-core 1.7. Check event types when upgrading.
Each snippet takes your configured agent, runner, or compiled graph as an
argument; it does not configure a model or create a conversation store.
Use a shared event format
The four recipes publish the same application event types:
| Type | Data | Write method |
|---|---|---|
delta |
{"text": ...} |
append() |
tool_call |
{"id": ..., "name": ..., "args": ...} |
append() |
tool_result |
{"id": ..., "output": ...} |
append() |
message |
{"text": ...}, the completed answer |
finish() |
turn.interrupted |
{"interrupts": [...]}, an approval or input request |
finish() |
Append delta text in the browser. Use the final message to set the completed
answer; appending it would duplicate the streamed text. The LangGraph recipe
also demonstrates turn.interrupted.
Map only the fields your interface needs. Whole framework events can contain
agents, tools, or conversation history that cannot be encoded or exceed
stream_max_event_bytes. Summarize large tool results. The default payload
coder accepts Pydantic models and dataclasses, but the output still must fit
the event limit. Tool arguments and results can contain sensitive data; publish
only what the authorized reader should see.
Start and serve a turn
Configure your FastAPI application as described in
Configure the application. Save your chosen
run_turn recipe in your application's myapp.streaming module. For OpenAI
Agents or PydanticAI, start it from an authorized route like this:
from fastapi import Depends
from pydantic import BaseModel
from redis_iris.agent_mesh.fastapi_sse import StreamBackendDep
from myapp.agent import agent
from myapp.auth import require_job_access
from myapp.streaming import run_turn
class Prompt(BaseModel):
text: str
@app.post("/jobs/{job_id}/runs", status_code=202)
async def start_turn(
body: Prompt,
streams: StreamBackendDep,
job=Depends(require_job_access),
):
producer = run_turn(body.text, agent=agent)
writer = await streams.start(
producer,
scope=f"tenant:{job.tenant_id}:job:{job.id}",
stream_id=job.run_id,
)
return {"stream_id": writer.stream_id}
myapp.agent supplies your configured agent. require_job_access is your own
authentication and authorization dependency, as in
Authorize scoped runs. It returns the authorized job
and its application-assigned run ID. Reuse that ID for retries of the same turn;
assign a new ID for a new turn. For ADK and LangGraph, replace the producer
assignment with the call shown in their recipe.
Serve the run using the existing job_events route. Authorize cancellation
through the same dependency:
@app.post("/jobs/{job_id}/runs/cancel", status_code=204)
async def cancel_turn(
streams: StreamBackendDep,
job=Depends(require_job_access),
) -> None:
await streams.cancel(
job.run_id, scope=f"tenant:{job.tenant_id}:job:{job.id}"
)
Disconnecting a reader leaves the producer running. With the SDK revision used by these recipes, cancellation in the producer's process cancels the task and reaches its awaited model call or tool. A cancellation handled by another process ends delivery to readers but does not stop that producer or its model spend. Do not catch and suppress task cancellation in your producer.
If the SDK raises, the backend ends the stream with
{"reason": "error", "code": "producer_error"} instead of exposing exception
text. A string code attribute on the exception overrides producer_error.
Keep conversation history in the framework's session or checkpoint store;
replaying SSE events does not resume agent computation.
OpenAI Agents SDK
Pass your configured agents.Agent as agent. Text deltas come from raw
Responses API events; tool calls and results come from run-item events.
import json
from agents import Runner
from openai.types.responses import ResponseTextDeltaEvent
from redis_iris.agent_mesh.fastapi_sse import StreamWriter
def run_turn(prompt: str, *, agent):
async def produce(writer: StreamWriter) -> None:
result = Runner.run_streamed(agent, prompt)
async for event in result.stream_events():
if event.type == "raw_response_event":
if isinstance(event.data, ResponseTextDeltaEvent):
await writer.append("delta", {"text": event.data.delta})
elif event.type == "run_item_stream_event":
if event.name == "tool_called":
call = event.item.raw_item
await writer.append("tool_call", {
"id": call.call_id,
"name": call.name,
"args": json.loads(call.arguments or "{}"),
})
elif event.name == "tool_output":
await writer.append("tool_result", {
"id": event.item.raw_item["call_id"],
"output": event.item.output,
})
await writer.finish("message", {"text": str(result.final_output)})
return produce
Read final_output after consuming stream_events(). This recipe handles turns
without tool approvals. If your tools require approval, handle
result.interruptions before writing the final message, as described in
Handle human input.
PydanticAI
Pass your configured pydantic_ai.Agent as agent. Consume both the initial text
part and subsequent text deltas so the first chunk is preserved.
from pydantic_ai import (
AgentRunResultEvent, FunctionToolCallEvent, FunctionToolResultEvent,
PartDeltaEvent, PartStartEvent, TextPart, TextPartDelta,
)
from redis_iris.agent_mesh.fastapi_sse import StreamWriter
def run_turn(prompt: str, *, agent):
async def produce(writer: StreamWriter) -> None:
async with agent.run_stream_events(prompt) as events:
async for event in events:
if isinstance(event, PartStartEvent) and isinstance(event.part, TextPart):
if event.part.content:
await writer.append("delta", {"text": event.part.content})
elif isinstance(event, PartDeltaEvent) and isinstance(event.delta, TextPartDelta):
await writer.append("delta", {"text": event.delta.content_delta})
elif isinstance(event, FunctionToolCallEvent):
await writer.append("tool_call", {
"id": event.part.tool_call_id,
"name": event.part.tool_name,
"args": event.part.args_as_dict(),
})
elif isinstance(event, FunctionToolResultEvent):
await writer.append("tool_result", {
"id": event.part.tool_call_id,
"output": event.part.content,
})
elif isinstance(event, AgentRunResultEvent):
await writer.finish("message", {"text": event.result.output})
return produce
Use async with for run_stream_events() so leaving the loop closes the run.
Publish only the result output, rather than its whole message history. This
recipe assumes tools finish within the turn; deferred-tool output needs the
human-input pattern.
Google ADK
Pass your configured ADK Runner as runner. Its session must exist before the
producer starts. Resolve user_id and session_id from authorized application
state, and create or load that session through your session service.
from google.adk.agents.run_config import RunConfig, StreamingMode
from google.genai import types
from redis_iris.agent_mesh.fastapi_sse import StreamWriter
def run_turn(prompt: str, *, runner, user_id: str, session_id: str):
async def produce(writer: StreamWriter) -> None:
message = types.Content(role="user", parts=[types.Part(text=prompt)])
config = RunConfig(streaming_mode=StreamingMode.SSE)
answer = ""
async for event in runner.run_async(user_id=user_id, session_id=session_id,
new_message=message, run_config=config):
if event.partial:
# A chunk of the answer. The final, non-partial event repeats
# the whole text, so only chunks become deltas.
parts = event.content.parts if event.content else None
for part in parts or []:
if part.text and not part.thought:
await writer.append("delta", {"text": part.text})
continue
for call in event.get_function_calls():
await writer.append("tool_call",
{"id": call.id, "name": call.name, "args": call.args})
for response in event.get_function_responses():
await writer.append("tool_result",
{"id": response.id, "output": response.response})
if event.is_final_response() and event.content and event.content.parts:
answer = "".join(part.text or "" for part in event.content.parts
if not part.thought)
await writer.finish("message", {"text": answer})
return produce
Use this producer assignment in the start route, with your application-owned runner and the authorized ADK user and session identifiers:
producer = run_turn(
body.text, runner=runner, user_id=user_id, session_id=session_id
)
StreamingMode.SSE enables partial text events. The final non-partial event
repeats the answer, so it becomes the final message, not another delta. The
recipe excludes thought parts from the browser output. Long-running tools that
wait for a person need the human-input pattern.
LangGraph
Pass a compiled graph with a checkpointer as graph. This recipe expects graph
state with a messages key and message-bearing node updates. It uses messages
for text deltas and updates for tool calls, tool results, and interrupts.
from langchain_core.messages import AIMessage, AIMessageChunk, ToolMessage
from redis_iris.agent_mesh.fastapi_sse import StreamWriter
def run_turn(inputs, *, graph, config):
async def produce(writer: StreamWriter) -> None:
answer = ""
async for mode, chunk in graph.astream(inputs, config,
stream_mode=["messages", "updates"]):
if mode == "messages":
message, _metadata = chunk
if isinstance(message, AIMessageChunk) and message.text:
await writer.append("delta", {"text": message.text})
continue
if "__interrupt__" in chunk:
# The graph waits for a person. End this turn; the answer
# resumes the graph as a new turn, on a new stream.
interrupts = [item.value for item in chunk["__interrupt__"]]
await writer.finish("turn.interrupted", {"interrupts": interrupts})
return
for update in chunk.values():
for message in (update or {}).get("messages", []):
if isinstance(message, ToolMessage):
await writer.append("tool_result", {
"id": message.tool_call_id, "output": message.content})
elif isinstance(message, AIMessage) and message.tool_calls:
for call in message.tool_calls:
await writer.append("tool_call", {
"id": call["id"], "name": call["name"],
"args": call["args"]})
elif isinstance(message, AIMessage):
answer = message.text
await writer.finish("message", {"text": answer})
return produce
For a new turn, use an application-authorized checkpoint thread ID:
thread = {"configurable": {"thread_id": checkpoint_thread_id}}
producer = run_turn(
{"messages": [("user", body.text)]}, graph=graph, config=thread
)
After an interrupt, start a new stream with a new run ID and the same checkpoint thread. Validate and authorize the answer before resuming:
from langgraph.types import Command
producer = run_turn(Command(resume=answer), graph=graph, config=thread)
message.text joins text blocks for providers that return structured content.
Avoid the values stream mode here; it sends the whole state after every step.
Keep checkpoint storage and access isolation in your application.
Handle human input
When an agent pauses for approval or an answer, finish the current stream with
writer.finish("turn.interrupted", ...). Publish only the approval fields the
reader needs, and persist resumable framework state in the session or checkpoint
store. After authorized input arrives, resume through the framework and start
a new stream with a new run ID.
The LangGraph recipe implements this for __interrupt__ updates. For OpenAI
Agents SDK interruptions, PydanticAI deferred tools, and ADK long-running tools,
add the corresponding framework-specific state and approval handling before
using these recipes with those features. Do not serialize whole framework
objects as the interruption payload.
These recipes use asynchronous producers in the FastAPI process. They do not add synchronous producers, external durable workers, or automatic computation restart after deployment.
Resume and recover
Save each event cursor after applying its update. Reconnect to the same scope
and run with Last-Event-ID or ?after=. Selection order is explicit
backend.sse(after=...), then the header, then the query, then the start of the
stream. Every supplied cursor is validated; repeated or malformed values fail.
A reconnect at the final cursor returns HTTP 204.
Retention bounds replay. Provide an authorized application endpoint returning
current state together with a matching cursor when clients need recovery.
Reload state when reconnecting returns HTTP 404 for an unknown or expired run,
or HTTP 410 for externally removed history.
A stream.gap event during delivery reports missing entries and the reader
continues; a client requiring complete history should recover application state.
Before headers, errors have the shape {"detail": "...", "code": "..."}.
Read the top-level code field:
| HTTP status | Code or condition | Client action |
|---|---|---|
| 401 | credential_expired |
Refresh verified credentials. |
| 404 | stream_not_found |
Retrieve application state. |
| 410 | stream_expired |
Reload state; entries were removed externally. |
| 409 | cursor_ahead |
Reconcile application state and cursor. |
| 422 | invalid_cursor, invalid_stream_name |
Correct the request. |
| 503 | stream_unavailable, stream_unsupported, or reader capacity |
Retry transient failures with backoff; check server requirements for unsupported features. |
| 204 | Final event consumed | Stop reconnecting. |
Authentication dependencies retain their own status and challenge headers.
After headers, transient failures close the connection and clients reconnect.
Handle SDK events stream.end (including error, cancelled and
producer_lost outcomes) and stream.error (credential expiry or read-only
producer-loss reporting). Native
EventSource cannot inspect refused-connection bodies; use the application
state endpoint or a fetch-based client to explain failures.
Configure retention
A run has a fixed lifetime from open(), bounded by stream_max_run. Nothing
extends it. Completion shortens remaining retention to at most stream_done_ttl.
Set stream_max_run for the longest expected run, up to 86,400 seconds.
A reader that sees no new entries for stream_silence_timeout ends the run with
a producer_lost final event. spawn() runs writer.keepalive() around the
producer, writing hidden pings during quiet work. An application-managed producer
can enter that context itself. Pings prevent silence detection but do not extend
the run's lifetime. A producer lost on a process exit is not restarted.
The SDK retains all events until the run expires. A writer that exceeds
stream_maxlen raises ValueError rather than trimming earlier events.
Setting (prefix with REDIS_) |
Default | Purpose |
|---|---|---|
STREAM_MAX_RUN |
3,600 seconds | Fixed run lifetime from creation. |
STREAM_DONE_TTL |
120 seconds | Maximum remaining retention after completion. |
STREAM_MAXLEN |
10,000 | Maximum events one writer can append. |
STREAM_MAX_EVENT_BYTES |
65,536 | Largest encoded event. |
STREAM_HEARTBEAT_INTERVAL |
15 seconds | SSE ping, blocking-read bound and keepalive check interval. |
STREAM_SILENCE_TIMEOUT |
60 seconds | Silence before a reader ends a lost producer's run; must exceed twice the heartbeat interval. |
STREAM_MAX_READERS |
256 | Live-reader admission per process. |
STREAM_REPLAY_PAGE |
100 | Events per Redis read. |
STREAM_MAX_CONNECTION_DURATION |
300 seconds | Connection cap before reconnect. |
STREAM_READ_RETRY_WINDOW |
30 seconds | Retry window for transient read failures. |
STREAM_SEND_TIMEOUT |
10 seconds | Bound for stalled sends. |
The demo sets REDIS_STREAM_DONE_TTL=3600; replay still ends at the run's
original lifetime. Application state in your own database has separate retention.
See the beta limits for deployment qualification.