Agent runs take minutes, call models many times and fail halfway. Here is how to move them out of the request into a queue and worker, track their status in Postgres, cap their cost, and run the setup in your own AWS account.
An AI agent task that researches a lead, triages a support ticket or refactors a file is a loop of model calls and tool calls that runs for anywhere between twenty seconds and twenty minutes. Running that loop inside an HTTP request works in a demo and fails in production: the load balancer closes the connection at its idle timeout, the client retries and starts a second run that bills the same tokens again, and a deploy in the middle of a run loses it without a trace.
This guide covers the queue and worker pattern for agent tasks, how to track status and make retries safe, how to cap cost, a plain Node.js implementation, and how to run the same setup with Encore (the safe way for AI agents to ship backend changes to your cloud) in your own AWS account.
Every hop between the user and your code has a timeout. An AWS Application Load Balancer closes connections idle for 60 seconds by default, AWS Lambda stops a function after at most 15 minutes, and browsers and mobile clients give up on their own schedule. A model call that takes 40 seconds is normal for a long reasoning step, and a run makes many of them.
The request should only validate input, create a run record and hand the work to a queue, which then delivers it to a worker that can take as long as the run needs. The queue also gives you redelivery: if the worker crashes or is replaced during a deploy, the message becomes visible again and another instance picks it up.
A runs table in Postgres holds each run's ID, input, status, current step and result. An endpoint inserts a row with status queued and publishes the run ID to a topic. A worker subscribed to the topic loads the run, executes the agent loop step by step and writes progress after each step. A status endpoint returns the row, and the client polls it or listens on a server-sent events stream.
Publish only the run ID, not the whole prompt and context, so the message stays small and the database stays the source of truth.
Queues deliver at least once, so a worker will sometimes see the same run twice, either after a crash or after a slow step outlasted the message's acknowledgement deadline. Two rules keep that cheap, and the first is to claim the run with a conditional update such as UPDATE runs SET status = 'running' WHERE id = $1 AND status = 'queued', and skip the message if no row changed, so a finished or already running run is never claimed twice. Second, save each completed step with its output, so a retried run resumes after the last saved step instead of repeating model calls and tool side effects such as sending an email.
Set the acknowledgement deadline longer than your slowest run, and set a retry limit so a run that fails every time ends up in a dead-letter queue instead of burning tokens indefinitely.
Agents loop, and a loop without a bound is a cost incident. Give every run a maximum number of steps and a token budget that the worker checks before each model call, then mark the run failed with the reason when either runs out. Limit how many runs each worker instance processes at once, which also keeps you under your model provider's rate limits. Record tokens used per run in the runs table so you can see which tasks cost the most.
The worker needs the model provider's API key, and agents working in the repository should not be able to read it. Store it in a secrets manager and read it at runtime, rather than committing it in a .env file that every coding agent with repository access can open.
Any queue with redelivery and a separate worker process implements the pattern. The sketch below uses BullMQ on Redis and the pg client for the runs table, with the API process calling startRun and a worker process running the loop:
// queue.ts
import { Queue, Worker } from "bullmq";
import pg from "pg";
const connection = { host: process.env.REDIS_HOST ?? "localhost", port: 6379 };
const db = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const MAX_STEPS = 8;
const runs = new Queue<{ runId: number }>("agent-runs", { connection });
// Called by the API handler: create the run, enqueue its ID, return at once.
export async function startRun(task: string): Promise<number> {
const { rows } = await db.query<{ id: number }>(
"INSERT INTO runs (task) VALUES ($1) RETURNING id",
[task],
);
const runId = rows[0].id;
await runs.add("run", { runId }, {
jobId: `run-${runId}`,
attempts: 4,
backoff: { type: "exponential", delay: 5_000 },
});
return runId;
}
// Runs in a separate worker process.
export function startWorker() {
return new Worker<{ runId: number }>("agent-runs", async (job) => {
const { runId } = job.data;
const { rows } = await db.query<{ task: string; step: number }>(
"UPDATE runs SET status = 'running' WHERE id = $1 AND status = 'queued' RETURNING task, step",
[runId],
);
if (rows.length === 0) return; // already finished or claimed
let completed = false;
try {
for (let step = rows[0].step; step < MAX_STEPS; step++) {
const { done, output } = await agentStep(rows[0].task, step);
await db.query("UPDATE runs SET step = $1, result = $2 WHERE id = $3", [step + 1, output, runId]);
if (done) {
completed = true;
break;
}
}
if (completed) {
await db.query("UPDATE runs SET status = 'done' WHERE id = $1", [runId]);
} else {
await db.query(
"UPDATE runs SET status = 'failed', result = $1 WHERE id = $2",
["maximum step limit reached", runId],
);
}
} catch (err) {
await db.query(
"UPDATE runs SET status = 'failed', result = $1 WHERE id = $2",
[String(err), runId],
);
throw err;
}
}, { connection, concurrency: 5 });
}
// Replace with your model and tool calls, reading the key from process.env.
async function agentStep(task: string, step: number) {
return { done: step >= 2, output: `step ${step} of: ${task}` };
}
The fixed jobId stops a retried API request from queueing the same run twice, attempts with backoff retries a failed run, and jobs that exhaust their attempts stay in the queue's failed set for inspection, which plays the role of a dead-letter queue. The concurrency option caps parallel runs per worker process. Around this code you still provision Redis and Postgres, deploy and restart the worker process, and supply the model key through a secrets manager in each environment.
Run in your terminal to get started locally.
Encore is the safe way for AI agents to ship backend changes to your cloud for TypeScript and Go, with a platform that provisions and deploys into your own AWS or GCP account. The run table, the queue, the worker and the model key are all declared in one TypeScript service, and Encore provisions Postgres, SNS and SQS with a dead-letter queue, and Secrets Manager for them on AWS.

The migration in runs/migrations/1_create_runs.up.sql:
CREATE TABLE runs (
id SERIAL PRIMARY KEY,
task TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued',
step INT NOT NULL DEFAULT 0,
result TEXT
);
The service, with runs/encore.service.ts exporting new Service("runs"):
// runs/runs.ts
import { api, APIError } from "encore.dev/api";
import { secret } from "encore.dev/config";
import { Subscription, Topic } from "encore.dev/pubsub";
import { SQLDatabase } from "encore.dev/storage/sqldb";
const db = new SQLDatabase("runs", { migrations: "./migrations" });
const modelKey = secret("ModelAPIKey");
const MAX_STEPS = 8;
interface RunRequested {
runId: number;
}
const runRequested = new Topic<RunRequested>("run-requested", {
deliveryGuarantee: "at-least-once",
});
export const start = api(
{ expose: true, method: "POST", path: "/runs" },
async ({ task }: { task: string }): Promise<{ runId: number }> => {
const row = await db.queryRow<{ id: number }>`
INSERT INTO runs (task) VALUES (${task}) RETURNING id
`;
await runRequested.publish({ runId: row!.id });
return { runId: row!.id };
},
);
export const status = api(
{ expose: true, method: "GET", path: "/runs/:id" },
async ({ id }: { id: number }) => {
const row = await db.queryRow<{ status: string; step: number; result: string | null }>`
SELECT status, step, result FROM runs WHERE id = ${id}
`;
if (!row) throw APIError.notFound("run not found");
return row;
},
);
const _ = new Subscription(runRequested, "agent-worker", {
ackDeadline: "10m",
maxConcurrency: 5,
retryPolicy: { maxRetries: 3 },
handler: async ({ runId }) => {
const run = await db.queryRow<{ task: string; step: number }>`
UPDATE runs SET status = 'running'
WHERE id = ${runId} AND status = 'queued'
RETURNING task, step
`;
if (!run) return; // already finished or claimed
let completed = false;
try {
for (let step = run.step; step < MAX_STEPS; step++) {
const { done, output } = await agentStep(run.task, step, modelKey());
await db.exec`UPDATE runs SET step = ${step + 1}, result = ${output} WHERE id = ${runId}`;
if (done) {
completed = true;
break;
}
}
if (completed) {
await db.exec`UPDATE runs SET status = 'done' WHERE id = ${runId}`;
} else {
await db.exec`UPDATE runs SET status = 'failed', result = ${'maximum step limit reached'} WHERE id = ${runId}`;
}
} catch (err) {
await db.exec`UPDATE runs SET status = 'failed', result = ${String(err)} WHERE id = ${runId}`;
throw err;
}
},
});
// Replace with your model and tool calls; return done when the task is complete.
async function agentStep(task: string, step: number, apiKey: string) {
return { done: step >= 2, output: `step ${step} of: ${task}` };
}
The worker resumes from the saved step when a message is redelivered, so a crash mid-run repeats at most one step, and a run that keeps failing moves to the dead-letter queue after three retries. The ackDeadline of ten minutes gives each run time to finish before the message is redelivered, and maxConcurrency caps how many runs each instance processes at once; both are covered in the Pub/Sub docs.
encore run starts Postgres locally, runs the topic in memory and calls the worker on publish, and the local development dashboard traces the request, the publish and every query the worker makes. The model key is set with encore secret set, and the secrets docs cover per-environment values. Secret values are not stored in the repository, so an agent reading the repository cannot read or leak the key.
On deploy to AWS, Encore provisions RDS, SNS and SQS with a dead-letter queue, stores the key in Secrets Manager, runs the service on Fargate or EKS and provisions an IAM role for the service, as listed in the AWS infrastructure docs. Your model calls, run data and logs stay in your own account. A coding agent extending the worker adds a step to the loop without touching queue or IAM configuration.
Best fit: TypeScript teams running agent tasks that take minutes, who want queued workers, status tracking and model keys in Secrets Manager in their own AWS account.
Accept the request, store a run record with status queued, publish a message to a queue and return the run ID immediately. A worker consumes the message, runs the agent loop, writes progress and the result to the database, and the client polls or streams the status by run ID.
An agent run makes several model calls and tool calls in sequence and often takes minutes. Load balancers, API gateways and serverless functions have request or idle timeouts well below that, for example the 60-second default idle timeout on an AWS Application Load Balancer and the 15-minute maximum on AWS Lambda.
Cap the number of steps per run, set a per-run token budget the worker checks before each model call, limit how many runs a worker processes at once, and make retries resume from the last saved step instead of starting over.
Declare a Topic and a Subscription from encore.dev/pubsub in a TypeScript service, set ackDeadline and maxConcurrency on the subscription, store runs in a database declared with new SQLDatabase and read the model key with secret(). Encore provisions SNS, SQS, the database and Secrets Manager in your AWS account.
Deploy to your cloud on AWS and Google Cloud
without managing infrastructure
Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.