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:readfor the list, accuracy, what-if, and staffing-optimizer calls. The recompute is a write — it requireswfm:write. - Roles. Reads are available to every role (
owner,admin,developer,viewer). The recompute requiresowneroradmin.
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: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:
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:
4. Read the forecast intervals
Once a recompute has run, read intervals for one or both of the channels you staff: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?”:predicted_service_level— the fraction of contacts answered within the target. Compare it toservice_level_target(default 0.80) yourself if you set a custom target, or trustmeets_target.traffic_erlangs— arrival rate × AHT. Whilescheduled_agentssits 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 omitarrival_rate_per_second, the route pulls the next forecast interval for the channel; if no forecast exists this flag comes backtrueand 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 greenmeets_target.
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: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.
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: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/recomputefor 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.
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/recomputeonly 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_idonly 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/forecastswithlimit+cursorrather 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_timeoutre-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.