Narrative Protocol

Events

Event definitions and versioning

Events

An Event in Narrative Protocol defines something that can happen in your world. Events are the actions, triggers, or occurrences that drive state changes in your simulation.

What is an Event?

Events describe behavior, not data. They answer the question: "What can happen in this world?"

Examples:

  • race_result - A horse race is completed
  • training_session - A horse undergoes training
  • injury_occurred - A horse gets injured
  • weather_change - Weather conditions shift

Event Structure

Each event belongs to a world and has:

FieldDescription
nameUnique identifier within the world
descriptionHuman-readable explanation
versionsOne or more versioned implementations

Creating an Event

Events are always created with their first version:

Request
POST /api/worlds/:worldAddress/events
Request Body
{
  "name": "race_result",
  "description": "Resolves a race and updates horse statistics",
  "firstVersion": {
    "inputSchema": { "raceId": "string" },
    "readEntities": [{ "schema": "horse" }, { "schema": "jockey" }],
    "stateChangeSchema": { "horse": "partial" },
    "outputSchema": { "winner": "string", "time": "string" },
    "mutationSettings": {
      "mode": "ai",
      "behaviorPrompt": "Determine the race winner based on horse stats."
    },
    "executionSettings": { "visibility": "admin" }
  }
}

Event Versioning

Events have versions that become immutable once locked. This ensures reproducibility and auditability of state changes over time.

Why Versioning?

Event behavior may need to change over time:

  • Fix bugs in behavior prompts
  • Add new input fields
  • Adjust state change logic

Without versioning, changing an event would make historical executions impossible to reproduce.

Version Lifecycle

Version 1 (draft)

    ▼ [lock]
Version 1 (locked) ─── immutable, bound to deployments

    ▼ [create new version]
Version 2 (draft)

    ▼ [lock]
Version 2 (locked) ─── immutable, can be bound

Draft State

New versions start as drafts. You can modify:

  • inputSchema - What data the event accepts
  • readEntities - Which entity schemas to read
  • stateChangeSchema - How state changes (partial/full/append)
  • outputSchema - What gets returned publicly
  • mutationSettings - Execution mode and settings (AI prompt or direct template)

Locked State

Once locked via POST /api/worlds/:worldAddress/events/:eventName/event-versions/:version/lock:

  • Version becomes immutable
  • Can be bound to deployments
  • Cannot be deleted

Irreversible Action

Locking a version is permanent. Once locked, the version cannot be modified or deleted. Always test drafts thoroughly before locking.

Version Fields

FieldTypeDescription
inputSchemaobjectExpected input fields and types
readEntitiesobject[]Entity schema read config (schema, optional filter)
stateChangeSchemaobjectPer-schema change mode (partial, full, append)
outputSchemaobjectPublic output structure
mutationSettingsobjectExecution mode settings: ai (with behaviorPrompt) or direct (with template)
executionSettingsobjectVisibility: admin, public, or cron (+ price/cron config)

readEntities and Filter

The readEntities field in event versions specifies which entity instances to load from the world state. The filter array tells the system which specific entity instance IDs to load from the event input.

Filter Syntax

filter is an array of input paths that tells the system which specific entity instance IDs to load from the event input (e.g., ["raceId"] looks up eventInput.raceId to find the target instance).

Examples

1. Simple input key

Event input:

{ "raceId": "RACE_001" }

readEntities config:

[
  { "schemaId": 1, "filter": ["raceId"] }
]

Loads instances where instanceId === "RACE_001"


2. Nested input key

Event input:

{ "data": { "raceId": "RACE_001" } }

readEntities config:

[
  { "schemaId": 1, "filter": ["data.raceId"] }
]

Uses dot notation to access nested values


3. Multiple filters (OR logic)

Event input:

{ "horse1": "HORSE_1", "horse2": "HORSE_2" }

readEntities config:

[
  { "schemaId": 1, "filter": ["horse1", "horse2"] }
]

Loads both HORSE_1 and HORSE_2 instances


4. Schema-prefixed instance IDs

If your instance IDs are prefixed with schema name (e.g., "horse:HORSE_1"), use the schema name in the filter:

Event input:

{ "winnerId": "horse:HORSE_1" }

readEntities config:

[
  { "schemaId": 1, "filter": ["winnerId"] }
]

Matches instanceId === "horse:HORSE_1" exactly


5. Multiple schemas with different filters

Event input:

{ "raceId": "RACE_001", "betId": "BET_001" }

readEntities config:

[
  { "schemaId": 1, "filter": ["raceId"] },
  { "schemaId": 2, "filter": ["betId"] }
]

Schema 1 loads race instances, schema 2 loads bet instances


6. No filter (load all instances of schema)

[
  { "schemaId": 1 }
]

Loads all instances of schema 1 (useful for listing queries)

Mutation Settings

mutationSettings controls how entity states are mutated during execution.

State mutation always ends as runtime stateChanges, and the version stateChangeSchema map decides how those changes apply (partial, full, append).

Mode-Specific Requirements

  • When mutationSettings.mode is "ai": behaviorPrompt is required, template must not be provided
  • When mutationSettings.mode is "direct": template is required, behaviorPrompt must not be provided

AI Mutation (mode: "ai")

In AI mode, the engine builds execution context from:

  • world definition (name, description, domainTags, promptSeed)
  • event version config (inputSchema, readEntities, behaviorPrompt, outputSchema)
  • current deployment state (filtered by readEntities)
  • runtime input

The model returns structured stateChanges and result, then the system validates and applies the changes.

Use AI mode when mutation logic depends on interpretation, simulation, or generative reasoning.

Direct Mutation (mode: "direct")

In direct mode, execution does not rely on LLM reasoning for mutation. The system resolves a deterministic mutationSettings.template and produces stateChanges/result directly.

Use direct mode when:

  • updates should be deterministic and explainable from input/template
  • you want lower variance than prompt-based execution
  • event logic is mostly transform/map/set operations

Direct Mode Requirement

mutationSettings.template is required when mode is direct.

Example (direct mutation):

{
  "inputSchema": { "winner": "string" },
  "outputSchema": { "winner": "string" },
  "readEntities": [{ "schema": "horse" }],
  "stateChangeSchema": { "horse": "partial" },
  "mutationSettings": {
    "mode": "direct",
    "template": {
      "stateChanges": {
        "horse:{{input.winner}}": { "wins": { "$increment": 1 } }
      },
      "result": {
        "winner": "{{input.winner}}"
      }
    }
  },
  "executionSettings": { "visibility": "admin" }
}

Template Engine (Direct + Cron)

Direct mutation templates and cron input templates use the same resolver.

Template syntax:

  • {{input.someKey}} from execution input
  • {{walletAddress}} wallet executing public app route (if present)
  • {{timestamp}} execution timestamp (ISO string)
  • Cron context variables (available in cron-triggered events):
    • {{date}} — Current date in ISO format (e.g., "2024-01-15")
    • {{unix}} — Current Unix timestamp in seconds
    • {{counter}} — Auto-incrementing counter that persists between cron runs
    • {{cronState.key}} — Access custom state stored from previous executions

Type behavior:

  • If a value is exactly {{...}}, the resolver preserves primitive type when possible (for example number).
  • If {{...}} appears inside a larger string, it is interpolated as text.

Operator support (applied against current instance state):

  • $set: set value directly (replaces entire field)
  • $merge: merge object into existing object field (preserves existing keys)
  • $increment: increment number (defaults to +1 if value omitted/non-numeric)
  • $append: append item to array

Direct template shape:

{
  "horse:HORSE_1": {
    "wins": { "$increment": 1 },
    "lastWinnerWallet": { "$set": "{{walletAddress}}" },
    "raceLog": { "$append": "{{input.raceId}}" }
  }
}

Cron Context Example

When executing events via cron, you can use all the context variables to track state across scheduled executions:

{
  "executionSettings": {
    "visibility": "cron",
    "cron": {
      "schedule": "0 * * * *",
      "input": {
        "currentTime": "{{unix}}",
        "currentDate": "{{date}}",
        "runNumber": "{{counter}}",
        "previousRun": "{{cronState.counter}}"
      }
    },
    "mutations": {
      "cronTracker:DAILY": {
        "totalRuns": { "$set": "{{counter}}" },
        "lastRunTime": { "$set": "{{unix}}" },
        "previousCounter": { "$set": "{{cronState.counter}}" }
      }
    }
  }
}

The counter increments automatically on each cron execution and persists across runs. Use {{cronState.counter}} to access the previous counter value, or store custom state using any key name that will be available in cronState for the next execution.

Notes:

  • Top-level keys should be schemaName:instanceId.
  • Missing instances can be auto-created during direct execution.
  • Input is validated before resolution (size/depth limits and blocked patterns).

Execution Settings

executionSettings controls who can run an event version and when it can run.

Concurrent Execution Lock

Events use a mutex (mutual exclusion) lock to prevent race conditions when multiple executions target the same entity data. An event cannot execute concurrently if:

  1. Same event - The identical event is already executing on the deployment
  2. Shared entity schema - Any entity schema is both read by the new event and modified by a running event (via readEntities or stateChanges)
// Lock prevents concurrent execution if:
eventA.readEntities ∩ eventB.stateChanges ≠ ∅

This ensures data consistency when events modify overlapping entities. If a conflicting execution is in progress, the new execution request will fail.

Visibility Modes

ValueMeaning
adminAPI-key authenticated execution via /api/worlds/:worldAddress/deployments/:deploymentAddress/execute
publicWallet-signed execution via /app/deployments/:address/execute
cronScheduled execution by the background cron executor

Public Execution Details

When visibility is public:

  • deployment must be public
  • caller must provide wallet, signature, message
  • if priceUsd > 0, caller must provide a valid x402 X-Payment header

Example (public with price):

{
  "executionSettings": {
    "visibility": "public",
    "priceUsd": 0.5
  }
}

Caller executes via:

POST /app/deployments/:address/execute

Cron Execution Details

When visibility is cron:

  • set executionSettings.cron.schedule (valid cron expression)
  • optionally set executionSettings.cron.input template
  • executor runs periodically and triggers due events automatically

Example (cron visibility):

{
  "executionSettings": {
    "visibility": "cron",
    "cron": {
      "schedule": "*/5 * * * *",
      "input": {
        "tick": "{{counter}}",
        "timestamp": "{{timestamp}}"
      }
    }
  }
}

Cron input template example:

{
  "executionSettings": {
    "visibility": "cron",
    "cron": {
      "schedule": "0 * * * *",
      "input": {
        "tick": "{{counter}}",
        "runAt": "{{timestamp}}",
        "day": "{{date}}",
        "lastCounter": "{{cronState.counter}}"
      }
    }
  }
}

State Change Modes

The stateChangeSchema maps entity schema names to change modes:

State Change Schema
{
  "horse": "partial",
  "race_record": "append"
}
ModeBehavior
partialMerge changes into existing state (default)
fullReplace entire state
appendAppend to arrays, merge objects

Binding Versions to Deployments

Deployments bind to specific versions. Multiple deployments can use different versions:

Event: "race_result"
├── Version 1 (locked) ─── bound to "Production"
├── Version 2 (locked) ─── bound to "Staging"
└── Version 3 (draft) ─── not yet locked

Creating a New Version

Request
POST /api/worlds/:worldAddress/events/:eventName/event-versions
Request Body
{
  "inputSchema": { "raceId": "string", "weather": "string" },
  "readEntities": [{ "schema": "horse" }, { "schema": "jockey" }],
  "stateChangeSchema": { "horse": "partial" },
  "mutationSettings": {
    "mode": "ai",
    "behaviorPrompt": "Consider weather conditions when determining the winner."
  }
}

Version numbers auto-increment based on existing versions.

Best Practices

Version Management Tips

  1. Test drafts thoroughly before locking
  2. Document changes in version descriptions
  3. Use staging deployments to test new versions
  4. Never rely on modifying locked versions - create new ones instead

API Reference

  • POST /api/worlds/:worldAddress/events - Create event with first version
  • POST /api/worlds/:worldAddress/events/:eventName/event-versions - Create additional versions
  • POST /api/worlds/:worldAddress/events/:eventName/event-versions/:version/lock - Lock (make immutable)