Shapes
A shape defines the data structure for things and assertions. Every thing and assertion belongs to a shape, and its data is validated against the shape’s field definitions.
Creating a Shape
Section titled “Creating a Shape”Use the CLI or a commit operation to create a shape:
wh shape create Location --fields '{"x": "number", "y": "number", "label": "string"}'Shapes can also be created as write operations.
Field Types
Section titled “Field Types”| Type | Description | Example value |
|---|---|---|
string | Text value | "hello" |
number | Numeric value | 42 |
boolean | True/false | true |
wref | Reference to another thing | "Location/cave" |
array | List of values (see Array Fields) | ["a", "b"] |
Optional Fields
Section titled “Optional Fields”There are two equivalent syntaxes for optional fields — append ? to either the field name or the type:
{ "x": "number", "y": "number", "label?": "string" }{ "x": "number", "y": "number", "label": "string?" }Both forms are interchangeable. Things created under this shape can omit label without validation errors. When an optional field is omitted, both null and undefined values are accepted during validation.
Array Fields
Section titled “Array Fields”Wrap the type in an array to define a list field:
{ "tags": ["string"], "scores": ["number"] }Typed array objects provide an alternative syntax with constraints and descriptions:
{ "tags": { "type": "array", "items": "string", "minItems": 1, "maxItems": 10 }, "scores": { "type": "array", "items": "number", "description": "Player scores" }}See Field constraints for the full set of array constraint keys.
Nested Objects
Section titled “Nested Objects”Use a plain sub-object to define nested structure:
{ "position": { "x": "number", "y": "number" }, "label": "string"}Nested objects are defined as plain sub-objects — do not wrap them in {"fields": ...}. The fields wrapper is only used at the top level of a shape definition, not inside nested types.
Typed Field Objects
Section titled “Typed Field Objects”Instead of a bare type string, you can use a typed field object to attach a description or constraints.
A type spec is any valid field type declaration. It can be a bare type string like "number", a typed field object like { "type": "number", "description": "Height" }, or an array shorthand like ["string"].
{ "x": { "type": "number", "description": "Horizontal position" }, "y": "number" }A typed field object must satisfy two rules:
- It has a
typekey (required) set to one of:"string","number","boolean","wref", or"array". Append?to mark the field as optional, e.g.{ "type": "string?", "description": "..." }. - All other keys must be recognised type-spec keys:
description, or any field constraint valid for the field’s type.
Typed field vs nested object — quick reference:
| Object | Interpretation | Why |
|---|---|---|
{ "type": "number", "description": "Height" } | Typed field | All keys are recognised type spec keys |
{ "type": "string", "value": "number" } | Nested object | "value" is not a recognised type spec key |
Any object with unrecognised keys is treated as a nested object, not a typed field. For example, { "type": "string", "value": "number" } is a nested object with two sub-fields because "value" is not a recognised constraint key.
Field Constraints
Section titled “Field Constraints”Typed field objects can include constraint keys to validate data at commit time. Constraints are validated when the shape is created and enforced when data is committed.
String constraints (type: "string"):
minLength(non-negative integer, at most65,536) — minimum string lengthmaxLength(non-negative integer, at most65,536) — maximum string length; must be ≥minLengthpattern(string) — regular expression the value must matchenum(non-empty string[]) — list of allowed values; must contain at least one entry
All string field values are also capped at 64 KiB (65,536 UTF-8 bytes) at commit time. minLength and maxLength are string-length constraints, not byte-size checks; Unicode-heavy strings can hit the UTF-8 byte cap before they reach 65,536 characters.
Number constraints (type: "number"):
minimum(number) — minimum value (inclusive)maximum(number) — maximum value (inclusive); must be ≥minimuminteger(boolean) — whentrue, value must be a whole number
Wref constraints (type: "wref"):
shape(string) — the referenced thing must belong to this shape
Array constraints (type: "array"):
items(type spec, required) — the element type (any valid type spec: primitive, typed field object, nested object, or array)minItems(non-negative integer) — minimum number of elementsmaxItems(non-negative integer) — maximum number of elements; must be ≥minItems
{ "status": { "type": "string", "enum": ["active", "inactive", "pending"] }, "score": { "type": "number", "minimum": 0, "maximum": 100, "integer": true }, "owner": { "type": "wref", "shape": "Player", "description": "The owning player" }, "tags": { "type": "array", "items": "string", "minItems": 1, "maxItems": 10 }}Shape Descriptions
Section titled “Shape Descriptions”Shapes can have an optional top-level description that documents the shape’s purpose:
wh shape create Location --fields '{"x":"number","y":"number"}' --description "A point in 2D space"The description is returned by wh shape view, wh repo describe, and the warmhub_repo_describe MCP tool.
CLI --fields Examples
Section titled “CLI --fields Examples”All field types work in --fields JSON:
# Array fieldswh shape create Tags --fields '{"labels":["string"],"scores":["number"]}'
# Optional fields (both syntaxes)wh shape create Review --fields '{"score":"number","reason?":"string"}'wh shape create Review --fields '{"score":"number","reason":"string?"}'
# Nested objectswh shape create Player --fields '{"name":"string","position":{"x":"number","y":"number"}}'
# Typed field objects with descriptionswh shape create Sensor --fields '{"value":{"type":"number","description":"Current reading"},"unit":"string"}'
# Constraintswh shape create GameItem --fields '{ "name": { "type": "string", "minLength": 1, "maxLength": 50 }, "rarity": { "type": "string", "enum": ["common", "rare", "epic"] }, "power": { "type": "number", "minimum": 0, "maximum": 100 }}'Versioning
Section titled “Versioning”Shapes are versioned just like things. Revising a shape creates a new version:
wh shape revise Location --fields '{"x": "number", "y": "number", "z": "number", "label": "string"}'Existing things retain their association with the shape version they were validated against. New things are validated against the latest shape version.
Back-Filling Required Fields
Section titled “Back-Filling Required Fields”Adding a required field is a backwards-incompatible revise: existing things still validate against the older shape version they were created under, but the next time you revise one of those things, validation runs against the new shape and rejects the operation if the new required field is missing.
For example, given an initial shape with two required fields:
wh shape create Product --fields '{"name": "string", "price": "number"}'wh commit submit --add Product/widget --data '{"name": "Widget", "price": 5}'Adding a third required field at the shape level is fine on its own:
wh shape revise Product --fields '{"name": "string", "price": "number", "category": "string"}'But the next revise of Product/widget must include the new field — passing the old payload alone fails:
# Fails: Missing required field "category"wh commit submit --revise Product/widget --data '{"name": "Widget", "price": 6}'
# Works: include all current required fieldswh commit submit --revise Product/widget --data '{"name": "Widget", "price": 6, "category": "tools"}'You have two options when adding a required field to a shape that already has things:
- Back-fill on next revise. Each subsequent revise must include the new field. Sensible when revises are infrequent and you can fold the new value into normal updates.
- Mass-revise immediately. Issue a revise for every existing thing in the same write so all instances are valid against the new shape going forward.
Option 1 keeps history clean (versions only when data actually changes), but leaves an “implicit migration” — until each thing is revised, it carries data validated only under the older shape version. Option 2 is louder in version history but eliminates the latent failure mode.
If you don’t have a value to back-fill, declare the field optional instead ("category?": "string" or "category": "string?").
Listing and Viewing Shapes
Section titled “Listing and Viewing Shapes”# List all shapes, including component-owned oneswh shape list
# View a specific shape with its field definitionswh shape view Location
# Filter by name patternwh shape list --match "Game*"
# Hide component-owned shapeswh shape list --exclude-components
# Show only shapes owned by one componentwh shape list --component com.acme.researchBuilt-in Shapes
Section titled “Built-in Shapes”Five core shapes are built-in and auto-created on first use. They cannot be created or revised manually. (Components may also install system-managed shapes like ComponentConfig — see Components for details.)
Collection shapes — see Collections for details:
- Pair — ordered pair of two things
- Triple — ordered triple of three things
- Set — unordered, deduplicated collection (1+ members)
- List — ordered collection with duplicates (1+ members)
Content shapes:
Content— built-in content shape (content: string) with three well-known instance names:Readme(human-facing README),Agents(AI-agent guidance), andLlmsTxt(synthesized sitemap, read-only). See Content Shape for the full fetch matrix and per-surface examples.
Designing Shapes
Section titled “Designing Shapes”Shapes define structure, but WarmHub is deliberately flexible about what that structure looks like. Some guidance:
Start simple. You can always revise a shape to add fields. You don’t need to anticipate every future field up front.
One shape per concept, not per use case. A Belief shape that captures confidence and evidence can be used across many contexts. You don’t need CaveBeliefShape and PlayerBeliefShape — use one Belief shape and let the about reference distinguish what each assertion is about.
Use wref fields for relationships. If a thing references another thing, use a wref-typed field. This creates a trackable link that WarmHub can resolve and validate.
Don’t cargo-cult the examples. The docs use game-world examples (Location/cave, Belief/cave-safe) to illustrate concepts. Your shapes should reflect your domain. A financial analyst might have Company, Earnings, and Forecast shapes. A research agent might have Paper, Claim, and Evidence shapes. The modeling primitives are the same — the names and fields are yours.
Undeclared Fields
Section titled “Undeclared Fields”The commit pipeline accepts fields beyond what a shape declares — this is intentional flexibility for agents that want to attach extra context. Undeclared fields are stored, returned by the API, and visible in the web UI’s raw data view. However, only declared fields get structured rendering — typed columns, filtering, and shape-aware display. Undeclared fields appear only in the raw JSON.
If a field matters for structured browsing or filtering, declare it in the shape. If it’s purely for downstream agent consumption, undeclared fields work fine — just know they won’t get first-class UI treatment.
Shape Names in Wrefs
Section titled “Shape Names in Wrefs”The shape name is always the first segment in a wref. Given a thing with wref Location/cave:
- Shape:
Location - Thing name:
cave
This means shape names occupy a reserved namespace — you cannot create a thing whose name collides with a shape name at the top level. For guidance on how thing names (the part after the shape) create navigable hierarchies, see Naming as Navigation.
Hit a problem or have a question? Get in touch.