Worldbuilding CMS — Agent Skill Guide
This document tells AI agents how to effectively use the worldbuilding model layer. Read this before interacting with a world.
Quick Start
import { WorldEngine } from "./src/index.js";
// Open a world using default built-in types
const engine = new WorldEngine({ worldDir: "world" });
await engine.open();
// Create entities — only name and body are required
await engine.create("character", {
name: "Kira Solane",
body: "A shipwright who discovered she can hear the ocean's memories.",
tags: ["protagonist", "gifted"],
species: "human",
status: "alive",
});
// Query, validate, close
const chars = await engine.list("character");
const result = await engine.validate();
engine.close();
Architecture You Need to Know
Agent (you)
↓ reads/writes via
WorldEngine
├── DuckDBLayer — SQL queries over flat JSON files (read_json_auto)
├── FileStore — one JSON file per entity in world/<type-plural>/
├── Validator — schema + referential integrity checks
└── VectorIndex — semantic search (cosine similarity)
Key principle: Entities are flat JSON files in git. Every entity file is the single source of truth. DuckDB queries them in-place. The search index is disposable.
Entity Model
Every entity has these base fields:
| Field | Type | Notes |
|---|---|---|
id | string | Auto-generated from name via slugify, or pass custom |
type | string | Entity type name (e.g. "character", "planet") |
name | string | Required. Display name |
body | string | Required. Freeform prose lore — the main content |
tags | string[] | Defaults to [] |
relations | Relation[] | Typed references to other entities. Defaults to [] |
metadata | object | Arbitrary key-value data. Defaults to {} |
created_at | ISO 8601 | Auto-set |
updated_at | ISO 8601 | Auto-set on create and update |
Plus type-specific fields (e.g. species, faction_id for characters).
Built-in Types
| Type | Directory | Key Fields |
|---|---|---|
character | characters/ | title, species, status, faction_id, location_id |
location | locations/ | region, parent_location_id, climate, population |
faction | factions/ | motto, leader_id, headquarters_id, alignment |
event | events/ | date_in_world, location_id, participants, outcome |
item | items/ | item_type, owner_id, location_id, rarity, properties |
lore | lore/ | category, era, scope |
Custom Types
Define in world.config.json:
{
"name": "My Universe",
"types": {
"planet": {
"plural": "planets",
"fields": {
"system": { "type": "string" },
"classification": { "type": "string", "enum": ["terrestrial", "gas_giant"] },
"population": { "type": "integer", "minimum": 0 },
"parent_star_id": { "type": "string", "reference": true }
}
}
}
}
Custom types get the same base fields, validation, search indexing, and DuckDB querying as built-ins. Set "builtInTypes": false to use only your custom types.
Load from config:
const engine = await WorldEngine.fromConfig("./world.config.json", {
worldDir: "./world",
});
CRUD Operations
Create
// Minimal — just name and body
await engine.create("character", {
name: "Elara Voss",
body: "A wandering mage.",
});
// Full — all optional fields
await engine.create("character", {
name: "Elara Voss",
body: "A wandering mage from the Northern Reaches.",
id: "custom-id", // optional, auto-slugified from name otherwise
tags: ["mage", "wanderer"],
relations: [{ target_id: "some-faction", target_type: "faction", relation: "member_of" }],
metadata: { alignment: "chaotic good" },
species: "human",
status: "alive",
faction_id: "some-faction",
});
Read
const entity = await engine.get("character", "elara-voss");
const allChars = await engine.list("character");
Update
await engine.update("character", "elara-voss", {
status: "dead",
body: "Updated lore text...",
});
Delete
await engine.delete("character", "elara-voss");
Querying
SQL via DuckDB
// WHERE clause on a type
const mages = await engine.queryWhere("character", "faction_id = 'mages-guild'");
// Raw SQL — full DuckDB power
const result = await engine.sql(`
SELECT c.name, f.name as faction_name
FROM read_json_auto('world/characters/*.json', union_by_name=true) c
JOIN read_json_auto('world/factions/*.json', union_by_name=true) f
ON c.faction_id = f.id
`);
Semantic Search
// Natural language query
const results = await engine.semanticSearch("political tensions in the north");
// Filter by type
const results = await engine.semanticSearch("magic system", { type: "lore", topK: 5 });
Important: Call await engine.reindex() after bulk operations to rebuild the search index. Individual creates/updates automatically update it incrementally.
Validation
const result = await engine.validate();
if (!result.valid) {
for (const err of result.errors) {
console.log(`[${err.entityType}/${err.entityId}] ${err.message}`);
}
}
Validation checks:
- Schema conformance — every entity matches its type's JSON Schema
- Referential integrity — all
*_idforeign keys and relation targets point to existing entities
Validation runs automatically on every create() and update() call (schema only). Run validate() for a full integrity check across the entire world.
Relations
Relations are explicit typed edges between entities:
await engine.create("character", {
name: "Commander Vex",
body: "...",
faction_id: "iron-compact", // foreign key (validated)
relations: [
{ target_id: "iron-compact", target_type: "faction", relation: "leads" },
{ target_id: "elara-voss", target_type: "character", relation: "rival_of" },
],
});
Foreign keys (faction_id, location_id, etc.) are type-specific fields marked with reference: true in the schema. They are validated for existence.
Relations are a generic edge list on every entity. The relation field is freeform — use whatever relationship names make sense for your world.
File Layout
my-world/
world.config.json ← optional: defines custom types
world/
characters/
elara-voss.json
locations/
ashenvale.json
factions/
iron-compact.json
planets/ ← custom type directory
kepler-442b.json
.worldindex/ ← gitignored, regenerable
vectors.json
Working With Git
- Each entity is one file → minimal merge conflicts
- Branch = alternative world state (e.g.
timeline/war-of-ash) - Meaningful diffs: changing a faction leader is a one-line change
- Run
npm run validateas a pre-commit hook - Run
npm run reindexafter switching branches
Common Patterns
Seeding a World
See examples/verdance/seed.ts (built-in types) and examples/the-lattice/seed.ts (custom types) for complete examples.
Cross-Entity Queries
// Characters in a location's region
const northern = await engine.queryWhere("character",
`location_id IN (
SELECT id FROM read_json_auto('world/locations/*.json')
WHERE region = 'Northern Reaches'
)`
);
Bulk Operations
// Create many entities, then reindex once
for (const char of characters) {
await engine.create("character", char);
}
await engine.reindex(); // rebuild search index once at the end
What NOT to Do
- Don't edit JSON files directly — use the engine so validation runs
- Don't commit
.worldindex/— it's a derived artifact, regenerate withnpm run reindex - Don't rely on the search index for correctness — it's for discovery; use DuckDB/SQL for precise queries
- Don't create circular foreign keys in a single batch — create entities first, then update with references (see the Verdance seed script's faction leader pattern)