Raymond Lin
04/22/2026, 11:57 PMMarvin
04/22/2026, 11:58 PMMarvin
04/23/2026, 12:01 AMreceive_input(...), or
- explicitly pause for human input with pause_flow_run(...) / suspend_flow_run(...)
For an interactive agent, I’d usually recommend receive_input for steering and pause_flow_run only for approval gates.
Relevant Prefect primitives:
- prefect.input.send_input(run_input, flow_run_id, ...)
- prefect.input.receive_input(input_type, ...)
- prefect.flow_runs.pause_flow_run(...)
- prefect.flow_runs.suspend_flow_run(...)
I also verified prefect deploy exists in 3.x.
Docs / references:
- Prefect docs
- Deployments
- Flows
- Work pools
- Run input API
- Flow run controls
A practical architecture for your stack
- FastAPI = control plane / user API
- Prefect deployment = orchestrated agent run
- ECS work pool = execution environment
- PydanticAI agent = the reasoning loop inside the flow
So instead of trying to “remote control a process directly”, you let Prefect orchestrate the process and use Prefect’s flow-run input channel as the steering interface.
Example flow of control:
text
Client/UI
-> FastAPI POST /agents/start
-> create Prefect flow run from deployment
-> ECS task starts and runs agent flow
Client/UI
-> FastAPI POST /agents/{flow_run_id}/steer
-> send_input(...) to that running flow run
Running flow
-> receive_input(...) reads commands
-> updates agent behavior / prompt / next action
Minimal example
First, define typed steering messages.
python
from pydantic import BaseModel
from prefect.input import RunInput
class AgentCommand(RunInput):
action: str # "guide", "stop", "approve"
message: str = ""
class AgentUpdate(RunInput):
status: str
detail: str
Now the Prefect flow that runs the agent:
```python
import asyncio
from prefect import flow, get_run_logger
from prefect.context import get_run_context
from prefect.input import receive_input, send_input
from pydantic_ai import Agent
class AgentCommand(RunInput):
action: str
message: str = ""
class AgentUpdate(RunInput):
status: str
detail: str
agent = Agent(
"openai:gpt-4o-mini",
system_prompt="You are a helpful agent. Incorporate operator guidance when provided."
)
@flow
async def steered_agent_flow(initial_prompt: str):
logger = get_run_logger()
flow_run_id = get_run_context().flow_run.id
current_goal = initial_prompt
stopped = False
step = 0
while not stopped:
step += 1
# Check for steering input without blocking forever
command_handler = receive_input(
AgentCommand,
flow_run_id=flow_run_id,
timeout=1,
poll_interval=0.5,
raise_timeout_error=False,
)
command = next(command_handler, None)
if command:
if command.action == "stop":
stopped = True
await send_input(
AgentUpdate(status="stopping", detail="Stop requested by operator"),
flow_run_id=flow_run_id,
)
break
if command.action == "guide":
current_goal = f"{current_goal}\n\nOperator guidance: {command.message}"
await send_input(
AgentUpdate(status="guided", detail=f"Applied guidance: {command.message}"),Marvin
04/23/2026, 12:01 AMThen in *FastAPI*, start and steer the deployment.
```python
from uuid import UUID
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from prefect.client.orchestration import get_client
from prefect.input import send_input
app = FastAPI()
class StartRequest(BaseModel):
prompt: str
class SteerRequest(BaseModel):
action: str
message: str = ""
class AgentCommand(RunInput):
action: str
message: str = ""
@app.post("/agents/start")
async def start_agent(req: StartRequest):
async with get_client() as client:
flow_run = await client.create_flow_run_from_deployment(
deployment_id="YOUR_DEPLOYMENT_ID",
parameters={"initial_prompt": req.prompt},
)
return {"flow_run_id": str(flow_run.id)}
@app.post("/agents/{flow_run_id}/steer")
async def steer_agent(flow_run_id: UUID, req: SteerRequest):
async with get_client() as client:
flow_run = await client.read_flow_run(flow_run_id)
if not flow_run.state or not flow_run.state.is_running():
raise HTTPException(status_code=400, detail="Flow run is not running")
await send_input(
AgentCommand(action=req.action, message=req.message),
flow_run_id=flow_run_id,
sender="fastapi",
)
return {"ok": True}
When to use pause_flow_run instead
If you want the agent to stop and wait for a human decision, use:
python
from prefect.flow_runs import pause_flow_run
approval = pause_flow_run(wait_for_input=AgentCommand, timeout=3600)
That’s better for:
- approval/reject
- selecting one of N plans
- editing a draft before proceeding
But for “live steering” of an agent loop, receive_input is usually the better fit.
ECS-specific notes
Running this in ECS is fine. The main things to think about are:
- Long-running flow runs: if your agent loop stays alive for a while, make sure the ECS task sizing/timeouts fit that usage.
- Networking: the task must be able to reach your Prefect API / Prefect Cloud.
- Stateless workers: treat the flow as the authoritative state machine; don’t rely on local container memory beyond the current run.
- Streaming UI updates: if you want a live UI, you’ll likely want your FastAPI app to poll Prefect state / artifacts / logs, or use a side channel for updates.
Recommended production pattern for your stack
I’d suggest this shape:
1. FastAPI endpoint starts deployment
2. Flow run ID is stored in your app/db
3. Agent flow loops:
- execute one reasoning/action step
- check receive_input(...) for operator commands
- persist meaningful intermediate state externally if needed
4. FastAPI sends steer commands with send_input(...)
5. Optional:
- use pause_flow_run(...) for explicit human approvals
- use Prefect artifacts/logs for traceability
One important design recommendation
Don’t try to run a single infinitely interactive chatbot session as one giant monolithic flow unless you really need that. Often a better approach is:
- one deployment run per “agent job” or “reasoning session”
- steering allowed during that run
- results/state persisted outside the container
That plays nicely with ECS and Prefect’s orchestration model.
Deployment
In Prefect 3.x, use prefect deploy or Python deployment APIs — not old 2.x methods.
CLI:
bash
prefect deploy --helpMarvin
04/23/2026, 12:01 AMpause_flow_run.
If you want, I can draft the exact files:
- main.py for FastAPI
- flows/agent_flow.py
- prefect.yaml
- ECS work pool assumptionsRaymond Lin
04/23/2026, 12:18 AMMarvin
04/23/2026, 12:18 AMMarvin
04/23/2026, 12:24 AMsend_input / receive_input for steering commands into the running flow
- Use Prefect events (or another outbound channel) for streaming updates out to the client
Why: receive_input(...) is polling-oriented and works well for control messages, but it’s not the best primitive for low-latency outbound streaming to browsers.
So the recommended pattern is:
text
Browser
<- SSE from FastAPI
FastAPI
<- Prefect events websocket
-> Prefect send_input() for steering
Running Prefect flow on ECS
-> emit_event() for progress / token-ish updates
<- receive_input() for operator steering
If you want, I can also show a simpler polling-only SSE version, but this is the more “real-time” Prefect-native approach.
Docs:
- Prefect docs
- Events
- Python events API
- Run input API
---
Updated example with SSE
## 1. Prefect flow: steerable agent that emits events
```python
import asyncio
from typing import Optional
from pydantic import BaseModel
from pydantic_ai import Agent
from prefect import flow, get_run_logger
from prefect.context import get_run_context
from prefect.events import emit_event
from prefect.input import RunInput, receive_input
class AgentCommand(RunInput):
action: str # "guide", "stop"
message: str = ""
agent = Agent(
"openai:gpt-4o-mini",
system_prompt="You are a helpful agent. Incorporate operator guidance when provided."
)
def emit_agent_event(event: str, resource_id: str, **payload):
emit_event(
event=event,
resource={
"prefect.resource.id": resource_id,
"prefect.resource.role": "flow-run",
},
payload=payload,
)
@flow(name="steered-agent-flow")
async def steered_agent_flow(initial_prompt: str):
logger = get_run_logger()
flow_run = get_run_context().flow_run
flow_run_id = str(flow_run.id)
resource_id = f"prefect.flow-run.{flow_run_id}"
current_goal = initial_prompt
stopped = False
step = 0
emit_agent_event(
"agent.session.started",
resource_id,
flow_run_id=flow_run_id,
message="Agent session started",
initial_prompt=initial_prompt,
)
while not stopped:
step += 1
command_stream = receive_input(
AgentCommand,
flow_run_id=flow_run.id,
timeout=1,
poll_interval=0.5,
raise_timeout_error=False,
)
command = next(command_stream, None)
if command:
if command.action == "stop":
emit_agent_event(
"agent.command.received",
resource_id,
flow_run_id=flow_run_id,
action="stop",
message="Stop requested",
)
stopped = True
break
if command.action == "guide":
current_goal = f"{current_goal}\n\nOperator guidance: {command.message}"
emit_agent_event(
"agent.command.received",
resource_id,
flow_run_id=flow_run_id,
action="guide",
guidance=command.message,
)
emit_agent_event(
"agent.step.started",
resource_id,
flow_run_id=flow_run_id,
step=step,
goal=current_goal,
)
result = await agent.run(current_goal)
output_text = str(result.output)
emit_agent_event(
"agent.step.completed",
resource_id,
flow_run_id=flow_run_id,
step=step,
output=output_text,
)Marvin
04/23/2026, 12:24 AMsend_input
- subscribes to Prefect events and relays them as SSE
```python
import asyncio
import json
from uuid import UUID
from fastapi import FastAPI, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
import websockets
from prefect.client.orchestration import get_client
from prefect.input import RunInput, send_input
PREFECT_API_URL = "https://api.prefect.cloud/api/accounts/ACCOUNT/workspaces/WORKSPACE"
PREFECT_API_KEY = "YOUR_PREFECT_API_KEY"
app = FastAPI()
class StartRequest(BaseModel):
prompt: str
class SteerRequest(BaseModel):
action: str
message: str = ""
class AgentCommand(RunInput):
action: str
message: str = ""
@app.post("/agents/start")
async def start_agent(req: StartRequest):
async with get_client() as client:
flow_run = await client.create_flow_run_from_deployment(
deployment_id="YOUR_DEPLOYMENT_ID",
parameters={"initial_prompt": req.prompt},
)
return {"flow_run_id": str(flow_run.id)}
@app.post("/agents/{flow_run_id}/steer")
async def steer_agent(flow_run_id: UUID, req: SteerRequest):
async with get_client() as client:
flow_run = await client.read_flow_run(flow_run_id)
if not flow_run.state or not flow_run.state.is_running():
raise HTTPException(status_code=400, detail="Flow run is not running")
await send_input(
AgentCommand(action=req.action, message=req.message),
flow_run_id=flow_run_id,
sender="fastapi",
)
return {"ok": True}
def prefect_events_ws_url() -> str:
if PREFECT_API_URL.startswith("https://"):
return PREFECT_API_URL.replace("https://", "wss://") + "/events/out"
if PREFECT_API_URL.startswith("http://"):
return PREFECT_API_URL.replace("http://", "ws://") + "/events/out"
raise ValueError("Unsupported PREFECT_API_URL")
@app.get("/agents/{flow_run_id}/events")
async def stream_agent_events(flow_run_id: UUID):
async def event_generator():
ws_url = prefect_events_ws_url()
resource_id = f"prefect.flow-run.{flow_run_id}"
yield f"event: ready\ndata: {json.dumps({'flow_run_id': str(flow_run_id)})}\n\n"
async with websockets.connect(
ws_url,
additional_headers={"Authorization": f"Bearer {PREFECT_API_KEY}"},
) as ws:
filter_message = {
"type": "filter",
"filter": {
"event": {
"name": [
"agent.session.started",
"agent.command.received",
"agent.step.started",
"agent.step.completed",
"agent.session.finished",
"prefect.flow-run.Running",
"prefect.flow-run.Completed",
"prefect.flow-run.Failed",
"prefect.flow-run.Crashed",
]
},
"resource": {
"id": [resource_id]
}
}
}
await ws.send(json.dumps(filter_message))
while True:
try:
message = await asyncio.wait_for(ws.recv(), timeout=20)
data = json.loads(message)
yield f"event: prefect\ndata: {json.dumps(data)}\n\n"Marvin
04/23/2026, 12:24 AM---
## 3. Browser/client example
```html
<!DOCTYPE html>
<html>
<body>
<button onclick="start()">Start</button>
<button onclick="guide()">Guide</button>
<button onclick="stopAgent()">Stop</button>
<pre id="log"></pre>
<script>
let flowRunId = null;
let es = null;
function log(msg) {
document.getElementById("log").textContent += msg + "\n";
}
async function start() {
const res = await fetch("/agents/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt: "Research the best ECS deployment pattern for AI agents" })
});
const data = await res.json();
flowRunId = data.flow_run_id;
log("Started flow run: " + flowRunId);
es = new EventSource(`/agents/${flowRunId}/events`);
es.addEventListener("ready", (e) => {
log("SSE ready: " + e.data);
});
es.addEventListener("prefect", (e) => {
log("EVENT: " + e.data);
});
es.addEventListener("ping", () => {
log("ping");
});
}
async function guide() {
if (!flowRunId) return;
await fetch(`/agents/${flowRunId}/steer`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "guide",
message: "Focus on cost efficiency and low operational overhead"
})
});
log("Sent guidance");
}
async function stopAgent() {
if (!flowRunId) return;
await fetch(`/agents/${flowRunId}/steer`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "stop",
message: ""
})
});
log("Sent stop");
}
</script>
</body>
</html>
---
Notes / caveats
## Why not stream directly from send_input?
Because send_input / receive_input are better modeled as control messages, not high-frequency event streaming. They’re great for:
- stop
- revise plan
- approve
- inject operator guidance
They’re less ideal for:
- frequent token streaming
- sub-second status fanout to browsers
## If you want token-level LLM streaming
If by SSE you mean actual token streaming from PydanticAI, I’d recommend:
- stream tokens from FastAPI directly if the LLM call originates there, or
- have the flow emit coarser-grained progress events and keep true token streaming outside Prefect orchestration
Running token-by-token through Prefect events is usually possible in theory but often too chatty for a production orchestration layer.
## ECS compatibility
This works fine on ECS as long as:
- the flow container can reach Prefect API / Cloud
- your FastAPI service can reach Prefect’s events websocket
- you size tasks for long-lived runs if the agent is interactive
## Security
Do not expose the Prefect API key to the browser.
Keep the websocket connection in FastAPI, and expose only your SSE endpoint to clients.
---
What I’d recommend for your stack
For FastAPI + PydanticAI + Prefect on ECS:
- FastAPI owns user sessions and SSE
- Prefect deployment owns orchestration and durable execution
- RunInput handles steering into the flow
- Prefect events handle outbound status streaming
- optional: store agent transcript/checkpoints in Redis/Postgres/S3 if you want replay/history
If you want, I can now turn this into a more complete working project layout with:
- main.py
- flows/agent_flow.py
- prefect.yamlMarvin
04/23/2026, 12:24 AMsse-starlette instead of raw StreamingResponse