The developer reference.
Everything you need to build, test, and publish extensions for the seed. Code-first. Copy-pasteable. Each section is self-contained.
Two files. One restart. Your extension is live.
// extensions/my-ext/manifest.js
export default {
name: "my-ext",
version: "1.0.0",
description: "My first extension",
needs: {
services: ["hooks"],
models: ["Node"],
},
provides: {},
};// extensions/my-ext/index.js
export async function init(core) {
core.hooks.register("afterNote", async ({ note, nodeId }) => {
console.log("Note written at", nodeId);
}, "my-ext");
return {};
}Restart the land. Your extension loads. The hook fires on every note write.
manifest.js declares what your extension needs and what it provides. The loader reads it before calling any code. Unmet needs = extension skipped.
The loader scopes core to only what you declare. hooks and modes are always available. Everything else must be listed or it's undefined.
contributionslogContribution, contribution queriesauthresolveTreeAccess, registerStrategyprotocolsendOk, sendError, ERR, WS, CASCADE constantssessioncreateSession, endSession, session lifecyclechatstartChat, finalizeChat, chat trackingllmprocessMessage, switchMode, runChat, runPipelinemcpconnectToMCP, closeMCPClientwebsocketemitNavigate, emitToUser, registerSocketHandlerorchestratorOrchestratorRuntime, acquireLock, releaseLockorchestratorsregister, get (orchestrator registry)cascadedeliverCascadeownershipaddContributor, removeContributor, setOwnertreegetAncestorChain, checkIntegrity, getCacheStatsCore models: Node, User, Note, Contribution, LlmConnection, Chat.
Same structure as needs. If missing, your extension still loads. Optional services get no-op stubs. Optional extensions just aren't there.
optional: {
services: ["energy"], // gets no-op stub if energy extension not loaded
extensions: ["billing"], // loaded after if present, ignored if not
}provides: {
routes: "./routes.js", // Express router mounted at /api/v1
tools: true, // init() returns tools array
modes: true, // init() registers modes
jobs: "./jobs.js", // Background job module
orchestrator: false, // Or path to orchestrator pipeline
hooks: {
fires: ["my-ext:afterProcess"],
listens: ["afterNote", "enrichContext"],
},
cli: [
{ command: "my-cmd", description: "Does a thing", method: "POST", endpoint: "/my-ext/do" },
],
env: [
{ key: "MY_EXT_API_KEY", required: true, description: "API key for external service" },
],
}The entry point. Receives the scoped services bundle. Register hooks, modes, socket handlers. Return routes, tools, jobs, exports.
export async function init(core) {
// Register hooks
core.hooks.register("afterNote", handler, "my-ext");
// Register modes
core.modes.registerMode("tree:my-mode", modeConfig, "my-ext");
// Register socket handlers
core.websocket.registerSocketHandler("myEvent", handler);
// Register auth strategy
core.auth.registerStrategy("myAuth", handler);
return {
router, // Express router at /api/v1
tools: [...], // MCP tools with zod schemas + handlers
jobs: { start() {} },// Background jobs with start/stop
pageRouter, // Pages at / (like html-rendering)
exports: { // For other extensions via getExtension()
doSomething,
myHelper,
},
};
}Extension data lives in the metadata Map on nodes and users. Each extension gets its own namespace. 512KB per namespace per node. Writes are atomic MongoDB $set operations.
import { getExtMeta, setExtMeta } from "../../seed/tree/extensionMetadata.js";
import { getUserMeta, setUserMeta } from "../../seed/tree/userMetadata.js";
// Node metadata
const data = getExtMeta(node, "my-ext"); // { key: "value" } or {}
await setExtMeta(node, "my-ext", { key: "value" }); // atomic $set
// User metadata
const prefs = getUserMeta(user, "my-ext");
setUserMeta(user, "my-ext", { theme: "dark" });
await user.save();setExtMeta is async. Always await it. Spatial scoping blocks writes from extensions that are blocked at the node's position.
27 kernel hooks. before hooks run sequentially and can cancel. after hooks run in parallel. 5s timeout per handler. Circuit breaker auto-disables after 5 failures (half-open recovery after 5 minutes).
// Listen to a kernel hook
core.hooks.register("afterNote", async ({ note, nodeId, userId }) => {
// React to note creation
}, "my-ext");
// Cancel an operation (before hooks only)
core.hooks.register("beforeNodeCreate", async (data) => {
if (data.name.startsWith("_")) return false; // cancels creation
}, "my-ext");
// Fire your own hook (other extensions can listen)
await core.hooks.run("my-ext:afterProcess", { result, userId });Declare hooks in your manifest under provides.hooks.fires and provides.hooks.listens. Spatial scoping: if your extension is blocked at a node, your hook handlers are skipped for operations on that node.
A mode defines how the AI thinks at a position. System prompt + tool list. The kernel injects [Position] before your prompt automatically. You never need to include rootId or currentNodeId.
// modes/coach.js
export default {
name: "tree:my-coach",
emoji: "🏋️",
label: "Coach",
bigMode: "tree",
toolNames: ["get-tree", "get-node", "create-new-node"],
buildSystemPrompt({ username, rootId }) {
return `You are ${username}'s personal coach.
Your job is to help them organize and track their goals.`;
},
};
// In init():
core.modes.registerMode("tree:my-coach", coachMode, "my-ext");Tools let the AI act on the tree. Define with a zod schema and an async handler. The loader registers them on the MCP server. readOnlyHint matters for spatial scoping (restricted extensions keep read-only tools).
import { z } from "zod";
const tools = [
{
name: "my-ext-lookup",
description: "Look up data in the my-ext index",
schema: {
nodeId: z.string().describe("Node to look up"),
query: z.string().optional().describe("Search query"),
},
annotations: { readOnlyHint: true },
handler: async ({ nodeId, query }) => {
const result = await doLookup(nodeId, query);
return {
content: [{ type: "text", text: JSON.stringify(result) }],
};
},
},
];
// In init():
return { tools };Declare in the manifest. The CLI discovers them from /api/v1/protocol. Each command maps to an HTTP method + endpoint.
provides: {
cli: [
{
command: "my-cmd [message...]",
description: "Do something with a message",
method: "POST",
endpoint: "/root/:rootId/my-ext",
bodyMap: { message: 0 }, // maps first arg to body.message
},
{
command: "my-list",
description: "List things",
method: "GET",
endpoint: "/user/:userId/my-ext",
},
],
}Required fields: command, description, method, endpoint. The CLI replaces :rootId, :userId, :nodeId with the current context.
Return an Express router from init(). Mounted at /api/v1. Use sendOk/sendError from protocol.
import express from "express";
import authenticate from "../../seed/middleware/authenticate.js";
import { sendOk, sendError, ERR } from "../../seed/protocol.js";
const router = express.Router();
router.get("/my-ext/data", authenticate, async (req, res) => {
try {
const data = await getData(req.userId);
sendOk(res, { items: data });
} catch (err) {
sendError(res, 500, ERR.INTERNAL, err.message);
}
});
// In init():
return { router };Return a jobs object with start(). Use .unref() on timers for graceful shutdown. Read intervals from land config.
// jobs.js
import { getLandConfigValue } from "../../seed/landConfig.js";
export function start() {
const interval = Number(getLandConfigValue("myExtInterval")) || 3600000;
const timer = setInterval(async () => {
// do periodic work
}, interval);
if (timer.unref) timer.unref();
}
// In init():
const jobs = await import("./jobs.js");
return { jobs };Three patterns. Extensions never import each other directly.
// 1. Hooks (pub/sub)
core.hooks.run("my-ext:afterProcess", { result });
// Another extension:
core.hooks.register("my-ext:afterProcess", handler, "other-ext");
// 2. Exports (direct call)
import { getExtension } from "../loader.js";
const other = getExtension("other-ext")?.exports;
if (other) other.doSomething();
// 3. Metadata (shared state on nodes)
// Extension A writes: await setExtMeta(node, "ext-a", { score: 42 });
// Extension B reads: const data = getExtMeta(node, "ext-a");When your metadata format changes, write a migration. The loader runs pending migrations at boot based on schemaVersion in your manifest.
// manifest.js
{ schemaVersion: 2, ... }
// migrations.js
export default {
2: async () => {
// Migrate from v1 to v2
const nodes = await Node.find({ "metadata.my-ext": { $exists: true } });
for (const node of nodes) {
const old = getExtMeta(node, "my-ext");
await setExtMeta(node, "my-ext", { ...old, newField: old.oldField });
}
},
};treeos ext publish my-ext sends your extension's manifest and files to the Horizon. The Horizon indexes metadata (name, version, description, deps). Extension code lives on your land. The Horizon is a search index, not a host.
Other operators install with treeos ext install my-ext. The CLI pulls from your land. SHA256 checksum verified on install.
core.hooks.register("enrichContext", async ({ context, node }) => {
const data = getExtMeta(node, "my-ext");
if (data.summary) {
context.myExt = "[My Extension] " + data.summary;
}
}, "my-ext");// manifest: optional: { services: ["energy"] }
let energy = null;
export function setEnergyService(svc) { energy = svc; }
// In init():
if (core.energy) setEnergyService(core.energy);
// When needed:
if (energy) await energy.useEnergy({ userId, action: "my-action" });import { getExtension } from "../loader.js";
// Check if HTML rendering is available
if (!getExtension("html-rendering")) {
return sendOk(res, jsonData);
}
// Render HTML
const html = getExtension("html-rendering").exports;
res.send(html.renderMyPage({ data }));Cannot read properties of undefined (reading 'sendOk')Missing needs.services: ["protocol"] in manifest. The loader scopes core to declared services.Cannot read properties of undefined (reading 'registerStrategy')Missing needs.services: ["auth"] in manifest.setExtMeta doesn't persistsetExtMeta is async. Add await. Without it, the MongoDB $set may not complete before the response sends.Extension skipped on boot (no error)Check the boot log. Missing required env var, unmet dependency, or manifest validation failure. The loader logs the reason.Tool not available to AICheck: (1) tool returned from init() in tools array, (2) tool name in mode's toolNames, (3) extension not blocked at this node position.Hook handler never firesCheck: (1) hook name spelling (typo detection warns but doesn't block), (2) extension blocked at node position, (3) circuit breaker tripped (5 failures).Module not found: ../../core/...Old path. The kernel is at ../../seed/... not ../../core/...The full extension format specification lives in land/extensions/EXTENSION_FORMAT.md in the repo. This page covers what you need to get started. The spec covers everything.