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
// 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
metadeclares title, icon and order. - Only
pages/<name>/index.tsx(orindex.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:
| Field | Default | Description |
|---|---|---|
title | Generated from the folder name (foo-bar → Foo Bar) | Navigation and header title |
icon | none | A lucide-style icon name, e.g. 'Box', 'Workflow' |
order | 100 | Navigation order; lower comes first |
hideInNav | false | Hide from navigation |
requiredPermissions | [] | Permissions required to access |
requiredRoles | [] | Roles required to access |
Page addresses
Address = plugin path + page name:
| File | Owning plugin | Address |
|---|---|---|
pages/index/index.tsx | sandbox | /sandbox |
pages/workroom/index.tsx | your bot (root) | /p-workroom |
pages/index/index.tsx | your 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.
Custom navigation and footer
pages/ has two reserved names:
pages/nav/index.tsx: the navigation areapages/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:
{
"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/adapterdiscovers the adapter underadapters/, providing the WebSocket/sandboxconnection.@zhin.js/pagediscoverspages/index/index.tsx, which is why Console shows a/sandboxchat 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:
// 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>.jsinto the project's.zhin/client/and served byGET /assets/client/*(cache-control: immutable; new content means a new file name).@zhin.js/console-contractis replaced with a no-op stub at bundle time, andmetais extracted statically. - One React only: browsers don't understand bare imports like
import 'react', so imports on theALLOWED_ESM_CANONICALallowlist (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 /consoleis the page index,GET /console/api/pagesreturns 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 withimport(moduleUrl)and mounts the default export on#root.
Next steps
- Convention directories: where other capabilities go.
- Console: what Console itself can do.