The Async Operation Handle Pattern: Making Long-Running Tasks Safe for Bots
Voice agent provisioning takes 60-120 seconds. Knowledge base synchronization from an external system can take 3-5 minutes. Batch agent creation for a new franchise group — 20 agents, 20 phone numbers, 20 KB document sets — can take 10 minutes.
None of these can be synchronous HTTP responses. A 30-second timeout is a generous client default; many clients timeout at 10 seconds. A bot hitting a provisioning endpoint, waiting 90 seconds, getting no response, and retrying — now you're running two provisioning pipelines in parallel, both spending tokens, both trying to purchase phone numbers. That's the failure mode.
The async operation handle pattern prevents it. Here's how we implemented it.
The Pattern
The mechanics are simple:
POST /v2/agentswith abusiness_url→ immediate202 Acceptedwith{ operationId, statusUrl }- Provisioning pipeline runs in the background
- Client polls
GET /v2/operations/{operationId}to check status - When status transitions to
completedorfailed, the result is in the response - Simultaneously:
agent.provisionedwebhook fires to any registered endpoint
The key insight: the HTTP response and the actual result are decoupled. The HTTP response acknowledges that we received and queued the work. The result is delivered through a separate channel (polling or webhook) when the work is done.
The TypeScript Types
// lib/operations/types.ts
export type OperationStatus = "pending" | "running" | "completed" | "failed";
export type OperationType =
| "agent_provision"
| "kb_sync"
| "batch_agent_create"
| "compliance_audit"
| "scout_extract";
/**
* Persisted operation record — maps to ww_operations table.
* Created on submission, updated as the background task progresses.
*/
export interface Operation {
/** Opaque ID, prefixed "op_" */
id: string;
/** Which client owns this operation */
clientId: string;
type: OperationType;
status: OperationStatus;
/**
* The typed result of the operation when completed.
* Null while pending/running.
* Contains error details when failed.
*/
result: unknown | null;
/** Progress 0-100 — updated by the background task for long operations */
progressPct: number | null;
/** Human-readable status message for display in dashboards */
statusMessage: string | null;
createdAt: Date;
updatedAt: Date;
/** When this operation started executing (transitioned from pending → running) */
startedAt: Date | null;
/** When this operation completed or failed */
completedAt: Date | null;
/**
* Operations are purged 48 hours after completion.
* Clients should retrieve the result before expiry.
*/
expiresAt: Date;
/**
* The idempotency key associated with the original request.
* Used to detect duplicate submissions and return the existing operation.
*/
idempotencyKey: string | null;
}
The Database Schema
-- ww_operations table
CREATE TABLE ww_operations (
id TEXT PRIMARY KEY, -- "op_a1b2c3d4"
client_id TEXT NOT NULL,
type TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
result JSONB,
progress_pct INTEGER,
status_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL,
idempotency_key TEXT,
CONSTRAINT valid_status CHECK (status IN ('pending', 'running', 'completed', 'failed'))
);
-- Index for polling queries — clients query by ID + client_id for security
CREATE INDEX idx_operations_client ON ww_operations (client_id, id);
-- Index for cleanup job — finds expired operations
CREATE INDEX idx_operations_expires ON ww_operations (expires_at)
WHERE status IN ('completed', 'failed');
The 202 Response
The submission endpoint returns immediately:
// app/api/v2/agents/route.ts
export async function POST(req: NextRequest) {
const actor = await withBotAuthCtx(req);
const body = await req.json();
// Check idempotency first — see the intersection section below
const existingOp = await findExistingOperation(actor, req.headers.get("Idempotency-Key"));
if (existingOp) {
return NextResponse.json(
{ data: formatOperationResponse(existingOp), meta: buildMeta(req) },
{ status: existingOp.status === "completed" ? 200 : 202 }
);
}
// Create the operation record
const operationId = `op_${generateId()}`;
const expiresAt = new Date(Date.now() + 48 * 60 * 60 * 1000); // 48 hours
await db.insert(operations).values({
id: operationId,
clientId: actor.clientId,
type: "agent_provision",
status: "pending",
expiresAt,
idempotencyKey: req.headers.get("Idempotency-Key"),
});
// Queue the Scout pipeline — returns immediately, runs in background
await queueScoutPipeline({ operationId, businessUrl: body.business_url, actor });
// Return 202 with the status URL — don't make the client know the URL shape
return NextResponse.json(
{
data: {
operationId,
status: "pending" as OperationStatus,
statusUrl: `/v2/operations/${operationId}`,
estimatedDurationSeconds: 90,
expiresAt: expiresAt.toISOString(),
},
meta: buildMeta(req),
},
{ status: 202 }
);
}
Two things worth noting: we include statusUrl in the response body rather than only as a Location header. Bots that parse JSON reliably can use data.statusUrl without parsing headers. We include the Location header too for HTTP clients that follow RFC 2616 conventions. And we include expiresAt so clients know how long they have to retrieve the result before it's purged.
Polling with Respect: X-Poll-After
Every GET /v2/operations/{id} response includes an X-Poll-After header:
// app/api/v2/operations/[id]/route.ts
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
const actor = await withBotAuthCtx(req);
const op = await db.query.operations.findFirst({
where: and(
eq(operations.id, params.id),
eq(operations.clientId, actor.clientId) // tenant isolation
),
});
if (!op) {
return v2Error({ code: "operation_not_found", message: `Operation ${params.id} not found.`, retryable: false, httpStatus: 404 });
}
const response = NextResponse.json(
{ data: formatOperationResponse(op), meta: buildMeta(req) },
{ status: 200 }
);
// Tell the client how long to wait before polling again.
// Shorter for running operations, longer for pending ones.
// This prevents thundering herd when multiple clients poll simultaneously.
const pollAfterSeconds = op.status === "running" ? 5 : 10;
response.headers.set("X-Poll-After", String(pollAfterSeconds));
return response;
}
Without X-Poll-After, a well-intentioned client might poll every 500ms for 90 seconds — 180 requests to check on one operation. With X-Poll-After: 5, that drops to 18 requests. For a client managing 50 concurrent provisioning operations, the difference is 9,000 requests vs. 900. The header is not enforced by the server (we can't stop a client from ignoring it), but well-behaved clients follow it.
Full Polling Cycle: curl Example
# Step 1: Submit the operation
curl -X POST https://api.workforcewave.com/v2/agents \
-H "Authorization: Bearer $WFW_API_KEY" \
-H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \
-H "Content-Type: application/json" \
-d '{"business_url": "https://ridgelinedental.com", "name": "Ridgeline Dental AI"}'
# Response: 202 Accepted
# {
# "data": {
# "operationId": "op_a1b2c3d4",
# "status": "pending",
# "statusUrl": "/v2/operations/op_a1b2c3d4",
# "estimatedDurationSeconds": 90,
# "expiresAt": "2026-06-18T14:00:00.000Z"
# }
# }
# Step 2: Poll (10 seconds later, per X-Poll-After)
curl https://api.workforcewave.com/v2/operations/op_a1b2c3d4 \
-H "Authorization: Bearer $WFW_API_KEY"
# Response: 200, status=running
# X-Poll-After: 5
# {
# "data": {
# "operationId": "op_a1b2c3d4",
# "status": "running",
# "progressPct": 45,
# "statusMessage": "Generating knowledge base documents..."
# }
# }
# Step 3: Poll again (5 seconds later)
curl https://api.workforcewave.com/v2/operations/op_a1b2c3d4 \
-H "Authorization: Bearer $WFW_API_KEY"
# Response: 200, status=completed
# {
# "data": {
# "operationId": "op_a1b2c3d4",
# "status": "completed",
# "result": {
# "agentId": "agt_xyz789",
# "phoneNumber": "+18432109876",
# "entityData": { "businessName": "Ridgeline Dental", ... }
# },
# "completedAt": "2026-06-16T14:01:27.000Z",
# "expiresAt": "2026-06-18T14:00:00.000Z"
# }
# }
The Idempotency Intersection
The most important edge case: what happens when a bot submits POST /v2/agents with an idempotency key for an operation that's already in progress or completed?
async function findExistingOperation(
actor: ActorContext,
idempotencyKey: string | null
): Promise<Operation | null> {
if (!idempotencyKey) return null;
return db.query.operations.findFirst({
where: and(
eq(operations.clientId, actor.clientId),
eq(operations.idempotencyKey, idempotencyKey)
),
});
}
If the operation is pending or running: return the existing operation with 202 Accepted. The bot gets the same statusUrl and can poll for completion. No new provisioning pipeline is launched.
If the operation is completed: return the operation with 200 OK and the full result. The bot gets the agent data immediately, as if the call had been synchronous. No new provisioning pipeline launched.
This is critical for the retry scenario: a bot submits, gets a network timeout, retries with the same idempotency key. Without this intersection, the retry would launch a second provisioning pipeline. With it, the retry gets the in-progress or completed operation. Exactly correct behavior.
Webhooks vs. Polling: Which to Use
Polling works for simple integrations and one-off provisioning. It's stateless — the client doesn't need to maintain any state between poll cycles, just the operationId.
Webhooks are better for production bot integrations. The bot doesn't poll; it receives a push notification when the operation completes. This means the bot doesn't need to maintain a "waiting for op_a1b2c3d4" state — it submits, continues other work, and handles the agent.provisioned event when it arrives.
The agent.provisioned webhook payload mirrors the completed operation response exactly — same result shape, same agentId. This means a bot that handles webhooks can use the same parsing code for both webhook delivery and poll-based completion detection.
For bots managing many provisioning operations concurrently, webhooks are almost always the right choice. Polling 50 operations at different stages is a state management problem. Webhooks push the state to you.
Ready to put AI voice agents to work in your business?
Get a Live Demo — It's FreeContinue Reading
Related Articles
What Artera Got Right (And What's Still Missing)
An honest look at the existing patient communications market and what it tells us about where voice AI is going.
Workforce Wave AI: The Engine Behind Auto-Provisioning
What happens inside the 5-step Workforce Wave pipeline when a partner enters a business URL, why partners get an operationId instead of a 30-second wait, and how ww_operations powers the fleet dashboard progress bar.
The Bot Creation Matrix: Four Ways to Deploy AI, Now All Live on WFW
Dual-mode agent support just shipped, completing the Bot Creation Matrix. WFW is now the only platform where a bot can be the creator and the consumer — entirely human-free.