Skip to content

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.

Use the CLI or a commit operation to create a shape:

Terminal window
wh shape create Location --fields '{"x": "number", "y": "number", "label": "string"}'

Shapes can also be created as write operations.

TypeDescriptionExample value
stringText value"hello"
numberNumeric value42
booleanTrue/falsetrue
wrefReference to another thing"Location/cave"
arrayList of values (see Array Fields)["a", "b"]

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.

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.

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.

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 type key (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:

ObjectInterpretationWhy
{ "type": "number", "description": "Height" }Typed fieldAll 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.

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 most 65,536) — minimum string length
  • maxLength (non-negative integer, at most 65,536) — maximum string length; must be ≥ minLength
  • pattern (string) — regular expression the value must match
  • enum (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 ≥ minimum
  • integer (boolean) — when true, 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 elements
  • maxItems (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
}
}

Shapes can have an optional top-level description that documents the shape’s purpose:

Terminal window
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.

All field types work in --fields JSON:

Terminal window
# Array fields
wh 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 objects
wh shape create Player --fields '{"name":"string","position":{"x":"number","y":"number"}}'
# Typed field objects with descriptions
wh shape create Sensor --fields '{"value":{"type":"number","description":"Current reading"},"unit":"string"}'
# Constraints
wh 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 }
}'

Shapes are versioned just like things. Revising a shape creates a new version:

Terminal window
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.

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:

Terminal window
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:

Terminal window
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:

Terminal window
# Fails: Missing required field "category"
wh commit submit --revise Product/widget --data '{"name": "Widget", "price": 6}'
# Works: include all current required fields
wh 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:

  1. 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.
  2. 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?").

Terminal window
# List all shapes, including component-owned ones
wh shape list
# View a specific shape with its field definitions
wh shape view Location
# Filter by name pattern
wh shape list --match "Game*"
# Hide component-owned shapes
wh shape list --exclude-components
# Show only shapes owned by one component
wh shape list --component com.acme.research

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), and LlmsTxt (synthesized sitemap, read-only). See Content Shape for the full fetch matrix and per-surface examples.

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.

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.

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.