State Tree
The state tree is the core data structure of SLOP. It is a rooted tree of nodes, where each node represents a semantic unit of application state.
Node schema
Section titled “Node schema”{ // REQUIRED "id": "msg-42", // Stable identifier, unique within the tree "type": "message", // Semantic type (see type taxonomy below)
// OPTIONAL "properties": { // Key-value pairs — the actual state "from": "alice@co.org", "subject": "Launch plan", "body": "Let's ship next week...", "unread": true, "timestamp": "2026-03-27T10:30:00Z" }, "children": [ ... ], // Ordered list of child nodes "affordances": [ ... ], // Actions available on this node (see affordances.md) "meta": { ... }, // Attention hints and tree metadata (see below) "content_ref": { ... } // Reference to large content (see extensions/content-references.md)}A string that uniquely identifies a node within the tree. Must be stable across patches — if a node’s id changes, it’s a different node. IDs are opaque to consumers; providers choose the format.
Requirements:
- Unique among siblings (no two children of the same parent share an ID)
- Stable across updates (the same logical entity keeps its ID)
- Must not change when properties change
- MUST NOT equal any reserved node-field keyword:
properties,children,affordances,meta,content_ref,id, ortype. These keywords have structural meaning in patch paths (see Messages), so an id colliding with one is unaddressable. - MUST NOT contain the characters
/(U+002F) or~(U+007E). Both are reserved in patch paths:/is the path segment delimiter and~is the JSON Pointer escape prefix (see Messages). Providers that generate IDs from external sources (URLs, file paths, external IDs) MUST escape, hash, or otherwise transform those characters before using the value as a SLOP id.
A string describing what kind of thing this node represents. Types are semantic, not structural — they describe meaning, not UI.
Core types (providers may define custom types):
| Type | Meaning | Example |
|---|---|---|
root | Top-level container | The app itself |
view | A screen or page the user is on | Inbox view, settings page |
collection | An ordered set of items | Message list, file browser |
item | A single entity in a collection | One email, one file |
document | A piece of content | Email body, code file, note |
form | An input area | Compose window, search bar |
field | A single input | To: field, subject line |
control | An interactive element | Button, toggle, dropdown |
status | A status indicator | Loading spinner, error banner |
notification | Something demanding attention | Alert, toast, badge |
media | Rich content | Image, video, chart |
group | Logical grouping | Sidebar section, tab group |
context | Ambient state | Current user, selected account, theme |
Custom types should use a namespace prefix: github:pull-request, vscode:editor-tab.
properties
Section titled “properties”A flat or shallowly nested key-value map. Properties hold the actual state payload of the node. Property keys are strings; values are JSON-serializable.
Properties are not prescribed by the protocol — each app defines what properties its nodes carry. However, some well-known property names have conventional meaning:
| Property | Type | Meaning |
|---|---|---|
label | string | Human-readable name or title |
description | string | Longer description |
value | any | Primary value (for fields, controls) |
selected | boolean | Whether this node is currently selected |
disabled | boolean | Whether this node is currently inactive |
visible | boolean | Whether this node is visible to the user (default: true) |
count | number | Count of items (for collections, badges) |
url | string | Associated URL |
icon | string | Icon identifier |
error | string | Error state |
children
Section titled “children”An ordered array of child nodes. Children represent containment — an inbox contains messages, a form contains fields.
Children may be:
- Inline — the full node object is present
- Truncated — only
id,type, and summarymetaare present (see progressive disclosure below) - Omitted —
childrenis absent or null, butmeta.total_childrenindicates they exist
Metadata about the node itself (not the domain data). See Attention & Salience for the full meta schema. Key fields:
{ "meta": { "summary": "12 unread messages, 3 flagged", // NL summary for truncated subtrees "salience": 0.8, // 0–1, how relevant this node is right now "pinned": false, // If true, never collapse this node or its children during auto-compaction "changed": true, // This node was modified in the last patch "total_children": 142, // Total children (when not all are inline) "window": [0, 25], // Which slice of children is inline [offset, count] "created": "2026-03-27T10:30:00Z", "updated": "2026-03-27T10:35:00Z" }}Progressive disclosure
Section titled “Progressive disclosure”The state tree supports depth-controlled resolution. When a consumer requests a subtree at depth d:
- Depth 0: Only the requested node (no children)
- Depth 1: The node + direct children (children’s children omitted)
- Depth N: N levels of nesting resolved
- Depth -1: Full subtree (use with caution)
Nodes beyond the requested depth become depth stubs.
Depth stub vs compacted node
Section titled “Depth stub vs compacted node”SLOP has two distinct collapsed shapes, and they are not interchangeable. They answer different questions:
| Shape | Emitted by | Keeps | Drops | Purpose |
|---|---|---|---|---|
| Depth stub | depth truncation (subscribe/query with a depth limit) | id, type, meta | properties, children, affordances, content_ref | ”There’s something here; ask again if you need it.” The consumer has opted into a shallow view. |
| Compacted node | Automatic compaction under max_nodes (see Attention §compaction) | id, type, properties, affordances, meta | children, content_ref | ”The whole node was relevant but we had to drop its subtree to fit budget.” The consumer still needs to reason about the node itself. |
A consumer can always distinguish the two: a compacted node carries properties and affordances; a depth stub does not. Providers MUST NOT blur the two shapes (e.g. emitting a depth stub with properties attached, or a compacted node with affordances dropped), because consumers use those fields to decide whether to re-query for detail vs. invoke the action directly.
A depth stub looks like:
{ "id": "msg-42", "type": "item", "meta": { "summary": "Launch plan from alice (unread)", "total_children": 2 }}Depth 0: inbox (12 unread, 142 total)
Depth 1: inbox ├── msg-1: "Launch plan" from alice (unread) ├── msg-2: "Bug report" from bob ├── msg-3: "Meeting notes" from carol ... (25 of 142 shown)
Depth 2: inbox ├── msg-1: "Launch plan" from alice (unread) │ ├── attachment: "plan.pdf" (2.1 MB) │ └── thread: 3 replies ...Windowed collections
Section titled “Windowed collections”When a collection has many children, the provider returns a window (a contiguous slice) and metadata about the full set:
{ "id": "inbox", "type": "collection", "properties": { "label": "Inbox" }, "meta": { "total_children": 142, "window": [0, 25], "summary": "142 messages, 12 unread, 3 flagged" }, "children": [ // 25 nodes (indices 0–24) ]}The consumer can request a different window via a query message (see Messages).
Consumer display format
Section titled “Consumer display format”When rendering a state tree as text for LLM context (e.g. in a system prompt or tool result), consumers SHOULD use the following canonical format. A consistent format across SDKs ensures the AI can parse tree output regardless of which SDK produced it.
Format rules
Section titled “Format rules”- Each node occupies one line, indented by 2 spaces per depth level.
- Header:
[type] id— always show the node type and stable ID. If the node has alabelortitleproperty that differs from the ID, append it:[type] id: Display Name. - Extra properties: All properties except
labelandtitle, formatted askey=valuein parentheses:(count=3, unread=true). Values are JSON-encoded. - Meta summary: When
meta.summaryis present, append— "summary text"(em-dash, quoted). - Meta salience: When
meta.salienceis present, appendsalience=0.85(rounded to 2 decimal places). - Affordances: Inline at end of line as
actions: {action1(param: type), action2}. Parameter types are extracted from the affordance’s JSON Schemaparams.properties. - Windowing: When
meta.total_children > len(children):- If
meta.windowis set: add a child line(showing N of M) - If no children are inline: add a child line
(M children not loaded)
- If
- Children: Recurse at
indent + 1.
Example
Section titled “Example”Given this tree:
{ "id": "store", "type": "root", "properties": { "label": "Pet Store" }, "meta": { "salience": 0.9 }, "affordances": [ { "action": "search", "params": { "type": "object", "properties": { "query": { "type": "string" } } } } ], "children": [ { "id": "catalog", "type": "collection", "properties": { "label": "Catalog", "count": 142 }, "meta": { "total_children": 142, "window": [0, 25], "summary": "142 products, 12 on sale" }, "children": [ { "id": "prod-1", "type": "item", "properties": { "label": "Rubber Duck", "price": 4.99, "in_stock": true }, "affordances": [ { "action": "add_to_cart", "params": { "type": "object", "properties": { "quantity": { "type": "number" } } } }, { "action": "view" } ] } ] }, { "id": "cart", "type": "collection", "properties": { "label": "Cart" }, "meta": { "total_children": 3, "summary": "3 items, $24.97" } } ]}The canonical text output is:
[root] store: Pet Store salience=0.9 actions: {search(query: string)} [collection] catalog: Catalog (count=142) — "142 products, 12 on sale" (showing 1 of 142) [item] prod-1: Rubber Duck (price=4.99, in_stock=true) actions: {add_to_cart(quantity: number), view} [collection] cart: Cart — "3 items, $24.97" (3 children not loaded)Example: full state tree
Section titled “Example: full state tree”A code editor exposing its state:
{ "id": "vscode", "type": "root", "properties": { "label": "VS Code", "workspace": "/home/user/my-project" }, "children": [ { "id": "editor-group-1", "type": "group", "properties": { "label": "Editor" }, "children": [ { "id": "tab-main.ts", "type": "document", "properties": { "label": "main.ts", "language": "typescript", "path": "src/main.ts", "selected": true, "dirty": true, "cursor": { "line": 42, "col": 10 }, "visible_range": { "start": 30, "end": 60 } }, "affordances": [ { "action": "save" }, { "action": "close" }, { "action": "goto", "params": { "type": "object", "properties": { "line": { "type": "integer" } }, "required": ["line"] } } ] }, { "id": "tab-readme", "type": "document", "properties": { "label": "README.md", "selected": false, "dirty": false } } ] }, { "id": "terminal-1", "type": "view", "properties": { "label": "Terminal", "shell": "zsh", "cwd": "/home/user/my-project" }, "meta": { "summary": "Last command: npm test (exit 0)" } }, { "id": "problems", "type": "collection", "properties": { "label": "Problems" }, "meta": { "total_children": 3, "summary": "2 errors, 1 warning" }, "children": [ { "id": "err-1", "type": "notification", "properties": { "severity": "error", "message": "Type 'string' is not assignable to type 'number'", "file": "src/main.ts", "line": 42 }, "meta": { "salience": 1.0 } } ] }, { "id": "ctx", "type": "context", "properties": { "git_branch": "feature/slop", "git_dirty": true, "extensions_active": 24 } } ]}