One Tree, Three Interfaces

CLI. AI. Browser. Same data.

Install an extension and it adds tools the AI can call, commands the CLI shows, and pages the browser renders. All from one package. Uninstall it and all three vanish together. Block it at a branch and the AI loses the tool, the CLI loses the command, and the browser loses the page. At that position. Same tree. Same rules. Three interfaces that always agree.

Why This Matters

Most platforms have a frontend team and a backend team and they disagree about what the UI should show. The frontend checks permissions its own way. The backend checks a different way. They drift. Buttons appear that lead to 404s. Links show for features that aren't installed. Admin panels display options the user can't actually use.

TreeOS doesn't have this problem. The same extension that registers an AI tool also registers the UI that displays it. The same spatial scoping that determines whether the AI can use a tool at a position determines whether the browser shows it. Install the solana extension and the wallet link appears on every node's values page. Block solana on a Journal tree and the wallet link vanishes there. Not because of a CSS rule. Because the kernel filtered it using the same resolution chain that filtered the AI's tools.

One extension. One init(). Backend logic, AI tools, CLI commands, and browser UI. All deployed together. All scoped together. All removed together. The frontend is not a separate app. It's a view into the same tree the AI sees.

?html

Every API endpoint returns JSON by default. Add ?html to the query string and the same endpoint returns a rendered HTML page instead. Same data. Same auth. Same URL. Different format.

# JSON (for CLI, AI, extensions, integrations)
GET /api/v1/node/abc-123
-> { "status": "ok", "data": { "name": "Fitness", "type": "goal", "children": [...] } }

# HTML (for browsers)
GET /api/v1/node/abc-123?html
-> <html>... rendered page with the same node data ...</html>

The kernel serves JSON. The html-rendering extension intercepts ?html requests and wraps the same data in a visual interface. Remove html-rendering and the API still works. The HTML layer is optional. The data layer is the truth.

Three Ways In

CLI

Terminal native. cd, ls, chat, note, tree. The fastest way to navigate and build. Extension commands appear automatically. Power users live here.

treeos cd Fitness
treeos chat "add back day"
treeos tree

AI

The AI sees the same tree you see. It reads nodes, notes, metadata. It calls tools via MCP. It writes notes, creates nodes, changes statuses. When you chat, the AI is reading and writing the same data the CLI and browser show.

[Position]
User: tabor
Tree: Fitness (abc-123)
Current node: Push Day

Browser

The HTML rendering extension wraps API data in a visual interface. Navigate trees, read notes, view values, manage extensions. Every page is the same API call with ?html appended. What the browser shows is what the CLI returns is what the AI reads.

/api/v1/node/abc-123?html
/api/v1/root/abc-123?html
/dashboard

UI Slots

Pages don't know which extensions are installed. They define named slots. Extensions register HTML fragments for those slots during init(). The page resolves whatever's registered. Same pattern as hooks, modes, and tools. Extensions register. The resolver filters.

// Extension registers a fragment for the apps page grid
const treeos = getExtension("treeos-base");
treeos?.exports?.registerSlot?.("apps-grid", "fitness", (ctx) => {
  const roots = ctx.rootMap.get("Fitness") || [];
  return `<div class="app-card">
    <div class="app-header">
      <span class="app-emoji">&#x1F4AA;</span>
      <span class="app-name">Fitness</span>
    </div>
    <div class="app-desc">Track workouts. Progressive overload.</div>
    ${roots.map(r => `<a class="app-active" href="...">${r.name}</a>`).join("")}
  </div>`;
}, { priority: 10 });

// The apps page resolves all registered cards:
const cards = resolveSlots("apps-grid", { userId, rootMap, tokenParam });
// -> fitness card + food card + kb card + whatever else is installed

Spatial scoping applies to slots. If an extension is blocked at the current node position, its slot fragments don't render. The page doesn't decide. The slot resolver uses the same spatial scoping that filters the AI's tools.

Slot Names

apps-gridApp cards on the /apps page. Fitness, food, recovery, study, kb each register one.
user-quick-linksLinks on the user profile page. Artifacts, Summons, Dids, Mail, Invites, etc.
user-profile-badgeTier badge or plan badge on the profile header.
user-profile-energyEnergy meter on the profile header.
user-profile-sectionsFull sections below the profile header. Raw idea capture form, etc.
tree-quick-linksBack-nav links on the tree overview page. Summons, etc.
tree-owner-sectionsOwner-only sections on the tree overview. Gateway config, etc.
tree-holdingsHoldings section on tree overview. Deferred cascade items.
tree-dreamDream schedule section on tree overview.
tree-teamTeam/collaboration section on tree overview.
node-detail-sectionsSections on the node detail page. Values, versions, etc.
node-detail-belowBelow the detail sections. Scripts, etc.
node-type-optionsOptions inside the node type dropdown.
energy-paymentPayment/billing UI on the energy page. Only renders if billing extension installed.
version-badgeBadge on version detail page.
version-meta-cardsMetadata cards on version detail.
version-detail-sectionsFull sections on version detail.

Raw Mode

By default, each slot fragment is wrapped in a <div data-slot="..." data-ext="..."> for live WebSocket updates. When slots render inside elements that don't allow div children (like <ul> or <select>), pass { raw: true } to skip the wrapper:

// Inside a <ul> - divs would break the HTML
<ul class="nav-links">
  ${resolveSlots("user-quick-links", { userId, queryString }, { raw: true })}
</ul>

// Inside a <select> - divs would completely break rendering
<select>
  ${resolveSlots("node-type-options", { node }, { raw: true })}
</select>

Live Dashboard Updates

When extension data changes (a workout is logged, a meal is tracked, a note is added), the dashboard updates without a page refresh. Two mechanisms work together.

After Summon Response

Every app dashboard includes a chat bar (via chatBarJs). After the AI responds, the built-in refreshDashboardData() function re-fetches the page HTML, parses it, and swaps the layout content. The dashboard updates in place. No full reload.

Background Changes via WebSocket

When data changes from other sources (cascade signals, background jobs, another device), extensions emit a dashboardUpdate event via WebSocket. The client catches it and re-fetches the page.

// Server side: extension hooks emit updates when data changes
core.hooks.register("afterNote", async ({ nodeId }) => {
  const node = await core.models.Node.findById(nodeId).select("rootOwner metadata").lean();
  if (!node?.rootOwner) return;
  const fm = node.metadata instanceof Map ? node.metadata.get("fitness") : node.metadata?.fitness;
  if (!fm?.role) return;
  core.websocket?.emitToUser?.(
    String(node.rootOwner),
    "dashboardUpdate",
    { rootId: String(node.rootOwner) }
  );
}, "fitness");

// Client side: chatBarJs connects via socket.io and listens
socket.on("dashboardUpdate", function(msg) {
  if (msg.rootId !== currentRootId) return;
  if (document.body.classList.contains("thinking")) return;
  refreshDashboardData();
});

emitSlotUpdate

For more targeted updates, emitSlotUpdate re-renders a single slot fragment and pushes just that HTML to the client. The client swaps the matching data-slot container.

// Re-render one extension's fragment for one slot and push to the user
const treeos = getExtension("treeos-base");
treeos?.exports?.emitSlotUpdate?.(core, userId, "apps-grid", "fitness", { rootMap });

The App Shell

The /dashboard route serves the app shell: a split-panel layout with a chat panel on the left and an iframe viewport on the right. The iframe loads HTML pages. The chat panel connects via WebSocket for real-time AI conversation.

When the iframe navigates to an app dashboard (e.g. /root/:id/fitness?html), the app shell detects the URL change, extracts the rootId, and emits urlChanged so the server switches the chat session to that tree. The tree's mode overrides kick in automatically. You're on the fitness page, the AI thinks in fitness mode.

inApp

The app shell adds ?inApp=1 to every iframe URL. Dashboard pages check this and skip rendering their own chat bar, because the app shell's chat panel handles conversation. Without this check, there would be two chat interfaces: the shell's panel and the iframe's bar.

// In your dashboard renderer
export function renderMyDashboard({ rootId, rootName, token, userId, inApp }) {
  return page({
    css: css + (!inApp ? chatBarCss() : ""),
    body: body + (!inApp ? chatBarHtml({ placeholder: "..." }) : ""),
    js: !inApp ? chatBarJs({ endpoint: `/api/v1/root/${rootId}/my-ext`, token, rootId }) : "",
  });
}

// In your route handler, pass inApp through
res.send(renderMyDashboard({
  rootId, rootName, token: req.query.token,
  userId: req.userId, inApp: !!req.query.inApp,
}));

When loaded standalone (direct URL, no iframe), dashboards keep their embedded chat bar. When loaded inside the app shell, the shell owns the chat. One codebase, two contexts.

Building an App Extension

A TreeOS app is an extension that registers pages, tools, modes, slots, and hooks. The tree is the database. The AI is the backend logic. The HTML is the frontend. The CLI is the power user interface. All four access the same data through the same API.

// manifest.js
export default {
  name: "my-app",
  version: "1.0.0",
  description: "My custom app",
  needs: { services: ["hooks", "modes", "metadata"], models: ["Node"], extensions: [] },
  optional: { extensions: ["treeos-base", "html-rendering"] },
  provides: { routes: true, tools: true, cli: [
    { command: "my-app", scope: ["tree"], description: "Talk to my app", method: "POST",
      endpoint: "/root/:rootId/my-app", body: { message: "$message" } },
  ]},
};

// index.js
export async function init(core) {
  // Register AI modes
  core.modes.registerMode("tree:my-app-log", myLogMode, "my-app");
  core.modes.registerMode("tree:my-app-plan", myPlanMode, "my-app");

  // Register app card on the apps page
  const treeos = getExtension("treeos-base");
  treeos?.exports?.registerSlot?.("apps-grid", "my-app", (ctx) => {
    return `<div class="app-card">...</div>`;
  }, { priority: 60 });

  // Live dashboard updates when data changes
  core.hooks.register("afterNote", async ({ nodeId }) => {
    // ... emit dashboardUpdate for this tree
  }, "my-app");

  core.hooks.register("afterMetadataWrite", async ({ nodeId, extName }) => {
    if (extName !== "my-app" && extName !== "values") return;
    // ... emit dashboardUpdate for this tree
  }, "my-app");

  // Enrich AI context
  core.hooks.register("enrichContext", async ({ context, node, meta }) => {
    const myMeta = meta?.["my-app"];
    if (!myMeta?.role) return;
    context.myAppState = await getState(String(node._id));
  }, "my-app");

  // Routes + tools
  const router = (await import("./routes.js")).default;
  const tools = (await import("./tools.js")).default();

  return { router, tools, modeTools: [
    { modeKey: "tree:my-app-plan", toolNames: ["my-app-create", "my-app-update"] },
  ]};
}

The Rendering Stack

No React. No build step. No client-side framework. Server-rendered HTML from template strings. The same Node.js process that runs the AI serves the pages. Fast, lightweight, works everywhere.

page()
html-rendering/html/layout.js. The HTML document skeleton. Every page calls this with title, css, body, js.
baseStyles
html-rendering/html/baseStyles.js. Shared CSS: glass cards, headers, forms, grids, animations. Import what you need.
chatBarJs()
html-rendering/html/chatBar.js. Embeddable chat widget. Handles send, receive, thinking animation, history, auto-send, live dashboard refresh.
renderAppDashboard()
html-rendering/html/appDashboard.js. Generic app dashboard. Pass hero, stats, bars, cards, commands. Gets chatbar, delete button, entry animations.
resolveSlots()
treeos-base/slots.js. Resolve registered HTML fragments for a named slot. Filters by spatial scoping.
emitSlotUpdate()
treeos-base/slots.js. Push a re-rendered slot fragment to the client via WebSocket.

The tree is the single source of truth. The CLI reads it. The AI reads it. The browser renders it. None of them own the data. The tree does. When you type treeos note "bench 135x10", the AI sees it. When the AI creates a node, the browser shows it. When you edit in the browser, the CLI reflects it. One tree. Three interfaces. Zero divergence.

Start BuildingExtensionsGuide