Build Extensions

The developer reference.

Everything you need to build, test, and publish extensions for the seed. Code-first. Copy-pasteable. Each section is self-contained.

Quick Start

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.

The Manifest

manifest.js declares what your extension needs and what it provides. The loader reads it before calling any code. Unmet needs = extension skipped.

needs.services

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 queries
authresolveTreeAccess, registerStrategy
protocolsendOk, sendError, ERR, WS, CASCADE constants
sessioncreateSession, endSession, session lifecycle
chatstartChat, finalizeChat, chat tracking
llmprocessMessage, switchMode, runChat, runPipeline
mcpconnectToMCP, closeMCPClient
websocketemitNavigate, emitToUser, registerSocketHandler
orchestratorOrchestratorRuntime, acquireLock, releaseLock
orchestratorsregister, get (orchestrator registry)
cascadedeliverCascade
ownershipaddContributor, removeContributor, setOwner
treegetAncestorChain, checkIntegrity, getCacheStats

needs.models

Core models: Node, User, Note, Contribution, LlmConnection, Chat.

optional

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

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" },
  ],
}

init(core)

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

Storing Data

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.

Hooks

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.

AI Modes

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");

MCP Tools

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

CLI Commands

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.

Routes

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

Background Jobs

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

Cross-Extension Communication

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");

Schema Migrations

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 });
    }
  },
};

Publishing

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.

Common Patterns

enrichContext (inject into AI prompts)

core.hooks.register("enrichContext", async ({ context, node }) => {
  const data = getExtMeta(node, "my-ext");
  if (data.summary) {
    context.myExt = "[My Extension] " + data.summary;
  }
}, "my-ext");

Optional energy wiring

// 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" });

HTML rendering integration

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

Common Mistakes

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.

Get StartedExtensionsThe Seed