{"slug": "your-ai-agent-doesn-t-belong-inside-an-http-request", "title": "Your AI Agent Doesn't Belong Inside an HTTP Request", "summary": "A developer argues that long-running AI agents should not be executed inside a single HTTP request, because coupling an agent's lifetime to a browser connection means a closed tab can kill a task that has already done substantial work. The proposed pattern has POST /api/agents create a persisted task and immediately return a task ID, with the agent continuing independently and the database, not the connection, serving as the source of truth for status (queued, running, completed, failed, cancelled).", "body_md": "A user sends a message to your AI application.\n\nYour server receives:\n\n```\nPOST /api/chat\n```\n\nSo you do what seems natural:\n\n``` js\napp.post('/api/chat', async (c) => {\n  const result = await runAgent(await c.req.json());\n\n  return c.json(result);\n});\n```\n\nIt works.\n\nThen your Agent gets smarter. Instead of one LLM call, it starts doing this:\n\n```\nUser\n  │\n  ▼\nAgent\n  │\n  ├── LLM call\n  ├── search\n  ├── database query\n  ├── tool call\n  ├── another LLM call\n  ├── file generation\n  └── final LLM call\n```\n\nThe request that used to take 5 seconds now takes 30 seconds, then 2 minutes, then 10.\n\nThe HTTP request is still there, but the HTTP connection is a poor place to represent the lifetime of an Agent.\n\nConsider this architecture:\n\n```\nBrowser\n   │\n   │ POST /api/agent\n   ▼\nNode.js\n   │\n   │ await\n   ▼\nAgent\n   │\n   ├── LLM\n   ├── Tool\n   ├── LLM\n   ├── Tool\n   └── LLM\n   │\n   ▼\nHTTP response\n```\n\nThe entire Agent run is coupled to one HTTP request. That raises an uncomfortable question:\n\nWhat happens when the browser disconnects?\n\nA server can detect a client disconnect through the request's `AbortSignal` and combine it with a server timeout:\n\n``` js\nconst signal = AbortSignal.any([\n  timeoutSignal,\n  c.req.raw.signal,\n]);\n```\n\nThen it passes the signal into the upstream LLM request:\n\n```\nawait fetch(LLM_URL, { signal });\n```\n\nFor a normal streaming chat response, this is what you want. If the user closes the tab halfway through, continuing to generate is wasteful.\n\nAn Agent is different.\n\nCompare two operations.\n\n```\nUser\n  │\n  ▼\nPOST /chat\n  │\n  ▼\nLLM\n  │\n  ▼\nstream response\n```\n\nThe operation exists to serve the current connection. If the user leaves, cancelling the generation makes sense.\n\n```\nUser\n  │\n  ▼\nPOST /agents\n  │\n  ▼\nAgent\n  │\n  ├── search web\n  ├── inspect documents\n  ├── call tools\n  ├── write files\n  ├── run code\n  └── generate final report\n```\n\nThis is no longer a response. It is a **task**, and tasks have their own lifecycle.\n\nTying an Agent to the connection says:\n\n\"This Agent exists as long as this browser connection exists.\"\n\nWhat the user actually asked is:\n\n\"Perform this task for me.\"\n\nThose are different lifetimes. The browser connection might last 10 seconds while the task takes 10 minutes:\n\n```\nBrowser connection\n       │\n       │ short-lived\n       ▼\nHTTP request\n\n                    Agent task\n                       │\n                       │ long-lived\n                       ▼\n                  Task lifecycle\n```\n\nThis is the same boundary the previous article ran into with cancellation. A client disconnect does not prove the server cancelled the work, and a server cancellation does not prove the provider stopped inference. Each of those is a separate fact.\n\nNow take it one step further. Suppose the Agent has already searched, queried the database, and generated a file, and then the user closes the browser. If the request was:\n\n\"Research these 20 documents and generate a report.\"\n\nyou probably don't want:\n\n```\nBrowser closed\n     ↓\nAgent killed\n     ↓\nreport lost\n```\n\nThe user didn't cancel the task. They closed the UI.\n\nInstead of running the Agent inside the request, the request creates a task and returns:\n\n```\nPOST /api/agents\n{\n  \"taskId\": \"task_01J...\"\n}\n```\n\nThe HTTP request is finished. The Agent continues on its own:\n\n```\nHTTP request\n     │\n     ▼\ncreate task ──────────► task_123\n     │                      │\n     ▼                      ▼\nHTTP response             Agent\n                            │\n                            ├── LLM\n                            ├── Tool\n                            └── LLM\n```\n\nThe task ID is an identity for the work itself:\n\n```\nBefore:  HTTP request = work\nAfter:   task ID      = work\n```\n\nThat lets you store the task and make the database, not the connection, the source of truth:\n\n```\ntype AgentTask = {\n  id: string;\n  status: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled';\n  createdAt: Date;\n  startedAt?: Date;\n  completedAt?: Date;\n  error?: string;\n};\n```\n\nThe browser stops owning the Agent. It owns a **subscription to the Agent's progress**:\n\n```\nPOST /api/agents                  → task_123\nGET  /api/agents/task_123         → current status\nGET  /api/agents/task_123/events  → live progress\n```\n\nA client can connect, disconnect, and reconnect. The task doesn't care.\n\nReconnection becomes ordinary. The user starts an Agent at 12:00 and the browser crashes at 12:01. At 12:05 the user reopens the app, and the frontend asks:\n\n```\nGET /api/agents/task_123\n{\n  \"id\": \"task_123\",\n  \"status\": \"completed\"\n}\n```\n\nNothing required the original TCP connection to survive five minutes.\n\nThe code for this article is a runnable demo: [aiengineering-book/agent-task-demo](https://github.com/aiengineering-book/agent-task-demo). It runs the same simulated Agent bound to a request and as a task, drops the connection partway through, cancels at different points, evicts a subscriber that stops reading, and checks what survives each time.\n\nThis doesn't mean you should stop streaming. Streaming is still the right way to deliver progress. What changes is what the stream represents:\n\n**Streaming becomes a view of a task, not the task itself.**\n\nThe task emits events:\n\n```\ntask.started\ntool.started\ntool.completed\nllm.started\nllm.delta\nllm.completed\ntask.completed\n```\n\nand any number of browsers can render them as they arrive:\n\n```\n                  Agent task\n                      │\n                      │ events\n                      ▼\n              ┌───────────────┐\n              │ Event stream  │\n              └───────┬───────┘\n                      │\n              ┌───────┴────────┐\n              ▼                ▼\n          Browser A        Browser B\n```\n\nAn SSE response is a subscription to `task_123`. It is not where the Agent lives:\n\n```\nTask lifecycle\n───────────────────────────────────────►\n\n        Agent running\n\nBrowser A\n───────────────X\n\n                         Browser B\n                         ────────────────►\n```\n\nThe subscription can disappear without destroying the work. The LLM stream itself moves one level down: it feeds the task's event stream instead of feeding the HTTP response directly.\n\nOnce the task is independent of HTTP, the server no longer has to execute it either. The API accepts the request, validates input, creates the task, and hands it to a worker. The worker owns execution.\n\nWith a task, \"the browser went away\" and \"the user wants this stopped\" stop being the same event.\n\n```\nBrowser\n   X\n   │\n   ▼\nsubscription ended\nAgent continues\nPOST /api/agents/task_123/cancel\n```\n\nNow the user has said explicitly: stop this task. That is a different semantic, and it lets you keep several policies side by side:\n\n| Event | What happens | \n|---|---|\n| User disconnects | The subscription ends. The Agent continues. | \n| User presses Stop | The task is cancelled. | \n| Task runs longer than its limit (for example 30 minutes) | The task is cancelled. | \n| Server shuts down (SIGTERM) | Task state is persisted. The task resumes later. | \n\nThese are four different events. Collapsing them into one `AbortSignal` hides the difference between them.\n\nA cancel request also needs a precise answer. \"Cancelled\" can mean different things depending on how far the work had got, and a status that hides the difference is misleading:\n\n| Outcome | Meaning | \n|---|---|\n| Client detached | The subscription ended. Nothing was cancelled. | \n| Queued work cancelled | The task had not started. Nothing ran. | \n| Tool call cancelled | A running step stopped before it finished. | \n| Side effect already committed | A step finished before the cancel arrived and cannot be undone. | \n\nReturn which of these happened. In the demo, the cancel endpoint answers `step_cancelled` or `side_effect_committed`. Give the task an ID that appears in every log line across the browser, the API, the worker, and the provider call, so the sequence can be reconstructed afterwards.\n\nDecoupling the task from the connection doesn't make the hard parts disappear. It moves them somewhere you have to design for.\n\n`queued`, `running`, and one of `completed`, `failed`, `cancelled`. The task decides these states, not the HTTP connection.\nNone of these are specific to AI. They are the standard problems of background work, and Agents run into them early because a single Agent run is already many asynchronous steps. Each deserves more space than this article can give it.\n\nNot every operation needs a task. For plain chat:\n\n```\nrequest\n  ↓\nLLM\n  ↓\nstream\n  ↓\nresponse\n```\n\nbinding the work to the connection is reasonable, and cancelling when the user leaves is often exactly right.\n\nThe previous article measured one provider on one day: DeepSeek, streaming chat requests, aborted at two points. Billing followed the abort point and stayed far below the cost of running each request to its token limit. For plain chat in that test, cancel-on-disconnect was cheap and effective. That is one provider, not a general statement, but it points to where the real reason to decouple Agents lies. It isn't cost. It is that an Agent run has side effects and results the user still wants after the tab is gone.\n\nAsk one question:\n\n**If the user closes the browser, should the work still exist?**\n\nIf the answer is no, use connection cancellation. If the answer is yes, create a task and return its ID.\n\nYou can sort most AI operations into three patterns:\n\n| Pattern | Shape | Examples | Disconnect | \n|---|---|---|---|\n| Request-bound | HTTP → LLM → HTTP response | chat completion, short transformations, simple streaming | Can cancel the work | \n| Persisted | HTTP → create work → storage → background processing | document indexing, file processing, generation written to history | Should not cancel the work | \n| Resumable Agent task | HTTP → task ID → worker (LLM, tools, checkpoints, state) | research agents, coding agents, multi-step workflows | The UI is just a client of the task | \n\nThe rule isn't \"never run an Agent inside an HTTP request\". It is:\n\n**Don't bind the lifetime of durable work to the lifetime of an ephemeral connection.**\n\nThe first generation of AI applications looked like this:\n\n```\nHTTP request → LLM → HTTP response\n```\n\nThat is fine for chat. Agents introduce something different:\n\n```\nHTTP request → create task → Agent → many asynchronous operations → durable result\n```\n\nOnce work can outlive the browser connection, the connection should no longer own it:\n\n```\n┌──────────────────┐\n│   Connection     │\n│                  │\n│ short-lived      │\n│ disposable       │\n│ reconnectable    │\n└────────┬─────────┘\n         │\n         │ subscribes to\n         ▼\n┌──────────────────┐\n│      Task        │\n│                  │\n│ durable          │\n│ cancellable      │\n│ resumable        │\n└────────┬─────────┘\n         │\n         ▼\n┌──────────────────┐\n│  Agent Runtime   │\n│                  │\n│ LLMs             │\n│ Tools            │\n│ State            │\n│ Checkpoints      │\n└──────────────────┘\n```\n\nThe browser connection is ephemeral. The Agent task is the thing that matters.\n\n**If the work should survive the connection, don't make the connection own the work.**\n\nThis is the third article in a series about what happens when a user closes the tab. [Part 1](https://dev.to/kristinz/your-llm-app-is-wasting-money-what-happens-when-users-close-the-tab-4k01) covers how a disconnect reaches your server and how to propagate it upstream with `AbortSignal`. [Part 2](https://dev.to/kristinz/you-aborted-the-llm-request-did-you-actually-stop-the-ai-2i7e) measures whether aborting the request actually stops the provider.\n\nThe demo code is in [aiengineering-book/agent-task-demo](https://github.com/aiengineering-book/agent-task-demo).\n\nThe server-side patterns this article builds on, including the difference between non-persisted chat work, persisted work, and long-running Agent tasks, are covered in Chapter 3 of *《AI Engineering with TypeScript — A Comprehensive Guide to Building AI Agents》* at [Leanpub](https://leanpub.com/aiengineering). The book's companion code is at `github.com/aiengineering-book/code`.", "url": "https://wpnews.pro/news/your-ai-agent-doesn-t-belong-inside-an-http-request", "canonical_source": "https://dev.to/kristinz/your-ai-agent-doesnt-belong-inside-an-http-request-399b", "published_at": "2026-11-01 12:20:56+00:00", "updated_at": "2026-11-01 12:22:14.655803+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "ai-infrastructure"], "entities": [], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/your-ai-agent-doesn-t-belong-inside-an-http-request", "markdown": "https://wpnews.pro/news/your-ai-agent-doesn-t-belong-inside-an-http-request.md", "text": "https://wpnews.pro/news/your-ai-agent-doesn-t-belong-inside-an-http-request.txt", "jsonld": "https://wpnews.pro/news/your-ai-agent-doesn-t-belong-inside-an-http-request.jsonld"}}