DEV Community

kongkong
kongkong

Posted on

A FastAPI Agent Template Is Not Production-Ready Until Task Ownership Crosses Every Layer

Vercel published an OpenAI Agents SDK with FastAPI template on July 17, 2026. A template can remove setup work, but successful generation is not the production boundary that usually breaks. Task ownership is.

Primary source: Vercel template, “OpenAI Agents SDK with FastAPI”.

Before adopting any agent starter, I would add one vertical test: Alice must be able to create and cancel her task; Bob must not be able to read, stream, or cancel it—even if he guesses the task ID.

State the cross-layer contract

UI -> POST /tasks -> ownership row -> worker
UI <- GET /tasks/:id <- authorization <- state
UI <- event stream <- authorization <- events
UI -> POST /tasks/:id/cancel -> authorization -> cancellation
Enter fullscreen mode Exit fullscreen mode

Use explicit states:

queued -> running -> succeeded
                 -> failed
queued|running -> cancelling -> cancelled
Enter fullscreen mode Exit fullscreen mode

The database, API response, stream, and UI must agree on the same task and owner.

Minimal schema

create table tasks (
  id text primary key,
  owner_id text not null,
  state text not null check (state in (
    'queued','running','succeeded','failed','cancelling','cancelled'
  )),
  created_at text not null,
  updated_at text not null,
  revision integer not null default 0
);

create table task_events (
  task_id text not null,
  revision integer not null,
  kind text not null,
  payload text not null,
  primary key (task_id, revision)
);
Enter fullscreen mode Exit fullscreen mode

Do not derive ownership from a browser-supplied field. Resolve the authenticated principal on the server and store it when creating the task.

FastAPI authorization seam

from fastapi import Depends, FastAPI, HTTPException

app = FastAPI()

def current_user():
    # Replace with verified session/JWT middleware.
    return {"id": "alice"}

def load_owned_task(task_id: str, user=Depends(current_user)):
    task = db_get_task(task_id)  # application function
    if task is None or task["owner_id"] != user["id"]:
        # Avoid revealing whether another user's task exists.
        raise HTTPException(status_code=404, detail="task not found")
    return task

@app.get("/tasks/{task_id}")
def get_task(task=Depends(load_owned_task)):
    return task

@app.post("/tasks/{task_id}/cancel")
def cancel_task(task=Depends(load_owned_task)):
    return request_cancel(task["id"], expected_revision=task["revision"])
Enter fullscreen mode Exit fullscreen mode

The same dependency must protect event history and streaming endpoints. Securing GET /tasks/{id} while leaving /tasks/{id}/events open still leaks prompts and outputs.

Cross-layer failure test

def test_bob_cannot_observe_or_cancel_alices_task(client, alice, bob):
    created = client.post("/tasks", headers=alice, json={"prompt": "demo"})
    task_id = created.json()["id"]

    for method, path in [
        ("get", f"/tasks/{task_id}"),
        ("get", f"/tasks/{task_id}/events"),
        ("post", f"/tasks/{task_id}/cancel"),
    ]:
        response = getattr(client, method)(path, headers=bob)
        assert response.status_code == 404

    visible = client.get(f"/tasks/{task_id}", headers=alice)
    assert visible.status_code == 200
Enter fullscreen mode Exit fullscreen mode

Add a stream-specific test that authenticates before sending the first event. A late authorization check can leak initial metadata.

Cancellation needs compare-and-set

Two cancel requests or a completion racing with cancellation should not produce impossible transitions.

update tasks
set state = 'cancelling', revision = revision + 1
where id = :id
  and owner_id = :owner
  and state in ('queued', 'running')
  and revision = :expected_revision;
Enter fullscreen mode Exit fullscreen mode

Zero updated rows means “reload and decide,” not “pretend cancellation succeeded.” The worker should check the durable cancellation state before each consequential tool call.

UI states are part of the contract

The cancel button should:

  • be visible only for queued or running;
  • switch to “Cancelling…” after the API accepts the transition;
  • remain disabled while waiting for the terminal event;
  • show retry only for a transport failure, not a completed task;
  • restore focus to the status heading after cancellation.

Do not optimistically label the task cancelled when the server only recorded cancelling.

Production caveats and rollback

The snippets omit a real identity provider, queue, database implementation, rate limits, sandbox policy, and secret management. Pin the template revision and dependencies before evaluating it.

Rollback checklist:

[ ] disable new task creation
[ ] revoke worker tool credentials
[ ] allow read-only task status
[ ] drain or mark queued work
[ ] preserve event and authorization logs
[ ] verify no cross-owner stream stayed open
Enter fullscreen mode Exit fullscreen mode

A starter proves that the happy path can boot. The ownership slice proves something more valuable: the same security invariant survives UI, API, persistence, worker, stream, and cancellation behavior.

Which endpoint in your agent stack is most likely to miss the ownership check: events, artifacts, or cancellation?

Top comments (0)