Most AI agents respond to tasks and stop. But real-world processes don't always work like that; they can last for hours or even days.
What if an agent could stay responsible for an order from its creation to completion?
That became Order Supervisor, a long-running AI agent I built using Temporal, FastAPI, PostgreSQL, Ollama, and Next.js. In this blog, I'll walk through how it works, the decisions behind the architecture, and what I learned while building it.
The goal was to give each order its own supervisor. It should keep track of what has happened, react when a new event comes in, and take action only when needed. If nothing requires attention, it simply waits until the next event or a scheduled check.
I also wanted the system to continue from where it left off after a restart instead of losing the state of an active order. This is where Temporal became an important part of the project.
The Next.js frontend handles creating orders, sending events, and showing what the supervisor is doing. FastAPI handles the backend APIs, while Temporal manages the long-running workflows. PostgreSQL stores the application data, and Ollama runs the local model used by the supervisor.
At the core of the system is a simple setup: one order, one Temporal workflow. The workflow starts with the order and stays active until the order is completed or terminated.
When a run is created, the backend creates a database record, starts a Temporal workflow with the run's ID, and sends the first signal:
@router.post("", response_model=RunResponse, status_code=201)
async def create_run(body, request, db):
run = Run(supervisor_id=body.supervisor_id, status="pending")
db.add(run)
db.commit()
client = _tc(request)
await client.start_workflow(
OrderSupervisorWorkflow.run,
run.id,
id=f"order-supervisor-{run.id}",
task_queue="order-supervisor-queue",
)
await _signal(
request,
run.id,
{"type": "run_created", "run_id": run.id}
)
return run
Each workflow is tied to its run through the workflow ID (order-supervisor-{run.id}), giving every order its own independent supervisor.
Most of the time, the workflow isn't actively doing anything. It waits until something happens that requires another decision.
An order can receive different events during its lifecycle, such as a payment update, customer request, fulfillment change, or delivery issue. The UI supports 10 event types, including payment_failed, shipment_delayed, refund_requested, and customer_message_received.
When an event comes in, it is sent to the existing workflow as a Temporal signal:
async def _signal(request: Request, run_id: int, event: dict) -> None:
handle = request.app.state.temporal_client.get_workflow_handle(
f"order-supervisor-{run_id}"
)
await handle.signal(
OrderSupervisorWorkflow.submit_event,
event
)
This lets new information reach the same supervisor without starting the process again from scratch. The workflow already has the context of what happened before and can continue from there.
You can also add instructions while the supervisor is running. They are included in the next prompt under "Additional Instructions", so the workflow doesn't need to be restarted. For example, if a customer requests special handling, the instruction can be added and picked up on the next cycle.
Each time the supervisor runs, the workflow builds a prompt with the current order state, latest event, timeline, memory, additional instructions, and available actions. The model then uses this context to decide what to do next.
def _build_content(context: LLMContext) -> str:
return f"""## Order
ID: {context.order_id}
Status: {context.status}
## Latest Event
{json.dumps(context.latest_event, indent=2)
if context.latest_event else "Workflow started"}
## Timeline
{_format_timeline(context.timeline)
if context.timeline else "No events yet."}
## Memory Summary
{context.memory_summary or "None"}
## Additional Instructions
{context.additional_instructions or "None"}
## Available Tools
{json.dumps(context.available_tools, indent=2)}
...
"""
The available actions are passed to the model as part of the input. The model can't create new actions; it can only choose from the ones it has been given or decide that nothing needs to happen yet.
For this project, I defined five actions for communicating with the customer and the payments, fulfillment, and logistics teams, along with an option to create an internal note.
Once the model returns a decision, the application checks the selected action and runs the corresponding logic. The model decides what to do from the options it has, while the application controls what it is actually allowed to do.
Sometimes, the right decision is to do nothing.
If an order is progressing normally, there is no reason to keep calling the model. The workflow simply waits until a new event arrives or its scheduled wake-up time is reached.
sleep_until = decision.get("sleep_until")
duration = None
if sleep_until:
w = datetime.fromisoformat(sleep_until)
if w.tzinfo is None:
w = w.replace(tzinfo=timezone.utc)
if w <= workflow.now():
w = workflow.now() + timedelta(minutes=1)
duration = w - workflow.now()
if duration > timedelta(minutes=2):
duration = timedelta(minutes=2) # capped for responsive demo
condition = lambda: (
self._pending_events
or self._force_complete
or self._status != "running"
or self._status in TERMINAL_STATUSES
)
await workflow.wait_condition(
condition,
timeout=duration
)
This allows the same supervisor to stay with an order throughout its lifecycle without keeping the model active the entire time.
For example, say an order is created and its workflow starts. The supervisor checks the current state and decides that nothing needs attention, so it waits.
Later, a shipment_delayed event arrives.
The same workflow wakes up and runs the supervisor again with the updated context. The model can now choose from the actions available to itβfor example, message_customer or message_logistics_team.
The application executes the selected actions, records the results, and the workflow goes back to waiting.
The actions that are executed are also recorded separately in the run's action history.
For the demo, I used Phi-3 Mini through Ollama so I could run everything locally. It was enough to show the full workflow, but the smaller model sometimes struggled to follow the expected response format and choose actions consistently.
This is also why I kept the model separate from the orchestration. The workflow isn't tied to Phi-3 Mini or Ollama, so the model can be replaced without changing how the long-running process works.
Since the LLM integration uses an OpenAI-compatible API, the model layer can be pointed to another compatible provider without changing the Temporal workflow itself.
One thing I learned while building this is that the LLM is only one part of an agent system like this. Managing state, handling events, deciding when to call the model, and keeping a process running over time are just as important.
If I continue working on it, I'd like to try a stronger model, test more orders running at the same time, and add better visibility into why each decision was made.
My biggest takeaway from this project was that an agent doesn't need to be constantly running to stay responsible for a process. Sometimes, it just needs to wait and know when to wake up again.
I'm still exploring this space, and I'd be interested to hear how others would approach the same problem.
Would you have handled it differently?





Top comments (0)