The Contact Center IVR Toolkit gives a Copilot Studio voice agent the four things it cannot get on its own: whether a queue is open, how busy it is, what to say about it, and how to ring somebody back. Open source, MIT, and now working end to end on a real phone line.


01 · The problem

An agent that cannot see the queue will confidently lie about it

Build a voice agent in Copilot Studio and the first ten minutes are magic. It greets the caller, understands a rambling Dutch sentence, and routes. Then somebody asks the only question callers actually ask.

“Zijn jullie nog open?”
Are you still open?

The agent has no idea. Opening hours live on an operating-hours calendar hanging off the workstream, expressed as recurrence rules that are genuinely awkward to read: the outer rule holds only the recurrence, and the real times sit on an inner calendar as an offset in minutes from midnight. Read them by hand and you get a queue that is open from midnight to midnight. Ask an agent to work it out and it will invent an answer, because inventing answers is what it is good at.

Live queue state is worse. Waiting counts and agent presence live in internal platform tables with no API guarantee. Callback needs writes, deduplication and a retry policy. And every one of these answers has to come back in a form a person can hear, in their language, in under half a second, because a caller is on the line.

That is the gap. It is the same gap on every contact centre project, it gets rebuilt badly each time under deadline pressure, and it is not interesting work. So it is now a toolkit instead.


02 · What it is

Seventeen Custom APIs, and one of them does most of the work

The toolkit is a Dataverse solution: plug-in assembly, configuration tables, a model-driven app for administrators, and a set of Custom APIs your agent calls. No Azure required. No infrastructure to own. It installs into the environment Contact Center is already running in.

Queues and hours

  • GetQueues
  • ResolveQueue
  • GetQueueHours
  • IsQueueOpen
  • GetNextOpenTime

Opening hours from the native calendar, or from the toolkit's own tables when you would rather not depend on internal schema.

Live state

  • GetQueueMetrics
  • GetQueueContext

Waiting counts, longest wait, representatives online and available. GetQueueContext returns all of it plus hours and a broadcast message in one round trip.

Callback

  • CheckCallbackEligibility
  • GetCallbackSlots
  • CreateCallback
  • GetCallbackStatus
  • CancelCallback
  • RescheduleCallback

The whole lifecycle. A caller can ring back tomorrow and move the slot they booked today.

Utility

  • ValidatePhoneNumber
  • GetBroadcastMessage
  • LogIvrOutcome
  • HealthCheck

Speech recognition produces dirty phone numbers. This one endpoint has saved more calls than any other.

GetQueueContext is the one to start with. It duplicates outputs that other endpoints also return, and that is the point: one call gives the agent open state, wait band, callback availability, a broadcast message and a recommended action. Four round trips on a voice call is four times the latency, and latency on voice is silence.


03 · Design

Seven rules, and the first one is the whole product

1. Bands, not numbers

Raw seconds are for supervisors. A caller does not want to hear “the current wait is 247 seconds”. They want to know whether to hold on. So the toolkit returns a WaitBand, and the thresholds are configuration rather than opinion.

Wait band, live thresholds 60,180,420
Moderate

De wachttijd is op dit moment een paar minuten.

95s waiting · agent action: offer a callback

Only the first number decides when callers stop being put straight through. The other two change what the agent says about the wait, not what it offers. Sales and retention tolerate less: try 30,90,240. Technical support with long handle times: 120,300,900.

2. Names, not GUIDs

A speech IVR never has a queue id. It has whatever the caller said, which might be “personeelszaken”, “HR afdeling” or “de boekhouder”. Every endpoint accepts a name, an alias or an id, and resolves it. When two queues match, it does not guess. It returns QUEUE_AMBIGUOUS so the agent asks a clarifying question, which is what a person would do.

3. Errors are data, not exceptions

An expected failure returns HTTP 200 with Success: false and a stable ErrorCode. A voice agent never sees a platform fault, and never tells a caller that a system is down. This extends to the MCP route, where an expected failure deliberately keeps isError false: QUEUE_AMBIGUOUS is an answer, and an agent that sees an error retries blindly instead of asking which queue was meant.

4. Every response is speakable

Structured data is not enough. Each response carries a locale-aware phrase the agent can read straight out, assembled from templates an administrator can edit without a developer.

“We zijn vandaag gesloten in verband met Koningsdag.
”Named holidays, from the holiday table“
We bellen u terug morgen om 10:30 op 06 5374 0141.”
Callback confirmation, with the number the caller actually said

5. Never let a metrics failure fail a call

The metrics reader touches internal platform tables that carry no API guarantee. When it throws, GetQueueContext catches it, defaults the wait band to Moderate and carries on. A caller must never hear that a system is down because a schema moved in a release wave. Every reference to those tables is quarantined in a single file, so a wave update is a one-file fix.

6. Write operations are idempotent

Voice agents retry on timeouts. CreateCallback deduplicates per queue and number, so a retry returns the existing booking rather than ringing somebody twice. Promotion is a status transition, so running it twice is harmless.

7. Nothing client specific in code

Aliases, thresholds, wording, hours, time zones and locales all live in configuration tables. If you find yourself typing a client's name into a C# file, stop.


04 · Callback

The part that is a real build

Direct callback is native. Configure it as a queue overflow action and the platform holds the caller's place and rings them back. The toolkit only reports on it. Rebuilding that would be foolish.

Scheduled callback is different. It is the caller who says “not now, ring me Tuesday morning”. That needs bookable slots derived from opening hours and existing bookings, a phone number normalised out of speech recognition, a booking that survives a retry, and a way for the caller to change their mind next week.

StageWhat happensStatus
OfferSlots derived from opening hours, minus slots already at capacity
BookNumber normalised to E.164, contact matched on the number, request writtenRequested
PromoteA flow runs every five minutes and hands due requests to proactive engagementQueued
PlaceProactive engagement dials, in whichever mode you configuredQueued
CloseThe next promotion run reads the delivery back and moves the record onCompleted

The toolkit does not dial. Proactive engagement owns pacing, consent, quiet hours, capacity and answering-machine detection, and reimplementing any of that would be both worse and a compliance problem. The toolkit books, dispatches and reports.


05 · The obvious question

Microsoft just shipped customer-first callback. Why build this?

Fair question, and the honest answer is that where the native feature fits, you should use it. Customer-first direct callback rings the customer before it reserves a representative, so nobody sits listening to a voicemail greeting. That is better than anything a toolkit would build, and it handles queue position and retries for you.

It answers a different question. It is an overflow action: the caller is already queueing, the queue is already overflowing, and the request keeps its place until it reaches the front. The caller never picks a time, because there is no time to pick. The promise is “we will not make you hold”, not “we will ring you at three”.

Customer-first direct callbackScheduled callback here
Triggered byQueue overflowAnything the agent decides
When the call goes outWhen the request reaches the front of the queueIn the window the caller chose
Caller picks a timeNoYes, from offered slots
Change it afterwardsNoReschedule or cancel by reference
Works out of hoursNo, the caller has to be queueingYes
Needs a Copilot Studio agentYes, and enabling is irreversibleNo

One row deserves emphasis. Enabling customer-first direct callback cannot be undone, and on setup the first callback profile is applied automatically to every queue already using direct callback as an overflow action. That is a decision, not a setting.

So they are complements. Use the native one on the overflow path. Use this for the caller who rings at nine in the evening, or does not want to hold at all, or booked Tuesday and now needs Wednesday. None of those start with a queue.


06 · Cost

The AI credits question, answered carefully

There is a real difference here, and it is narrower than a marketing page would make it. Here is exactly what can be substantiated from Microsoft's own documentation.

💡
What Microsoft documents

Customer-first direct callback requires provisioning a Copilot Studio Callback Agent, and the documentation states plainly that “AI agent use can consume available Copilot or Microsoft service credits”. The AI agent sits on the outbound leg, engaging the customer after they answer, verifying them and collecting information before a representative joins.

The toolkit's scheduled callback puts no AI agent on the outbound leg. In preview dial mode a human representative accepts the notification and speaks to the customer. The outbound call consumes telephony, not Copilot credits.

Now the caveats, because they matter:

  • The inbound leg is a Copilot Studio agent in both cases. This toolkit does not make your IVR free. It changes what the callback leg costs, not what the IVR costs.
  • If you choose Copilot dial mode for proactive engagement, you have put an AI agent back on the outbound leg, and the difference disappears. The dial mode is yours to choose and the toolkit has no opinion about it.
  • No measurement has been taken. There is no benchmark here, no percentage, no cost-per-callback comparison. The claim is structural: one architecture requires an AI agent in the callback path and the other does not.

If somebody tells you a number for this, ask them how they measured it. The honest version is simply that a human-led callback does not spend Copilot credits, and a customer-first callback is documented as being able to.


07 · Administration

Everything a client changes, a client can change

The toolkit ships a model-driven app. Not because every project needs one, but because the alternative is a consultant editing environment variables in the maker portal at four in the afternoon while somebody waits.

The Contact Center IVR Toolkit Settings page, showing default locale, time zone, country code and the wait band thresholds with a worked example
Every setting shows the default that shipped with the solution alongside the current value, so “what did this used to be” has an answer. The worked example under the wait bands recalculates as you type.
The Active Queue profiles list showing each voice queue with its speakable name, hours source and time zone
One row per voice queue. The hours source is per queue, so most queues can read the native operating hours calendar while one awkward queue uses the toolkit's own tables.

Queue aliases are the highest-return configuration in the toolkit

A queue called “HR” in Dataverse is called “personeelszaken”, “personeel” and “human resources” by actual callers. Aliases map what people say to what the system calls it. And when resolution fails, the spoken text is recorded on the outcome row, so the list of things callers say that you do not recognise writes itself from real calls.

The Active Queue aliases list, mapping spoken Dutch phrases to Dataverse queues
Every row here is a call that would otherwise have ended in a clarifying question, or worse, in the wrong queue. Note the four different ways of asking for the accountant.
The Active Message templates list showing every spoken phrase in nl-NL and en-GB with its placeholders
The whole spoken vocabulary, seeded in both locales so an administrator can see it and edit any phrase rather than guessing at key names that exist only in source.
A broadcast message record for the HR queue, valid between two dates, with Dutch message text
A dated announcement for one queue or for everything. The agent reads it before it says anything else, which is how a client warns every caller about an outage without anyone touching the agent.

And the reporting writes itself

One row per conversation, with the outcome, the intent, the duration and whatever context the agent chose to keep. Containment and deflection reporting comes out of a single table, and the queue text column doubles as your backlog of missing aliases.

An IVR outcome record showing outcome CallbackBooked, the caller intent, the agent name and the resolved queue
A real outcome from a real call. The conversation id joins this back to the conversation in Contact Center, and the queue text column stays empty because resolution succeeded.

08 · Installation

Half an hour, if the outbound side is already configured

Everything below assumes a Dynamics 365 Contact Center environment and the Power Platform CLI. Nothing here needs Azure.

Install the solution

Download the managed solution from the release and import it, or run the installer, which creates the schema, registers the Custom APIs, seeds the configuration and builds the administration app.

# everything, in order
./build/install.ps1 -EnvironmentUrl https://yourorg.crm4.dynamics.com

Create queue profiles

One row per voice queue, with its speakable name, its locale and its time zone. The script creates them for every voice queue it finds, and you correct the ones that need correcting.

Add aliases

The single highest-return thing you will do. Sit with whoever answers the phones and write down what callers actually say.

Point your agent at it

In Copilot Studio, add the Custom APIs as tools through the Dataverse connector. Nine of them cover the whole surface an agent needs. Start with GetQueueContext and add the callback lifecycle when you need it.

Only if you want scheduled callback

Configure an outbound workstream with a proactive engagement on it, set the audience to the CCaaS API and the contact identifier to contactid, then run the promotion flow script. The toolkit finds the engagement on the workstream, so there is no id to copy.

./build/New-PromotionFlow.ps1 -EnvironmentUrl https://yourorg.crm4.dynamics.com

Run the health check

It reads what is configured and what is actually happening, including whether callbacks are going overdue, which is the difference between a configuration that looks right and one that works.

./build/Test-Installation.ps1 -EnvironmentUrl https://yourorg.crm4.dynamics.com

There is a second route, if you want one

An optional MCP server proxies the same Custom APIs over streamable HTTP, so tools appear in agents dynamically when the contract changes. It is roughly three hundred lines and the tool catalogue is generated from the contract file at startup, so the two routes cannot disagree about what an agent needs.

Most deployments should not use it. The Dataverse connector calls the same APIs with one fewer hop and no infrastructure to own, and the extra hop costs thirty to eighty milliseconds on every call. On voice, that is real. Deploy it when several teams share the toolkit and you want one place to change the tool surface.


MIT licensed. Fourteen documents. No strings.

Built to be reusable across deployments rather than for one client, which raises the bar on contract stability, configuration separation and documentation. Fork it, install it, file an issue, or tell me the design decision I got wrong.

Get it on GitHub