Flow & Runner¶
AF separates what your workflow does (Flow) from how it's executed (Runner).
Flow: Business Logic¶
A Flow is a regular async Python function:
async def my_flow(user_message: str) -> str:
async with af.phase("Research"):
research = await researcher(user_message).stream()
async with af.phase("Response", persist=True):
return await responder(f"Based on: {research}").stream()
Flow's responsibilities:
| Responsibility | Example |
|---|---|
| Agent call order | Which agents run in what sequence |
| Control flow | if, for, while, exception handling |
| Data transformation | Combining agent outputs |
| Phase structure | Where to put boundaries |
Flow does NOT know about:
- Sessions — Injected by Runner
- Handlers — Injected by Runner
- ChatKit — Integrated at Runner level
This separation keeps business logic clean.
Runner: Execution Environment¶
A Runner wraps a Flow and provides the execution environment:
import agentic_flow as af
from agents import SQLiteSession
runner = af.Runner(
flow=my_flow,
session=SQLiteSession("chat.db"),
handler=my_handler,
)
result = await runner("Hello!")
Runner's responsibilities:
| Responsibility | How |
|---|---|
| Session injection | Via contextvars |
| Handler injection | Via contextvars |
| Flow execution | Calls await self.flow(user_message) |
How Injection Works¶
Runner uses Python's contextvars to inject dependencies:
async def __call__(self, user_message: str) -> Any:
# Inject session
session_token = current_session.set(self.session)
# Inject handler
handler_token = current_handler.set(self.handler)
try:
return await self.flow(user_message)
finally:
# Clean up
current_handler.reset(handler_token)
current_session.reset(session_token)
This means:
- Flow code never sees
sessionorhandlerdirectly af.ExecutionSpec.execute()reads them from context when needed- Context is properly scoped and cleaned up
Synchronous Execution¶
Runner provides synchronous execution for scripts and Jupyter:
# Option 1: run_sync()
result = runner.run_sync("Hello")
# Option 2: run().sync()
result = runner.run("Hello").sync()
Both methods handle event loop creation appropriately:
- No running loop: Uses
asyncio.run() - Running loop (Jupyter): Uses a thread pool
sync() is a Runner adapter
sync() is NOT a third execution trigger for af.ExecutionSpec. It's a Runner-level convenience that internally awaits the flow.
Working Without Runner¶
You can use agents without Runner — they'll just lack session context:
import agentic_flow as af
assistant = af.Agent(name="assistant", instructions="...", model="gpt-5.2")
# Works, but no session
result = await assistant("Hello")
Each call is independent with no conversation history.
Handler Pattern¶
Handlers receive AF events (not SDK streaming deltas):
import agentic_flow as af
def my_handler(event):
if isinstance(event, af.PhaseStarted):
print(f"\n[{event.label}]")
elif isinstance(event, af.PhaseEnded):
print(f" ({event.elapsed_ms}ms)")
elif isinstance(event, af.AgentResult):
print(event.content)
Handlers are called for:
af.AgentResult— Full-text agent output (both streaming and non-streaming paths)af.PhaseStarted/af.PhaseEnded— Phase boundary events
Display fallback priority: ChatKit > Handler > print() (mutually exclusive). When no handler or ChatKit is active, output is printed to stdout.
Summary¶
| Concept | Role |
|---|---|
| Flow | Business logic — agent orchestration |
| Runner | Execution environment — session/handler injection |
| Session | Conversation history (from SDK) |
| Handler | Event receiver for streaming output |
| contextvars | Injection mechanism |
graph TB
subgraph Flow["Flow (Business Logic)"]
A(Agent Calls)
B(phase Structure)
C(Python Control Flow)
end
subgraph Runner["Runner (Execution Environment)"]
D(Session Injection)
E(Handler Injection)
F(ChatKit Integration)
end
Flow -->|"injected into"| Runner
Next: Phase