Categorize and Route

First classify the broad domain, then run a second classification limited to domain suboptions.
View as Markdown

Classify a free-text request into a broad category with an LLM Action, then classify it again using options from that category, like IT then Account Access, or HR then Payroll.

Use this pattern when requests arrive in people’s own words and a keyword ruleset is ineffective or grows too large to maintain. If keywords or another field already decides the category well, skip the LLM and route with switch and DSL (Moveworks’ data-mapping syntax) instead.

Context and problem

Think about a time where you’ve needed to route a request within your organization, or even across your team. VPN and Laptop only make sense to route to IT. PTO and Payroll make sense to route to HR. You could solve this by making one overarching list of options where similar options sit side by side.

But what about when the scenarios blur? A “Software” request could get filed as IT (like requesting access to an existing internal contract) when it’s really a procurement question for finance. We’re likely to see requests get routed incorrectly because of ambiguity and inaccuracy. Splitting the decision into two stages fixes that. The first stage picks the domain. The second stage classifies again, but only against that domain’s own options: the IT classifier routes between otions like VPN and Laptop, the HR classifier routes between options like PTO and Payroll.

This pattern fits problems like:

  • Service-desk triage — sort a request into IT, HR, or Finance, then by that domain’s specific services.
  • Operations-event triage — sort an incoming alert into a product area, then into its specific failure type.

Solution

Build this as a Compound Action, a sequence of steps that runs on its own from start to finish. It can be triggered by a user reporting a problem, a webhook, or a schedule. If a request doesn’t cleanly match any option, it gets flagged for human review instead of being forced into the wrong bucket.

This pattern uses the following components:

  • It first uses the LLM Action generate_structured_value_action to classify the broad domain and returns a fixed value from a list that you’ve pre-defined.
  • It uses a switch action to route down the appropriate path (domain) based on the selected value.
  • The generate_structured_value_action LLM Action is then used to categorize the request against the available options for that specific path (domain).

When to use this pattern

Use this pattern when both of the following hold:

  • Each top-level category has its own set of subcategories, so stage one determines which smaller label set stage two uses.
  • A single classifier over all the options is not accurate enough — usually because there are too many labels, or because the domains blur together (e.g. “Software” issues could plausibly be IT or Finance). Confirm this with a quick before/after check: take 50-100 real past requests, write down what the correct domain and service should have been for each, then run your current single classifier over them and count the misses. Run the same requests through this two-stage version and count its misses too. Only adopt the two-stage version if it makes fewer mistakes than the single classifier.

When not to use this pattern

  • One classification is accurate enough. If a single LLM Action against a flat set of options classifies reliably, use it. Don’t add a stage (with another LLM call) you don’t need.
  • A fixed rule determines the appropriate domain. If a field value, a keyword, or any other deterministic rule decides the domain for the same input, skip the LLM and pick the domain with switch and DSL (Moveworks’ data-mapping syntax) instead. See LLM vs DSL.

Example: cross-department service-desk triage

A user reports a problem in their own words, and nothing about its domain or service is known yet. A Compound Action classifies the request, then files it as a properly-routed ticket. This example runs against Purple Suite’s Service Desk Tickets Grid base, so each classified request lands there as a new ticket.

Both classifications (the category and route) include an UNKNOWN option. That way, a request that doesn’t clearly match anything still gets filed and flagged for manual review, instead of forced into the wrong bucket. Steps 3 and 4 show how that fallback plays out.

The overall structure and flow here is tailored to our Purple Suite example. You’ll need to define your own domain categories and service routes, and ultimately, the endpoint that they’ll be sent onto.

We can configure an Input Argument in a Compound Action to receive data into the workflow while making the workflow reusable. In our instance, that would be the problem description that the user has inputted, so we’ll set that as problem_description. We can then refer to that value later in code as data.problem_description. We have some examples that we can try out later.

Step 1: Classify the broad domain

If you’re building this in the Low Code Editor, add this as a structured LLM Action step from the step picker instead of writing YAML. The editor generates the same configuration shown below, so use whichever option you prefer.

This step passes data.problem_description to a structured LLM Action. As we are using the generate_structured_value_action, the LLM is instructed to return data in a specific structure. That is the structure shown in the output_schema block in the below code, meaning the result can be set to IT, HR, Finance, or UNKNOWN. It is a required property and no other properties are allowed, based on the code configuration below. That value is accessible to future actions using the variable data.domain_result.generated_output.domain (Note: domain_result is the name of the output_key in the action, while domain is the name of the property in the output schema).

Compound Action: triage_ticket (step 1)
1steps:
2 - action:
3 action_name: mw.generate_structured_value_action
4 output_key: domain_result
5 input_args:
6 payload: data.problem_description
7 system_prompt: '''Classify this request into a single domain.'''
8 output_schema: >-
9 {
10 "type": "object",
11 "properties": {
12 "domain": { "type": "string", "enum": ["IT", "HR", "Finance", "UNKNOWN"] }
13 },
14 "required": ["domain"],
15 "additionalProperties": false
16 }
17 # additionalProperties: false rejects any field not in the schema above;
18 # strict: 'true' forces the model to pick one of the enum values, never free text.
19 strict: 'true'

Step 2: Classify the service, constrained to the chosen domain

We then use switch to route to the domain identified in Step 1. You will notice in the code that we have a case (or a branch) for each domain. This allows us to run a different action depending on the domain. In this example, each domain is configuerd to use the generate_structured_value_action to select a service from a pre-configured list. Put another way, each domain can only classify services in its path.

Notice that in each branch, the output_key has been named service_result. This is important for step 3. It means that whichever path is taken across the domains (or cases), it writes the result to the same data.service_result.generated_output.service, meaning we have a predictable variable that we can use for the rest of our workflow.

Compound Action: triage_ticket (step 2)
1 - switch:
2 cases:
3 - condition: data.domain_result.generated_output.domain == 'IT'
4 steps:
5 - action:
6 action_name: mw.generate_structured_value_action
7 output_key: service_result
8 input_args:
9 payload: data.problem_description
10 system_prompt: '''Classify the IT service for this request.'''
11 output_schema: >-
12 {
13 "type": "object",
14 "properties": {
15 "service": { "type": "string",
16 "enum": ["Laptop", "VPN", "Account Access", "Software", "UNKNOWN"] }
17 },
18 "required": ["service"],
19 "additionalProperties": false
20 }
21 strict: 'true'
22 - condition: data.domain_result.generated_output.domain == 'HR'
23 steps:
24 - action:
25 action_name: mw.generate_structured_value_action
26 output_key: service_result
27 input_args:
28 payload: data.problem_description
29 system_prompt: '''Classify the HR service for this request.'''
30 output_schema: >-
31 {
32 "type": "object",
33 "properties": {
34 "service": { "type": "string",
35 "enum": ["PTO", "Benefits", "Payroll", "UNKNOWN"] }
36 },
37 "required": ["service"],
38 "additionalProperties": false
39 }
40 strict: 'true'
41 - condition: data.domain_result.generated_output.domain == 'Finance'
42 steps:
43 - action:
44 action_name: mw.generate_structured_value_action
45 output_key: service_result
46 input_args:
47 payload: data.problem_description
48 system_prompt: '''Classify the Finance service for this request.'''
49 output_schema: >-
50 {
51 "type": "object",
52 "properties": {
53 "service": { "type": "string",
54 "enum": ["Expenses", "Procurement", "Accounts Payable", "UNKNOWN"] }
55 },
56 "required": ["service"],
57 "additionalProperties": false
58 }
59 strict: 'true'
60 default:
61 # No domain matched (domain came back UNKNOWN): run nothing here.
62 # Step 3 still files the ticket, just with UNKNOWN/UNKNOWN instead
63 # of guessing a service with no domain to constrain it.
64 steps: []

Step 3: Create the ticket in Purple Suite

With both classifiers complete, the next step files them as a ticket in PurpleSuite. Unlike Steps 1 and 2, this one starts outside the Compound Action’s YAML. You will need to create an HTTP Action to send the payload to an external system. You can create one by importing the cURL command from PurpleSuite. Name it create_ticket to match the action_name used by the next action in YAML. See HTTP Actions if you need the full walkthrough for setting up a HTTP connector.

create_ticket
$curl -X POST 'https://marketplace.moveworks.com/api/purple-suite/grid/records' \
> -H 'Content-Type: application/json' \
> -d '{"tableId": "TBL-SVC-DESK-TICKETS", "name": "{{title}}", "status": "submitted", "fields": {"domain": "{{domain}}", "service": "{{service}}", "description": "{{description}}"}}'

TBL-SVC-DESK-TICKETS is the fixed ID of Purple Suite’s Service Desk Tickets Grid table, so you can import this command as-is. The other {{...}} placeholders get filled in from the matching input_args value in the YAML below.

The HTTP action below assumes that we need to pass several inputs, including a title, domain, service and description. Remember that the user provided their problem description as an input to our compound action, so we can access that using data.problem_description. Through this process, we’ve categorized the domain, and we’ve categorized the service, so we can reference those from their respective actions.

One interesting point to observe is that the service field below uses a conditional from DSL. We’re writing an inline IF ... THEN ... ELSE statement, which allows this step to always file a ticket, even when it’s categorized as UNKNOWN (instead of silently dropping a request the classifiers couldn’t confidently place).

Compound Action: triage_ticket (step 3)
1 - action:
2 action_name: create_ticket
3 output_key: ticket_result
4 input_args:
5 title: data.problem_description
6 domain: data.domain_result.generated_output.domain
7 # If Step 1 came back UNKNOWN, Step 2 never ran, so there's no
8 # service_result to read — file 'UNKNOWN' instead of erroring.
9 service: >-
10 IF data.domain_result.generated_output.domain == 'UNKNOWN'
11 THEN 'UNKNOWN'
12 ELSE data.service_result.generated_output.service
13 description: data.problem_description

Step 4: Return the result

return sends the finished result back out of the Compound Action: the new ticket’s ID, its filed domain and service. But you’ll notice there’s one additional field, in there as well, a needs_review field. The needs_review field is set to true whenever either stage landed on UNKNOWN. This shows that the process couldn’t confidently categorize the ticket, and allows subsequent steps in a process to flag it to a human for review and escalation if required.

Compound Action: triage_ticket (step 4)
1 - return:
2 output_mapper:
3 ticket_id: data.ticket_result.id
4 domain: data.domain_result.generated_output.domain
5 service: >-
6 IF data.domain_result.generated_output.domain == 'UNKNOWN'
7 THEN 'UNKNOWN'
8 ELSE data.service_result.generated_output.service
9 needs_review: >-
10 IF data.domain_result.generated_output.domain == 'UNKNOWN'
11 THEN true
12 ELSE data.service_result.generated_output.service == 'UNKNOWN'

Open the created ticket in Purple Suite to see the classification you just ran land as a real record.

Here are some exmaple problem descriptions for inspiration:

problem_descriptionLikely domain / service
”My VPN keeps dropping every 20 minutes”IT / VPN
”I can’t log in after resetting my password”IT / Account Access
”How many vacation days do I have left this year?”HR / PTO
”My last paycheck is missing overtime hours”HR / Payroll
”This vendor invoice was submitted twice by mistake”Finance / Accounts Payable
”I need budget approval for a new software purchase”Finance / Procurement

Issues and considerations

Now that you’ve seen the pattern built, keep these in mind:

  • Constrain each second-stage classifier to its own domain’s options. Give the IT classifier only IT services, the HR classifier only HR services, and so on. That’s what makes two stages more accurate than one flat classifier.
  • Always include an UNKNOWN option and a default path. Fall back automatically when a safe default exists, or send the request to human review when it genuinely needs a judgment call. A silently dropped request is worse than one flagged for review, as in the example’s UNKNOWN/UNKNOWN ticket.
  • If there’s a live user, ask instead of just flagging. Flagging UNKNOWN for review is the right call when nobody’s there to clarify — but that’s the wrong default in a live conversation. If a service classification comes back UNKNOWN and a person is right there, ask a follow-up question (e.g. “Can you provide more information to help us route this to the correct team?”) and re-run that classification with their answer, falling back to human review only if they still can’t clarify. That needs a Conversational Process rather than a Compound Action — see Control Flow for branching on a classification result.
  • Know where a structured result lives. A structured LLM Action’s output sits at data.<output_key>.generated_output.<field>, as in data.domain_result.generated_output.domain throughout this example.
  • Re-run the before/after check if accuracy looks off once this is live. If you categorize the domain incorrectly, the ticket will never be routed to the right service. A bad domain classifier is worse than no hierarchy at all. Use the check outlined in when to use this pattern.
  • Two classifications cost more than one. Each stage is its own LLM call, so this pattern is slower and more expensive per request than a single flat classifier — factor that into whether the accuracy gain above is worth it.