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/actorsThe 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 42This 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 existsSearch 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
- Narrative API - request and response schemas
- Scopes and promotion - where an actor lives and how it moves
- Control grants - blocking other players from mutating an actor