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:
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
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
deliverableSpecRefis 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.keyBy: nameexposes per-target outputs. Downstream consumers indexneeds.build.outputs.byKey[<name>]to reach a specific target's deliverable.matrixGather:flattens for the cross-target step. The summary job receives the full array of build outputs, no per-target indexing needed.- Variable count is automatic. Five targets = five iterations. Three targets = three. Zero targets = the
buildjob doesn't run, andpublish-summary(which depends on it viamatrixGather) 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
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:
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.targetsis[], the matrix expands to zero iterations, no agent runs, no validation runs. DownstreammatrixGatherjobs see an empty array. - Duplicate
name— fail-fast at dispatch withRUNTIME_MATRIX_KEY_DUPLICATE. ThekeyByvalue must be unique across iterations. - Cardinality cap —
maxEntries: 16caps the array length. An upstream returning 100 targets fails withRUNTIME_DYNAMIC_MATRIX_OVERFLOWrather than fanning out 100 expensive agent runs.
Previous: ← Response-only