> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Workforce forecasts and staffing optimization

> Run the WFM forecast surface end to end: trigger a recompute, read interval-level predictions, answer service-level what-if questions with Erlang-C, compare dedicated vs blended staffing pools, and close the accuracy loop to decide when to re-forecast.

# 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:

| Stage    | What it computes                                                                                                                          | Where it lives                    |
| -------- | ----------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- |
| Training | Counts inbound volume per (day-of-week × 30-minute slot) over the trailing 90 days, averaged per week — 336 slots per channel.            | Message and call history          |
| Forecast | Projects each slot forward over the next 7 days: 336 intervals per channel, each with a predicted volume, an AHT, and a confidence score. | `wfm_forecasts` rows              |
| Staffing | Converts each interval's predicted volume into required agents via Erlang-C at a default target of **80% answered within 20 seconds**.    | `required_agents` on the same row |

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:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/wfm/forecasts/recompute \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{}'
```

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:

```json theme={null}
{
  "data": {
    "correlation_id": "wfmRecompute_k4f9d2",
    "partial": false,
    "outcome": "completed",
    "channels_completed": 7,
    "channels_total": 7,
    "upserted": 2352,
    "duration_ms": 12400,
    "holiday_calendar_id": null,
    "holiday_overrides": 0,
    "channels": [
      { "channel": "voice", "status": "completed", "upserted": 336, "duration_ms": 2100 },
      { "channel": "sms", "status": "completed", "upserted": 336, "duration_ms": 900 }
    ]
  },
  "meta": {
    "request_id": "req_123",
    "timestamp": "2026-09-25T12:00:00.000Z"
  }
}
```

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`:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/wfm/forecasts/recompute \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "channel": "email" }'
```

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:

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/wfm/forecasts?channel=voice&from=2026-09-25T12:00:00Z" \
  -H "X-API-Key: dv_live_sk_your_key_here"
```

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/wfm/forecasts?channel=sms&from=2026-09-25T12:00:00Z" \
  -H "X-API-Key: dv_live_sk_your_key_here"
```

Each row is one 30-minute interval on one channel:

```json theme={null}
{
  "data": [
    {
      "id": "wfmForecast_hf92x1",
      "channel": "voice",
      "interval_start": "2026-09-25T12:00:00.000Z",
      "interval_seconds": 1800,
      "predicted_volume": 41,
      "predicted_aht_seconds": 240,
      "required_agents": "5.85",
      "service_level_target": "0.800",
      "service_level_target_seconds": 20,
      "algorithm": "holt_winters",
      "confidence": "0.822",
      "generated_by": "auto_forecast",
      "created_at": "2026-09-25T12:00:00.000Z"
    }
  ],
  "meta": {
    "request_id": "req_124",
    "timestamp": "2026-09-25T12:00:00.000Z",
    "cursor": "2026-09-25T12:30:00.000Z",
    "has_more": true
  }
}
```

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?":

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/wfm/forecasts/what-if \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "voice",
    "scheduled_agents": 8,
    "expected_aht_seconds": 240,
    "arrival_rate_per_second": 0.022
  }'
```

```json theme={null}
{
  "data": {
    "channel": "voice",
    "scheduled_agents": 8,
    "arrival_rate_per_second": 0.022,
    "traffic_erlangs": 5.28,
    "expected_aht_seconds": 240,
    "predicted_service_level": 0.968,
    "service_level_target": 0.8,
    "service_level_target_seconds": 20,
    "estimated_queue_depth": 0.31,
    "queue_unstable": false,
    "arrival_rate_source_missing": false,
    "zero_traffic": false,
    "meets_target": true
  },
  "meta": {
    "request_id": "req_125",
    "timestamp": "2026-09-25T12:00:00.000Z"
  }
}
```

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:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/wfm/staffing/optimize \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "channels": [
      { "channel": "voice" },
      { "channel": "sms" }
    ],
    "pools": [
      { "pool": "blended", "channels": ["voice", "sms"] }
    ]
  }'
```

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.

```json theme={null}
{
  "data": {
    "interval_start": null,
    "service_level_target": 0.8,
    "service_level_target_seconds": 20,
    "channels": [
      {
        "channel": "voice",
        "arrival_rate_per_second": 0.0222,
        "expected_aht_seconds": 240,
        "concurrency": 1,
        "traffic_erlangs": 5.33,
        "server_equivalents": 5.33,
        "required_agents": 8,
        "predicted_service_level": 0.92
      },
      {
        "channel": "sms",
        "arrival_rate_per_second": 0.011,
        "expected_aht_seconds": 120,
        "concurrency": 4,
        "traffic_erlangs": 1.32,
        "server_equivalents": 0.33,
        "required_agents": 2,
        "predicted_service_level": 0.97
      }
    ],
    "pools": [
      {
        "pool": "blended",
        "channels": ["voice", "sms"],
        "combined_arrival_rate_per_second": 0.0332,
        "weighted_aht_seconds": 231.4,
        "effective_concurrency": 1.0,
        "traffic_erlangs": 7.68,
        "server_equivalents": 7.68,
        "required_agents": 10,
        "predicted_service_level": 0.93
      }
    ],
    "dedicated_agents_total": 10,
    "blended_agents_total": 10,
    "agents_saved": 0,
    "pooling_efficiency_pct": 0.0
  },
  "meta": {
    "request_id": "req_126",
    "timestamp": "2026-09-25T12:00:00.000Z"
  }
}
```

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:

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/wfm/forecasts/accuracy?channel=voice&days=30" \
  -H "X-API-Key: dv_live_sk_your_key_here"
```

```json theme={null}
{
  "data": {
    "period_days": 30,
    "channel_summary": [
      {
        "channel": "voice",
        "evaluated_intervals": 960,
        "mean_absolute_percentage_error": 0.11,
        "accuracy": 0.89
      }
    ],
    "intervals": [
      {
        "channel": "voice",
        "interval_start": "2026-08-26T12:00:00.000Z",
        "predicted_volume": 38,
        "actual_volume": 42,
        "algorithm": "holt_winters",
        "confidence": "0.81",
        "ape": 0.0952
      }
    ]
  },
  "meta": {
    "request_id": "req_127",
    "timestamp": "2026-09-25T12:00:00.000Z"
  }
}
```

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

<CodeGroup>
  ```bash cURL theme={null}
  API_KEY="dv_live_sk_your_key_here"
  BASE="https://api.orbit.devotel.io"

  # 1. Recompute both channels (one call)
  curl -sS -X POST "$BASE/api/v1/wfm/forecasts/recompute" \
    -H "X-API-Key: $API_KEY" -H "Content-Type: application/json" -d '{}'

  # 2. Read the next intervals for voice and sms
  curl -sS "$BASE/api/v1/wfm/forecasts?channel=voice" -H "X-API-Key: $API_KEY"
  curl -sS "$BASE/api/v1/wfm/forecasts?channel=sms"  -H "X-API-Key: $API_KEY"

  # 3. What-if for 8 voice agents on the first interval
  curl -sS -X POST "$BASE/api/v1/wfm/forecasts/what-if" \
    -H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \
    -d '{"channel":"voice","scheduled_agents":8,"expected_aht_seconds":240}'

  # 4. Blend voice + sms into one pool
  curl -sS -X POST "$BASE/api/v1/wfm/staffing/optimize" \
    -H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \
    -d '{"channels":[{"channel":"voice"},{"channel":"sms"}],
         "pools":[{"pool":"blended","channels":["voice","sms"]}]}'

  # 5. Accuracy for the last 30 days
  curl -sS "$BASE/api/v1/wfm/forecasts/accuracy?channel=voice&days=30" \
    -H "X-API-Key: $API_KEY"
  ```

  ```javascript Node.js theme={null}
  const API_KEY = process.env.ORBIT_API_KEY; // dv_live_sk_*
  const BASE = "https://api.orbit.devotel.io";

  async function call(path, method = "GET", body) {
    const res = await fetch(`${BASE}${path}`, {
      method,
      headers: {
        "X-API-Key": API_KEY,
        ...(body ? { "Content-Type": "application/json" } : {}),
      },
      body: body ? JSON.stringify(body) : undefined,
    });
    if (!res.ok) throw new Error(`${path} -> ${res.status}`);
    return res.json();
  }

  // 1. Recompute both channels (one call)
  await call("/api/v1/wfm/forecasts/recompute", "POST", {});

  // 2. Read the next intervals for voice and sms
  const voice = await call("/api/v1/wfm/forecasts?channel=voice");
  const sms = await call("/api/v1/wfm/forecasts?channel=sms");

  // 3. What-if for 8 voice agents on the first interval
  const whatIf = await call("/api/v1/wfm/forecasts/what-if", "POST", {
    channel: "voice",
    scheduled_agents: 8,
    expected_aht_seconds: 240,
  });

  // 4. Blend voice + sms into one pool
  const optimize = await call("/api/v1/wfm/staffing/optimize", "POST", {
    channels: [{ channel: "voice" }, { channel: "sms" }],
    pools: [{ pool: "blended", channels: ["voice", "sms"] }],
  });

  // 5. Accuracy for the last 30 days
  const accuracy = await call(
    "/api/v1/wfm/forecasts/accuracy?channel=voice&days=30",
  );

  console.log({
    voiceIntervals: voice.data.length,
    smsIntervals: sms.data.length,
    whatIf: whatIf.data.predicted_service_level,
    dedicated: optimize.data.dedicated_agents_total,
    blended: optimize.data.blended_agents_total,
    saved: optimize.data.agents_saved,
    accuracy: accuracy.data.channel_summary[0]?.accuracy,
  });
  ```
</CodeGroup>

## Tuning the loop — put the accuracy readback in a cron

<CodeGroup>
  ```javascript Node.js theme={null}
  const API_KEY = process.env.ORBIT_API_KEY;
  const BASE = "https://api.orbit.devotel.io";

  async function call(path, method = "GET", body) {
    const res = await fetch(`${BASE}${path}`, {
      method,
      headers: {
        "X-API-Key": API_KEY,
        ...(body ? { "Content-Type": "application/json" } : {}),
      },
      body: body ? JSON.stringify(body) : undefined,
    });
    if (!res.ok) throw new Error(`${path} -> ${res.status}`);
    return res.json();
  }

  const CHANNELS = ["voice", "sms", "chat"];
  const REFORECAST_THRESHOLD = 0.7;

  for (const channel of CHANNELS) {
    const acc = await call(
      `/api/v1/wfm/forecasts/accuracy?channel=${channel}&days=7`,
    );
    const summary = acc.data.channel_summary[0];
    if (!summary || summary.evaluated_intervals === 0) continue;
    if (summary.accuracy !== null && summary.accuracy < REFORECAST_THRESHOLD) {
      console.log(`recompute ${channel} (accuracy ${summary.accuracy})`);
      await call("/api/v1/wfm/forecasts/recompute", "POST", { channel });
    }
  }
  ```

  ```python Python theme={null}
  import os
  import urllib.request
  import json

  API_KEY = os.environ["ORBIT_API_KEY"]
  BASE = "https://api.orbit.devotel.io"

  def call(path, method="GET", body=None):
      req = urllib.request.Request(
          f"{BASE}{path}",
          method=method,
          data=None if body is None else json.dumps(body).encode(),
          headers={"X-API-Key": API_KEY, "Content-Type": "application/json"},
      )
      with urllib.request.urlopen(req) as res:
          return json.load(res)

  CHANNELS = ["voice", "sms", "chat"]
  REFORECAST_THRESHOLD = 0.7

  for channel in CHANNELS:
      acc = call(f"/api/v1/wfm/forecasts/accuracy?channel={channel}&days=7")
      summary = acc["data"]["channel_summary"][0] if acc["data"]["channel_summary"] else None
      if not summary or summary["evaluated_intervals"] == 0:
          continue
      accuracy = summary["accuracy"]
      if accuracy is not None and accuracy < REFORECAST_THRESHOLD:
          print(f"recompute {channel} (accuracy {accuracy})")
          call("/api/v1/wfm/forecasts/recompute", method="POST", body={"channel": channel})
  ```
</CodeGroup>

See also: the [umbrella WFM workflow](/guides/wfm-workflows) (shift templates, schedule generation, adherence), the [WFM model concept](/concepts/wfm-model), and the [Workforce Management API reference](/api-reference/wfm).
