Fan Out and Synthesize

Query several systems in parallel, then use an LLM Action to turn their combined results into one answer, like a summary or a recommendation.
View as Markdown

Query independent systems at the same time, then use an LLM Action to turn their combined results into one answer like a summary, a priority call, a recommendation that no single source had on its own.

If the results just need to be combined or calculated, skip the LLM and use DSL (Moveworks’ data-mapping syntax) in return directly.

Context and problem

A complete picture often doesn’t live in one system: a CRM record, a ticketing system, and a usage or delivery tracker might each hold part of the story, and none of them can produce the full picture alone. This pattern fetches from all of them at once, then reasons across the results to produce that picture.

For example, combining a CRM record (e.g. Salesforce), open ITSM incidents (e.g. ServiceNow), and Product Management delivery status (e.g. Asana or Jira) into a renewal briefing that explains risk, recent changes, and talking points for a Customer Success Manager. The same shape applies to other combinations of systems and synthesized output like:

  • Onboarding readiness check — combine identity status (e.g. Okta), hardware provisioning (e.g. an asset system), and software/access grants into a single summary of what’s still blocking a new hire from being productive.
  • Incident briefing — combine active monitoring alerts, recent change records, and on-call ownership into a short narrative of what’s likely related and who should respond, instead of making the on-call engineer piece it together from three dashboards.

Solution

A Compound Action lets you define a sequence of steps that runs on its own, with no user input needed partway through. Think of it as a container that holds every step below and runs them in order.

Inside that Compound Action, fetch the data from each system in parallel, through separate branches, so all the calls go out at once instead of one after another. Once every branch is done, run one LLM Action that reads the combined results and writes the synthesized answer, then send that back with return.

You can build that sequence visually in the Low Code Editor or write the YAML shown below directly; the Editor and the YAML stay in sync, so go with the option you prefer.

The pattern is composed of the following components:

  • parallel with branches runs each source query at the same time and waits for all of them to finish before moving on.
  • An LLM Action does the synthesis.
    • Use generate_text_action when the deliverable is prose (a briefing, a summary).
    • Use generate_structured_value_action when a later step needs the LLM’s judgment as data, like a computed risk level and a list of talking points, rather than the source records.
  • return shapes what gets sent back — the synthesized output plus which sources succeeded.

When to use this pattern

Use fan out and synthesize when all of the following hold:

  • You need data from two or more independent sources. No source depends on another’s output.
  • The sources can be fetched all at once, and fetching them one after another would be needlessly slow.
  • Getting the real insight requires understanding what the data means together, not just merging it. That could be summarizing, prioritizing, explaining, or drawing a conclusion across sources.

When not to use this pattern

  • You only need the source records themselves, unchanged. If the consumer just needs each system’s data reshaped into a specific format, with no judgment involved, use parallel and shape the raw results straight into return.
  • The combination is deterministic. Summing numbers, concatenating fields, or checking the status of fields (like whether identity, hardware, and access statuses are all green in an onboarding flow) doesn’t need an LLM to interpret meaning — DSL and data mappers can express that logic directly. Use them in the return mapper instead. See LLM vs DSL.
  • The sources are dependent. If source B needs a value that source A returns, you can’t fan out — run them one after another in the same Compound Action instead (see the Golden Rule).

Example: renewal briefing

A Compound Action pulls a CRM record, ITSM incidents, and Product Management delivery status in parallel, then synthesizes a briefing for a renewal call. The example runs against Purple Suite, which comes with built-in sample data for each of these systems:

  • CRM (account record) — Purple Suite’s own CRM app, standing in for something like Salesforce.
  • ITSM (incidents) — Purple Suite’s own ITSM app, standing in for something like ServiceNow.
  • Product Management (projects/tasks) — Purple Suite’s own PM app, standing in for something like Asana or Jira.

The overall structure and flow here is what we used for our Purple Suite example. You will need to define your own actions and swap them in to retrieve data from whichever systems make sense for your workflow.

We should configure a couple of Input Arguments on the Compound Action to feed it the data it needs: account_id and account_name. Mark both as required in the Input Args panel. That way, Agent Studio itself blocks the Compound Action from running with either one missing or blank, instead of letting it run and quietly return a wrong briefing later on (see Step 1 for why an empty account_name in particular is a problem for our particular workflow).

Step 1: Fan out to the four sources, resiliently

If you’re building this in the Low Code Editor, you can add parallel, try_catch, and action steps from its step picker avoid using YAML.

This is effectively what the editor would generate for you, letting us share the shape of the process easily.

The parallel block queries CRM, ITSM, and Product Management (projects and tasks) at the same time, instead of one after another. The same three-step shape repeats four times, with the action name and output_key changing between branches.

Each branch is wrapped in try_catch: if one source fails (say, ITSM is down), it doesn’t stop the other three from returning and the briefing from being built. The catch records the failure with a short, reusable script step you can copy as-is for every branch.

data.account_id and data.account_name refer to the input arguments configured above. The CRM branch scopes its query with account_id directly. Purple Suite’s ITSM and PM apps don’t use the account ID as a foreign key, but each incident/project/task’s title/name includes the account’s name. It is better to retrieve only the data that we need, rather than gathering additional unecessary data, so we therefore filter the data at source using the $filter query parameter avaialble for that API endpoint.

While this might sound like an implementation detail for the Purple Suite, it’s important consideration when you integrate with your own systems and the available API endpoints. Filtering data before it reaches the LLM, is more reliable than asking the LLM to sift a full, unfiltered table itself. However, if we passed an empty account_name to the API, an empty filter value would match nothing (or everything), instead of just this account’s records. That’s why we have required account_name as an input argument in this example.

You can create each branch’s HTTP Action by importing the cURL command below, naming it to match the action_name used in the YAML above. Purple Suite seeds four fixed accounts (ACC-0001 through ACC-0004) with matching ITSM incidents and PM projects/tasks, so you can try these against the Purple Suite endpoints right away.

get_crm_account
$curl -X GET 'https://marketplace.moveworks.com/api/purple-suite/crm/accounts/{{account_id}}' \
> -H 'Authorization: Bearer YOUR_PAT' \
> -H 'X-Instance-ID: YOUR_INSTANCE_ID'
get_itsm_incidents
$curl -X GET "https://marketplace.moveworks.com/api/purple-suite/itsm/incidents?$filter=contains(title,'{{account_name}}')" \
> -H 'Authorization: Bearer YOUR_PAT' \
> -H 'X-Instance-ID: YOUR_INSTANCE_ID'
get_pm_projects
$curl -X GET "https://marketplace.moveworks.com/api/purple-suite/pm/projects?$filter=contains(name,'{{account_name}}')" \
> -H 'Authorization: Bearer YOUR_PAT' \
> -H 'X-Instance-ID: YOUR_INSTANCE_ID'
get_pm_tasks
$curl -X GET "https://marketplace.moveworks.com/api/purple-suite/pm/tasks?$filter=contains(title,'{{account_name}}')" \
> -H 'Authorization: Bearer YOUR_PAT' \
> -H 'X-Instance-ID: YOUR_INSTANCE_ID'

You can retrieve your Personal Access Token (PAT) and instance ID from the Purple Suite Credentials page. Remember that a PAT is a secret credential, and you should make sure you handle it securely, only saving it in the secure inputs. The {{...}} placeholders get filled in from the matching input_args value in the YAML below — account_id from data.account_id, account_name from data.account_name — the same way {{title}} and friends work in the Categorize and Route example.

Compound Action: build_renewal_briefing (fan out)
1steps:
2 - parallel:
3 branches:
4 - steps:
5 - try_catch:
6 try:
7 steps:
8 - action:
9 action_name: get_crm_account
10 output_key: crm_result
11 input_args:
12 account_id: data.account_id
13 catch:
14 steps:
15 - script:
16 output_key: crm_result # Reusing the same key
17 code: "{'error': 'CRM system unavailable or account not found'}"
18 - steps:
19 - try_catch:
20 try:
21 steps:
22 - action:
23 action_name: get_itsm_incidents
24 output_key: itsm_result
25 input_args:
26 account_name: data.account_name
27 catch:
28 steps:
29 - script:
30 output_key: itsm_result
31 code: "{'error': 'ITSM system unavailable'}"
32 - steps:
33 - try_catch:
34 try:
35 steps:
36 - action:
37 action_name: get_pm_projects
38 output_key: pm_projects_result
39 input_args:
40 account_name: data.account_name
41 catch:
42 steps:
43 - script:
44 output_key: pm_projects_result
45 code: "{'error': 'PM Projects unavailable'}"
46 - steps:
47 - try_catch:
48 try:
49 steps:
50 - action:
51 action_name: get_pm_tasks
52 output_key: pm_tasks_result
53 input_args:
54 account_name: data.account_name
55 catch:
56 steps:
57 - script:
58 output_key: pm_tasks_result
59 code: "{'error': 'PM Tasks unavailable'}"

Step 2: Synthesize with an LLM Action

A generate_text_action reads the results from all four systems, decides what they mean together and writes the briefing. Because the HTTP actions already filter the data by the account name, the LLM only needs to reason about what the (already-relevant) results mean — it isn’t asked to filter anything itself. The prompt tells it what to reason about and how to handle a missing source. RENDER() builds the text sent to the LLM. Each {{...}} placeholder in the template gets filled in with the matching value listed under args, while $STRINGIFY_JSON converts a branch’s result into readable text first:

Synthesize a prose briefing
1 - action:
2 action_name: mw.generate_text_action
3 output_key: briefing
4 input_args:
5 system_prompt: >-
6 '"You are a renewal analyst. Evaluate evidence across CRM, ITSM,
7 and PM (each has already been scoped to this account). Describe
8 time trends, severity/status, delivery trajectory, counter-signals,
9 and uncertainty, and recommend a renewal posture. Do not claim
10 incidents caused churn; distinguish association from proof. If any
11 source is unavailable, label the briefing PARTIAL and say which
12 evidence is missing."'
13 temperature: '0.2'
14 user_input:
15 RENDER():
16 template: |
17 ACCOUNT NAME: {{account_name}}
18
19 CRM:
20 {{crm}}
21
22 ITSM:
23 {{itsm}}
24
25 PM PROJECTS:
26 {{projects}}
27
28 PM TASKS:
29 {{tasks}}
30 args:
31 account_name: data.account_name
32 crm: $STRINGIFY_JSON(data.crm_result)
33 itsm: $STRINGIFY_JSON(data.itsm_result)
34 projects: $STRINGIFY_JSON(data.pm_projects_result)
35 tasks: $STRINGIFY_JSON(data.pm_tasks_result)

If a later step needs the LLM’s judgment as data instead of prose (like a computed risk level or a list of talking points), use generate_structured_value_action with a schema instead — see LLM Actions.

Step 3: Return the result

return is the step that sends the finished result back out of the Compound Action.

A source can fail quietly earlier in the run, so before sending that result off, it’s worth telling the user which sources actually came through. That’s why we define a sources_available property in the result. Without that, the user would have no way to tell the difference between “CRM had nothing to report” and “CRM never responded.” Because the catch step reuses the try step’s output_key, data.crm_result (and the other three) is always populated, either with the real result or with the {'error': ...} object the catch step wrote instead. Checking .error == NULL tells us which one it is.

Return
1 - return:
2 output_mapper:
3 account_id: data.account_id
4 account_name: data.account_name
5 analysis: data.briefing.generated_output
6 sources_available:
7 crm: data.crm_result.error == NULL
8 itsm: data.itsm_result.error == NULL
9 pm_projects: data.pm_projects_result.error == NULL
10 pm_tasks: data.pm_tasks_result.error == NULL

To try this yourself, set up a Purple Suite instance, then create these steps as a new Compound Action in the Low Code Editor, along with your data query actions.

Purple Suite seeds four fixed accounts, ACC-0001 through ACC-0004, each with its own consistent story across CRM, ITSM, and PM. Their names are randomly generated per instance, so we can’t provide a list of account names here. Look up each account’s name in the CRM app (or via get_crm_account) first. Run the Compound Action once per account and compare the briefings: since each account has a different mix of incidents and delivery status behind it, you should see a different renewal assessment for each one, ranging from strong renewal .

Issues and considerations

A few things worth knowing before you build this yourself:

  • The LLM Action does its own shaping. There’s no separate mapping step between parallel and the LLM Action; its input_args are where you select and format the branch results. If a value isn’t plain text already, turn it into text first, like the example does with $STRINGIFY_JSON.
  • Give every branch its own output_key. That’s the name each branch’s result gets stored under once it finishes, and it’s how you read that result back later as data.<output_key>.
  • One failed source can take down the whole thing. Left alone, an error in any branch can stop the entire Compound Action, so the requester gets an error instead of an answer. Wrapping a branch in try_catch lets the others keep going, so you can still return a partial result and say which sources were missing.
  • You only move as fast as your slowest branch. The branches run at once, but each finishes on its own schedule, and the next step doesn’t start until every branch has finished. So a single slow or high-timeout source holds up the whole thing, even though the others were done long before it.
  • Match the LLM Action to what happens next:
    • Use generate_text_action when creating prose that a person will read.
    • Use generate_structured_value_action when a later step requires a result with the LLM’s judgement and a specific schema, returning exactly the fields you expect.
  • Filter at the source whenever you can. If a system lets you scope its results, do it there instead of sending unecessary information to the LLM. It’s more reliable finding the right answer in a small, relevant set of data than searching for it in a large, unfiltered one (here’s why). Even without a foreign key back to the account, this example’s ITSM/PM branches use each endpoint’s $filter query parameter to match on the account name, so the LLM never sees another account’s records in the first place. If a branch’s $filter value is ever wrong or missing, the giveaway is a briefing whose counts match the whole table rather than just this account’s records — check for that if the numbers look too high.
  • Watch what data reaches the LLM. If a branch pulls in content you don’t fully control, like a web page or an uploaded file, it could carry hidden instructions that hijack the LLM (a prompt injection). That becomes dangerous once a later step can send data back out. See Security guidance for how to stay safe.