> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://help.moveworks.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://help.moveworks.com/_mcp/server.

# Categorize and Route

Classify a free-text request into a broad category with an [LLM Action](/agent-studio/actions/llm-actions), 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`](/agent-studio/actions/compound-actions/switch) and [DSL](/agent-studio/core-platform/configuration-languages/moveworks-dsl-reference) (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](/agent-studio/actions/compound-actions), 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.

```mermaid
flowchart TD
  Request["Known request text"] --> Domain["Structured LLM Action<br />classify domain"]
  Domain --> Route{"switch on<br />the classified domain"}
  Route -->|IT| IT["Structured LLM Action<br />classify IT service<br />(IT-only options)"]
  Route -->|HR| HR["Structured LLM Action<br />classify HR service<br />(HR-only options)"]
  Route -->|Finance| Fin["Structured LLM Action<br />classify Finance service<br />(Finance-only options)"]
  Route -->|No match| Review["Fallback / needs review"]
  IT --> Result["return domain + service"]
  HR --> Result
  Fin --> Result
```

This pattern uses the following components:

* It first uses the [LLM Action](/agent-studio/actions/llm-actions) `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`](/agent-studio/actions/compound-actions/switch) action to route down the appropriate path (domain) based on the selected value.
* The `generate_structured_value_action` [LLM Action](/agent-studio/actions/llm-actions) 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](/agent-studio/actions/llm-actions) 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`](/agent-studio/actions/compound-actions/switch) and [DSL](/agent-studio/core-platform/configuration-languages/moveworks-dsl-reference) (Moveworks' data-mapping syntax) instead. See [LLM vs DSL](/agent-studio/guides/getting-started/decision-frameworks#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](/agent-studio/actions/compound-actions) classifies the request, then files it as a properly-routed ticket. This example runs against [Purple Suite](/agent-studio/quickstart-guide/purple-suite-setup)'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](/agent-studio/actions/compound-actions#getting-started), 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](/agent-studio/actions/llm-actions). 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).

```yaml title="Compound Action: triage_ticket (step 1)"
steps:
  - action:
      action_name: mw.generate_structured_value_action
      output_key: domain_result
      input_args:
        payload: data.problem_description
        system_prompt: '''Classify this request into a single domain.'''
        output_schema: >-
          {
            "type": "object",
            "properties": {
              "domain": { "type": "string", "enum": ["IT", "HR", "Finance", "UNKNOWN"] }
            },
            "required": ["domain"],
            "additionalProperties": false
          }
        # additionalProperties: false rejects any field not in the schema above;
        # strict: 'true' forces the model to pick one of the enum values, never free text.
        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.

```yaml title="Compound Action: triage_ticket (step 2)"
  - switch:
      cases:
        - condition: data.domain_result.generated_output.domain == 'IT'
          steps:
            - action:
                action_name: mw.generate_structured_value_action
                output_key: service_result
                input_args:
                  payload: data.problem_description
                  system_prompt: '''Classify the IT service for this request.'''
                  output_schema: >-
                    {
                      "type": "object",
                      "properties": {
                        "service": { "type": "string",
                          "enum": ["Laptop", "VPN", "Account Access", "Software", "UNKNOWN"] }
                      },
                      "required": ["service"],
                      "additionalProperties": false
                    }
                  strict: 'true'
        - condition: data.domain_result.generated_output.domain == 'HR'
          steps:
            - action:
                action_name: mw.generate_structured_value_action
                output_key: service_result
                input_args:
                  payload: data.problem_description
                  system_prompt: '''Classify the HR service for this request.'''
                  output_schema: >-
                    {
                      "type": "object",
                      "properties": {
                        "service": { "type": "string",
                          "enum": ["PTO", "Benefits", "Payroll", "UNKNOWN"] }
                      },
                      "required": ["service"],
                      "additionalProperties": false
                    }
                  strict: 'true'
        - condition: data.domain_result.generated_output.domain == 'Finance'
          steps:
            - action:
                action_name: mw.generate_structured_value_action
                output_key: service_result
                input_args:
                  payload: data.problem_description
                  system_prompt: '''Classify the Finance service for this request.'''
                  output_schema: >-
                    {
                      "type": "object",
                      "properties": {
                        "service": { "type": "string",
                          "enum": ["Expenses", "Procurement", "Accounts Payable", "UNKNOWN"] }
                      },
                      "required": ["service"],
                      "additionalProperties": false
                    }
                  strict: 'true'
      default:
        # No domain matched (domain came back UNKNOWN): run nothing here.
        # Step 3 still files the ticket, just with UNKNOWN/UNKNOWN instead
        # of guessing a service with no domain to constrain it.
        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](/agent-studio/actions/http-actions#import-from-curl) from PurpleSuite. Name it `create_ticket` to match the `action_name` used by the next action in YAML. See [HTTP Actions](/agent-studio/actions/http-actions) if you need the full walkthrough for setting up a HTTP connector.

```bash title="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](/agent-studio/core-platform/configuration-languages/moveworks-dsl-reference). 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).

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

### Step 4: Return the result

[`return`](/agent-studio/actions/compound-actions/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.

```yaml title="Compound Action: triage_ticket (step 4)"
  - return:
      output_mapper:
        ticket_id: data.ticket_result.id
        domain: data.domain_result.generated_output.domain
        service: >-
          IF data.domain_result.generated_output.domain == 'UNKNOWN'
          THEN 'UNKNOWN'
          ELSE data.service_result.generated_output.service
        needs_review: >-
          IF data.domain_result.generated_output.domain == 'UNKNOWN'
          THEN true
          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_description`                                | Likely 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](/agent-studio/conversation-process) rather than a [Compound Action](/agent-studio/actions/compound-actions) — see [Control Flow](/agent-studio/conversation-process/control-flow) for branching on a classification result.
* **Know where a structured result lives.** A [structured LLM Action](/agent-studio/actions/llm-actions)'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](#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.

## Related guidance

#### [LLM Actions](/agent-studio/actions/llm-actions)

Use `generate_structured_value_action` when a later step needs a fixed, limited-choice field back — not free text.

#### [Switch](/agent-studio/actions/compound-actions/switch)

Choose between actions inside a Compound Action based on a condition.

#### [Control Flow](/agent-studio/conversation-process/control-flow)

Decision Policies and branching in Conversational Processes — for asking a clarifying question instead of flagging for review.

#### [Compound Actions vs. Conversational Processes](/agent-studio/cookbooks/when-to-use-compound-actions-vs-conversational-processes)

Keep backend decisions in a Compound Action; use a Conversational Process when the decision changes what the user is asked next.