Skip to main content

Workforce forecasts and staffing optimization

The forecast surface answers two different questions. A forecast says how much work arrives — per channel, per 30-minute interval, over the next seven days. Staffing says how many agents that work requires — Erlang-C converts each interval’s predicted volume into the minimum agent count that still hits your service-level target. You read one to plan the other; this guide walks the loop end to end: recompute forecasts from history, read the intervals, sanity-check staffing with what-if, compare dedicated vs blended pools, read accuracy, and decide when to re-forecast. The same loop is visible on the dashboard under Voice → Scheduling → Forecast. This guide is the API version — everything below is runnable from cURL or an SDK.

1. Mental model

Forecast volume and staffing requirement are two separate computations, joined at run time: Two things to hold in mind:
  • Intervals, not days. Every number is per 30-minute bucket. “Nine agents on Tuesday” is really “nine agents for Tuesday 09:00–09:30, eleven for 09:30–10:00, …” — the Intraday wallboard and schedule generator read the same interval rows, so the granularity matches the ACD.
  • Erlang-C is the staffing rule. Volume and AHT never become agents directly; Erlang-C finds the smallest agent count where the number answering within the service target still meets the goal. The what-if and staffing-optimizer endpoints run the same model, so a what-if answer today matches the staffing tab tomorrow.

2. Prerequisites

  • History. The training window is the trailing 90 days of inbound messages and call legs. Below 90 days the forecast still runs but confidence drops; a channel with almost no history should stay excluded until it feeds meaningfully (see the production checklist below).
  • API key scopes. wfm:read for the list, accuracy, what-if, and staffing-optimizer calls. The recompute is a write — it requires wfm:write.
  • Roles. Reads are available to every role (owner, admin, developer, viewer). The recompute requires owner or admin.
Create the key in Settings → API Keys and replace the placeholder dv_live_sk_your_key_here below with it (use a dv_test_sk_* key against the sandbox while you iterate).

3. Trigger a recompute

History lands continuously; the stored forecast does not refresh itself on its own. The scheduled auto-forecast runs every six hours per tenant, but after a migration, a backlog drop, or a first-time onboarding you want a run on demand:
The body is optional: pass channel to recompute one channel, or omit it to run all seven (voice, chat, sms, whatsapp, email, inbox, social). Pass calendar_id (an org holiday calendar) to exclude closure dates from the training window and zero the forecast on those dates. The route runs synchronously and returns 200 with a per-channel summary:
A full sweep aggregates 90 days per channel and writes up to 336 rows per channel as one batch — expect several seconds. The route caps itself at 25 seconds (just under the gateway’s default timeout). If it hits the ceiling, it still returns 200 with partial: true and outcome: "partial_timeout", and the remaining channels report status: "skipped_timeout". On the timeout path the completed channels are already refreshed — you only need to re-trigger for the channels marked skipped_timeout:
Each channel writes the next 336 intervals (seven days) as one batch, so the outcome is all-or-nothing per channel — you never see half a week of a channel written by a timed-out run.

4. Read the forecast intervals

Once a recompute has run, read intervals for one or both of the channels you staff:
Each row is one 30-minute interval on one channel:
Query parameters: channel (one channel at a time — run one query per channel for a multi-channel view), from / to (ISO timestamps; default is now through +7 days), limit (1–500, default 336), and cursor for pagination — pass the meta.cursor back until has_more is false. The algorithm tells you what produced the row: continuous channels (voice, chat, sms at any reasonable volume) run Holt-Winters; a channel where more than ~70% of historical intervals are zero (email, social, sparse queues) runs Croston’s sparse method. confidence drops as history thins. generated_by distinguishes the scheduled auto-forecast from an intraday reforecast — an intraday tick re-pace the remaining intervals of today when actuals diverge materially from plan, so same-day numbers can refresh even without a manual recompute.

5. What-if — answer a staffing question before you schedule

The what-if endpoint is a pure function over Erlang-C — no reads, no writes, no side effects. Give it an arrival rate (or let it pull the next forecast interval for the channel) and it answers “if I schedule N agents, what service level do I hit?”:
Reading the response:
  • predicted_service_level — the fraction of contacts answered within the target. Compare it to service_level_target (default 0.80) yourself if you set a custom target, or trust meets_target.
  • traffic_erlangs — arrival rate × AHT. While scheduled_agents sits below the offered load, the queue diverges: queue_unstable: true, estimated_queue_depth: Infinity (serialized as 1e999), and staffing cannot fix it regardless of target stringency.
  • arrival_rate_source_missing — when you omit arrival_rate_per_second, the route pulls the next forecast interval for the channel; if no forecast exists this flag comes back true and the SL verdict is withheld (meets_target: null) rather than computed on fabricated zero traffic.
  • zero_traffic — Erlang-C trivially returns 100% SL for any staffing on zero load, so the verdict is withheld. Read this flag before trusting a green meets_target.
Use what-if for one channel × one staffing level per call; use the optimizer (next section) when the question spans channels.

6. Compare dedicated vs blended staffing with the optimizer

Cross-trained agents serving two channels on one pool beat two dedicated pools of the same headcount. The staffing optimizer quantifies that: for each channel it computes an Erlang-C special-team count, then pools the channels and runs one Erlang-C per pool — the difference is the agents pooling frees:
Omitting per-channel demand makes the route pull the next stored forecast interval per channel (or an explicit interval_start). Each channel carries defaults for AHT and concurrency — e.g. chat blends 3 concurrent contacts per agent, sms 4, voice 1 — and you can override any of them inline.
The useful numbers are dedicated_agents_total (each channel staffed alone) vs blended_agents_total (the pool staffed jointly) — agents_saved and pooling_efficiency_pct quantify the pooling win. In this run voice dominates the pool so saving is zero; on two comparable channels the saving is usually positive and becomes the business case for cross-training. Run it per day-part (peak vs off-peak differ materially) using interval_start.

7. Close the accuracy loop — re-forecast when it drifts

Forecasts drift. Run accuracy after each interval passes and treat it as the re-forecast trigger:
The summary rolls the absolute-percentage-error per interval into a per-channel mean (mape) and the complement accuracy (1 − mape). Use the channel summary, not the raw intervals, as the signal:
  • Accuracy ≥ 0.85: the forecast is healthy — check the channel daily but re-forecast only on material drift.
  • Accuracy 0.70–0.85: drift — recompute after a data change (new queue, changed routing) or when the same channel stays in this band for a few days.
  • Accuracy < 0.70: the pattern has shifted — run POST /wfm/forecasts/recompute for that channel, then re-read accuracy after the next full day.
  • evaluated_intervals ≈ 0: no past intervals joined — either the channel is new (no history) or the forecast just ran. Do not treat zero intervals as zero accuracy.
Because actuals are read from the same tables the training query uses, a day where logging was down will loop back as wrong “actuals” — treat accuracy readings across a degraded day with care.

8. Production checklist

  • Cadence. The scheduled auto-forecast runs every 6 hours per tenant; a separate intraday reforecast tick runs every 30 minutes and re-pace only today’s remaining intervals when actuals diverge materially from plan. Use POST /wfm/forecasts/recompute only for onboarding, migrations, or when the accuracy loop says drift. Historic-forecasts-forward schedulers are the only supported flow — do not wire forecasts to outbound contact (invariant #45 keeps outbound MT on the Devotel softswitch).
  • Channel exclusion on sparse history. A channel with effectively no inbound history produces low-confidence Erlang-C noise; exclude it from staff planning until accuracy stabilizes above ~0.70, and from blended pools while its forecast is unreliable.
  • Holiday calendars. Pass calendar_id only when the org calendar is current. Holidays zero the forecast volume and required agents on closure dates; leaving the id off is the right default while a calendar is not maintained.
  • Response sizes. A full recompute writes up to 336 intervals per channel — page GET /wfm/forecasts with limit + cursor rather than raising the query limit.
  • Timeout floor. Recompute’s 25-second ceiling sits just under the gateway default (~30s) so it never gets killed mid-write; on partial_timeout re-trigger the skipped channels individually, as in step 3.
  • Feeding the schedule-generation wizard. Generation reads the stored forecast rows — run a recompute before you open Voice → Scheduling → Generate so the optimizer and capacity views see the same numbers the wizard will staff to.

Worked example — a voice + SMS run end to end

Tuning the loop — put the accuracy readback in a cron

See also: the umbrella WFM workflow (shift templates, schedule generation, adherence), the WFM model concept, and the Workforce Management API reference.