ServicesNarrative Engine

Actors and Attributes

An actor is a character, item or location, usually stamped out of an actor template (the blueprint) and carrying typed attributes whose shape is declared by attribute templates. The two are independent registries: an actor template describes what an actor is, an attribute template describes what a number or string on an actor means.

Actor templates

snug narrative create-actor-template -u <universe-id> -n "Warrior" \
  -t character -d "A melee fighter" --tags "combat,npc"
snug narrative list-actor-templates -u <universe-id> \
  --publish-state published --tags combat -l 25
snug narrative update-actor-template -u <universe-id> -t <template-id> -n "Brawler"
snug narrative publish-actor-template -u <universe-id> -t <template-id>
snug narrative delete-actor-template -u <universe-id> -t <template-id>

Actor types are character, item and location; publish states are draft, published and archived. Templates start as draft.

Publishing freezes a template. Once published, edits to its content are rejected while the publish state stays changeable - you can archive a published template but not rename it:

snug narrative update-actor-template -u $U -t $T -n "Warrior-Renamed"
{ "status": 400, "error": "validation_error",
  "msg": "Validation error: Cannot modify published template: ZCbaDgynNyYZ" }

Publishing is a curation signal, not a gate: a draft template instantiates just as happily as a published one. Note there is no get-actor-template command in the CLI even though GET /universes/{universe_id}/actor-templates/{template_id} exists on the server - use list-actor-templates or call the endpoint directly.

Instantiating actors

instantiate-actor is sugar over create-actor with a template id. It copies the template's type, name and default properties, and records a snapshot of the template version it was built from:

snug narrative instantiate-actor -u <universe-id> -t <template-id> -n "Thorin"
{
  "actor_id": "HYqUBqMpEJrA",
  "template_id": "ZCbaDgynNyYZ",
  "template_snapshot": {
    "template_id": "ZCbaDgynNyYZ",
    "template_version": 1,
    "default_properties": {}
  },
  "actor_type": "character",
  "scope": "universe",
  "name": "Thorin",
  "attributes": [],
  "publish_state": "draft",
  "version": 1
}

The snapshot is taken once - editing the template later does not reach back into actors already built from it. Scope comes from the flags you pass: --session-id gives a session-scoped actor, --story-id a story-scoped one, neither leaves it at universe scope. See Scopes and promotion.

properties is a fixed struct, not free-form JSON

This is the easiest thing to get wrong. --properties on a template and on an actor both deserialize into a four-field struct - display_name, description, image_name, avatar_image_name - and anything else is silently dropped, with no error. Verified:

snug narrative create-actor-template -u $U -n "Warrior" -p '{"class":"fighter"}'
# -> "default_properties": {}
snug narrative create-actor-template -u $U -n "Mage" \
  -p '{"display_name":"Arch Mage","description":"caster"}'
# -> "default_properties": {"display_name": "Arch Mage", "description": "caster"}

Arbitrary per-actor data belongs in metadata or in typed attributes.

Attribute templates

An attribute template names a value and declares its type, optionally with numeric bounds:

snug narrative create-attribute-template -u <universe-id> -n "health" \
  -t number -d "Hit points" --min-value 0 --max-value 100
snug narrative list-attribute-templates -u <universe-id> -t number
snug narrative get-attribute-template -u <universe-id> -t <template-id>
snug narrative update-attribute-template -u <universe-id> -t <template-id> -n "hp"
snug narrative delete-attribute-template -u <universe-id> -t <template-id>

Attribute types are number, string and boolean. These declarations are descriptive, not enforced. The server stores attribute_type, min_value and max_value and never checks a value against them. All three of these were accepted live against a number template bounded to 0-100: 9999 (above max_value, stored as 9999.0); {"type":"string","value":"plenty"} (wrong type); and a template_id that does not exist at all, stored with the raw id as the attribute's name. The attribute_type_mismatch and attribute_constraint_violation codes exist in the error enum but are never raised here - validate in your own code.

Attaching and updating attributes

Attributes are attached when the actor is created, via an attributes array of {template_id, value} pairs. The CLI's create-actor has no flag for this, so the first write is HTTP-only:

curl -X POST -H "Authorization: Bearer $SNUG_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"Aria","actor_type":"character","scope":"universe","tags":["hero"],
       "attributes":[{"template_id":"eawYDFDWVqpQ",
                      "value":{"type":"number","value":75}}]}' \
  http://localhost:4000/api/v1/narrative/universes/$UNIVERSE_ID/actors

The stored attribute picks up the template's name and gets its own attribute_id and version counter:

{
  "attribute_id": "CQVaFZMCzhfN",
  "template_id": "eawYDFDWVqpQ",
  "name": "health",
  "value": { "type": "number", "value": 75.0 },
  "version": 1,
  "updated_at": "2026-08-29T12:47:14.087616Z"
}

Afterwards the CLI updates it in place, addressed by attribute template id:

snug narrative update-actor-attribute -u <universe-id> -a <actor-id> \
  --attribute-id <attribute-template-id> --value-number 42

This only updates an attribute the actor already has. Pointing it at a valid attribute template that was never attached returns 404 attribute_not_found

  • the same error as a nonexistent template, so the two are hard to tell apart:
{ "status": 404, "msg": "Attribute not found: eawYDFDWVqpQ",
  "error": "attribute_not_found" }

Listing and querying

list-actors filters indexed columns; query-actors adds attribute predicates and is the one for anything numeric:

snug narrative list-actors -u <universe-id> -t character -s universe --tag hero
snug narrative query-actors -u <universe-id> --tags hero
snug narrative query-actors -u <universe-id> -s session
snug narrative query-actors -u <universe-id> \
  --filters '[{"path":"attributes.health","operator":"gte","value":50}]'

Operators: eq, ne, gt, gte, lt, lte, contains, starts_with, ends_with.

A filter path only ever resolves against typed attributes, matched by attribute template id or by attribute name, with an optional attributes. prefix - so attributes.health and health are the same filter. There is no way to filter on an actor's own columns: a filter on name matches an attribute called name and quietly returns nothing.

snug narrative query-actors -u $U --filters '[{"path":"name","operator":"contains","value":"Aria"}]'
# -> 0 results, even though an actor named docs4-Aria exists

Search actor names with the q parameter on GET .../actors instead. Results are capped at NARRATIVE_MAX_QUERY_RESULTS (500 by default), which is also the page_size that query-actors reports back.

Reference

On this page