Distributing a support backlog across agents
The only case here that is not a developer tool. It is also the one where the boundary matters most, so that comes first.
This is for backlogs, not live routing
Production help desks route online: a ticket arrives and gets assigned within seconds, weighing agent availability and capacity per arrival. Zendesk's omnichannel routing works this way. aequitas cannot. It is a batch solver with no notion of a queue or of time passing.
The honest fit is the pile that already exists. Monday morning with 200 unassigned tickets from the weekend, a shift handover, a reassignment after someone calls in sick. You have a fixed set of work and a fixed set of people, once. That is a batch problem, and it is a real one.
If you need per-arrival routing, use your help desk's own engine.
The problem
Help-desk capacity rules count tickets. Zendesk's, for instance, cap how many open tickets an agent may hold, such as ten email tickets at a time.
Count caps miss the thing that actually matters. A password reset and a GDPR deletion request both consume one unit of a ten-ticket allowance, and they are not remotely the same afternoon. Balance the count and the person holding four heavy tickets is buried while the person holding four light ones is idle, and both look compliant on the dashboard.
The mapping
| aequitas | Here |
|---|---|
| Bin | An agent |
Bin's max | Ticket minutes available today |
| Item | A queued ticket |
Item's weight | Estimated effort in minutes |
violations | Minutes of work that do not fit anyone's day |
The code
Monday's backlog, each ticket carrying an effort estimate, across four agents with four hours of ticket time each:
import { suggest } from "aequitas";
const backlog = [
{ id: "billing-dispute", weight: 120 },
{ id: "data-export-request", weight: 90 },
{ id: "gdpr-deletion-request", weight: 80 },
{ id: "sso-login-failure", weight: 75 },
{ id: "refund-escalation", weight: 70 },
{ id: "webhook-not-firing", weight: 65 },
{ id: "api-rate-limit-question", weight: 60 },
{ id: "mobile-crash-report", weight: 55 },
{ id: "duplicate-charge", weight: 45 },
{ id: "onboarding-question", weight: 35 },
{ id: "feature-request-triage", weight: 30 },
{ id: "seat-upgrade-request", weight: 25 },
{ id: "invoice-copy-request", weight: 20 },
{ id: "password-reset", weight: 15 },
];
const agents = [
{ id: "jane-doe", max: 240 },
{ id: "john-doe", max: 240 },
{ id: "mary-major", max: 240 },
{ id: "richard-roe", max: 240 },
];
const plan = suggest(backlog, agents);
plan.loads;
// → { "jane-doe": 200, "john-doe": 200, "mary-major": 190, "richard-roe": 195 }
plan.violations; // → 0785 minutes of work, 13h05 if one person did it all. Against handing the queue out round-robin, which is what a count-based rule amounts to:
| Tickets | Minutes | ||
|---|---|---|---|
jane-doe | 4 | 255 | over her 240 cap |
john-doe | 4 | 205 | |
mary-major | 3 | 170 | |
richard-roe | 3 | 155 |
Everybody holds three or four tickets, so every count rule in the system is satisfied, and the busiest agent still carries 1.6 times the quietest. Jane is over her cap by a quarter of an hour and nothing flagged it, because nothing was counting minutes.
Weighted by effort instead, the spread closes to 190-200 and nobody breaches capacity:
| Agent | Tickets | Minutes | |
|---|---|---|---|
jane-doe | 3 | 200 | billing-dispute, mobile-crash-report, seat-upgrade-request |
john-doe | 4 | 200 | data-export-request, api-rate-limit-question, feature-request-triage, invoice-copy-request |
mary-major | 3 | 190 | gdpr-deletion-request, webhook-not-firing, duplicate-charge |
richard-roe | 4 | 195 | sso-login-failure, refund-escalation, onboarding-question, password-reset |
Note that the ticket counts are now uneven, three against four, which is the point. Jane holds three tickets because one of them is a two-hour billing dispute.
Somebody is on a half day
Capacity is per agent, so a short day is one number:
const today = agents.map((a) =>
a.id === "mary-major" ? { ...a, max: 120 } : a,
);
suggest(backlog, today).loads;
// → { "jane-doe": 225, "john-doe": 225, "mary-major": 115, "richard-roe": 220 }Mary gets 115 minutes, everyone else absorbs the rest, and violations stays 0. The same shape covers part-time contracts, someone half-allocated to a project, or an agent reserved for escalations.
Tickets that need a specialist
Two of these need an engineer, and only Jane and John are engineers. aequitas cannot express "this ticket may only go to these agents", since affinities are soft and exclusions pair bins rather than tying items to them.
The general answer is to filter the bin list per item, which Recipes covers for a single forbidden pairing. With a whole group of tickets restricted to a whole group of agents, solve it in two passes instead, subtracting what the first pass consumed:
const engineerOnly = ["sso-login-failure", "webhook-not-firing"];
const engineers = agents.filter((a) => a.id === "jane-doe" || a.id === "john-doe");
// Pass 1: the specialist tickets, engineers only.
const pass1 = suggest(
backlog.filter((t) => engineerOnly.includes(t.id)),
engineers,
);
// → { "jane-doe": 75, "john-doe": 65 }
// Pass 2: everything else, against whatever capacity is left.
const remaining = agents.map((a) => ({ ...a, max: a.max - (pass1.loads[a.id] ?? 0) }));
// → jane-doe 165, john-doe 175, mary-major 240, richard-roe 240
const pass2 = suggest(
backlog.filter((t) => !engineerOnly.includes(t.id)),
remaining,
);Combined, that is 230 / 225 / 170 / 160 minutes, and nobody exceeds 240.
It works, and it costs you evenness. The two-pass result spreads 160 to 230 where the single pass managed 190 to 200, because pass 2 is optimising against a constraint pass 1 already committed to. Put the most constrained tickets in the first pass, since they have the fewest options, and accept the layout is good rather than best.
Finding out the day does not fit
The genuinely useful output arrives on a bad Monday. After an outage, three follow-up investigations land on top of the normal queue, taking the backlog to 1600 minutes against 960 minutes of team capacity:
const over = suggest(spike, agents);
over.loads; // → { "jane-doe": 405, "john-doe": 400, "mary-major": 405, "richard-roe": 390 }
over.violations; // → 640640 is not an abstract penalty. It is 1600 - 960: the exact number of minutes of work with nowhere to go. In this page's own units, where a day is 240 minutes of ticket time rather than a full 8 hours, that is 640 / 240 = 2.7 agent-days, which is a concrete number to take to a shift lead.
That arithmetic holds when every agent is over their max, which is the case worth detecting. Otherwise violations is the total distance out of band, a sum that does not say which agent is over, and needs reading alongside loads.
Wired into a morning job, this is a queue alarm that quantifies itself:
const { violations } = suggest(backlog, agents);
if (violations > 0) {
notify(`Backlog exceeds today's capacity by ${violations} minutes`);
}Where this stops
Batch, not a queue. Worth repeating because it is the one that bites. This assigns a pile once. Tickets arriving after you solve are not routed, and nothing reacts as agents work through their share.
Estimates are estimates. The whole thing rests on predicting ticket effort, which is harder than reading a test's duration from CI. If your estimates are guesses, the balance is a guess wearing a table. Category medians from historical resolution times are a reasonable starting point.
No skills model. Eligibility needs the two-pass workaround above, and it does not scale gracefully past two or three overlapping skill groups.
No priority or SLA. Every minute of weight is equal here. A ticket forty minutes from breaching its SLA looks exactly like a routine one of the same size. Order by urgency within an agent's share yourself, after placement.
Nothing about fairness over time. Each solve is independent, so it has no memory of who got the grim ticket yesterday.