Supervisor-Worker Agents for Ticket Triage
Route support tickets with a supervisor that classifies and workers that act, so each ticket has one owner and a recorded handoff on your VPS.
Netbay Developer Relations
Netbay Engineering
On this page
A ticket queue is a classification problem until it is an action problem. Most teams collapse both into one model session: read the mail, invent a reply, maybe file a bug, maybe refund. That session has every tool and no second opinion. A supervisor-worker split keeps classification boring and actions narrow. The supervisor labels. Workers execute one kind of work. A human still owns refunds and anything the schema cannot name.
This is not a swarm and it is not a chatbot bolted onto your helpdesk. It is a supervisor process that writes an envelope, plus workers that refuse work they were not assigned. On one Lucknow VPS that is two systemd units, a JSON mailbox, and a closed set of labels. Intel Xeon Platinum handles the JSON; the model API handles the language.
What the supervisor is allowed to do
The supervisor reads a ticket and returns a record, not a paragraph. It may call one tool: lookup_customer, which returns plan, region, and last four tickets. It may not send mail, close tickets, or touch git. If the model wants to be helpful beyond the schema, the parser drops the extra keys.
# /opt/agents/supervisor.py
SCHEMA = {
"type": "object",
"additionalProperties": False,
"required": ["label", "severity", "summary", "assignee_role"],
"properties": {
"label": {
"type": "string",
"enum": ["billing", "outage", "how_to", "bug", "abuse", "human"],
},
"severity": {"type": "string", "enum": ["p0", "p1", "p2", "p3"]},
"summary": {"type": "string", "maxLength": 800},
"assignee_role": {
"type": "string",
"enum": ["billing_worker", "status_worker", "docs_worker", "human"],
},
},
}
PROMPT = (
"Classify the ticket. Use lookup_customer if the writer is a customer. "
"Never draft a reply. Never invent an order id. "
"If billing plus anger plus a dollar amount, severity is at least p1. "
"If more than one host is down, label is outage and severity is p0."
)The prompt is a policy document, not a personality. Put the policy in git. When billing wants "never auto-refund," that is a worker rule, not a supervisor flourish.
Workers own one verb
Map each label to one worker with one verb. billing_worker may draft a reply and tag finance; it may not issue refunds. status_worker may paste the public status URL; it may not SSH. docs_worker may link a runbook; it may not edit production. human is a mailbox, not a model.
# /opt/agents/triage_worker.py
import json, os
from envelope import put
ROLE = os.environ["AGENT_ROLE"]
VERBS = {
"billing_worker": ["draft_reply", "tag_finance"],
"status_worker": ["draft_reply", "link_status"],
"docs_worker": ["draft_reply", "link_runbook"],
}
def run(job):
if job["to"] != ROLE:
return
allowed = VERBS[ROLE]
action = job["payload"].get("requested_action")
if action not in allowed:
put("request_human", {"reason": "verb_denied", "job": job["id"]}, ROLE, "human")
return
# call the model with a role prompt, then write the draft to disk
put("draft_ready", {"ticket": job["payload"]["ticket_id"]}, ROLE, "human")Notice the last hop is always human for anything that leaves the building. Auto-send is how a misclassified abuse report becomes a public reply. The worker writes /var/lib/agents/drafts/ticket.md and stops.
Routing table, not a debate
Keep the supervisor deterministic after the JSON lands. A p0 outage always goes to status_worker and pages. A how_to with no customer id goes to docs_worker. abuse always goes to human. Do not let the model vote on routing after it has classified; the table is code.
- Parse and validate JSON against SCHEMA.
- Overwrite assignee_role from the table if the model disagrees with the label.
- Write the envelope with to=assignee_role.
- Ack the helpdesk so the ticket shows "bot classified, waiting on ROLE."
The overwrite step is the whole point of a supervisor. Models are good at summaries and bad at org charts. If label is billing, assignee_role is billing_worker, even if the model thought docs_worker would "explain the invoice more nicely."
Failure modes you should write down
Misclassification is the common case. A how_to that is actually an outage will be caught if status_worker is allowed to escalate to p0 when lookup_customer shows five open outage tickets from the same /24. Put that check in the worker, not the supervisor: the supervisor has no time-series, the worker can query status.json.
Loops are the dangerous case. If docs_worker puts a new envelope back to the supervisor "for a better label," you have rebuilt a swarm. Workers may nack to human or finish. They may not reclassify.
Secrets are the quiet case. The supervisor's lookup_customer key should be a read-only helpdesk token. The billing worker's key should not open the payment provider. Separate Unix users, separate EnvironmentFile units — that pattern is the last post in this series. For now, refuse to run both roles as the same uid.
Takeaway: a supervisor that only classifies plus workers that only act gives you ticket triage you can reason about at 3 a.m. The routing table is the org chart. Spin up Ubuntu 24.04 in Lucknow DC01 on Netbay and wire the two units in under 60 seconds — netbayhosts.in.
Keep reading
Follow along on a real VPS
Deploy Linux in under 60 seconds
These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.
Deploy an instance