AI Voice Agents

Provisioning 100 Voice Agents in 10 Minutes: A Batch Guide

Workforce Wave

April 17, 20266 min read
#api#batch#developers#provisioning

You've signed 100 dental practices. Each one needs a WFW voice agent. You could provision them one at a time through the dashboard — or you could run a script that finishes before your coffee gets cold.

This post covers the complete batch provisioning pattern: reading a customer list, queuing requests to stay inside rate limits, polling for completion, handling failures gracefully, and validating the results. All examples use the /v2/ API paths.

The Pattern at a Glance

Read customers.csv
    │
    ▼
Queue 100 POST /v2/agents requests
  (5 concurrent max — rate limit headroom)
    │
    ▼
Collect operation_ids
    │
    ▼
Poll each operation until active or failed
  (exponential backoff, 3-minute timeout per agent)
    │
    ▼
Log failures, continue with the rest
    │
    ▼
GET /v2/agents?status=active to validate final count

Straightforward. The details are in the concurrency control, the polling strategy, and the failure handling.

Concurrency: Don't Fire 100 Simultaneous Requests

The agents:write rate limit is tighter than read operations. Firing all 100 provisioning requests simultaneously will exhaust your budget, trigger 429 errors, and turn a 10-minute job into an hour-long retry loop.

The right approach is a queue with controlled concurrency — 5 simultaneous requests is safe for most service accounts:

// Process an array of tasks with a concurrency limit
async function pLimit(tasks, concurrency) {
  const results = [];
  const queue = [...tasks];
  const active = new Set();

  await new Promise((resolve) => {
    function next() {
      while (active.size < concurrency && queue.length > 0) {
        const task = queue.shift();
        const promise = task().then(result => {
          results.push(result);
          active.delete(promise);
          if (queue.length === 0 && active.size === 0) resolve();
          else next();
        }).catch(err => {
          results.push({ error: err.message });
          active.delete(promise);
          if (queue.length === 0 && active.size === 0) resolve();
          else next();
        });
        active.add(promise);
      }
    }
    next();
  });

  return results;
}

Five concurrent requests means 100 agents queue in roughly 20 requests × ~6 seconds each = 2 minutes of API calls, leaving 8 minutes for Workforce Wave provisioning to complete in parallel.

The Full Provisioning Script

const fs = require('fs');
const fetch = require('node-fetch');

const API_BASE = 'https://api.workforcewave.com';
const TOKEN = process.env.WFW_API_TOKEN;
const CONCURRENCY = 5;

// Read customers from CSV: customer_id,business_name,business_url
function readCustomers(csvPath) {
  return fs.readFileSync(csvPath, 'utf8')
    .split('\n')
    .slice(1) // skip header
    .filter(line => line.trim())
    .map(line => {
      const [customer_id, business_name, business_url] = line.split(',');
      return { customer_id: customer_id.trim(), business_name: business_name.trim(), business_url: business_url.trim() };
    });
}

// POST /v2/agents — uses customer_id as idempotency key so reruns are safe
async function provisionAgent(customer) {
  const res = await fetch(`${API_BASE}/v2/agents`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${TOKEN}`,
      'Content-Type': 'application/json',
      // Idempotency key derived from customer_id — rerunning the script won't create duplicates
      'Idempotency-Key': `provision-customer-${customer.customer_id}`,
    },
    body: JSON.stringify({
      payload: {
        name: `${customer.business_name} AI Receptionist`,
        business_url: customer.business_url,
        template_id: 'dental_receptionist',
      }
    }),
  });

  if (!res.ok) {
    const err = await res.json();
    throw new Error(`Provision failed for ${customer.customer_id}: ${err.error?.message}`);
  }

  const data = await res.json();
  return { customer_id: customer.customer_id, operation_id: data.data.operation_id };
}

// Poll GET /v2/operations/{id} until active or failed — give up after 3 minutes
async function pollOperation(operationId, customerId) {
  const deadline = Date.now() + 3 * 60 * 1000; // 3 minutes
  let delay = 2000; // start at 2s, increase exponentially

  while (Date.now() < deadline) {
    await new Promise(r => setTimeout(r, delay));
    delay = Math.min(delay * 1.5, 15000); // cap at 15s

    const res = await fetch(`${API_BASE}/v2/operations/${operationId}`, {
      headers: { 'Authorization': `Bearer ${TOKEN}` },
    });

    const data = await res.json();

    if (data.data.status === 'active') {
      return { customer_id: customerId, status: 'active', agent_id: data.data.agent_id };
    }
    if (data.data.status === 'failed') {
      return { customer_id: customerId, status: 'failed', error: data.data.error?.message };
    }
    // status === 'pending' — keep polling
  }

  return { customer_id: customerId, status: 'timeout', error: 'Provisioning exceeded 3 minutes' };
}

async function main() {
  const customers = readCustomers('customers.csv');
  console.log(`Provisioning ${customers.length} agents...`);

  // Step 1: Fire provisioning requests with concurrency limit
  const provisionTasks = customers.map(c => () => provisionAgent(c).catch(err => ({
    customer_id: c.customer_id, error: err.message
  })));

  const provisionResults = await pLimit(provisionTasks, CONCURRENCY);

  // Separate successes from immediate failures (bad URL, auth error, etc.)
  const succeeded = provisionResults.filter(r => r.operation_id);
  const failed = provisionResults.filter(r => r.error);
  console.log(`Provisioning queued: ${succeeded.length} succeeded, ${failed.length} failed immediately`);
  if (failed.length > 0) console.error('Immediate failures:', failed);

  // Step 2: Poll all operation_ids in parallel (polling is cheap — read rate limit)
  const pollTasks = succeeded.map(r => () => pollOperation(r.operation_id, r.customer_id));
  const pollResults = await pLimit(pollTasks, 20); // polling can be more concurrent

  // Step 3: Report results
  const active = pollResults.filter(r => r.status === 'active');
  const pollFailed = pollResults.filter(r => r.status !== 'active');

  console.log(`\nProvisioning complete:`);
  console.log(`  Active agents: ${active.length}`);
  console.log(`  Failures: ${pollFailed.length}`);
  if (pollFailed.length > 0) {
    console.error('Failed agents:', pollFailed);
    fs.writeFileSync('failed-agents.json', JSON.stringify(pollFailed, null, 2));
    console.log('Failures written to failed-agents.json for manual review.');
  }
}

main().catch(console.error);

Idempotency for Reruns

The idempotency key provision-customer-${customer.customerid} is the most important line in the script. If the script crashes midway — network drop, process kill, provisioning timeout — you can rerun it against the same CSV. WFW recognizes the keys it's already seen and returns the original operationid without creating new agents for customers that were already provisioned.

Without this, a crash at customer 47 means customers 1–46 get a second agent provisioned on the next run. With it, rerunning is safe. Always derive idempotency keys from durable business identifiers, not from UUIDs generated at runtime.

Error Handling: Log and Continue

Some agents will fail. A customer might have a broken website that Workforce Wave can't crawl. A DNS timeout. A malformed URL in the CSV. The right response is to log the failure and continue with the rest — not to abort the entire batch.

The script above writes failures to failed-agents.json for manual review after the batch completes. Common failure modes:

  • scoutcrawlfailed — Workforce Wave couldn't access the business URL. Check the URL, try again manually.
  • invalid_url — the URL was malformed. Fix the CSV.
  • templatenotfound — the template_id doesn't exist for your account. Check available templates in the dashboard.

For a production batch job, consider sending the failure list to a monitoring channel (Slack, PagerDuty) rather than just writing a JSON file.

Post-Batch Validation

After the batch completes, verify the count:

curl "https://api.workforcewave.com/v2/agents?status=active&limit=200" \
  -H "Authorization: Bearer $WFW_API_TOKEN" \
  | jq '.meta.total'

The meta.total field gives the total count of active agents matching the query, regardless of the page size. If you provisioned 100 and expected 98 to succeed (based on your failure log), and meta.total shows 98, the batch is complete and accurate.


This post completes the SaaS Integration series. For the rate limit and idempotency patterns referenced here, see the previous post: Rate Limiting and Idempotency: What Your Bot Needs to Know.

Share this article

Ready to put AI voice agents to work in your business?

Get a Live Demo — It's Free