cxone-wfm-intraday-mcp
# CXone WFM Intraday MCP (Sparkathon Prototype)
An MCP server that exposes governed CXone WFM **Intraday** tools to an AI assistant.
It proves one Real-Time Analyst workflow — *"Why is Billing Support at risk today,
and what can we do to recover SLA?"* — grounded entirely in structured tool calls
over deterministic synthetic data. Simulation only; no live systems, no write-back.
Design spec: `docs/superpowers/specs/2026-07-15-cxone-wfm-intraday-mcp-design.md`.
## Tools
| Tool | Purpose |
|---|---|
| `list_intraday_metrics` | Catalog of queryable Intraday metrics |
| `resolve_intraday_entity` | Fuzzy name → canonical queue |
| `get_intraday_snapshot` | Current state of all/selected queues + highest-risk queue |
| `get_forecast_vs_actual` | Per-interval forecast vs actual variance |
| `get_intraday_risk_drivers` | Ranked reasons a queue is at risk |
| `get_available_capacity` | Donor queues with spare capacity + trade-offs |
| `simulate_recovery_action` | Before/after impact of moving N agents for Y minutes |
| `summarise_intraday_recommendation` | Plain-English what / why / action / impact / trade-offs |
| `load_intraday_data` | Replace the dataset with your own queues (bring-your-own-data) |
| `reset_intraday_data` | Restore the built-in demo dataset |
## Ways to run it
Pick the path that fits — all three end with the same 10 tools available to your assistant:
- **Fastest, no install** → [Quick start (uv)](#quick-start-no-clone-via-uv): one `uvx` command, nothing to clone.
- **Local dev / offline / to change the data** → [Setup](#setup): clone + venv + `pip install`.
- **Share via a URL (teammates install nothing)** → [Run as a remote HTTP MCP](#run-as-a-remote-http-mcp-optional): deploy to Cloud Run.
Then, in your assistant, ask *"Which queues are at risk right now?"* and follow the [Demo script](#demo-script).
## Quick start (no clone, via uv)
If you have [uv](https://docs.astral.sh/uv/) installed and access to this repo, register
the server with a single command — no `git clone`, no venv, no `pip install`. `uv` fetches,
installs, and runs it in an ephemeral environment straight from the repo.
**Claude Code:**
```bash
claude mcp add cxone-wfm-intraday -- uvx --from git+https://github.com/ssolanky/cxone-wfm-intraday-mcp cxone-wfm-intraday
```
**Claude Desktop** — add to `claude_desktop_config.json` (Settings → Developer → Edit Config):
```json
{
"mcpServers": {
"cxone-wfm-intraday": {
"command": "uvx",
"args": ["--from", "git+https://github.com/ssolanky/cxone-wfm-intraday-mcp", "cxone-wfm-intraday"]
}
}
}
```
Notes: this is a **local** server — `uv` runs it on your machine, it just removes the manual
clone/install steps. Because the repo is private, `uv` needs Git access to it (a credential
helper via `gh auth setup-git`, or a token). No `uv`? Use the full **Setup** below instead.
## Setup
```bash
git clone https://github.com/ssolanky/cxone-wfm-intraday-mcp.git
cd cxone-wfm-intraday-mcp
python -m venv .venv
# Windows: .venv\Scripts\activate macOS/Linux: source .venv/bin/activate
python -m pip install -e ".[dev]"
python -m pytest -q # all tests should pass (64 tests)
```
> **NICE machines:** `pip` here defaults to a private CodeArtifact index that 401s and
> lacks `mcp`. Install from public PyPI instead:
> `python -m pip install --index-url https://pypi.org/simple/ -e ".[dev]"`
> (or add `index-url = https://pypi.org/simple/` under `[global]` in `.venv/pip.ini`).
## Run against Claude
**Claude Code:**
```bash
claude mcp add cxone-wfm-intraday -- python -m cxone_wfm_intraday_mcp.server
```
(run from this project directory, using the interpreter where the package is installed).
**Claude Desktop:** copy the block from `claude_desktop_config.example.json` into your
`claude_desktop_config.json` (Settings → Developer → Edit Config), fixing `command`
to your Python interpreter, then restart Claude Desktop.
## Run as a remote HTTP MCP (optional)
The same server can serve the **Streamable HTTP** transport instead of stdio, so teammates
add just a URL — nothing installed locally. It's **zero config**: the server serves HTTP
automatically when a `PORT` env var is present (as on Google Cloud Run) or `MCP_TRANSPORT=http`
is set, and otherwise stays stdio for local use. HTTP endpoint path is `/mcp`.
**Test it locally:**
```bash
# macOS/Linux
MCP_TRANSPORT=http PORT=8080 python -m cxone_wfm_intraday_mcp.server
# Windows PowerShell
$env:MCP_TRANSPORT="http"; $env:PORT="8080"; python -m cxone_wfm_intraday_mcp.server
# now serving at http://127.0.0.1:8080/mcp
```
**Deploy to Google Cloud Run** (free tier, scales to zero) — from the repo root, using the
included `Dockerfile`:
```bash
gcloud run deploy cxone-wfm-intraday --source . --region <region>
```
Cloud Run builds the image, injects `PORT`, and the server serves HTTP automatically. It
prints a service URL; register it with any assistant:
```bash
claude mcp add --transport http cxone-wfm-intraday https://<service-url>/mcp
```
> **Auth:** a public URL serving even mock data should be protected. Cloud Run requires IAM
> auth by default (omit `--allow-unauthenticated`); for other hosts put a token/proxy in
> front. Authentication was deliberately deferred by the Sparkathon spec — add it before any
> non-demo use.
## Demo script
1. "Which queues are at risk right now?" → snapshot flags **Billing Support**.
2. "Why is Billing Support at risk?" → volume +18%, AHT +11%, staffing −3.
3. "What can we do to recover?" → move 2 agents from **General Enquiries**.
4. "What would the impact be?" → SLA **72% → ~81%**, General Enquiries stays above target.
## What you can ask (and what you'll get back)
You never call tools by name — just ask in plain language and the assistant picks the right
tool(s). The tables below list example prompts, the tool(s) that fire, and the answer you can
expect **on the built-in demo data** (swap in your own data and the same questions work — see
[Bring your own data](#bring-your-own-data)).
### Reading current state
| Ask something like… | Tool(s) | Expected answer (demo data) |
|---|---|---|
| "What metrics can I query?" / "What data do you have?" | `list_intraday_metrics` | The 11 Intraday metrics with definitions/units: SLA, SLA target, forecast/actual volume, forecast/actual AHT, planned/actual staffing, occupancy, backlog, risk status. |
| "Which queues are at risk right now?" / "Give me the intraday snapshot" | `get_intraday_snapshot` | All 5 queues with SLA/target, volume, AHT, staffing, occupancy, backlog, risk — and **highest risk: Billing Support (72% vs 80% target)**. |
| "How is Sales doing?" / "Show me just Billing and General Enquiries" | `resolve_intraday_entity` → `get_intraday_snapshot` | The selected queue(s) only. Sales → 83% vs 80% target, **watch**. |
| "Does 'GE' / 'the billing queue' / 'tech support' map to a real queue?" | `resolve_intraday_entity` | Resolves fuzzy names/aliases → General Enquiries / Billing Support / Technical Support (with a confidence level). |
### Explaining risk
| Ask something like… | Tool(s) | Expected answer (demo data) |
|---|---|---|
| "Why is Billing Support at risk?" | `resolve_intraday_entity` → `get_intraday_risk_drivers` | **Primary: volume +18% above forecast.** Also AHT +11%, staffing 3 below plan, occupancy 92%, backlog 47. Severity: **high**. |
| "How are actuals tracking vs forecast for Billing this morning?" | `get_forecast_vs_actual` | Six 30-min intervals (08:00–10:30); variance **widens** from ~0% to **+18% volume / +11% AHT**, staffing drifts 15 → 12. |
| "Which queue is under the most pressure and why?" | `get_intraday_snapshot` → `get_intraday_risk_drivers` | Billing Support, driven by the volume spike + AHT + staffing gap (as above). |
### Finding capacity & recovering
| Ask something like… | Tool(s) | Expected answer (demo data) |
|---|---|---|
| "Is there spare capacity to help Billing?" / "Who can lend agents?" | `get_available_capacity` | **General Enquiries can spare 2** (SLA 94% → ~92%, still above target); **Complaints can spare 1**. Sales & Technical Support: none (staffed to plan). |
| "What's the best recovery action?" / "How do we recover Billing's SLA?" | `summarise_intraday_recommendation` | **Move 2 agents from General Enquiries to Billing Support for 60 min → Billing SLA 72% → ~81.2%**, GE stays above target. Trade-off + "monitor AHT and backlog" included. |
| "What happens if I move 2 agents from General Enquiries to Billing for 60 minutes?" | `simulate_recovery_action` | Billing **72% → 81.2%** (occupancy 92 → 79); GE **94% → 91.9%** (occupancy 71 → 81), **stays above its 80% target**; no new risk. |
| "What if I move 3 instead?" | `simulate_recovery_action` | Billing **→ 84.6%**, GE **→ 90.6%** — but **flagged**: 3 exceeds GE's spare capacity of 2, so GE drops below its own plan. |
| "Can I pull 2 from Sales instead?" | `simulate_recovery_action` | Billing → 81.2%, but **Sales falls 83% → ~71%, below its 80% target** — not a safe donor (risk created). |
### Bring-your-own-data & housekeeping
| Ask something like… | Tool(s) | Expected answer |
|---|---|---|
| "Load this intraday data, then tell me which queue is at risk: `{…}`" | `load_intraday_data` | "Loaded N queues; highest risk: `<id>`." Every later question then analyzes **your** data. |
| "Reset to the demo data" | `reset_intraday_data` | Restores the built-in 5-queue demo for your session. |
### A full worked conversation (demo data)
> **You:** Which queues are at risk right now?
> **Assistant:** Five queues are live. **Billing Support is the highest risk** — SLA 72% against an 80% target. General Enquiries (94%), Technical Support (88%) and Complaints (91%) are healthy; Sales (83%) is on watch.
>
> **You:** Why is Billing Support at risk?
> **Assistant:** Three things are compounding: **volume is 18% above forecast**, **AHT is 11% higher than expected**, and **staffing is 3 agents below plan** — pushing occupancy to 92% with a backlog of 47. The variance has widened steadily since 08:00.
>
> **You:** What can we do to recover?
> **Assistant:** **General Enquiries has 2 agents of spare capacity** (it's 2 over plan at 94% SLA). Complaints could lend 1; Sales and Technical Support have none.
>
> **You:** What would moving 2 from General Enquiries do?
> **Assistant:** **Billing Support SLA improves from 72% to ~81.2%** and its occupancy eases from 92% to ~79%. **General Enquiries dips to ~91.9% — still comfortably above its 80% target.** No new risk is created. Recommend the move for 60 minutes and keep watching AHT and backlog.
### When something's off (error responses)
| Ask something like… | What you'll get |
|---|---|
| "Analyze the Refunds queue" | An error naming the valid queues: *"Could not resolve 'Refunds'… Valid queues: Billing Support, Complaints, General Enquiries, Sales, Technical Support."* |
| "Move 50 agents from General Enquiries to Billing" | *"Cannot move 50 agents from General Enquiries; only 16 are staffed."* |
| Loading data with a missing/invalid field | A clear message (e.g. *"queue 'x': missing required field 'sla_current'"* or *"risk_status must be one of ('at_risk','watch','healthy')"*), and the current dataset is left untouched. |
## Where the data comes from
All data is **synthetic and hardcoded** in `cxone_wfm_intraday_mcp/data.py` — a fixed
in-memory list of 5 queues loaded once at startup. There is **no database, API, CXone
tenant, network call, or randomness**, so every run returns identical numbers (a
repeatable demo by design). This matches the spec: for the prototype, mocked data is
acceptable; the point is proving the pattern of grounded, tool-driven answers.
What's **stored by hand** vs. **computed at call time**:
- **Stored** (the demo "facts"), per queue in `data.py`: `sla_current`, `sla_target`,
`volume_actual/forecast`, `aht_actual/forecast`, `staffing_planned/actual`,
`occupancy`, `backlog`, a `risk_status` label (`at_risk` / `watch` / `healthy`), and
a 6-interval forecast-vs-actual history.
- **Computed** from those stored fields at call time — this is the real "analysis":
- `get_intraday_snapshot` ranks queues by stored `risk_status` (`analysis.py`).
- `get_forecast_vs_actual` / `get_intraday_risk_drivers` compute volume/AHT/staffing
variance from the stored actual-vs-forecast values and flag drivers over thresholds
(`analysis.py`).
- `get_available_capacity` derives spare agents (surplus over plan, validated against
the SLA curve).
- `simulate_recovery_action` recomputes SLA for both queues via the formula below
(`simulation.py`).
So *"Billing Support is at risk"* is a hand-authored label, but *"…because volume is
+18%, AHT +11%, staffing −3, and moving 2 agents lifts SLA 72→81.2%"* is genuinely
calculated from the stored fields — never invented by the assistant.
**Change the scenario** (make a different queue at risk, tweak SLA/volume/staffing,
add a queue) by editing the literals in `data.py` — nothing else needs to change. In a
real product, `data.py` would be replaced by live CXone WFM as the source of truth; the
tool contracts stay identical.
## Bring your own data
Consumers can analyze **their own** Intraday data without editing any code, via two tools:
- **`load_intraday_data`** — replace the dataset with your own queues; every other tool then
analyzes your data.
- **`reset_intraday_data`** — restore the built-in demo.
Just ask the assistant (e.g. *"Load this Intraday data and tell me which queue is at risk"*)
and provide queues shaped like:
```json
{
"queues": [
{"id": "payments", "name": "Payments", "sla_current": 64, "sla_target": 85,
"volume_forecast": 200, "volume_actual": 260, "aht_forecast": 280, "aht_actual": 320,
"staffing_planned": 18, "staffing_actual": 14, "occupancy": 94, "backlog": 40,
"risk_status": "at_risk", "aliases": ["pay"]},
{"id": "onboarding", "name": "Onboarding", "sla_current": 97, "sla_target": 80,
"volume_forecast": 90, "volume_actual": 80, "aht_forecast": 300, "aht_actual": 290,
"staffing_planned": 7, "staffing_actual": 10, "occupancy": 55, "backlog": 0,
"risk_status": "healthy"}
]
}
```
Per-queue fields: `id`, `name`, `sla_current`, `sla_target`, `volume_forecast`,
`volume_actual`, `aht_forecast`, `aht_actual`, `staffing_planned`, `staffing_actual`
(**≥ 1** — the SLA curve divides by it), `occupancy`, `backlog`, `risk_status`
(`at_risk` | `watch` | `healthy`). Optional: `aliases` (list) and `intervals` (list of
`{interval, volume_forecast, volume_actual, aht_forecast, aht_actual, staffing_planned,
staffing_actual}`). Percentages are 0–100, times are seconds, staffing is agent counts.
Invalid input is rejected with a clear message and leaves the current dataset untouched.
> **Isolation:** loaded data is scoped to your **MCP session** — on a shared HTTP server each
> conversation gets its own dataset (one client's load never affects another). `reset_intraday_data`,
> or the session ending, restores the demo for that session only. stdio has a single session (just
> you). Nothing is persisted to disk; the dataset lives in memory for the life of the session.
## The simulation model
Rule-based and deterministic. Each queue has a staffing→SLA response curve
`sla(n) = 100 − (100 − sla_current)·decay^(n − staff_actual)`, with
`decay = exp(−2.4 / staff_actual)`, anchored so `sla(actual) = current SLA`. Every
number the assistant reports comes from this formula given tool inputs — not from
the model's own reasoning. See §6 of the design spec.
TDQS
Scored across 10 tools
Each tool has a distinct purpose: snapshot, forecast comparison, risk analysis, capacity query, simulation, recommendation, data loading/reset, entity resolution, metrics listing. No overlapping functionality.
All tool names follow a consistent verb_noun pattern in snake_case (e.g., get_available_capacity, simulate_recovery_action, reset_intraday_data). No mixing of conventions.
10 tools is well-scoped for a WFM intraday server. Each tool serves a needed function without being excessive or too sparse.
Covers snapshot, forecast comparison, risk analysis, capacity, simulation, recommendation, and data management. Minor gap: no direct tool for updating individual queue attributes, but load_intraday_data allows bulk replacement.