Skip to content

Document schemas

Version 0.1.0 — 2026-08-27

This is the authoritative field-level reference for Tarinoi's document types: the shape of every payload as it arrives from the public API or a Git remote.

Writing your own integration covers behaviour — traversal, expression dispatch, choice ordering, seen-card handling. This document covers shape. Read that one first; come here when you need to know exactly what a field is called and what may be in it.

These schemas are tighter than what we validate

Tarinoi validates the document envelope on write and leaves payload shape to application logic. The schemas below constrain payloads further than that — closed enumerations, value patterns, required fields — because those constraints hold in practice and a consumer is better off knowing them. Treat a violation as a bug worth reporting, not as content you must tolerate.

They are hand-maintained against the internal type definitions rather than generated from them, so they can lag a release. The changelog is where format changes are announced.

All schemas are JSON Schema 2020-12. Definitions shared between types live in §9 and are referenced as common.schema.json#/$defs/….


1. The envelope

Every document, by either transport, is a TDocument: a metadata envelope wrapping a payload. Everything type-specific is in payload.

json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://tarinoi.com/schemas/public/document.schema.json",
  "title": "Tarinoi document envelope",
  "type": "object",
  "required": ["document_id", "collection_id", "document_type", "payload"],
  "properties": {
    "tenant_id": {
      "type": "string",
      "description": "Workspace that owns the document. Constant across a project; you will not normally need it."
    },
    "group_id": {
      "type": "string",
      "description": "Group within the workspace."
    },
    "project_id": {
      "type": "string",
      "description": "Project the document belongs to."
    },
    "namespace": {
      "type": "string",
      "default": "documents",
      "description": "Storage namespace. Always \"documents\" for narrative content."
    },
    "layer_id": {
      "type": "string",
      "description": "Which layer this version of the document belongs to. Two layers matter to a consumer: the committed layer, and the uncommitted author buffer on top of it. See Writing Your Own, §2."
    },
    "document_id": {
      "type": "string",
      "description": "Stable, globally unique id. This is what a connection string, a card link and a collection membership all name. Never reused, never changed by a rename."
    },
    "identifier": {
      "type": ["string", "null"],
      "maxLength": 30,
      "description": "Human-authored machine name, unique within its kind. This is the name that appears in reference tokens: an entity's identifier is what a card's entity_ref names, a template's is what template_ref names, a variable's is the leaf of Var.group.name. An avatar wrapper's identifier is the {entityIdentifier}--{variant} link its entity holds. Null on document types addressed only by document_id — cards above all, which is why a connection string and a card link both name an id. Capped at 30 characters — Tarinoi rejects longer ones outright rather than truncating."
    },
    "collection_id": {
      "type": "string",
      "description": "document_id of the collection holding this document. On a collection manifest this names the PARENT collection, not itself — see §2."
    },
    "document_type": {
      "$ref": "common.schema.json#/$defs/documentType"
    },
    "created_at": {
      "type": ["string", "null"],
      "format": "date-time"
    },
    "created_by": {
      "type": ["string", "null"],
      "description": "Opaque user id, or \"__tarinoi__\" for content that came from a built-in project template."
    },
    "last_touched_at": {
      "type": ["string", "null"],
      "format": "date-time"
    },
    "last_touched_by": {
      "type": ["string", "null"]
    },
    "is_active": {
      "$ref": "common.schema.json#/$defs/flag",
      "description": "Derived: true when the document is neither tombstoned, archived nor moved. The one flag most consumers need."
    },
    "is_tombstone": {
      "$ref": "common.schema.json#/$defs/flag",
      "description": "The document was deleted. Tombstones are server-minted and appear in a project's history and git commits by design."
    },
    "is_archived": { "$ref": "common.schema.json#/$defs/flag" },
    "is_moved": {
      "$ref": "common.schema.json#/$defs/flag",
      "description": "This record is a move marker left behind in the old collection, not the document itself."
    },
    "moved_from": {
      "type": ["string", "null"],
      "description": "Previous collection_id, on a document that has moved."
    },
    "moved_to": {
      "type": ["string", "null"],
      "description": "Destination collection_id, on a move marker."
    },
    "origin_tag": {
      "type": ["string", "null"],
      "description": "Which client wrote this version. \"__server__\" for server-originated writes."
    },
    "document_digest": {
      "type": "string",
      "description": "Content hash over collection_id, the archived/moved flags, data_version and payload. Two documents with the same digest have the same content."
    },
    "data_version": {
      "type": ["string", "null"],
      "description": "Semantic version of the document data format, NOT of the document's content. Check it at load and refuse a major version you do not know. Currently \"1.0.0\"."
    },
    "payload": {
      "type": ["object", "null"],
      "description": "The document itself. Which schema applies is determined by document_type — see §2."
    }
  }
}

Booleans are not always booleans

is_active and its siblings are booleans over the API and integers 0/1 in some storage contexts. Coerce, do not compare identically.

Fields beginning with _ (_update_key, _local_origin_tag, _skip_index) are client-local and never leave a client. Fields beginning with $ inside a payload are hydration artefacts — resolved pointers the editor builds at runtime — and are stripped before storage. If you ever see one, ignore it; it is not part of the data contract.


2. Document types and their payloads

document_typePayload schemaWhere it lives
project-manifest§3.1Project root
collection-manifest§3.2collection.json in every collection directory
layer-manifest§3.3Project root
card§5.2boards/
card-template§5.1templates/
entity§6.2entities/
entity-template§6.1templates/
avatar-wrapper§6.3avatars/
variable-declaration§7.1variables/
function-declaration§7.2functions/
list-spec§7.3lists/
documentGeneric fallback; no narrative meaning

A collection manifest's collection_id is its parent

collection.json is an ordinary document envelope whose document_id is the collection's own id — the id that every member's collection_id names. Its own collection_id field names the collection it sits inside. Populating a collections table from collection_id rather than document_id produces a tree that looks plausible and is off by one level.

Every payload extends a small common base:

json
{
  "$id": "https://tarinoi.com/schemas/public/payload-base.schema.json",
  "title": "Common payload fields",
  "type": "object",
  "properties": {
    "document_version": {
      "type": "integer",
      "minimum": 0,
      "description": "Incremented on every author edit. A monotonic edit counter, unrelated to data_version."
    },
    "label": {
      "type": "string",
      "description": "Author-facing display name. Free text, not unique, and NOT a reference key — use identifier for that."
    },
    "icon": { "type": "string", "description": "Icon name for the authoring UI." },
    "color": {
      "type": "string",
      "description": "Palette token (\"amber.70\") or a literal hex. Presentation only; expresses no semantics."
    },
    "tags": { "type": "array", "items": { "type": "string" } },
    "description": {
      "type": "string",
      "description": "Author's note about this document. On an imported variable, this is where the original external name is kept."
    },
    "_errors": {
      "type": "array",
      "description": "Transient validation errors surfaced in the editor. Ignore.",
      "items": {
        "type": "object",
        "properties": {
          "property": { "type": "string" },
          "message": { "type": "string" }
        }
      }
    }
  }
}

3. Structural documents

3.1 Project manifest

json
{
  "$id": "https://tarinoi.com/schemas/public/project-manifest.schema.json",
  "title": "Project manifest payload",
  "allOf": [{ "$ref": "payload-base.schema.json" }],
  "type": "object",
  "required": ["label", "description"],
  "properties": {
    "label": { "type": "string", "description": "Project name." },
    "description": { "type": "string" },
    "project_status": {
      "type": "string",
      "description": "Set while a project is being cloned from a remote; absent on a settled project."
    }
  }
}

3.2 Collection manifest

json
{
  "$id": "https://tarinoi.com/schemas/public/collection-manifest.schema.json",
  "title": "Collection manifest payload",
  "allOf": [{ "$ref": "payload-base.schema.json" }],
  "type": "object",
  "required": ["collection_type", "member_types"],
  "properties": {
    "collection_type": {
      "enum": [
        "root-collection",
        "folder",
        "board",
        "entity-collection",
        "variable-collection",
        "function-collection",
        "list-collection",
        "card-template-collection",
        "entity-template-collection",
        "collection"
      ],
      "description": "What this collection holds. \"board\" is a dialogue graph; \"folder\" is a project-tree node. Only folders nest — a board is always a leaf of the project tree."
    },
    "member_types": {
      "type": "array",
      "items": { "$ref": "common.schema.json#/$defs/documentType" },
      "description": "document_type values this collection accepts."
    },
    "name_required": {
      "type": "boolean",
      "description": "Members must carry a unique identifier."
    },
    "member_order": {
      "type": "array",
      "items": { "type": "string" },
      "description": "document_ids in author-chosen order. Meaningful only when sort_policy is \"fixed\". Need not be exhaustive — unlisted members follow, in natural order."
    },
    "sort_policy": {
      "enum": ["none", "fixed", "sorted"],
      "description": "\"none\": natural order. \"fixed\": member_order, then the rest. \"sorted\": by sort_by."
    },
    "sort_by": {
      "type": "array",
      "items": {
        "type": "object",
        "additionalProperties": { "enum": ["asc", "desc"] }
      },
      "description": "Ordered sort keys, applied when sort_policy is \"sorted\"."
    },
    "template_opts": {
      "type": "object",
      "description": "Default template for new members, used by the authoring UI.",
      "properties": {
        "template_ref": { "type": "string", "description": "identifier of a template document." },
        "base_ref": { "$ref": "common.schema.json#/$defs/cardBaseRef" }
      }
    },
    "word_counts": { "$ref": "common.schema.json#/$defs/wordCounts" }
  }
}

Collection order is not document order

member_order is authoring intent for the project tree and lists. It is not how a dialogue branches. Choice order comes from card geometry — see Writing Your Own, §7.

3.3 Layer manifest

json
{
  "$id": "https://tarinoi.com/schemas/public/layer-manifest.schema.json",
  "title": "Layer manifest payload",
  "allOf": [{ "$ref": "payload-base.schema.json" }],
  "type": "object",
  "required": ["layer_type", "parent_layer_id"],
  "properties": {
    "layer_type": {
      "enum": ["isolated", "transparent"],
      "description": "\"transparent\": documents not present in this layer fall through to the parent. This is what the author buffer is."
    },
    "parent_layer_id": { "type": "string" },
    "merge_info": {
      "type": "object",
      "properties": {
        "last_synced_at": { "type": "number" }
      }
    }
  }
}

A project has exactly two persistent layers: the committed main layer and the uncommitted author buffer. Merge them on read as described in Writing Your Own, §2.


4. The templating system

This is the part of the model that most often surprises. It is worth reading before §5 and §6, because both card and entity documents are built on it.

4.1 The idea

A template is a schema an author writes inside their project. An instance — a card or an entity — is a document created from one. Templates are documents like any other, in card-template-collection and entity-template-collection collections, so a consumer reads them the same way it reads content.

Three fields carry the relationship:

  • template.identifier — the template's machine name.
  • instance.template_ref — the same string, on the instance. This is the join.
  • instance.data — the instance's values, keyed by property name.

4.2 props on a template vs. props on an instance

Both a template and its instances have a props array, and they are not the same thing.

On a template, props is the declaration. Each entry is a TTemplatePropDef: a name, a data type, an optional reference sub-type, a label, constraints, a default, UI hints. This is the full description of one field.

On an instance, props is a stripped-down echo — a TDocPropDef, carrying only name, data_type, and nested props for arrays and dictionaries. It exists so that a consumer can read a card without loading its template: it gives the property order, and it gives enough type information to interpret a value. Everything else — labels, constraints, sub-types, defaults — lives only on the template.

So:

template.props[i] = { name, data_type, sub_type?, label?, required, default_value?, … }

                              │  minting an instance copies name + data_type (recursively)

instance.props[i] = { name, data_type, props? }

template.props[i].default_value

                              │  … and seeds a value under the same name

instance.data[name]  = <the value>

data is a flat object keyed by property name. A property declared line on the template is instance.data.line, whatever its label says.

props is an ordering hint, not an inventory

instance.props may not declare every key in instance.data, and may declare keys that are no longer in data. A template edited after a card was authored produces exactly this. Iterate data for values; consult props for order and type. Never treat props as the complete list of what a card holds.

4.3 Property order matters

The order of instance.props is the order the author put the fields in, and it is significant in one place: side-effect expressions in data are evaluated in props order. Properties present in data but absent from props are evaluated last. See Writing Your Own, §5.4.

4.4 Drift between a template and its instances

A template can be edited after instances exist. Three fields track how far apart they have drifted, and all three appear on both the template and its instances:

FieldOn a templateOn an instance
template_versionCurrent version. Incremented on every material edit.The version this instance was minted from or last reconciled with.
template_compat_lastThe most recent version whose change was not safe for existing instances.Copied at mint.
template_compat_hashFingerprint of the parts of the template instances depend on: property names, data types, the constraints that can invalidate stored data, and pin names.The hash the instance agrees with.

The comparison a consumer can make:

  • instance.template_compat_hash == template.template_compat_hash — the instance is current. Nothing to think about.
  • Hashes differ, and instance.template_version >= template.template_compat_last — the template changed, but only in ways that cannot invalidate stored data: a new optional property, a relaxed constraint, a changed label. The instance's data is still valid.
  • Hashes differ, and instance.template_version < template.template_compat_last — a breaking change landed after this instance was minted. A property was removed, a data type changed, a pin was renamed, or a constraint tightened. data may contain values that no longer fit the declaration, and keys the template no longer declares.

The third case is not an error and does not mean the content is broken — Tarinoi keeps the values and lets an author reconcile them. But an integration that generates typed bindings from templates should treat it as the case where a card may carry a field its template does not describe. Reading data rather than the declaration is what makes this safe.

4.5 Bases

Above templates sit card bases and the entity base — built-in shapes that are never stored as documents. base_ref names one. A base fixes what a card fundamentally is: a jump has one card-link property and no author-defined ones; a line has its text and its speaker; an annotation has no pins and is never presented. A template chooses a base and adds properties to it.

A consumer needs base_ref for traversal and can ignore bases otherwise: everything a base contributes is already materialised onto the documents.


5. Cards and card templates

5.1 Card template

json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://tarinoi.com/schemas/public/card-template.schema.json",
  "title": "Card template payload",
  "allOf": [{ "$ref": "payload-base.schema.json" }],
  "type": "object",
  "required": [
    "template_type", "base_ref", "card_type", "props",
    "template_version", "template_compat_last", "template_compat_hash"
  ],
  "properties": {
    "template_type": { "const": "card_template" },
    "base_ref": { "$ref": "common.schema.json#/$defs/cardBaseRef" },
    "card_type": { "$ref": "common.schema.json#/$defs/cardType" },
    "structural": {
      "type": "boolean",
      "description": "The card controls flow rather than presenting content (start, jump). Structural templates are not offered to authors as content types."
    },
    "template_version": { "type": "integer", "minimum": 1 },
    "template_compat_last": {
      "type": "integer",
      "minimum": 1,
      "description": "Most recent version whose change could invalidate existing instance data. See §4.4."
    },
    "template_compat_hash": {
      "type": "string",
      "description": "Fingerprint of names, data types, risky constraints and pin names. See §4.4."
    },
    "props": {
      "type": "array",
      "items": { "$ref": "common.schema.json#/$defs/templatePropDef" },
      "description": "The property declarations. Order is authoring order and is preserved onto instances."
    },
    "label_template": {
      "type": "string",
      "description": "Mustache template producing a card's display label from its content, e.g. \"{{ payload.data.line }}\". Authoring UI only."
    },
    "geo": {
      "$ref": "common.schema.json#/$defs/geometry",
      "description": "Default size for new cards of this type. Position is meaningless here."
    },
    "display_slots": {
      "type": "object",
      "description": "Which properties are painted on the card face, by slot. Presentation intent for the authoring canvas; a game should render from data, not from slots.",
      "additionalProperties": {
        "type": "object",
        "required": ["fields"],
        "properties": {
          "label": { "type": "string" },
          "max_fields": { "type": "integer", "minimum": 0 },
          "fields": {
            "type": "array",
            "items": { "type": "string" },
            "description": "Property names, in paint order."
          }
        }
      }
    },
    "input_pin": { "$ref": "common.schema.json#/$defs/pin" },
    "output_pins": {
      "type": "array",
      "items": { "$ref": "common.schema.json#/$defs/pin" },
      "description": "Fixed by the template: every instance has exactly these pins, by name."
    },
    "output_selector": {
      "type": "object",
      "description": "When present, instances carry an output_selector expression that chooses an output pin by name. See Writing Your Own, §5.5.",
      "properties": {
        "selector_mask": {
          "type": "string",
          "description": "Constrains which functions may be selected."
        },
        "ref_effect": { "$ref": "common.schema.json#/$defs/functionEffect" }
      }
    },
    "entity_ref": {
      "type": ["string", "null"],
      "description": "Default speaker for cards of this type: the identifier of an entity."
    },
    "line_mode": {
      "enum": ["pc", "npc", "inherit"],
      "description": "Overrides whether a line reads as the player's or an NPC's. Defaults to inherit, which follows the speaking entity's is_player_character."
    },
    "opacity": {
      "type": "number", "minimum": 0, "maximum": 1,
      "description": "Canvas rendering hint. Annotations are translucent."
    },
    "omit_title": {
      "type": "boolean",
      "description": "The card's title strip collapses to zero height and is not painted."
    }
  }
}

5.2 Card

json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://tarinoi.com/schemas/public/card.schema.json",
  "title": "Card payload",
  "allOf": [{ "$ref": "payload-base.schema.json" }],
  "type": "object",
  "required": ["base_ref", "card_type", "geo", "data"],
  "properties": {
    "template_ref": {
      "type": "string",
      "maxLength": 30,
      "description": "identifier of the card template this card was minted from. Absent on a card minted directly from a base."
    },
    "template_version": { "type": "integer", "minimum": 1 },
    "template_compat_last": { "type": "integer", "minimum": 1 },
    "template_compat_hash": { "type": "string" },
    "base_ref": { "$ref": "common.schema.json#/$defs/cardBaseRef" },
    "card_type": { "$ref": "common.schema.json#/$defs/cardType" },
    "structural": { "type": "boolean" },
    "props": {
      "type": "array",
      "items": { "$ref": "common.schema.json#/$defs/docPropDef" },
      "description": "Ordering and type hint for `data`. NOT an inventory — see §4.2."
    },
    "data": {
      "type": "object",
      "description": "Property values, keyed by property name. A string value matching the Fn.* call pattern is a side-effect expression to evaluate when the card is committed to; see Writing Your Own, §5.4.",
      "additionalProperties": true
    },
    "geo": {
      "$ref": "common.schema.json#/$defs/geometry",
      "description": "Position on the authoring canvas. `y` determines the order choices are presented in — see Writing Your Own, §7."
    },
    "entity_ref": {
      "type": ["string", "null"],
      "maxLength": 30,
      "description": "identifier of the speaking entity. Present on line cards."
    },
    "line_mode": { "enum": ["pc", "npc", "inherit"] },
    "shown_once": {
      "type": "boolean",
      "description": "Once the player has seen this card, it is no longer a valid continuation. See Writing Your Own, §9."
    },
    "opacity": { "type": "number", "minimum": 0, "maximum": 1 },
    "input_pin": {
      "$ref": "common.schema.json#/$defs/pin",
      "description": "Condition required to enter this card. Exactly one, or none."
    },
    "output_pins": {
      "type": "array",
      "items": { "$ref": "common.schema.json#/$defs/pin" },
      "description": "Named exits. Fixed by the template; a card cannot add or remove one."
    },
    "output_selector": {
      "type": ["string", "null"],
      "description": "Serialised function call returning the name of the output pin to follow. Present only when the template declares one."
    },
    "connections": {
      "type": "array",
      "description": "Outgoing wires.",
      "items": {
        "type": "string",
        "pattern": "^[^>]+>>[^>]+$",
        "description": "\"{outputPinName}>>{targetDocumentId}\". The reserved target \"flow:end\" terminates the flow rather than advancing — it marks a deliberate terminus, not a dangling exit."
      }
    },
    "word_counts": { "$ref": "common.schema.json#/$defs/wordCounts" }
  }
}

Card base types, the values of base_ref:

base_refcard_typestructuralMeaning
startnodeyesBoard entry point. A board may have several. Find them by base_ref == "start".
linenodeA line of dialogue. Speaker in entity_ref, text in data.line.
blanknodeGeneric content card. Everything is in data. Used as a hub, and as the flow-side half of a linked sub-flow.
medianodeAn authoring reference image. data.media_link holds a clio:// URL. Not game content.
jumpnodeyesTransfers flow unconditionally. Destination in data.target — see below.
annotationannotationAuthor's note. No output pins. Never presented.
backdropbackdropyesVisual grouping device on the canvas. No output pins.

Cards with neither input_pin nor output_pins are authoring artefacts. Skip them in traversal.

A jump's destination is a card-link property

The jump base declares exactly one property — target, of sub-type card-link — and data.target holds the destination card's document_id, plain, with no collection component. Resolve it as you would any document id.

A card-link is not exclusive to jumps: any template may declare a card-link property, and an imported sub-flow uses one (named subflow) to point a container card at its board's start card. Only a jump card follows its link as flow; on any other base a card link is a cross-reference the author navigates, not an edge your traversal takes. Look at base_ref, not at the presence of a link, to decide whether to follow it.


6. Entities, entity templates and avatars

6.1 Entity template

json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://tarinoi.com/schemas/public/entity-template.schema.json",
  "title": "Entity template payload",
  "allOf": [{ "$ref": "payload-base.schema.json" }],
  "type": "object",
  "required": [
    "template_type", "base_ref", "props",
    "template_version", "template_compat_last", "template_compat_hash"
  ],
  "properties": {
    "template_type": { "const": "entity_template" },
    "base_ref": { "const": "entity" },
    "template_version": { "type": "integer", "minimum": 1 },
    "template_compat_last": { "type": "integer", "minimum": 1 },
    "template_compat_hash": { "type": "string" },
    "props": {
      "type": "array",
      "items": { "$ref": "common.schema.json#/$defs/templatePropDef" }
    },
    "dialog_capable": {
      "type": "boolean",
      "description": "Entities of this type can speak. A non-speaking entity is a thing, a faction, a location — data, not a voice."
    },
    "has_avatar": {
      "type": "boolean",
      "description": "Entities of this type carry avatar images."
    },
    "vo_capable": { "type": "boolean", "description": "Reserved for voice-over support." },
    "is_voice_actor": { "type": "boolean", "description": "Reserved for voice-over support." },
    "card_type_hint": {
      "type": "string",
      "description": "identifier of the card template new lines by this entity default to. Authoring convenience."
    }
  }
}

6.2 Entity

json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://tarinoi.com/schemas/public/entity.schema.json",
  "title": "Entity payload",
  "allOf": [{ "$ref": "payload-base.schema.json" }],
  "type": "object",
  "required": ["data"],
  "properties": {
    "template_ref": { "type": "string", "maxLength": 30 },
    "template_version": { "type": "integer", "minimum": 1 },
    "template_compat_last": { "type": "integer", "minimum": 1 },
    "template_compat_hash": { "type": "string" },
    "props": {
      "type": "array",
      "items": { "$ref": "common.schema.json#/$defs/docPropDef" }
    },
    "data": { "type": "object", "additionalProperties": true },
    "dialog_capable": { "type": "boolean" },
    "vo_capable": { "type": "boolean" },
    "is_player_character": {
      "type": "boolean",
      "description": "Marks the player. At most one per project. Drives line rendering and playback."
    },
    "has_avatar": {
      "type": "boolean",
      "description": "Inherited from the template; not settable on an entity."
    },
    "is_voice_actor": {
      "type": "boolean",
      "description": "Inherited from the template; not settable on an entity."
    },
    "card_type_hint": {
      "type": "string",
      "description": "Inherited from the template, overridable per entity."
    },
    "avatar_refs": {
      "type": "array",
      "description": "Avatar variants belonging to this entity.",
      "items": {
        "type": "object",
        "required": ["variant", "link"],
        "properties": {
          "variant": {
            "type": "string",
            "description": "Variant name, unique within this entity. \"default\" always exists on an entity with avatars."
          },
          "link": {
            "type": "string",
            "pattern": "^.+--.+$",
            "description": "\"{entityIdentifier}--{variant}\". This is the identifier of the avatar-wrapper document holding the image. Empty string on an entity whose template allows avatars but which has none yet."
          },
          "version": { "type": "integer", "minimum": 1 }
        }
      }
    },
    "word_counts": { "$ref": "common.schema.json#/$defs/wordCounts" }
  }
}

An entity's identifier is what a card's entity_ref names. It is also the first segment of an Ent. reference token — see §8.

6.3 Avatar wrapper

json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://tarinoi.com/schemas/public/avatar-wrapper.schema.json",
  "title": "Avatar wrapper payload",
  "allOf": [{ "$ref": "payload-base.schema.json" }],
  "type": "object",
  "required": ["format", "w", "h", "data"],
  "properties": {
    "format": { "const": "webp" },
    "w": { "const": 320 },
    "h": { "const": 320 },
    "data": {
      "type": "string",
      "pattern": "^data:image/webp;base64,",
      "description": "The image, inline, as a data URL. An avatar is a document, not a file reference — there is nothing else to fetch."
    }
  }
}

The wrapper's identifier is the link value on the entity's avatar_refs entry: {entityIdentifier}--{variant}.

Avatar wrappers have no backing collection document

Avatars are projected flat, under avatars/, and are not members of a collection the way every other document type is. Code that enumerates content by walking collections will miss them.

Avatars are 320×320 authoring aids, not game-ready art. Which variant to show, and how, is the game's decision; Tarinoi expresses no display intent.


7. Logic documents

7.1 Variable declaration

json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://tarinoi.com/schemas/public/variable-declaration.schema.json",
  "title": "Variable declaration payload",
  "allOf": [{ "$ref": "payload-base.schema.json" }],
  "type": "object",
  "required": ["data_type"],
  "properties": {
    "data_type": { "enum": ["string", "number", "boolean"] },
    "default_value": {
      "type": ["string", "number", "boolean"],
      "description": "A suggestion, not an initial state. Scope, storage and lifecycle belong entirely to the game."
    },
    "selector_mask": {
      "type": "string",
      "description": "Constrains where this variable may be selected. Authoring UI only."
    }
  }
}

The variable's name is the envelope's identifier, and its collection's identifier is the group — together they form Var.{group}.{identifier}.

Tarinoi does not store variable values

A variable declaration says a variable exists and what type it is. Nothing in Tarinoi holds its runtime value. default_value is what the author suggests it should start at; persistence, save files and scope are the game's business.

7.2 Function declaration

json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://tarinoi.com/schemas/public/function-declaration.schema.json",
  "title": "Function declaration payload",
  "allOf": [{ "$ref": "payload-base.schema.json" }],
  "type": "object",
  "required": ["function_args", "function_returns", "function_effect"],
  "properties": {
    "function_returns": {
      "enum": ["string", "number", "boolean", "void", "any"],
      "description": "A function used as a condition must return boolean. A function used as an output_selector must return string."
    },
    "function_effect": {
      "enum": ["pure", "side-effect", "mutation"],
      "description": "\"pure\": safe to call speculatively and to cache. \"side-effect\" and \"mutation\": change game state, so call in declaration order and never speculatively."
    },
    "function_args": {
      "type": "array",
      "description": "Parameters, in call order.",
      "items": {
        "type": "object",
        "required": ["arg_name", "data_type"],
        "properties": {
          "arg_name": { "type": "string" },
          "data_type": { "enum": ["string", "number", "boolean", "object"] },
          "sub_type": {
            "enum": [
              "literal",
              "entity-reference",
              "deep-entity-reference",
              "list-reference",
              "variable-reference",
              "context-card"
            ],
            "description": "How the argument is passed. A variable-reference argument receives the REFERENCE, not the value, so an implementation can write as well as read. A context-card argument receives the calling card's whole payload."
          },
          "allow_literal": {
            "type": "boolean",
            "description": "The author may supply a literal instead of a reference. Applies only to reference sub-types."
          },
          "selector_mask": { "type": "string" },
          "default_value": { "type": ["string", "number", "boolean"] }
        }
      }
    }
  }
}

The function's name is the envelope's identifier and its collection's identifier is the group: Fn.{group}.{identifier}( … ).

Tarinoi ships no function library

A function declaration is a contract, not an implementation. Your game implements every one of them, bound by collection identifier. A declaration Tarinoi's built-in project templates provide — CheckFlag, SetFlag, the counter comparators — is still yours to implement; only its signature is given.

7.3 List specification

json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://tarinoi.com/schemas/public/list-spec.schema.json",
  "title": "List specification payload",
  "allOf": [{ "$ref": "payload-base.schema.json" }],
  "type": "object",
  "required": ["data_type", "list_options"],
  "properties": {
    "data_type": {
      "enum": ["string", "number", "boolean"],
      "description": "Type of `value` in each option."
    },
    "list_options": {
      "type": "array",
      "items": {
        "type": "object",
        "required": ["key", "value"],
        "properties": {
          "key": {
            "type": "string",
            "description": "Machine identifier. This is what a list-reference token names, and what you generate constants from."
          },
          "label": {
            "type": "string",
            "description": "Display text. Authoring only. Never match on this."
          },
          "value": {
            "type": ["string", "number", "boolean"],
            "description": "The value the option carries."
          }
        }
      }
    }
  }
}

Resolve list options by key, never by label or position

A list-reference holds an option's key. Matching on label breaks the moment an author edits the display text; matching on array position breaks the moment they reorder the list. Both official plugins have shipped this bug, and it fails quietly — a plausible-looking zero rather than an error.


8. Reference token grammar

Wherever a Tarinoi document refers to something else by name, it does so with a dotted token. These appear inside expression strings and as the values of reference-typed properties.

TokenShapeNames
VariableVar.{group}.{identifier}A variable declaration, by its collection and its own identifier
Function callFn.{group}.{identifier}( … )A function declaration, with its arguments
List optionLs.{group}.{list}.{optionKey}One option of one list
EntityEnt.{templateRef}.{group}.{identifier}An entity — note the template segment, which the other tokens do not have
Context cardCard.CurrentContextCardThe card being evaluated. Pass its whole payload.
json
{
  "$id": "https://tarinoi.com/schemas/public/tokens.schema.json",
  "$defs": {
    "variableToken": {
      "type": "string",
      "pattern": "^Var\\.[^.]+\\.[^.]+$"
    },
    "listToken": {
      "type": "string",
      "pattern": "^Ls\\.[^.]+\\.[^.]+\\.[^.]+$"
    },
    "entityToken": {
      "type": "string",
      "pattern": "^Ent\\.[^.]+\\.[^.]+\\.[^.]+$"
    },
    "functionCall": {
      "type": "string",
      "pattern": "^Fn\\.[^.]+\\.[^.(]+\\("
    },
    "cardLinkValue": {
      "type": "string",
      "description": "A bare document_id. Card links do NOT use a dotted token."
    },
    "mediaLinkValue": {
      "type": "string",
      "pattern": "^clio://media/",
      "description": "clio://media/{tenantId}/{groupId}/{projectId}/{branch}/{mediaId}"
    }
  }
}

Two values in the model are deliberately not tokens. A card link (data.<prop> on a card-link property) is a bare document_id, and entity_ref on a card is a bare identifier.

The reason is that a dotted token names a thing by where it livesVar.{group}.{name} and Ent.{template}.{group}.{identifier} both carry the collection in the middle. That is fine for variables, functions and lists, which stay put. Cards and entities do not: moving a card to another board and reorganising entities between collections are ordinary authoring actions, and a token would go stale on every one of them. A document_id and an identifier survive the move, so the link keeps pointing at the same thing wherever the author puts it.

Conditions and output selectors are expression strings, not single tokens: a boolean algebra over function calls, combined with &&, ||, ! and parentheses. There are no comparison operators, no arithmetic and no string literals — every value enters through a call or a member lookup. An empty condition is unconditional. The grammar and the dispatch rules are in Writing Your Own, §5.


9. Shared definitions

json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://tarinoi.com/schemas/public/common.schema.json",
  "$defs": {
    "flag": {
      "description": "A semantic boolean. Boolean over the API, 0/1 in some storage contexts. Coerce before comparing.",
      "oneOf": [
        { "type": "boolean" },
        { "type": "integer", "enum": [0, 1] }
      ]
    },

    "documentType": {
      "enum": [
        "project-manifest",
        "collection-manifest",
        "layer-manifest",
        "card",
        "card-template",
        "entity",
        "entity-template",
        "avatar-wrapper",
        "variable-declaration",
        "function-declaration",
        "list-spec",
        "document"
      ]
    },

    "cardBaseRef": {
      "enum": ["start", "blank", "line", "media", "jump", "annotation", "backdrop"],
      "description": "Built-in card shape. Determines traversal behaviour; see §5.2."
    },

    "cardType": {
      "enum": ["node", "annotation", "backdrop"],
      "description": "Broad canvas role. Only \"node\" participates in flow."
    },

    "dataType": {
      "enum": ["string", "number", "boolean", "array", "dictionary", "void"],
      "description": "Tarinoi has one numeric type — there is no integer/float distinction. \"void\" is valid only on a property whose sub_type is function-call."
    },

    "subType": {
      "enum": [
        "list-reference",
        "variable-reference",
        "entity-reference",
        "media-link",
        "card-link",
        "function-call",
        "condition"
      ],
      "description": "Refines a property beyond its data_type, giving it its own editor and its own serialised value format. A property with no sub_type holds a plain value of its data_type. NOTE: function ARGUMENTS draw from a different, overlapping set — see function_args in §7.2. \"literal\", \"context-card\" and \"deep-entity-reference\" are argument sub-types only and never appear on a template property."
    },

    "functionEffect": {
      "enum": ["pure", "side-effect", "mutation"]
    },

    "geometry": {
      "type": "object",
      "required": ["x", "y", "w", "h"],
      "description": "Position and size on the authoring canvas, in canvas units. y determines choice presentation order.",
      "properties": {
        "x": { "type": "number" },
        "y": { "type": "number" },
        "w": { "type": "number", "exclusiveMinimum": 0 },
        "h": { "type": "number", "exclusiveMinimum": 0 },
        "z": { "type": "number", "description": "Stacking order. Presentation only." }
      }
    },

    "pin": {
      "type": "object",
      "required": ["name"],
      "description": "A named connection point. An input pin's condition gates ENTRY to the card; an output pin's condition gates that exit.",
      "properties": {
        "name": {
          "type": "string",
          "description": "Unique among the card's pins. \"default\" is the conventional single-exit name. This is the left-hand side of a connection string, and what an output_selector must return."
        },
        "color": { "type": "string" },
        "condition": {
          "type": "string",
          "description": "Serialised boolean expression. Empty string means unconditional."
        }
      }
    },

    "wordCounts": {
      "type": "object",
      "description": "Cached word count over the document's counted properties. Authoring metric; not game data.",
      "properties": {
        "raw": { "type": "integer", "minimum": 0 }
      }
    },

    "docPropDef": {
      "type": "object",
      "required": ["name", "data_type"],
      "description": "A property as an INSTANCE echoes it: order and type, nothing else. See §4.2.",
      "properties": {
        "name": { "type": "string" },
        "data_type": { "$ref": "#/$defs/dataType" },
        "index": {
          "type": "integer",
          "description": "Position within a dictionary-typed parent."
        },
        "props": {
          "type": "array",
          "items": { "$ref": "#/$defs/docPropDef" },
          "description": "Element or member declarations, for array and dictionary types."
        }
      }
    },

    "templatePropDef": {
      "type": "object",
      "required": ["name", "data_type", "required"],
      "description": "A property as a TEMPLATE declares it: the full description. See §4.2.",
      "properties": {
        "name": {
          "type": "string",
          "description": "The key under which the value appears in an instance's `data`."
        },
        "data_type": { "$ref": "#/$defs/dataType" },
        "sub_type": { "$ref": "#/$defs/subType" },
        "label": {
          "type": "string",
          "description": "Display name for authors. Never a key — bind against `name`."
        },
        "required": { "type": "boolean" },
        "is_base_prop": {
          "type": "boolean",
          "description": "Contributed by the card base rather than declared by the author (a jump's `target`, a line's `line`). Not author-removable."
        },
        "default_value": {
          "description": "Seeded into an instance's `data` at mint time. Type follows data_type."
        },
        "is_counted": {
          "type": "boolean",
          "description": "Included in word counts — i.e. the author considers this prose the player will read."
        },
        "is_whitespace_counted": { "type": "boolean" },
        "is_localized": {
          "type": "boolean",
          "description": "Marked for localisation. Reserved: Tarinoi's localisation support is in design, and a document currently holds one string per property."
        },
        "collection_ref": {
          "type": "string",
          "description": "For function-call, variable-reference and list-reference: the collection the referenced item lives in."
        },
        "ref_data_type": {
          "type": "string",
          "description": "For reference sub-types: the semantic type required of the referenced item — a variable's data_type, a list's data_type, or a function's return type."
        },
        "ref_effect": { "$ref": "#/$defs/functionEffect" },
        "selector_mask": {
          "type": "string",
          "description": "Constrains which group or item an author may select. Authoring UI only."
        },
        "min_value": { "type": "number" },
        "max_value": { "type": "number" },
        "min_length": { "type": "integer", "minimum": 0 },
        "max_length": { "type": "integer", "minimum": 0 },
        "form_control": {
          "enum": ["textarea", "text-field"],
          "description": "Editor hint for string properties."
        },
        "auto_expand": { "type": "boolean" },
        "default_rows": { "type": "integer", "minimum": 1 },
        "props": {
          "type": "array",
          "items": { "$ref": "#/$defs/templatePropDef" },
          "description": "Element or member declarations, for array and dictionary types."
        }
      }
    }
  }
}

min_value, max_value, min_length, max_length, required and selector_mask are the constraints that feed template_compat_hash: tightening any of them can invalidate values already stored on instances, which is what §4.4 is about.


10. What a schema cannot tell you

Three things about this data are not expressible in JSON Schema and matter more than most fields:

data is open by design. Its keys come from a template an author wrote, so no schema can enumerate them. Read props for order, read data for values, and never assume the two agree exactly.

Layering. Any document may exist twice — once committed, once in the author buffer — and the merge rules decide which one is real. A schema validates one record; it says nothing about which record you should have been looking at. See Writing Your Own, §2.

Flow is in strings. A card's outgoing edges are strings in connections, its gating logic is strings on pins, and its side effects are strings in data. A card can validate perfectly and point at a card that does not exist. Validate the graph, not just the documents.


Next