Overview#
A TERSL document is a tree. Containers give it its hierarchy, objects are the things inside containers, attributes describe both, and containers can also carry a block of text. This page describes each construct, the rules for names, and how sibling order is kept. After reading it you will be able to write a state file that a TERSL engine accepts and that reads naturally to a person.
Containers#
A line that starts with # is a container. The number of # characters is its depth, and a container holds other containers and objects.
# Chars
## Kell
### Inventory
### Mind
A dotted path in a header opens a deep container without restating every level above it. The two lines below put steel sword in Chars.Kell.Inventory, creating any container on the path that does not exist yet.
# Chars.Kell.Inventory
steel sword(good condition)
The # count is a navigation level. A dotted header consumes its level, so after ## D.E a following ## F is a sibling of D, and you write ### F to put F under E. A dotted segment refers to the container that already stands under that name, matched by its normalized identifier (see Names), and it never renames it. # Places.Grand-Hall re-enters a standing Grand Hall, because both normalize to Grand_Hall, and the display name stays Grand Hall. A difference in letter case does not match, so # Places.grand hall creates a second container.
The document itself starts inside an implicit root container. Lines above the first header are objects at root, # (schema: v3) sets attributes on root, and a # with nothing after it returns to root.
Objects#
A line under a container with no # is an object. It may carry attributes in parentheses.
# Chars
## Kell(level: 5; class: fighter)
wounded
current thought(focused on escape)
Object names are unique under their parent. An object has no children and no TEXT. If something has its own condition, history or contents, make it an object, or make it a container when it has children of its own.
Attributes#
Attributes go in parentheses after a name, separated by semicolons. Commas do not separate attributes, and an attribute list never spans more than one line. There are three kinds:
| Kind | Form | Use it for |
|---|---|---|
| Flag | creaky |
A human-readable descriptor with no value. |
| Identifier | hp: 25 |
A value a program or a [WHERE] filter will read. The colon must be followed by a space. |
| Raw | 0.95 or "a note" |
An unkeyed value for human context. Give it a key if a machine will need it. |
plate armor(creaky; enchanted)
health(hp: 25; max hp: 80)
confidence(0.95; verified)
An identifier's value is typed in this order: boolean, integer, float, date, and otherwise a bareword. A value is a string if and only if it was quoted, so count: "5" is the string 5 and count: 5 is the integer. A bareword such as class: fighter comes back unquoted and a quoted string comes back quoted. Dates are written YYYY-MM-DD, YYYY-MM-DDTHH:MM[:SS] or MM/DD/YYYY. A colon with no space after it is an ordinary character, so schedule(at 12:00) is one flag.
# Chars
## Kell(level: 5; class: fighter; active: true; joined: 2026-07-08; accuracy: 0.94; code: "5")
The atomic rule#
Each attribute is one property of the thing it is attached to. It is never a list of other things. When you find yourself writing a list inside the parentheses, the items are objects:
// Wrong: attributes as a shopping list of other things
# Chars
## Kell
### Inventory(sword; shield; armor)
// Right: inventory is a place; items are things with their own atoms
# Chars
## Kell
### Inventory(almost full)
steel sword(good condition; damage: +1)
plate armor(creaky)
wooden shield(standard)
almost full describes the inventory. The sword is not a property of the inventory; it is an object under it. The same reasoning puts intrinsic identity in the name: write steel sword, not item(type: sword; material: steel).
TEXT#
Only containers hold text. A quoted line on its own is the container's TEXT, and a block between """ lines holds several lines.
# Places
## Resort
"A mountain lodge above the tree line."
# Notes
## Session 3
"""
Subject improved on the second block.
Keep the same equipment next visit.
"""
Inside a """ block the only escape is \""", which stands for a literal """; every other character is kept as written. A container has at most one TEXT payload. Writing a second one without [APPEND] replaces the first and raises the warning TERSE045.
A path whose last segment is TEXT addresses the payload directly, from anywhere in a document:
# Places.TEXT "every place worth going"
# Places.TEXT "and then some" [APPEND]
# Places.TEXT [REMOVED]
The first line sets the payload, the second joins more text to it with a single newline, and the third removes it while leaving the container in place. ? Places.TEXT reads the payload back. Because of this address, exact-case TEXT cannot be used as a name; Text and text are ordinary names. Appending is described further on Writing with declarations.
References#
@ followed by a dotted path points at a container, object or identifier elsewhere in the state. A reference can be an object line of its own, part of a flag, or an identifier's value:
# Characters
## Kell
current location @Places.Resort
@Items.steel sword
door(east to @Resort.Yard)
room(exit: door to @Resort.Hall)
References are not allowed in container names or on the left of :. Inside a quoted string @ is an ordinary character, so write e-mail addresses as email: "ada@example.com". A path may use display names or their normalized form: @Places.New York and @Places.New_York point to the same node.
Queries do not follow a reference, and renaming, moving or removing its target does not rewrite it. A reference whose target is absent is dangling: it contributes no state and, when something resolves it, raises the warning TERSE043. Applying a document never fails because of a dangling reference, since the target may arrive later.
Names#
A name is natural language and may contain spaces. It may contain letters of any script, digits and underscores, plus three more characters: space, hyphen and apostrophe. Nothing else is accepted in a container name, an object name, an identifier key or a reference path segment.
| Name | Accepted | Note |
|---|---|---|
New York |
Yes | |
O'Brien |
Yes | |
high-quality widget |
Yes | |
42 thieves |
Yes | Its normalized form is _42_thieves. |
Кел |
Yes | Any script is allowed. |
Profit? |
No (TERSE028) |
Write Profit(uncertain). |
Bad$Name |
No (TERSE028) |
Write Bad Name(currency: "$"). |
a*b |
No (TERSE921) |
* is reserved; write ab(sym: "a*b"). |
Foo.Bar |
No (TERSE028) |
. separates path segments. |
Each name has a display name, which is what you wrote, and a normalized identifier (the specification calls it a handle) that programs and filters use. The identifier is derived by applying Unicode NFKC normalization, turning spaces and hyphens into underscores, dropping apostrophes, and prefixing an underscore if the name starts with a digit. New York becomes New_York and O'Brien becomes OBrien. Identifiers are case-sensitive, so Places and places are two different nodes. Two siblings whose display names differ but normalize to the same identifier are merged into one node that keeps the first display name, with the warning TERSE031.
The Iron Rule governs round-tripping: when state is stored and written back out, the only change permitted to names, flags and quoted text is whitespace normalization. For example, # Places (all cool) comes back as # Places(all cool), and every other character of a name survives as written. Typed identifier values are written back in their canonical form, so damage: +1 comes back as damage: 1.
Sibling order#
Sibling order is part of the state, and no engine sorts it. Each container keeps two ordered runs: one of objects and one of sub-containers. Under any container, the objects always come before the sub-containers, so a container's canonical layout is its header, then its TEXT, then its objects in order, then its sub-containers in order:
# Places
## Resort
"A mountain lodge above the tree line."
front desk(open)
ski rack
### Yard
### Hall
Because new lines follow the line above them within their run, a complete state file applied to an empty store reproduces itself exactly, order included. Attribute order inside the parentheses is also kept. Changing order in standing state takes an explicit placement directive, described on Writing with declarations.