DocsNew Run

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.5 SessionStartPayload.max_suspend_ms) threaded into the initiator's sessionStart exactly like ttlMs. Omit it (or set 0) 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 the suspend-demo template under the fraud pack, which pairs maxSuspendMs: 15000 with the suspend customerId sentinel that triggers risk-decider.worker.ts's initiator-driven suspend/resume flow.

extensions (optional). A mapping copied verbatim into the initiator's sessionStart.extensions. Every value must be a string; protobuf bytes travel as base64 in JSON per RFC-MACP-0001 §10.3. The shape (scenario and template overrides.launch.extensions) is checked by scenario:validate, scenario:lint, and the compiler, which rejects a bad value with COMPILATION_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:

FieldTypeRequiredDescription
idstringyesStable commitment identifier (matches what agents will emit)
titlestringyesShort human-readable label
descriptionstringnoOne-line explanation of what the commitment represents
requiredRolesstring[]noParticipant roles expected to contribute
policyRefstringnoPolicy 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:

  1. Create the pack directory: packs/{slug}/pack.yaml
  2. Create the scenario directory: packs/{slug}/scenarios/{scenario-slug}/{version}/
  3. Write scenario.yaml with the schema above
  4. Add at least a default.yaml template in templates/
  5. Ensure agentRef values in participants match entries in the example agent catalog
  6. The service auto-discovers new packs on the next request (when REGISTRY_CACHE_TTL_MS=0)
  7. npm run scenario:validate -- packs/{slug}/scenarios/{scenario-slug}/{version}/scenario.yaml to 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 writeYou getWrite this instead
key: ~ · key: Null · key: NULLthe strings '~', 'Null', 'NULL'key: null, or omit the key
key: with no valuethe empty string ''key: null, or omit the key
key: True · key: TRUEthe strings 'True', 'TRUE'key: true
key: False · key: FALSEthe strings 'False', 'FALSE'key: false
key: +5 · key: .5 · key: 007the strings '+5', '.5', '007'key: 5, key: 0.5, key: 7
key: 0x1F · key: 0o17 · key: 0b101the strings '0x1F', '0o17', '0b101'the decimal value
key: 1_000the string '1_000'key: 1000
key: .inf · key: .nanthe 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:lint sees a non-empty string and reports nothing.
  • someFlag: False is the string 'False', which is truthy in JavaScript — so a flag you meant to switch off reads as on. Always lowercase false.

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.