Adding a New Kind
This guide documents the end-to-end process for introducing a new schema-driven
kind into Plantangenet at the level that plant_zine and plant_narrative
currently occupy: a spec + runtime + service + walker panel + dev server
stack. This is the pattern to follow when the kind is primarily driven by
navigation and event flow rather than by the spatial compile pipeline used by
plant_region.
For kinds that require the full forge compile graph (genome assignment, JTL
generators, topological compile), see FORGE-PORT-GUIDE.md and the
plant_forge overview instead.
Overview
A kind at this level consists of five artefacts that must be built in order:
1. Schema (plantangenet-core/schemas/<kind>/v1/spec.json)
2. Example spec (plantangenet-core/schemas/<kind>/v1/examples/<name>.yml)
3. Rust crate (plantangenet/<kind>/src/{spec,runtime,service,bin/server}.rs)
4. Walker panel (walker/src/panels/<kind>/ + walker/src/state/<kind>*.ts)
5. Workspace wiring (Cargo.toml membership + workspace dep alias)
Reference implementations:
| Kind | Schema kind string | Rust crate | Walker panel |
|---|---|---|---|
| Visual novel | simulation.ren |
plant_narrative (bin: n-server) |
vn / VnPanel |
| Publication layout | publication.zine |
plant_zine (bin: zine-server) |
zine / ZinePanel |
Step 1 — Define the JSON Schema
Create schemas/<kind>/v1/spec.json using JSON Schema draft 2020-12.
Mandatory envelope properties every kind spec must carry:
{
"required": ["apiVersion", "kind", "meta"],
"properties": {
"apiVersion": { "type": "string", "const": "net.plantange/v1" },
"kind": { "type": "string", "const": "<namespace>.<kind>" },
"meta": { "$ref": "../../meta/v1/meta.json" }
}
}
kind uses dotted namespace convention. Current namespaces:
| Namespace | Kinds |
|---|---|
simulation |
game, quest, recipe, task, region, area, feature, world, producer, item, vehicle, droid |
earth |
animal, vegetation |
social |
fate, league, team, turnir, zavod |
publication |
zine |
Define $defs for all structural sub-shapes. Use additionalProperties: false
on all closed objects. Use relative $ref for cross-schema links. Set $id
to the canonical hosted URL but validate locally with --base-uri.
Step 2 — Author an Example YAML
Create schemas/<kind>/v1/examples/<name>.yml. Every field in the example
should be real and exercisable — this is the document the server will load
during development.
Required frontmatter:
apiVersion: net.plantange/v1
kind: <namespace>.<kind>
<kind_id>: <machine_name>
meta:
title: "..."
uuid: <kind>-<name>-1
After authoring, add an entry to schemas/STATUS.md:
- Add the new
<namespace>.<kind>to thekindnamespace list. - Add a
## <kind>/section with a table row for the schema and example files.
Step 3 — Create the Rust Crate
3a. Cargo.toml
[package]
name = "plant_<kind>"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "<kind>-server"
path = "src/bin/server.rs"
[lib]
crate-type = ["rlib"]
[dependencies]
anyhow = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
serde_yaml = { workspace = true }
plant_forge = { workspace = true }
plant_frame = { workspace = true }
plant_runner_host = { workspace = true }
axum = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread","net","macros","sync","time"] }
tokio-stream = { version = "0.1", features = ["sync"] }
tower-http = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[dev-dependencies]
tokio = { workspace = true, features = ["rt", "macros"] }
3b. spec.rs
spec.rs owns the YAML-loadable document type. It must:
- Deserialise with
serde_yamlfrom the canonical YAML shape. - Validate
apiVersionandkindin the parser function. - Provide helper methods that the runtime needs (e.g. flatten a tree, collect all slots).
- Expose an outcome type for session end.
/// Parse and validate a spec document from a YAML string.
pub fn parse_<kind>_spec_yaml(raw: &str) -> Result<KindSpecDocument> {
let doc: KindSpecDocument = serde_yaml::from_str(raw)?;
if doc.api_version != "net.plantange/v1" { return Err(...); }
if doc.kind != "<namespace>.<kind>" { return Err(...); }
Ok(doc)
}
3c. runtime.rs
runtime.rs owns the stateful, synchronous, headless session driver. No
async, no rendering. The Walker panel drives it via events; the service
integrates it into the frame lifecycle.
Required structure:
pub enum KindSessionStatus { Idle, Active { ... }, Ended(SessionStatus) }
pub struct KindSessionRuntime {
spec: Arc<KindSpecDocument>,
status: KindSessionStatus,
// ... position tracking, history, started_at
}
impl KindSessionRuntime {
pub fn new(spec: Arc<KindSpecDocument>, ...) -> Self { ... }
pub fn start(&mut self) -> Result<...> { ... }
pub fn advance(&mut self) -> Result<bool> { ... } // true = still active
pub fn cancel(&mut self) -> Result<()> { ... }
pub fn take_outcome(&mut self) -> Option<KindSessionOutcome> { ... }
}
3d. service.rs
service.rs owns a BTreeMap-keyed registry of runtimes and exposes the
methods the server binary calls. Key pattern:
pub struct KindForgeService {
name: String,
sessions: BTreeMap<String, KindSessionRuntime>,
outcomes: Vec<KindSessionOutcome>,
}
impl KindForgeService {
pub fn load_yaml(&mut self, raw: &str) -> Result<String> { ... }
pub fn start_session(&mut self, id: &str) -> Result<...> { ... }
pub fn advance(&mut self, id: &str) -> Result<bool> { ... }
pub fn cancel_session(&mut self, id: &str) -> Result<()> { ... }
pub fn drain_outcomes(&mut self) -> Vec<KindSessionOutcome> { ... }
}
Always call drain_outcomes_for(id) immediately after any operation that can
transition a session to an ended state.
3e. bin/server.rs
The binary is an Axum server following a fixed template:
const DEFAULT_BIND: &str = "127.0.0.1:<port>"; // choose an unused port
struct AppState {
sse: SseBus,
service: Arc<Mutex<KindForgeService>>,
status: Arc<Mutex<ServerStatus>>,
received_outcomes: Arc<Mutex<Vec<Value>>>,
}
Required routes:
| Method | Path | Purpose |
|---|---|---|
| GET | /health |
health_ok() — Walker readiness probe |
| GET | /state |
state_sse(...) — SSE event stream |
| POST | /<kind>/load |
Parse and register a spec |
| POST | /<kind>/start |
Start session; emit start + initial position events |
| POST | /<kind>/advance |
Advance; emit position or end event |
| POST | /<kind>/cancel |
Cancel; emit end event |
| GET | /<kind>/status |
Query session status |
| POST | /<kind_underscored>.outcome.v1 |
Receive Walker outcome (diagnostic) |
| GET | /<kind>/received_outcomes |
Drain received outcomes (diagnostic) |
Required SSE events (emitted via emit_and_snapshot):
Every server must emit at minimum:
<kind>.<object>.start.v1— full inventory on session start<kind>.navigate.v1(or equivalent position event) — after each advance/navigate<kind>.<object>.end.v1— on completion or cancellation
Use runner_event(event_name, "turn", value) from plant_runner_host to
format all SSE payloads.
main template:
#[tokio::main]
async fn main() -> anyhow::Result<()> {
init_tracing("<kind>_server=info,tower_http=info");
let args: Vec<String> = std::env::args().collect();
let (bind, _) = parse_bind_arg(&args, DEFAULT_BIND);
let app = Arc::new(AppState { ... });
let router = Router::new()
.route("/health", get(health))
.route("/state", get(state_stream))
// ... kind routes
.layer(CorsLayer::permissive())
.with_state(app);
let listener = tokio::net::TcpListener::bind(&bind).await?;
info!("<kind>-server listening on http://{bind}");
info!("ready"); // Walker integration tests watch for this line
axum::serve(listener, router).await?;
Ok(())
}
The info!("ready") line is critical — Walker integration test fixtures use it
as the startup signal.
3f. lib.rs
pub mod runtime;
pub mod service;
pub mod spec;
pub use runtime::{KindSessionRuntime, KindSessionStatus};
pub use service::KindForgeService;
pub use spec::{KindSpecDocument, KindSessionOutcome, parse_kind_spec_yaml, ...};
Step 4 — Add to the Workspace
Cargo.toml (workspace root):
# In [workspace] members:
"<kind>",
# In [workspace.dependencies]:
plant_<kind> = { path = "./<kind>" }
Then verify the crate compiles cleanly in the workspace context:
cargo check -p plant_<kind>
cargo check --bin <kind>-server
Both must finish with no errors. Warnings on unused imports should be resolved before committing.
Step 5 — Walker Panel
5a. Types (src/panels/<kind>/zineTypes.ts)
Define the TypeScript mirror of the server's SSE event payload shapes:
// Session start payload
export interface KindSessionStartEvent {
<kind>_id: string;
title: string;
// ... full inventory fields
}
// Position/navigation event
export interface KindNavigateEvent {
<kind>_id: string;
// ... current position fields
}
// Session end event
export interface KindSessionEndEvent {
<kind>_id: string;
status: "completed" | "cancelled" | "skipped" | string;
elapsed_ms?: number;
}
// Walker-side state shape
export interface KindCoreState {
activeId?: string;
title?: string;
// ... current position, history, ended
}
5b. Core (src/panels/<kind>/KindCore.ts)
Synchronous state machine that consumes typed events:
export class KindCore {
private _state: KindCoreState = { ... };
private readonly _listeners = new Set<KindUpdateListener>();
onUpdate(listener): () => void { ... }
startSession(event: KindSessionStartEvent): void { ... }
navigate(event: KindNavigateEvent): void { ... }
endSession(event: KindSessionEndEvent): void { ... }
reset(): void { ... }
dispose(): void { this._listeners.clear(); }
}
5c. Host Store (src/state/kindHostStore.ts)
Zustand store that wraps the core and exposes user actions and server-apply methods. Required shape:
export interface KindHostStoreState {
core: KindCoreState;
state: KindHostState;
// user-intent submissions queue
submissions: KindSubmission[];
// User actions (modify core + enqueue submissions)
goNext(): { ok: boolean; error?: string };
goPrev(): { ok: boolean; error?: string };
goTo(id: string): { ok: boolean; error?: string };
consumeSubmissions(): KindSubmission[];
// Server-apply methods (called by transport lifecycle)
applyServerSessionStart(value: unknown): void;
applyServerNavigate(value: unknown): void;
applyServerSessionEnd(value: unknown): void;
reset(): void;
}
5d. Surface Store (src/state/kindSurfaceStore.ts)
Thin derived store for the panel renderer. Subscribes to the host store and re-shapes state for display:
export const kindSurfaceStore = createStore<KindSurfaceStoreState>((set, get) => ({
// display fields
activeId: null,
title: null,
status: "idle",
// delegate actions
goNext() { return kindHostStore.getState().goNext(); },
// ...
}));
kindHostStore.subscribe((hostState) => {
kindSurfaceStore.setState({ ... });
});
5e. Panel (src/panels/<kind>/KindPanel.ts)
DOM panel using the same CSS-in-JS pattern as VnPanel and ZinePanel:
export class KindPanel {
readonly element: HTMLElement;
private readonly _unsub: () => void;
constructor() {
this.element = document.createElement("div");
injectStyles(document);
this._unsub = kindSurfaceStore.subscribe((s) => this._render(s));
this._render(kindSurfaceStore.getState());
}
private _render(state): void {
if (state.status === "cancelled" || state.status === "completed") {
this.element.style.display = "none";
return;
}
this.element.innerHTML = `...`;
}
dispose(): void {
this._unsub();
}
}
export const kindDescriptor: PanelDescriptor = {
id: "<kind>",
displayName: "...",
description: "...",
factory(_params) {
const panel = new KindPanel();
return {
element: panel.element,
dispose() {
panel.dispose();
},
};
},
};
5f. Socket Declarations (src/panels/<kind>/kindSockets.ts)
Add any new socket type tags to src/canvas/socketTypes.ts (SocketTypeTag
union), then declare the panel's socket map:
export const KIND_SOCKETS: PanelSocketMap = {
panel_id: "<kind>_host",
inputs: {
session_spec: { id: "session_spec", accepts: ["<kind>_spec"], ... },
// ...
},
outputs: {
navigation_intent: { id: "navigation_intent", produces: ["<kind>_navigate"], ... },
session_outcome: { id: "session_outcome", produces: ["<kind>_outcome"], ... },
},
};
5g. Register the panel
In the Walker app init (or dock setup):
import { kindDescriptor } from "./panels/<kind>/KindPanel.js";
registry.register(kindDescriptor);
Port Checklist
Use this before committing a new kind:
- [ ]
schemas/<kind>/v1/spec.json— valid JSON Schema,additionalProperties: falseon closed objects - [ ]
schemas/<kind>/v1/examples/<name>.yml— validates against the schema locally - [ ]
schemas/STATUS.md— kind namespace entry + section table updated - [ ]
zine/src/spec.rs—parse_*_yamlvalidatesapiVersionandkind - [ ]
zine/src/runtime.rs—start,advance,cancel,take_outcomeall implemented - [ ]
zine/src/service.rs—drain_outcomesdrains immediately after session ends - [ ]
zine/src/bin/server.rs— emitsinfo!("ready")as last startup log - [ ]
zine/src/lib.rs— all public types re-exported - [ ]
Cargo.toml— crate in[workspace] membersand[workspace.dependencies] - [ ]
cargo check -p plant_<kind>— clean - [ ]
cargo check --bin <kind>-server— clean - [ ] Walker
zineTypes.ts— TypeScript mirrors of all SSE payload shapes - [ ] Walker
ZineCore.ts—startSession,navigate,endSession,reset,dispose - [ ] Walker
zineHostStore.ts—applyServer*methods, submissions queue,consumeSubmissions - [ ] Walker
zineSurfaceStore.ts— thin derived store, subscribes to host store - [ ] Walker
ZinePanel.ts— hides on terminal states, exportsPanelDescriptor - [ ] Walker
zineSockets.ts— socket map declared; new type tags added tosocketTypes.ts - [ ] Panel registered at app init