Scenario Authoring Guide
Directory Structure
Each scenario pack follows this layout:
packs/{pack-slug}/
pack.yaml
scenarios/{scenario-slug}/{version}/
scenario.yaml
templates/
default.yaml
*.yaml
data/ # optional — bulk JSON/YAML referenced via !include
fixtures/ # optional — sample inputs used by scenario:validate / scenario:dry-run
packs/_shared/ # cross-pack fragments — loader ignores _-prefixed dirs
See Splitting large scenarios with !include and Sharing fragments across scenarios for data/ and _shared/.
Pack File
pack.yaml defines the pack metadata:
apiVersion: scenarios.macp.dev/v1
kind: ScenarioPack
metadata:
slug: fraud # URL-safe identifier
name: Fraud # Display name
description: Fraud and risk decisioning demos
tags: [fraud, risk, demo]
Scenario Version File
scenario.yaml defines a single versioned scenario:
apiVersion: scenarios.macp.dev/v1
kind: ScenarioVersion
metadata:
pack: fraud
scenario: high-value-new-device
version: 1.0.0
name: High Value Purchase From New Device
summary: Description shown in the catalog
tags: [fraud, demo]
spec:
runtime:
kind: rust
version: v1
inputs:
schema: # Standard JSON Schema
type: object
properties:
transactionAmount:
type: number
default: 2400
minimum: 1
required: [transactionAmount]
launch:
modeName: macp.mode.decision.v1
modeVersion: 1.0.0
configurationVersion: config.default
policyVersion: policy.default # optional
policyHints: # optional — see policy-authoring.md § Policy Hints
type: none
ttlMs: 300000
maxSuspendMs: 15000 # optional — session-bound suspend cap (proto 0.1.5)
initiatorParticipantId: risk-agent # optional
participants:
- id: fraud-agent
role: fraud
agentRef: fraud-agent # matches example-agent catalog
commitments: # optional — authoring/lint metadata only (see Commitments)
- id: fraud-risk-assessed
title: Fraud risk assessed
description: Fraud specialist has recorded a risk verdict.
requiredRoles: [fraud]
policyRef: policy.default
contextTemplate: # {{ inputs.* }} substitution
transactionAmount: "{{ inputs.transactionAmount }}"
contextId: fraud-ctx-001 # optional — passed through to sessionStart.contextId
extensions: # optional — sessionStart.extensions
demo.trace: ZGVtby10cmFjZQ== # string values, base64 (see note below)
metadataTemplate:
demoType: fraud-decision
kickoffTemplate:
- from: risk-agent
to: [fraud-agent]
kind: proposal
messageType: Proposal
payloadEnvelope:
encoding: proto
proto:
typeName: macp.modes.decision.v1.ProposalPayload
value:
proposal_id: "{{ inputs.customerId }}-review"
execution:
idempotencyKey: fraud-demo-001 # optional — copied to runDescriptor.execution
tags: [demo, fraud]
requester:
actorId: macp-playground
actorType: service
outputs:
expectedDecisionKinds: [approve, step_up, decline]
expectedSignals: [suspicious_device]
maxSuspendMs(optional). Binds a session-level suspension cap (proto 0.1.5SessionStartPayload.max_suspend_ms) threaded into the initiator'ssessionStartexactly likettlMs. Omit it (or set0) to accept the runtime default (7 days). A small cap is useful for suspend/resume demos: hold a suspended session past the cap and the runtime expires it against the cap. See thesuspend-demotemplate under the fraud pack, which pairsmaxSuspendMs: 15000with thesuspendcustomerId sentinel that triggersrisk-decider.worker.ts's initiator-driven suspend/resume flow.
extensions(optional). A mapping copied verbatim into the initiator'ssessionStart.extensions. Every value must be a string; protobufbytestravel as base64 in JSON per RFC-MACP-0001 §10.3. The shape (scenario and templateoverrides.launch.extensions) is checked byscenario:validate,scenario:lint, and the compiler, which rejects a bad value withCOMPILATION_ERROR(400). How the SDK decodes the value is the SDK's contract, not this repo's.
Template File
Templates provide default overrides and launch configuration variants:
apiVersion: scenarios.macp.dev/v1
kind: ScenarioTemplate
metadata:
scenarioVersion: fraud/high-value-new-device@1.0.0
slug: strict-risk
name: Strict Risk
spec:
defaults: # Override scenario schema defaults
deviceTrustScore: 0.08
priorChargebacks: 2
overrides:
launch: # Deep-merged with scenario launch config
ttlMs: 180000
metadataTemplate:
posture: strict-risk
runtime: # Override runtime selection
kind: rust
version: v2
execution: # Override execution config
tags: [strict, fraud]
Template Substitution
Use {{ path.to.value }} placeholders in contextTemplate, metadataTemplate, and kickoffTemplate. During compilation:
- Exact match (
"{{ inputs.amount }}") — preserves the original type (number, boolean, etc.) - Embedded (
"Amount: {{ inputs.amount }}") — coerces to string - Nested paths are supported:
{{ inputs.nested.field }} - Undefined placeholders throw a
COMPILATION_ERROR
Commitments
launch.commitments is an optional array of commitment definitions that declare the discrete governance steps the session is expected to produce. It is authoring metadata only: the compiler does not emit it — it appears nowhere in the CompileLaunchResult, the runDescriptor, the agent bootstrap, or the CP-1 submission. Its only consumers are scenario:validate and scenario:lint (see scenario-cli.md), which check descriptions and template overrides.
Each entry:
| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | Stable commitment identifier (matches what agents will emit) |
title | string | yes | Short human-readable label |
description | string | no | One-line explanation of what the commitment represents |
requiredRoles | string[] | no | Participant roles expected to contribute |
policyRef | string | no | Policy version this commitment is evaluated under |
Template overrides that set commitments replace the scenario's array (array values are not merged element-wise); scenario:lint warns when the replacement is shorter than the original.
Because the compiler never reads commitments, {{ inputs.* }} placeholders inside them are not substituted. scenario:validate still checks that any placeholder written there is reachable from the schema or a fixture.
Default Merge Precedence
JSON Schema defaults < Template defaults < User-provided inputs
Adding a New Scenario
The fastest path is npm run scenario:new -- <pack> <slug> — see scenario-cli.md. Manually:
- Create the pack directory:
packs/{slug}/pack.yaml - Create the scenario directory:
packs/{slug}/scenarios/{scenario-slug}/{version}/ - Write
scenario.yamlwith the schema above - Add at least a
default.yamltemplate intemplates/ - Ensure
agentRefvalues in participants match entries in the example agent catalog - The service auto-discovers new packs on the next request (when
REGISTRY_CACHE_TTL_MS=0) npm run scenario:validate -- packs/{slug}/scenarios/{scenario-slug}/{version}/scenario.yamlto confirm before booting the service.
Splitting large scenarios with !include
Scenario YAML files can grow unwieldy when they carry bulky context data, long input schemas, or repeated participant blocks. The loader supports a custom !include tag that inlines a sibling YAML or JSON file at load time — keeping scenario.yaml readable while data lives next to it on disk.
launch:
participants: !include ../../../../_shared/participants/4-agent-fraud.yaml
commitments: !include ../../../../_shared/commitments/fraud.yaml
contextTemplate:
customers: !include ./data/customers.json # 200-row sample list
priorCases: !include ./data/cases.json
Path resolution. !include <path> is resolved relative to the file that contains the tag. So an include from templates/strict.yaml resolves from inside the templates/ directory, not from the scenario root.
Supported targets. .yaml, .yml, and .json. YAML files may themselves contain further !include tags (recursive includes are followed). Other extensions throw INVALID_PACK_DATA.
Security bound. Resolved paths must stay inside PACKS_DIR. Any include that escapes (../../../etc/passwd, absolute paths outside PACKS_DIR, etc.) throws INVALID_PACK_DATA at load time.
Cycle detection. a.yaml → b.yaml → a.yaml throws.
When to reach for it. Any time the same fragment is copy-pasted across two or more scenarios, or any time a single field crosses ~50 lines of inline data.
Writing scalars: pack YAML accepts only JSON spellings
Pack files are parsed with js-yaml's JSON_SCHEMA, deliberately — it keeps YAML 1.1's surprise
coercions out, so a mode name like on or a version like 2024-01-15 stays the text you wrote
instead of turning into a boolean or a Date.
The trade-off is that null, true, false and numbers must be written the way JSON writes
them. Anything outside that grammar is a plain string. This is silent — nothing errors, the value
simply arrives as text:
| If you write | You get | Write this instead |
|---|---|---|
key: ~ · key: Null · key: NULL | the strings '~', 'Null', 'NULL' | key: null, or omit the key |
key: with no value | the empty string '' | key: null, or omit the key |
key: True · key: TRUE | the strings 'True', 'TRUE' | key: true |
key: False · key: FALSE | the strings 'False', 'FALSE' | key: false |
key: +5 · key: .5 · key: 007 | the strings '+5', '.5', '007' | key: 5, key: 0.5, key: 7 |
key: 0x1F · key: 0o17 · key: 0b101 | the strings '0x1F', '0o17', '0b101' | the decimal value |
key: 1_000 | the string '1_000' | key: 1000 |
key: .inf · key: .nan | the strings '.inf', '.nan' | avoid; use a real bound |
null, true, false, 42, -5 and 0.5 all behave exactly as you would expect.
The two that bite hardest:
description: ~meaning "no description" ships a commitment whose description is a literal tilde.npm run scenario:lintsees a non-empty string and reports nothing.someFlag: Falseis the string'False', which is truthy in JavaScript — so a flag you meant to switch off reads as on. Always lowercasefalse.
If a value must be absent, prefer omitting the key entirely over any spelling of null.
Placeholder and half-written pack files
A pack.yaml or scenario.yaml that contains no document at all — empty, whitespace only, comments
only, a bare ---, or a sequence or scalar where a mapping belongs — is treated as "not a pack
document" rather than as a broken one. The loader logs an error naming the file and the shape it
found, then skips just that pack (or, for scenario.yaml, just that version) and carries on serving
everything else. Scaffolding a new pack therefore cannot take the catalog down.
That containment is deliberate and is tested, because it is not how a malformed file behaves. A
file that does contain a mapping but gets apiVersion or kind wrong — or, for pack.yaml only, a
missing metadata.slug — is a real error: it raises INVALID_PACK_DATA and fails the entire
registry load, so every catalog route returns HTTP 500 until it is fixed. The same is true of YAML
that js-yaml cannot parse at all, including two documents in one file (--- twice) and a %YAML
directive with no following ---.
The rule of thumb: a file you have not written yet costs you that one pack; a file you have written
wrongly costs you the whole catalog. If you are scaffolding, leave the file empty or commented out
rather than half-filled with a wrong apiVersion.
Sharing fragments across scenarios
Conventionally, fragments live under packs/_shared/ (the leading underscore tells the loader to skip the directory during pack discovery). The seeded layout:
packs/_shared/
participants/
4-agent-fraud.yaml # fraud / growth / compliance / risk roster
4-agent-lending.yaml
4-agent-claims.yaml
commitments/
fraud.yaml # standard 3-commitment fraud set
lending.yaml # 4-commitment lending set
claims.yaml # 3-commitment claims set
policy-hints/
default.yaml
lending-conservative.yaml
claims-majority.yaml
Rule of thumb: the third caller is when you promote a fragment to _shared/. Two scenarios that happen to share a participant list usually aren't worth the indirection; three is.
_-prefix discovery rule. Any directory directly under PACKS_DIR whose name starts with _ is ignored for pack discovery. Use this for fragment libraries, generators, or any non-pack scaffolding.