Skip to content

Shapes

A shape defines the data structure for things and assertions. A thing or assertion declares a set of shapes: one, several, or none. Its data is validated against the field definitions of every shape it declares, and no shape in the set is primary.

A shape body accepts three top-level keys: fields (required), description (optional), and composes (optional). The composes list lets you build composite shapes — see Composite Shapes below.

The same add shape operation works on every surface — the CLI takes --fields, while the SDK and MCP wrap the field map in the operation’s data.fields. See Write operations for the full contract.

In the SDK and MCP examples below, the shape name is written as hub/shapes/Location — that is the wref for a shape named Location (explained in Shape Names in Wrefs), and shapes: [] is correct because shape definitions are not themselves typed by other shapes.

Terminal window
# CLI
wh shape create Location --fields '{"x": "number", "y": "number", "label": "string"}'
// SDK
await client.commit.apply("myorg", "world", "Add Location shape", [
{ operation: "add", name: "hub/shapes/Location", shapes: [], data: { fields: { x: "number", y: "number", label: "string" } } },
])
// MCP — warmhub_thing_create
{
"name": "warmhub_thing_create",
"arguments": {
"orgName": "myorg",
"repoName": "world",
"submissionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"items": [
{ "name": "hub/shapes/Location", "shapes": [], "data": { "fields": { "x": "number", "y": "number", "label": "string" } } }
]
}
}
TypeDescriptionExample value
stringText value"hello"
numberNumeric value42
booleanTrue/falsetrue
wrefReference to a shape or shaped thing"hub/shapes/Location" or "cave"
arrayList of values (see Array Fields)["a", "b"]

This vocabulary is closed: string, number, boolean, wref, and array are the only field types, each optionally nullable with a trailing ? (see Optional Fields). Any other type name — such as text, integer, json, or date — is rejected. The check is authoritative on the server at apply time, and the CLI and SDK also run it locally when you create or revise a shape (wh shape create / wh shape revise, client.shape.create / client.shape.revise, or the SDK OperationBuilder) so the write fails before it leaves your machine. Either way the verdict is identical — a shape-level wrapper that names the shape, followed by the specific field error:

Invalid shape definition for "Event": Invalid type at "fields.starts": "date" (expected number|string|boolean|wref|array, optionally with ? suffix)

When several fields are off-vocabulary, each field error is appended to the same wrapper, separated by ; .

Rich values are still fully supported — use number for integers and USD amounts, boolean for flags, array for lists, and wref for references. The restriction is on the type-name vocabulary only, not on the values those types can hold.

Field names — both at the top level of a shape definition and inside nested objects — are validated for safety. A field name is rejected if it:

  • Is empty
  • Has leading or trailing whitespace
  • Contains control characters (for example: tabs, newlines, escape characters, or other C0/C1 control codes)
  • Contains invisible or dangerous Unicode characters, specifically zero-width characters, bidirectional control characters, and line/paragraph separators

Any of these conditions causes the operation to fail with:

Invalid shape definition for "MyShape": Invalid field name at "fields.<name>": ...

This check runs on wh shape create and is enforced server-side for all write surfaces — CLI, SDK, and MCP — so a new unsafe field name is always rejected before it is stored. For wh shape revise, the local preflight defers field-name safety to the server, which allows unsafe field names that are already present in the stored shape definition (so existing legacy shapes are not retroactively blocked) but rejects any new unsafe field names introduced by the revise.

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.

Field name rules (see Field Name Rules) apply to nested field names as well as top-level ones.

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. Patterns run on a linear-time engine, so a crafted value can’t slow matching to a crawl; patterns the engine can’t safely evaluate are rejected when you create the shape (see Pattern matching is backtracking-safe)
  • 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 constraint passes when the named Shape is in the target’s Shape set: a Shape the target declares, or one reached from those through composition. Any one match is enough; no Shape in the set is primary. A shapeless thing and a Shape definition never pass, so a same-named Shape does not satisfy it. See What Wrefs Can Target.
  • binding (string) — controls how the wref tracks the target over time. Accepted values:
    • "versioned" — the wref is intended to pin one exact version of the target (e.g. cave@v3). When you author a floating HEAD ref (e.g. cave) on a versioned-bound field, the write is accepted and the ref is automatically resolved and pinned to the current version at write time. Use this when you need a stable snapshot that does not change as the target is revised. This is the default when binding is omitted.
    • "identity" — the wref follows the target thing as it evolves; it always resolves to the current HEAD of the target. When you author a pinned version ref (e.g. cave@v3) on an identity-bound field, the write is accepted and the version pin is silently dropped — the stored wref becomes the floating HEAD ref, and the write result includes an ignoredVersionPins warning.
    • "either" — both floating HEAD refs and pinned version refs are accepted without any automatic conversion.

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
}
}

WarmHub evaluates pattern with a linear-time regular-expression engine (RE2) rather than the JavaScript engine. Linear-time matching means the work grows in proportion to the length of the value being checked — there’s no catastrophic backtracking, so a maliciously crafted value can’t make validation hang. This is what lets WarmHub safely accept patterns authored by anyone who can write to the repo.

Almost everything you’d reach for works: literals, anchors, character classes, escapes, quantifiers (including bounded {m,n}), groups, named captures, and alternation. The rule is simply that any pattern the engine can’t compile is rejected, caught when you create or revise the shape so you find out before any data is written. There are two rejection branches:

  • Syntactically invalid regex — a pattern that isn’t valid regex at all (e.g. an unclosed group like (foo) is rejected immediately with "pattern" at "..." is not a valid regular expression.
  • Unsupported patterns — constructs the RE2 engine cannot compile, including JavaScript-only features such as backreferences (e.g. (\w+)\s+\1) and lookaround (e.g. (?=...), (?!...), (?<=...), (?<!...)), as well as patterns that produce an automaton too large to compile — chiefly deeply nested bounded repetitions such as ^(?:a{0,1000}){7}$, where the counts multiply and the resulting automaton exceeds RE2’s size cap. A single bounded quantifier like a{0,1000} is fine; nesting them so the counts multiply is what trips the cap.

To make this concrete:

PatternAccepted?Why
^[A-Z][a-z]+$✓Plain anchors and character classes
^\d{3}-\d{4}$✓Bounded quantifier, not nested
(a?)+$✓Compiled and evaluated at runtime; yields does not match pattern rather than has unsupported pattern
(foo✗Syntactically invalid regex — unclosed group
(\w+)\s+\1✗Backreference — not supported by RE2
(?<=foo)bar✗Lookbehind — not supported by RE2
^(?:a{0,1000}){7}$✗Nested bounded repetition — automaton too large

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:

If your shell makes inline JSON quoting awkward, put the same JSON object in a file and use --file fields.json with wh shape create or wh shape revise. This is especially useful for typed field objects whose descriptions contain spaces.

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"}'
# The same object can be saved in sensor-fields.json instead
wh shape create Sensor --file sensor-fields.json
# 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 }
}'

A shape can declare a composes list — an ordered list of Shape HEAD refs — to build a composite shape that inherits fields from one or more other shapes.

{
"fields": { "confidence": "number" },
"composes": ["Location", "Player"]
}

When a shape body includes composes, WarmHub compiles the full composite closure: the fields from every shape in the list (and their own composes chains, resolved transitively) are merged with the shape’s own fields. The resulting certified closure is what validation runs against when things are committed under the composite shape.

A composite captures the exact version of each member when the composite is committed:

  • Every later write that declares the composite validates against that captured closure, and each thing’s certificates record those member versions. Certificates on things already written never change.
  • Revising a member shape does not change the composite. Its things keep validating against the member version the composite captured until the composite itself is revised.
  • Every revise of the composite — including one that changes only fields or only description — re-captures each shape in its composes list at that shape’s current version and checks the closure for conflicts again. A member that is itself a composite contributes the closure it captured at its own last revise.
  • A system Shape member, for example Assertion or LicenseDeclaration, is brought to its current platform definition before a composite captures it.
  • A revise whose body is unchanged still creates a new composite version when a member has a newer version.
  • If a member’s current version now conflicts with the composite, the composite revise is refused with an incompatible definitions error that names the field and the member version. Fix the member or the composite’s own fields, then revise again.
  • A thing can declare shapes that reach one shape at only one version. If two declared composites captured different versions of a shape, or you declare a shape directly beside a composite that captured an older version of it, the write is refused with an error that names the shape and both versions. Revise the composite that captured the older version so it re-captures the shape at its current version, then write again.

Composition is local: every shape in composes must be a shape in the same repository. A shape cannot compose a shape from another repository, directly or through a member’s own composes chain. A wh: reference to another repository’s shape is refused, and so is a durable ID, which is not a shape reference.

Pass --compose once per parent shape on the CLI. The SDK and MCP include composes directly in data:

Terminal window
# CLI — repeat --compose for each parent
wh shape create Sighting --fields '{"confidence":"number"}' --compose Location --compose Player
// SDK
await client.commit.apply("myorg", "world", "Add Sighting shape", [
{
operation: "add",
name: "hub/shapes/Sighting",
shapes: [],
data: {
fields: { confidence: "number" },
composes: ["Location", "Player"],
},
},
])
// MCP — warmhub_thing_create
{
"name": "warmhub_thing_create",
"arguments": {
"orgName": "myorg",
"repoName": "world",
"submissionId": "b2c3d4e5-f6a7-4890-bcde-f12345678901",
"items": [
{
"name": "hub/shapes/Sighting",
"shapes": [],
"data": {
"fields": { "confidence": "number" },
"composes": ["Location", "Player"]
}
}
]
}
}

wh shape revise (and the equivalent SDK/MCP revise operation) replaces the entire composes list when you supply a new one. To clear composition entirely, pass --clear-composition:

Terminal window
# Replace the composition list
wh shape revise Sighting --fields '{"confidence":"number","notes":"string"}' --compose Location
# Clear composition entirely
wh shape revise Sighting --fields '{"confidence":"number","notes":"string"}' --clear-composition

Omitting both --compose and --clear-composition leaves the existing composes list unchanged — it does not clear it. Use --clear-composition to explicitly send an empty list and remove all composition. The members in the unchanged list are still re-captured at their current versions, as for any composite revise. If a member was renamed since the composite was last revised, the kept list names that member by its new name.

Shapes are versioned just like things. Revising a shape mints a new version when the definition actually changes. If the submitted definition is identical to the current version, no new version is created — the write is a no-op. See Write operations for the exact result-field shape returned in that case.

Terminal window
# CLI
wh shape revise Location --fields '{"x": "number", "y": "number", "z": "number", "label": "string"}'
// SDK
await client.commit.apply("myorg", "world", "Add z to Location", [
{ operation: "revise", name: "hub/shapes/Location", shapes: [], data: { fields: { x: "number", y: "number", z: "number", label: "string" } } },
])
// MCP — warmhub_thing_revise
{
"name": "warmhub_thing_revise",
"arguments": {
"orgName": "myorg",
"repoName": "world",
"submissionId": "c3d4e5f6-a7b8-4901-8def-123456789012",
"items": [
{ "name": "hub/shapes/Location", "shapes": [], "data": { "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 version of each shape they declare. A shape reached only through a composite is validated at the version the composite captured; see Composite Shapes.

Shape wrefs follow ordinary version semantics: Location and Location@HEAD resolve the current definition, while Location@vN resolves that exact historical version. After a shape rename, every old name-based spelling — including an old @vN spelling — stops resolving; use the new name for both current and historical versions.

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 widget --shape Product --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 widget must include the new field — passing the old payload alone fails:

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

Shapes are retracted by name, like any other entity. Existing things keep the shape version they were validated against; the shape is hidden from default reads. A floating shape wref (Location or Location@HEAD) then fails on write paths, while an existing pinned version such as Location@v2 remains a valid historical target. See Retract, Rename & Schema Changes for what this does to dependent things.

Terminal window
# CLI
wh shape retract Location --reason "replaced by the v2 Location model" -m "retract"
// SDK
await client.commit.apply("myorg", "world", "Retract Location shape", [
{ operation: "retract", name: "hub/shapes/Location", reason: "replaced by the v2 Location model" },
])
// MCP — warmhub_thing_retract
{
"name": "warmhub_thing_retract",
"arguments": {
"orgName": "myorg",
"repoName": "world",
"submissionId": "d4e5f6a7-b8c9-4012-9ef0-234567890123",
"items": [
{ "name": "hub/shapes/Location", "reason": "replaced by the v2 Location model" }
]
}
}
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 acme:veritas

For a composite shape, wh shape view prints the composes list and the shape’s own fields, then one inherited from <wref>: block for each shape in the captured closure. Each block shows that shape’s fields at the version the composite captured, and members reached through a nested composes chain get their own block. For example, using the shapes in Composite Shapes:

Sighting@v1 shape
composes: Location, Player
fields:
confidence: number
inherited from wh:myorg/world/Location@v1:
x: number
y: number
label: string
inherited from wh:myorg/world/Player@v1:
name: string
position: {"x":"number","y":"number"}

The @v versions in the inherited from lines are the captured versions. They do not move when a member shape is revised; revise the composite to re-capture its members (see Revising composition). If your token cannot read every member shape, the view shows only the composite’s composes list and its own fields. With --format json, the same data is in the composition array of the result.

Some shape names 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:

  • Arc — directed relationship from one thing to another
  • Bond — symmetric relationship between two 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.

Query shape:

  • View — a named, shareable query definition.

License shapes:

  • LicenseSubject — a licensable body of work in this repository.
  • LicenseDeclaration — declares the license of a LicenseSubject.

View, LicenseSubject, and LicenseDeclaration are materialized lazily: the shape appears in the repository the first time you write a thing under that name, so wh shape list will not show it beforehand.

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. An Observation shape that captures confidence and evidence can be used across many contexts. You don’t need CaveObservationShape and PlayerObservationShape — use one Observation shape and let the about reference distinguish what each assertion is about.

Use wref fields for relationships. If a thing references a shape or shaped thing, use a wref-typed field. This creates a trackable link that WarmHub can resolve and validate. An untyped wref accepts either target class. A constrained wref such as { "type": "wref", "shape": "Player" } passes when Player is in the target’s Shape set: a Shape the target declares, or one reached from those through composition. Any one match is enough; no Shape in the set is primary. A shapeless thing and a Shape definition never pass, so the Player Shape itself does not satisfy the constraint.

Don’t cargo-cult the examples. The docs use game-world examples (a Location named cave, an Observation named 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.

A Thing’s wref is its name; a Shape’s wref is hub/shapes/<Name>. Given a thing cave that declares the Location Shape:

  • Shape: Location (wref hub/shapes/Location)
  • Thing name: cave (wref cave)

Shape names and thing names occupy separate namespaces: a thing may carry the same name as a shape, so a thing named Player and a shape named Player coexist in one repository without conflict.

Within each of those namespaces, a name is held by one active entity at a time. Thing names are unique per repository across all shapes: two active things cannot share a name even under different shapes, and a write that would claim a name already held under a different shape is refused with NAME_TAKEN. Shape names are likewise unique among active shapes. Retracting an entity releases its name for reuse in either case. For guidance on how thing names create navigable hierarchies, see Naming as Navigation.

A thing cannot be named hub or any name that begins with hub/. WarmHub keeps that space for its own names, such as every shape’s hub/shapes/<Name> wref and the names it keeps for redacted things. A shape cannot be named hub either, because hub/shapes/X would then be ambiguous. These rules match exact case only: names such as Hub/x or hubs/x are ordinary names you can use.