Skip to content

Console pages and layouts ​

Plugins can do more than send chat messages — they can contribute whole React pages to Remote Console. The Sandbox adapter's /sandbox chat page is one.

Like commands, pages live in a convention directory, pages/; files are discovered automatically and compiled into modules the browser can load.

Your first page ​

tsx
// pages/index/index.tsx
import { definePage } from '@zhin.js/console-contract';

export const meta = definePage({
  title: 'My Plugin',
  icon: 'Box',
  order: 10,
});

export default function MyPluginPage() {
  return <div>Hello from my plugin</div>;
}
  • The default export is a React component — the page content.
  • The named export meta declares title, icon and order.
  • Only pages/<name>/index.tsx (or index.ts) is a page; other files in the folder can be imported as components and helpers.

Start the bot, open Console, and the page appears in the navigation.

Page metadata ​

Every meta field is optional; definePage throws on unknown keys:

FieldDefaultDescription
titleGenerated from the folder name (foo-bar → Foo Bar)Navigation and header title
iconnoneA lucide-style icon name, e.g. 'Box', 'Workflow'
order100Navigation order; lower comes first
hideInNavfalseHide from navigation
requiredPermissions[]Permissions required to access
requiredRoles[]Roles required to access

Page addresses ​

Address = plugin path + page name:

FileOwning pluginAddress
pages/index/index.tsxsandbox/sandbox
pages/workroom/index.tsxyour bot (root)/p-workroom
pages/index/index.tsxyour bot (root)/

index maps to the plugin path itself; other pages map to p-<name>. Two pages resolving to the same address fail at startup. Implementation: pageRoute in packages/console/plugin-contract/src/page.ts.

pages/ has two reserved names:

  • pages/nav/index.tsx: the navigation area
  • pages/footer/index.tsx: the footer

Default-export a React component; no meta needed. When both .ts and .tsx exist for a slot, .tsx wins. Projects created by npm create zhin-app already include both as examples.

Example: the Sandbox chat page ​

The Sandbox adapter (plugins/adapters/sandbox) provides both an adapter and a page, so its package.json#zhin declares two Features:

json
{
  "zhin": {
    "protocol": 1,
    "type": "plugin",
    "entry": "./plugin.ts",
    "runtime": "trusted",
    "features": [
      { "package": "@zhin.js/adapter", "api": "^1.0.0" },
      { "package": "@zhin.js/page", "api": "^1.0.0" }
    ]
  }
}
  • @zhin.js/adapter discovers the adapter under adapters/, providing the WebSocket /sandbox connection.
  • @zhin.js/page discovers pages/index/index.tsx, which is why Console shows a /sandbox chat page.

The page's SandboxChat component connects to /sandbox over WebSocket (address and token in pages/sandboxTransport.ts). Sending a message in the page is equivalent to a real platform receiving one: it goes through middleware, command matching and the AI. So you can fully debug commands, component rendering and the Agent without any real platform. Sandbox displays outbound HTML directly instead of converting it to an image or text — see Middleware & components.

The plugin entry itself needs nothing; the page and adapter are discovered automatically:

ts
// plugins/adapters/sandbox/plugin.ts
import { definePlugin } from 'zhin.js';

export default definePlugin({
  name: 'sandbox',
  metadata: { displayName: 'Sandbox Adapter' },
});

How it works ​

Page files run in the browser, so Node doesn't load them; the client builder (TypeScriptClientBuilder, packages/console/pagemanager) bundles them with esbuild:

  • Output: each page is bundled as <owner>-<localName>-<contentHash>.js into the project's .zhin/client/ and served by GET /assets/client/* (cache-control: immutable; new content means a new file name). @zhin.js/console-contract is replaced with a no-op stub at bundle time, and meta is extracted statically.
  • One React only: browsers don't understand bare imports like import 'react', so imports on the ALLOWED_ESM_CANONICAL allowlist (react, react-dom, react-dom/client, react/jsx-runtime(-dev), react-router, react-router-dom) are rewritten to /esm/<enc>.mjs, built and proxied on demand by the Host so the whole Console shares one React instance. Anything off the allowlist returns 403.
  • Routes: registered in basic/cli/src/plugin-runtime/console/host.ts. GET /console is the page index, GET /console/api/pages returns the page manifest, and other paths are matched to pages and served a page shell (with an importmap and mount script); no match returns 404, insufficient permission 403. The browser loads the page with import(moduleUrl) and mounts the default export on #root.

Next steps ​