Example: Matrix Mixed Kinds

The "5 microservices + 1 frontend" case. A clarification step decides what to build, and a matrix downstream produces a different number of deliverables of mixed kinds — without any new DSL primitive.


The shape of the upstream output

The clarification agent emits an array of build targets, each carrying its own specRef plus a unique name for the matrix key:

json
1{
2  "targets": [
3    { "name": "auth",    "kind": "microservice", "specRef": "microservice-template", "prompt": "Auth service for OAuth2 + JWT issuance" },
4    { "name": "billing", "kind": "microservice", "specRef": "microservice-template", "prompt": "Billing service with Stripe webhook ingestion" },
5    { "name": "search",  "kind": "microservice", "specRef": "microservice-template", "prompt": "Search service backed by OpenSearch" },
6    { "name": "users",   "kind": "microservice", "specRef": "microservice-template", "prompt": "User profile + preferences service" },
7    { "name": "audit",   "kind": "microservice", "specRef": "microservice-template", "prompt": "Audit log ingestion + query service" },
8    { "name": "web",     "kind": "frontend",     "specRef": "frontend-template",     "prompt": "Customer dashboard with auth, billing, profile pages" }
9  ]
10}

Five microservices, one frontend — six entries total. A different request would produce a different array.

Workflow

yaml
1apiVersion: xema.dev/workflow/v1alpha1
2kind: Workflow
3metadata:
4  name: matrix-mixed-kinds-example
5  version: 1.0.0
6on:
7  workflow_dispatch:
8    inputs:
9      request:
10        type: string
11        required: true
12permissions:
13  artifacts: write
14  repos: write
15jobs:
16  clarify:
17    title: Decide what to build
18    uses: xema/agent
19    with:
20      agentRef: clarification-coordinator
21      stageKey: clarification
22      deliverableSpecRef: build-plan
23      agentSession: true
24      agentContext:
25        prompt: ${{ inputs.request }}
26    outputs:
27      targets: ${{ job.outputs.deliverable.content.value.targets }}
28
29  build:
30    title: Build each target
31    needs: [clarify]
32    strategy:
33      dynamic:
34        from: ${{ needs.clarify.outputs.targets }}
35        as: target
36        keyBy: name
37        maxEntries: 16
38      maxParallel: 4
39    uses: xema/agent
40    with:
41      agentRef: builder
42      stageKey: build
43      changeUnitId: ${{ matrix.target.name }}
44      deliverableSpecRef: ${{ matrix.target.specRef }}
45      agentSession: false
46      agentContext:
47        target: ${{ matrix.target }}
48        prompt: ${{ matrix.target.prompt }}
49    permissions:
50      repos: write
51    outputs:
52      handoff: ${{ job.outputs.structuredOutput }}
53
54  publish-summary:
55    needs: [build]
56    matrixGather: [build]
57    uses: xema/agent
58    with:
59      agentRef: governance
60      stageKey: governance
61      deliverableSpecRef: build-summary
62      agentSession: false
63      agentContext:
64        builtTargets: ${{ needs.build.outputs }}

Why this works without new primitives

  1. deliverableSpecRef is expression-shaped. Each iteration's ${{ matrix.target.specRef }} resolves to a different spec at dispatch time. The runtime fetches each spec, drives the agent, and validates against the matching contract.
  2. keyBy: name exposes per-target outputs. Downstream consumers index needs.build.outputs.byKey[<name>] to reach a specific target's deliverable.
  3. matrixGather: flattens for the cross-target step. The summary job receives the full array of build outputs, no per-target indexing needed.
  4. Variable count is automatic. Five targets = five iterations. Three targets = three. Zero targets = the build job doesn't run, and publish-summary (which depends on it via matrixGather) skips.

The matrix mechanism is the single answer for "many or zero or different-kind deliverables." There's no parallel deliverableSpecRefs: [] plural primitive — that would duplicate the fan-out concept.

Reading per-target results

yaml
1# Inside a downstream job, read a specific target's deliverable
2artifactIds: ${{ needs.build.outputs.byKey['auth'].deliverables }}
3# Or read it via the matrix binding when fanning out further
4artifactIds: ${{ needs.build.outputs.byKey[matrix.target.name].deliverables }}

For mixed-kind targets, downstream consumers may need to branch on deliverable.kind:

yaml
1if: ${{ needs.build.outputs.byKey[matrix.target.name].deliverable.kind == 'custom' }}

Use this when the per-target work depends on whether the agent produced code (custom) vs a frontend SPA (also custom but with a different post-step).

Edge cases

  • Zero targets — clarify.outputs.targets is [], the matrix expands to zero iterations, no agent runs, no validation runs. Downstream matrixGather jobs see an empty array.
  • Duplicate name — fail-fast at dispatch with RUNTIME_MATRIX_KEY_DUPLICATE. The keyBy value must be unique across iterations.
  • Cardinality cap — maxEntries: 16 caps the array length. An upstream returning 100 targets fails with RUNTIME_DYNAMIC_MATRIX_OVERFLOW rather than fanning out 100 expensive agent runs.

Previous: ← Response-only