← Tidelines/Guides

Building a ticket-triage agent, prompt-first

A router is the easiest agent in the contact center to build and the easiest one to build wrong, because its entire contract is a list of queue names and almost nobody writes the list down. Here is the whole build, from a one-line brief to a spec that grades A, with every claim run through the checker.

by TypeGlish team8 min read#guides
Five queues. One word out.

TL;DR A triage agent's contract is a closed set of queue names plus a standing refusal to answer anything, so declare the set with one of, mirror it as a typed $TOOL parameter, bound the reply to one word, and the compiler can check the shape of the output before a model ever runs.

Triage is usually the first thing a midmarket CX team automates, because it is the one job where a wrong answer is cheap and a wrong route is merely annoying. It is also the job where the prompt is shortest, which is why it tends to get written in one sitting and never reviewed again. This walkthrough takes the brief a real inbox owner would hand you and turns it into a file that states its own contract. No model is called anywhere in it: every number below came out of typeglish check, score, and test --dry.

§1The brief, typed out

The brief is one sentence long, and it arrives in Slack: read each incoming support message and send it to the right team. Typed straight into a file, it comes out like this, which is roughly what every first draft of a router looks like.

triage.tg - the first draft✕ D (55/100)
You are a helpful triage bot. Read each incoming message and figure out where it should go.
Try to route it to the right team as quickly as possible.
If a message is about money, send it to billing. Technical issues go to the technical team.
Sales questions should probably go to sales.
Always be accurate and never guess.
Five lines, and a person reading it would say it is fine. It is not fine, and the specific ways it is not fine are the outline for the rest of this post.
tg check triage.tg - output
triage.tg:2:1   info   prompt/hedging  Hedging - "Try to" turns this instruction into a
  suggestion the model may skip. Delete the hedge, or commit to a modal (MUST / NEVER / SHOULD).
triage.tg:2:1   info   prompt/unregistered-doer  A bare generic doer never enters the world
  model - instruction to the agent, or background about users?
triage.tg:3:1   warn   typeglish/if-then  IF needs a THEN - write IF <condition> THEN <action>.
triage.tg:4:17  info   prompt/hedging  Hedging - "should probably" turns this instruction into a
  suggestion the model may skip.
triage.tg:4:1   info   prompt/unregistered-doer  A bare generic doer never enters the world model.
triage.tg:5:1   error  prompt/undefined-adjective  "accurate" claims on an UNNAMED axis - the checker
  holds it with one hand.
triage.tg:5:1   warn   prompt/unmeasurable  Unmeasurable rule - no observable action or bound, so
  nothing can check compliance.

 1 file - 1 error, 2 warning, 4 info
D (55/100). Note what is not in that list: nothing says the prompt never names the set of teams it is routing to. No checker can tell you that, because no checker knows your org chart. That one is on you, and it is the first thing to fix.

§2Name the set before the behavior

A router does not really have behavior. It has a return type. Everything downstream of this agent, the queue assignment, the SLA clock, the wallboard, keys off one token, and that token has to come from a list somebody agreed on. So the first line of the real file is the list.

Write the output before you write the behavior. A router that has not declared its set is a classifier with no classes.

In TypeGlish the closed set has its own spelling, and it is not decoration: Name ARE one of a, b declares a set whose members are mutually exclusive, and the checker holds you to that.

the set, and two lines that fight over it✕ 2 errors
# Role
You are the triage agent for a broadband support inbox.

# Constraints
@@ a: why
Queue ARE one of billing, technical, sales, retention, unknown.
@@ b: why
Queue IS billing.
@@ c: why
Queue IS technical.
Contrived on purpose, to show the set is load-bearing rather than a comment.
tg check - output
tool2.tg:8:1   error  logic/redeclaration  Mutually-exclusive values - this line asserts
  "billing" while line 6 asserts "technical", and both belong to one closed "one of" set
  (an object holds exactly ONE member). Keep one assertion, or widen the declared set.
tool2.tg:10:1  error  logic/redeclaration  Mutually-exclusive values - this line asserts
  "technical" while line 5 asserts "billing", and both belong to one closed "one of" set
  (an object holds exactly ONE member). Keep one assertion, or widen the declared set.

 1 file - 2 error, 0 warning, 0 info
An object holds exactly one member. Declare the set and the compiler starts refusing files where two lines put it in two places at once, which is a real failure mode once a prompt has grown a dozen routing rules written by three people.

One honest limit, because the whole point of this blog is not overselling the checker: a declared set does not police the queue names you mention in ordinary prose. We tried it. A rule reading WHEN a message asks for money back THEN you MUST reply with refunds, where refunds is not a member of the declared Queue set, compiles at 0 error, 0 warning, 0 info. So does a pair of rules that route the same trigger to two different queues. Prose is prose. What the declaration buys you is the mutual exclusion above, plus the far more important thing: the set is now written down somewhere a human reviewer will find it.

The fifth member is the one people argue about. unknown exists so the agent has somewhere honest to put a message it cannot place. Leave it out and you have not removed ambiguity, you have only forced the model to resolve it silently, in favour of whichever queue name it liked best.

§3The three rules that make it a router

With the set fixed, the behavior is short. A triage agent needs exactly three things said to it, and the third is the one that gets left out.

  • Bound the output. You MUST reply with one queue name in at most 1 word. The bound is the half a checker can measure, and it is what stops a router from producing a friendly sentence that a downstream parser then has to guess at.
  • Remove the permission to answer. You NEVER answer a customer question. A router that answers is a second support agent that nobody reviewed, nobody tested, and nobody knows exists until it quotes a price.
  • Name the fallback. WHEN a message fits no other queue THEN you MUST reply with unknown. Without this line the model still has to do something with an off-topic message, and it will.

The first two are a pair and they fail apart. A prohibition with no bound on the output still leaves the model a whole paragraph to be helpful inside, which is the same mechanism we took apart in how to keep your AI support agent on topic. Remove the permission and remove the room.

§4Where the set becomes a schema

The declaration is what the model reads. It is not what the runtime enforces. For that the set has to appear a second time, as a typed parameter on the tool that actually moves the ticket, and TypeGlish uses the same English type grammar in both places.

the tool that moves the ticket✓ compiles
$TOOL assign_queue
  - description:: Moves a ticket to the support queue that owns it.
  - input::
    - queue:: one of billing, technical, sales, retention, unknown
    - ticket_id:: string
  - request:: POST https://api.example.com/tickets/@{ticket_id}/queue

# Role
You are the triage agent for a broadband support inbox.

# Constraints
@@ handoff: the inbox moves the ticket, the reply only names where
- You MUST call @[assign_queue] with the queue you named.
The one of row is the enum the host runtime will validate against. If your tool already lives in the host, swap the block for $IMPORT tool assign_queue; the difference between the two, and the three ways the wiring comes loose, is connecting an agent tool to a real API.

Writing the set twice looks like duplication and is not. The declaration is legible to the model and reasoned over by the checker; the schema row is binding at call time and reaches nothing but the runtime. They answer to different readers, which is the split we described in your prompt has a second reader. What you do want is a habit of changing them in the same commit, and a review that notices when someone did not.

The member name that read as a verb

Now the channel. The inbox takes email, live chat, and transcribed voicemail, and each needs a slightly different reading instruction, so the source is a typed input with an arm per member. This is where the build hit its one genuine surprise.

tg check - the same file, one member renamed
# $REQUIRE variable source: one of email, chat, voicemail
triage.tg:27:3  info   prompt/unregistered-doer  A bare generic doer never enters the world
  model - instruction to the agent, or background about users? Use the imperative if the
  agent acts, or a definite party ("The user should ...") to register the doer.

 1 file - 0 error, 0 warning, 1 info      A (90/100)

# $REQUIRE variable source: one of email, chat, phone
 1 file - 0 error, 0 warning, 0 info      A (91/100)
Identical files apart from the member name and the arm key. voicemail draws a finding on its arm; phone does not. callback behaves like voicemail; sms behaves like phone.

The explanation is the one thing about TypeGlish that catches everybody once: your enum members are English words and they get read as English words. voicemail and callback both carry a verb reading, and a verb needs a doer, so the arm containing them trips prompt/unregistered-doer. This is not a bug so much as the cost of a language that parses your prompt instead of pattern-matching it. The practical rule is small: pick member names that are unambiguously nouns, and re-run check after you rename one, because the rename is a semantic edit and not a cosmetic one.

§5The finished spec, and the tests that need no model

Everything above, assembled. Twenty-one lines of content, a note over every rule, and two cases.

triage.tg - shipped✓ A (91/100)
<$CONFIG>
  $REQUIRE variable source: one of email, chat, phone
  $IMPORT tool assign_queue
  $DEFINE word subject
    - class:: noun
    - unique:: true
</$CONFIG>

# Role
You are the triage agent for a broadband support inbox. You read one customer message and name the queue it belongs in. You are not the agent that answers it.

# Constraints
@@ closed_set: naming the set is the whole job, so the set is written down
Queue ARE one of billing, technical, sales, retention, unknown.
@@ shape: a downstream router parses this reply, so prose is a routing failure
- You MUST reply with one queue name in at most 1 word.
@@ no_answers: a router that answers is a second agent nobody reviewed
- You NEVER answer a customer question.
@@ handoff: the inbox moves the ticket, the reply only names where
- You MUST call @[assign_queue] with the queue you named.
@@ honest_miss: a confident wrong queue costs more than an admitted miss
- WHEN a message fits no other queue THEN you MUST reply with unknown.

$SWITCH ON @{source}
  - email:: You MUST read the subject before you name a queue.
  - chat:: You MUST read at most 3 turns before you name a queue.
  - phone:: You MUST read a call transcript before you name a queue.

$TEST billing_increase
  - input:: My bill went up by 15 dollars and nobody told me.
  - expect::
    - contains "billing"
$TEST out_of_scope
  - input:: What is the weather in Denver tomorrow?
  - expect::
    - contains "unknown"
The $DEFINE word subject block is not ornamentation either. Delete it and the subject in the email arm becomes a definite article pointing at a noun the file never introduced, which is prompt/unintroduced-definite, one info finding. Three lines of declaration, one fewer thing for the model to guess.
tg check, score, and test --dry
 1 file - 0 error, 0 warning, 0 info

triage.tg - A (91/100)  proven errors: none  tiers: base+z3
  planes  runtime 93 (what the model reads) · hygiene 86 (source only)
  facets  enforceability 79 x.21 · hardness 100 x.12 · directness 93 x.08
          consistency 100 x.17 · structure 100 x.12 · annotation 71 x.12
          style 100 x.08 · security 100 x.08

 triage.tg  coverage: 0/4 rules exercised
  · billing_increase - "My bill went up by 15 dollars and nobody told me." (not run)
       contains "billing"
  · out_of_scope - "What is the weather in Denver tomorrow?" (not run)
       contains "unknown"
 1 prompt - 0 failed
Both expectations are deterministic asserts rather than judged prose, which is why --dry can validate them with no ANTHROPIC_API_KEY and no model call. For a router that is not a compromise, it is the natural fit: the output is one token from a list of five, so there is nothing for a judge to have an opinion about. The trade-off in the general case is the asserts-versus-judge bake-off.

The coverage: 0/4 is worth reading rather than skipping past. It says the two cases attribute to none of the four constraint rules, because neither expectation is phrased in a rule's own words. That is a fair complaint and it is the next commit, not a blocker: coverage is a reporting number under --dry, not a gate.

Enforceability sits at 79 and the score names it as the lever. The rule holding it down is the WHEN a message fits no other queue fallback, which is a genuine judgment call with no bound to give it. That is the right place for a prompt to be soft, and pushing the number up by inventing a fake threshold would make the file score better and behave worse. Ship it at 79.

Field note

Every figure above came from the real CLI on the real file. The one that surprised us was voicemail, and it is the reason this build has a section about naming your enum members: TypeGlish reads the prompt as a language rather than a blob, so the tokens you choose for a domain are part of the program. If you want the general version of that idea, it is your system prompt is an API contract, and the exhaustiveness proof that keeps a $SWITCH honest when somebody adds a fourth channel is in the multichannel bake-off.

FAQCommon questions

How do I build an AI ticket-triage agent?
Write the output before the behavior. A triage agent produces one member of a closed set, so start by declaring that set (Queue ARE one of billing, technical, sales, retention, unknown.), bound the reply to one word, forbid the agent from answering anything, and name the tool that actually moves the ticket. Everything else in the prompt is channel detail. The worked spec in this post is 21 lines of rules and checks at 0 error, 0 warning, 0 info and A (91/100).
Should the list of queues live in the prompt or in the tool schema?
Both, and they are not redundant. The declaration in the prompt is what the model reads, and it is the thing the checker reasons over: two lines asserting different members of one declared set are a blocking logic/redeclaration error. The typed one of row on the $TOOL is what the runtime enforces, and it is the only one of the two that can reject a bad value at call time. The prompt makes the set legible; the schema makes it binding.
How do I stop a triage agent from answering the customer instead of routing?
Two rules, and the second one is the one people leave out. Write You NEVER answer a customer question. to remove the permission, and You MUST reply with one queue name in at most 1 word. to remove the room. A prohibition with no bound on the output still leaves the model a paragraph to be helpful in, and the bound is the half a checker can measure.
Why does the checker flag one of my enum values?
Because the member names are English too. In the worked example, declaring the source domain as one of email, chat, voicemail produced a prompt/unregistered-doer finding on the voicemail arm, and the identical file with the member renamed to phone came back at 0 error, 0 warning, 0 info. Words like voicemail and callback carry a verb reading the parser has to resolve. Pick member names that are plainly nouns, and re-run the check after you rename one.
∿ washed up Aug 1, 2026 ∿