Skip to content

Guides

Tasks

A task dials a list of recipients with one agent, following rules you set up front - budget, calling window, number of attempts. Nothing gets dialed until you start the task yourself.

What a task is

A task (task_...) is an outbound campaign: one agent on the volai engine, a list of recipients with a phone number and optional variables for the system prompt, and rules (budget, calling window, maximum attempts and call duration). Three types by purpose - invoice (payment reminder), appointment (appointment confirmation) and custom (anything else) - for the first two, when source is integration, every recipient additionally carries a reference to the invoice or event (externalRefs) that the agent mentions during the call.

The agent must be on volai's own engine (useForOutboundTasks: true at creation, see POST /v1/agents), not ElevenLabs - the engine supports system-prompt variables, call-duration caps and verification that its configuration hasn't changed while the campaign was running. The agent's draft must be PUBLISHED (PATCH /v1/agents/{id} or POST /v1/agents/{id}/draft/publish) AND the published version must be TESTED (POST /v1/agents/{id}/simulate) - skip either step and start returns agent_not_ready.

Statuses and transitions

In practice a task always goes from `draft` straight to `running` (`start`); deleting (`DELETE`) only works in the marked statuses.

draft
What it meansJust created, never started yet. Editable (PATCH), deletable.
ready
What it meansA model status between draft and running that start accepts as a valid starting point - no public endpoint (REST or MCP) actually moves a task into it today, so you won't encounter it in practice. If it did occur, it's editable and deletable just like draft.
running
What it meansDialing recipients. NOT EDITABLE - PATCH returns task_not_editable, no exception. Anyone who needs to change variables must stop the task first (POST .../pause) and edit it while paused - and only while no recipient has been dialed yet; after the first attempt recipients can no longer be replaced (items_immutable), so create a new task instead. Cannot be deleted. When it hits a call limit (rate_limited - the in-house engine's outbound-minute limit, its cap on concurrent outbound calls, by default 10 calls per minute or 1000 per day), it stays running: it sets lastError: "rate_limited", the attempt does not count, and waiting contacts get items[].nextAttemptAt at the end of the limit (from Retry-After, otherwise in 5 minutes; for the engine's cap on concurrent calls the engine sends Retry-After: 30). After that it continues on its own. It waits the same way when the account's concurrent agent call quota is full (relay_lease_limit, 2 running calls by default): lastError: "relay_lease_limit", next attempt in a minute, the attempt does not count. A busy number (destination_busy - another call to the same number is running right now, for example from another task or a manual call, or the number is locked for 15 minutes after an uncertain dial) makes only the affected contact wait, not the whole task: items[].lastError: "destination_busy", items[].nextAttemptAt from the remaining time of the number lock (at least 30 s, at most 16 min, 90 s without a lock), the attempt does not count (countsAsAttempt: false) and the other contacts keep being dialed. After the 6th busy result in a row (5 waits) the contact goes to review with reviewReason: "dial:destination_busy". A permanent problem with the number (on_dnc, destination_auto_blocked, cannot_call_own_number, cannot_call_volai_number, invalid_number, unsupported_country, blocked_destination) does not stop the task either: the contact goes to review with reviewReason: "dial:<code>" (the call never happened, so the attempt has countsAsAttempt: false and does not use up maxAttempts), the task stays running, keeps dialing the other contacts and moves to needs_attention only when no other work is left. If the volai operator manually suspends the task's agent, the task stays running and dials nothing; it continues on its own once the agent is restored. A suspended agent shows suspended: true in GET /v1/agents/{id}.
paused
What it meansStopped before the next call (POST .../pause); a call already in flight finishes. Editable, deletable, start resumes it. A pause in the middle of a call leaves reviewReason: "dispatch_in_flight" until reconcile (the scheduler does it within minutes) settles that call, and start is refused with task_needs_attention until then; afterwards a task with work left stays paused even while other contacts wait in review. If the call in flight ends without a known price, the task first moves to needs_attention with reviewReason: "billing_unknown" and returns to paused once the price arrives (reconcile fills it in). If you lower maxAttempts meanwhile so that no contact waiting for another attempt has one left, the task stays paused (such a contact counts as work), but start is refused with task_not_startable until you raise maxAttempts again.
completed
What it meansEvery recipient has a final outcome. Deletable (nothing left to charge).
needs_attention
What it meansSomething needs a look - typically the agent's configuration changed while running (agent_version_changed), the engine was temporarily unreachable (engine_contract_unavailable), or the price of a call that already ran is not known yet (billing_unknown). POST .../reconcile reconciles the state; deletable as long as no item is still in_progress. Decide a contact in review through POST .../items/{itemId}/resolve (retry/skip); a contact with a permanent problem in the number (dial:on_dnc, dial:destination_auto_blocked, dial:cannot_call_own_number, dial:cannot_call_volai_number, dial:invalid_number, dial:unsupported_country, dial:blocked_destination, or dial:destination_busy after the 6th busy result in a row) stops the task only once the other contacts have finished. An uncertain dial result (for example dial:engine_unavailable) moves the task to needs_attention right away and the other contacts are not dialed. When the call behind such an interruption (or an unknown price) is settled by reconcile, the task becomes paused while any contact is still waiting for an attempt, even if others are in review; needs_attention only when nothing is left to dial but a contact in review, and completed when nothing is left. A dial whose result stays uncertain (no call can be found for it) keeps the task in needs_attention until that contact is decided. For an item-level reason, after the final decision the task moves to paused if work remains, or completed if every contact is completed or skipped. A task-level reason stays in needs_attention until its own recovery is complete; the exception is budget_exceeded, where skipping every contact waiting for another attempt completes the task when no provider state remains unresolved. See the special billing_unknown flow below.

The exact delete condition: the status is one of the marked ones AND no item has status: "in_progress" (a call that started dialing and hasn't finished) - otherwise task_not_deletable.

Recipient import (CSV)

POST /v1/tasks/csv-preview parses a CSV and returns it back with per-row errors - NOTHING is saved, it's just a preview before creating or updating a task. The new-task form in the portal calls the same function.

  • The delimiter is detected automatically (comma or semicolon) from the first line.
  • A phone column must exist - case, surrounding spaces and a trailing colon do not matter, and telefon, tel or mobile are accepted too; the preview always returns the column as phone. A bare list of numbers without a header (one per line, a single column) works as well. Otherwise the whole CSV fails with a single phone error on the header.
  • Every other column is a variable for the agent's system prompt ({{column_name}}) - at most 20 columns, name up to 64 characters, value up to 512 characters.
  • The phone number is normalized the same way as everywhere in the API (normalizePhone) - an invalid or empty number is an error only on THAT row, the rest of the CSV is still processed.
  • A cell that looks like a spreadsheet formula (starts with =, +, - or @) is rejected - protection against CSV injection when the file is opened in Excel.
  • At most 1000 rows per CSV (the same cap as a task's recipient count) and 1 MB of raw text; the whole task including its variables must fit in 2 MB, otherwise creation returns validation.

The response carries headers, delimiter, rows (each row with its own errors), validRows (only the clean ones, in exactly the shape recipients in POST /v1/tasks expects) and a summary errors array. Send validRows straight through as recipients when creating or updating the task.

Budget and calling window

Budget

budgetHal is the spending CAP for the whole task, in hellers. At creation it must cover at least one attempt at the maximum duration (budget_too_low otherwise); at start, before EVERY dial the highest possible price of the attempt is reserved (reservedBudgetHal), and only an answered call turns it into spentHal. The reservation is the price of a call lasting exactly maxDurationSecs (the call leg and the agent leg, each rounded up to a heller exactly as billing does), and a call ended by the duration cap is billed for at most maxDurationSecs, even when it ends a fraction of a second later. A task never spends more than the budgetHal you gave it.

Calling window

callingWindow is {timezone: "Europe/Prague", start, end, days} - today the only supported timezone is Prague, start/end as HH:MM (the end is inclusive of that whole minute: a 09:00-18:00 window still allows a call at 18:00:59, and a window ending at 23:59 covers the whole day), and days are numbers 0-6 (0 = Sunday). Without one, weekdays 9:00-18:00 apply. Outside the window, start returns window_closed - either wait for it to reopen, or change the window with PATCH. Optional expiresAt is the campaign deadline as a positive safe-integer Unix timestamp in milliseconds. It is checked at start and before every dial, including the first. At or after this deadline, no new call starts and no attempt is consumed; a call in progress finishes normally. The retry horizon of lorela_enterprise (retryMaxAgeDays, 5 by default) instead counts N × 24 hours from the first counted attempt, not from task creation.

Attempts and call duration

maxAttempts is 1-3 without a policy (default 1), or 1-4 with retryPolicy: "lorela_enterprise" (creation default 4). It is the highest number of automatic dials of one contact, the first call included (1 = no retries). Every attempt where the call could have happened counts, including an unanswered call, a call declined by the person called and an attempt with an uncertain result. An attempt that volai rejected before dialing does not count (for example a blocked, your own or an invalid number, the do-not-call list, a number with another call in progress, call limits); such an attempt has items[].attempts[].countsAsAttempt: false. A call whose route never reached the carrier in time, so the recipient's phone never rang, does not count either (the attempt has errorCode: "relay_not_routed", the call endReason: "rejected"): the contact gets a new attempt no sooner than a minute later (with lorela_enterprise, on its schedule), and after the third such attempt in a row it goes to review with reviewReason: "call:relay_not_routed". Without a policy, the cap applies to automatic dials only: a contact that you give resolve with action: "retry" after its attempts are used up gets one more dial. maxDurationSecs (30-300) caps the length of ONE call, after which it ends automatically - the default duration is 120 seconds. Enable the optional lorela_enterprise policy through API or MCP before the first attempt; existing tasks do not change automatically. The second attempt is no earlier than 8 minutes after the first real attempt starts, within 08:00-22:00 Europe/Prague. The third uses the next other 08:00-09:00 or 17:00-19:00 window; the fourth uses the opposite window on the following calendar day. The task callingWindow and its weekdays still apply; no policy retry starts after the retry horizon: the optional retryMaxAgeDays, a whole number of days 1-30 (5 when omitted), valid only with the policy. The horizon ends N × 24 hours from the first counted attempt of each contact (fixed 24-hour days, so across a daylight-saving change the local end time shifts by an hour). It can be changed with PATCH also after attempts have started (not on a running or completed task, which cannot be edited): raising it on a paused task returns the contacts that were skipped only for the horizon (reviewReason: "retry_age_expired") back to failed (waiting for their next attempt) with a new nextAttemptAt, when they still have an attempt left and the next slot of the schedule falls inside the new horizon and before expiresAt; lowering it reopens nothing. Only a PATCH that raises the horizon reopens contacts, so send retryMaxAgeDays together with a prolonged expiresAt in one request. Only no_answer, missed, busy, completed voicemail and an answered call in which the person called said nothing qualify (all without human contact). Human contact, refusal and engine failure stop automatic retries; ambiguous results need review. The last allowed attempt (maxAttempts) that ends with such a retryable result (unanswered, busy, voicemail, or an answered call in which the person called said nothing) closes the contact as skipped with reviewReason: "retry_policy_exhausted" (like retry_age_expired when the horizon runs out): there is nothing to review or retry. An ambiguous result of the last attempt stays in review with the same reason. Without a policy nothing changes: the last unanswered attempt is still review. A verified human callback after the attempts are used up turns the contact into completed. DNC, budget and call limits still apply: automatic blocking after 3 failures within 24 hours may prevent the fourth attempt. PATCH without maxAttempts preserves the existing limit. With the policy, manual resolve with action: "retry" returns validation; only scheduled automatic attempts are allowed. A provably undialed expired claim may be restored before the first real attempt: no active worker, dispatch intent, provider call, reservation, charge or callback, and only proven pre-dial rejections in its history. Recovery preserves history and limits and returns the contact to pending in a paused task; it does not dial. Coupon reminders cannot be reopened. Manual retry cannot bypass four attempts, the retry horizon or stopping after human contact.

Global throughput

Dialing for every task on the WHOLE platform (not just your account) goes through one cron job that runs every 2 minutes and processes at most 20 items per run - BUT that cap is shared across concurrently running tasks, not something a single task can use up on its own: exactly ONE recipient is dialed from EACH active task per run. So even on a completely empty platform, one task never dials more than one recipient every 2 minutes - 100 recipients therefore doesn't finish in ~20 minutes but in roughly 200 minutes (100 x 2 minutes). Plan the calling window for time-sensitive campaigns accordingly (for example an appointment reminder the day before).

Flow for an agent

A typical run, in this order:

  1. The task's agent must be PUBLISHED and the published version TESTED (POST /v1/agents/{id}/draft/publish, then POST /v1/agents/{id}/simulate) - otherwise a later start returns agent_not_ready.
  2. Optionally POST /v1/tasks/csv-preview - checks the recipient CSV before creating the task.
  3. POST /v1/tasks - creates the task in draft status. Nothing gets dialed.
  4. GET /v1/tasks/{id} - read issues (dry-run validation) and confirm the task is ready to start.
  5. As needed, PATCH /v1/tasks/{id} - adjust the rules, ALWAYS with the revision from the last read.
  6. POST /v1/tasks/{id}/start - starts dialing. Every answered call is billed immediately.
  7. POST /v1/tasks/{id}/pause at any point while it runs; POST /v1/tasks/{id}/start again resumes it.
  8. After an interruption (a pause in the middle of a call, an expired claim, a call with a price not known yet, or an uncertain dial whose call was found), POST /v1/tasks/{id}/reconcile reconciles the state against the actual calls; such a task with work left lands in paused even if some contacts wait in review, so start can resume it. A running task and other reasons (variables_limit, a failed invoice or calendar check, a dial whose call cannot be found) are not changed by reconcile; decide the contacts in review with resolve.
  9. Decide a contact that ended up in review (items[].status: "review") with POST /v1/tasks/{id}/items/{itemId}/resolve and action: "retry" or "skip". For a regular item-level reason, retry returns the contact to pending and the task to paused; skip closes the contact and the task becomes paused if work remains, or completed if every contact is completed or skipped. Resume a paused task with POST /v1/tasks/{id}/start.
  10. When a contact has reviewReason: "dial:destination_auto_blocked", the number is automatically blocked for 30 days after repeated unanswered calls, and retry alone does not unblock it - the contact would go straight back to review. Lift the block first (POST /v1/dnc/{e164}/unblock, in MCP unblock_destination, take the number from items[].phone), then resolve with action: "retry". For dial:on_dnc, first remove the number from the do-not-call list (DELETE /v1/dnc/{e164}), otherwise skip the contact. For the account's own number, a number of another volai account, an invalid number, a number outside Czechia and Slovakia and a premium-rate line (dial:cannot_call_own_number, dial:cannot_call_volai_number, dial:invalid_number, dial:unsupported_country, dial:blocked_destination) another attempt cannot help: use skip only.
  11. When a contact has reviewReason: "billing_unknown", the original call may already have happened but its price is still unknown. Call reconcile first and inspect the linked call. retry records that the contact should be tried again, but no new call is allowed until the original price settles; the task then moves to paused and needs a new start. skip prevents another call; the original call stays linked and its reservation keeps counting against the budget until the price settles. When work remains, the task moves straight to paused after skip and can be started; with no work left it waits in needs_attention and becomes completed once the price settles. A call that was never dialed (the engine refused it) is never billed and settles at 0 right away.
  12. DELETE /v1/tasks/{id} at the end, if the task never ran or already finished and you want the record gone.

Almost every write call (PATCH, start, pause, DELETE) takes revision from the last read of the task - the same optimistic concurrency as agent drafts. A mismatch returns revision_conflict: read the task again (GET) and repeat with the current revision. reconcile is the exception - its revision is optional.

MCP

The same tools as REST (list_tasks, get_task, create_task, update_task, pause_task, resolve_task_item, reconcile_task, start_task) - except for one difference at start.

start_task confirms the numbers

MCP start_task additionally takes confirmedRecipients and confirmedBudgetHal - they must match the task's actual recipient count and budget exactly, otherwise it returns validation with the real values spelled out and starts NOTHING. A third-party AI agent acting on one loosely understood instruction can't accidentally launch a paid campaign on made-up numbers this way - REST POST .../start has no such mechanism, because there the account owner calls it directly with their own key.

delete_task and the CSV preview are not in MCP - deletion stays in REST and the portal (an irreversible action that requires typing the name), and the CSV preview is just a form step.

Errors

  • task_not_found - No task with this id exists on the account.
  • task_not_editable - The task is running or completed, or is needs_attention without a way to recover - its rules can't be changed right now. POST .../items/{itemId}/resolve returns the same code for a running task: a contact in review shows up while the task is still running and dials the others - wait until it moves to needs_attention, or pause it (POST .../pause).
  • items_immutable - The recipient list can't be replaced once at least one dial attempt has happened.
  • budget_too_low - The budget doesn't cover even one attempt at the maximum call duration - raise budgetHal or lower maxDurationSecs.
  • task_not_startable - The task isn't in draft/ready/paused status, or has no recipient left to dial.
  • task_needs_attention - A POST .../reconcile is needed before the next start - something (the agent version, the engine) changed while it ran.
  • agent_not_ready - The agent's draft is either not published or the published version hasn't been tested - publish it (POST /v1/agents/{id}/draft/publish) and then simulate the published version (POST /v1/agents/{id}/simulate) before starting.
  • agent_version_changed - The agent's configuration changed WHILE the task was running - test it and restart via reconcile.
  • engine_required - The task's agent is not on the volai engine (useForOutboundTasks: true) - outbound tasks require the engine today, not ElevenLabs.
  • engine_contract_unavailable - The engine does not currently advertise support for outbound-task variables and caps - try starting again later.
  • window_closed - Outside the configured calling window - wait for it to reopen, or change callingWindow.
  • task_not_running - Pausing only works on a running task.
  • item_not_resolvable - The contact is not in review - a decision (retry/skip) is only possible for a contact waiting for review, and only while the task is not running.
  • task_not_deletable - Only a task without an item in progress, in draft/ready/paused/completed/needs_attention, can be deleted.
  • revision_conflict - The task changed in the meantime - read it again (GET) and repeat with the current revision.

The full error table for every endpoint is in the Tasks section of the REST API reference.