Custom workspace views
ui.workspace.custom_views adds app-defined tabs to the workspace mode
menu, next to Code / Preview / Changes / Activity / Documents. Each tab is
a static file the app already ships under its own {app}/web/dist/
bundle, the same bundle Excalidraw/Craft-style apps use for their whole
UI. It is served as-is through the existing web-static route, no daemon
change required, and the @digitornai/sdk bridge (session, theme, workdir
access) works inside it exactly the way it does in the main Preview tab.
This is how to add a real, custom, VSCode-style panel to an app - an agent dashboard, a settings screen, a diagram the agent draws into - by shipping a small React (or plain HTML) build, with zero changes to the Go daemon.
Declaring a view
ui:
workspace:
position: right
custom_views:
- id: dashboard
label: Dashboard
icon: layout-dashboard
entry: dashboard.html
| Field | Role |
|---|---|
id | Stable identifier. Also works as default_view: dashboard to open the tab first. |
label | Tab name in the mode menu. |
icon | A lucide icon name. Falls back to layout-dashboard when omitted or unknown. |
entry | Path inside the app's own {app}/web/dist/ bundle, e.g. dashboard.html or custom/dashboard/index.html. |
theme | digitorn (default) or none - see Theme below. |
custom_views is hidden by default like every other opt-in ui:
surface: an app with none declared shows nothing extra in the mode menu.
What ships the actual view
The YAML only carries the tab's name and which file to open - the view
itself is a normal static build the app already has in its bundle. Any
static site works: a plain HTML file with a <script> tag, or a real
Vite/React app whose dist/ output lands at the entry path. There is
no separate build pipeline to learn: it is the exact same web/dist/
folder Excalidraw, drawdb, and Craft already ship for their own UI.
Theme
By default the view follows Digitorn's own light/dark theme live: it is
seeded on load and pushed again over postMessage on every toggle, so
switching the host's theme switches the view's theme too, without a
reload. A real React app gets this for free just by rendering inside
<Digitorn> - @digitornai/sdk applies data-theme on the document and
exposes the current value through useTheme(). A view built without a
bundler follows the same contract by hand:
<script>
const q = new URLSearchParams(location.search);
document.documentElement.dataset.theme = q.get("theme") === "dark" ? "dark" : "light";
window.addEventListener("message", (e) => {
if (e.data?.type === "digi:theme-change") {
document.documentElement.dataset.theme = e.data.theme.mode === "dark" ? "dark" : "light";
}
});
</script>
Set theme: none on the view to skip all of this and let it keep its
own look, untouched:
custom_views:
- id: dashboard
entry: dashboard.html
theme: none
Top bar actions
A view can add its own buttons - or a small dropdown menu - to the host's top bar, right where Digitorn renders its own Code/Preview tabs and the GitHub/Publish buttons. They only show up while THIS view is the active tab; switching to another view removes them automatically, so nothing ever lingers from a screen the user has left.
import { useTopBarActions, switchView } from "@digitornai/sdk";
useTopBarActions([
{ id: "refresh", label: "Refresh", icon: "refresh-cw", onClick: () => reload() },
{
id: "more",
label: "More",
icon: "more-horizontal",
items: [
{ id: "export", label: "Export", onClick: () => exportData() },
{ id: "open-code", label: "Open Code tab", onClick: () => switchView("code") },
],
},
]);
| Field | Role |
|---|---|
id | Stable identifier, relayed back on digi:topbar-click. |
label | Button (or menu trigger) text. |
icon | A lucide icon name. Falls back to layout-dashboard when omitted or unknown, same as custom_views. |
onClick | Runs in the view's own code. Omit it on an entry that only opens a menu (items) or sets href. |
href | Opens this URL in a new tab, opened by the host itself instead of relayed - see below. Ignored if set alongside items. |
style | Size, weight, shape, variant, tone - see Styling below. |
items | Turns the entry into a small dropdown instead of a plain button. Each sub-item takes the same id / label / icon / onClick / href / style shape. |
Only id / label / icon / href cross into the host - onClick
stays in the view's own code and never leaves the iframe. A click is
relayed back by id, so the view decides what it means: run something
locally, open its own modal or panel, or call switchView("code") to
hand control back to Digitorn and jump to another view. switchView
accepts a built-in view (code, preview, changes, activity,
documents, project) or another custom_views id. Because the
handler lives entirely in the view, a click can do anything an ordinary
web app can do - open a full settings dialog, run a multi-step form,
drive local state - there is nothing the host needs to know about
beyond which button was pressed.
Opening a new tab is the one thing a relayed click can't do reliably -
by the time the click reaches the view over postMessage and it calls
window.open, the browser no longer considers it tied to the original
click and silently blocks the popup. Set href instead of onClick for
that entry and the host opens it directly, synchronously, in the click
it actually received:
useTopBarActions([
{ id: "open-live", label: "Open live site", icon: "external-link", href: deployedUrl },
]);
href must be an absolute http(s) URL - anything else (including a
javascript: URI) is dropped rather than opened.
Up to 6 top-level entries and 12 items per dropdown render; anything past that is dropped rather than crowding out the rest of the bar.
Styling an action
useTopBarActions([
{ id: "refresh", label: "Refresh", icon: "refresh-cw", onClick: reload, style: { size: "sm" } },
{
id: "deploy",
label: "Deploy",
icon: "rocket",
onClick: () => setDeployOpen(true),
style: { variant: "solid", tone: "accent", weight: "semibold", shape: "pill" },
},
]);
| Field | Values | Default |
|---|---|---|
size | sm | md | lg | md |
weight | normal | medium | semibold | bold | medium |
shape | square | rounded | pill | rounded |
variant | ghost | outline | solid | ghost |
tone | default | accent | default |
Each field is a closed enum, not a CSS string or a color value - the host
maps every value to its own design tokens, so an action can stand out (a
solid accent "Deploy" button reading as the primary action on a tab)
without a view being able to hand the host arbitrary styling or markup.
An unrecognized value is dropped rather than applied.
A view built without a bundler talks the same protocol directly:
<script>
window.parent.postMessage({
type: "digi:topbar-register",
items: [{ id: "refresh", label: "Refresh", icon: "refresh-cw" }],
}, "*");
window.addEventListener("message", (e) => {
if (e.data?.type === "digi:topbar-click" && e.data.id === "refresh") {
reload();
}
});
</script>
Registering is always a full replace, never a diff - send the whole
current set of buttons every time it changes, and an empty array clears
them. useTopBarActions also clears on unmount, so leaving the screen
inside the app (not just switching tabs) is enough to drop stale buttons.
Reading and writing the agent's workdir live
Inside the view, @digitornai/sdk gives full, reactive access to the
session's workdir - the same primitives that make the agent draw into
Excalidraw in real time:
import { useSharedDoc } from "@digitornai/sdk";
function Dashboard() {
const [status] = useSharedDoc("status.json", { task: "", progress: 0 });
return <div>{status?.task} - {status?.progress}%</div>;
}
The agent writes status.json to its workdir (a normal file write, no
special tool); useSharedDoc re-reads it the moment the daemon reports
the change over the same event socket the host chat uses, no polling
loop to write. setStatus(next) writes back the other way, so a click
in the dashboard can hand state back to the agent for its next turn -
the same round trip Excalidraw uses when a user edits a shape the agent
drew.
useWatch, useFile, useFileJson, and useWorkspace are the lower-
level primitives useSharedDoc is built from, for apps whose state
is not a single JSON file - a folder, many files, binary data. See the
SDK's own README for the full API.
A view built without a bundler can reach the same data with a plain
fetch, reading its own session from the query string the daemon seeds
on load (app, session, t):
<script>
const q = new URLSearchParams(location.search);
const url = `/api/apps/${q.get("app")}/sessions/${q.get("session")}` +
`/preview/files/status.json?t=${q.get("t")}`;
fetch(url).then(r => r.json()).then(renderStatus);
</script>
Auth
The daemon issues a session-scoped preview token for the view - the same short-lived token Excalidraw/Craft already run under - not the user's own account credential. The view can read and write the session's workdir files; it cannot reach anything outside that session.
Example: a live agent dashboard
ui:
workspace:
position: right
default_open: true
default_view: dashboard
custom_views:
- id: dashboard
label: Dashboard
icon: layout-dashboard
entry: dashboard.html
The agent's system prompt asks it to keep status.json in its workdir
up to date as it works:
{ "task": "Writing the quarterly report", "progress": 42, "updated_at": "2026-08-20T09:27:00Z" }
{app}/web/dist/dashboard.html reads that file, live, and renders it -
no polling library, no build step, using the plain-fetch pattern
above. Opening the app lands directly on the Dashboard tab
(default_view: dashboard); Code, Changes, and the rest of the built-in
views are still reachable from the mode menu once opted in via
shown_views (see Client manifest).
A "Refresh" button next to the tab, wired the same way the plain-HTML
protocol example above shows, re-reads status.json on demand instead of
waiting for the next workspace_changes push - the whole tab, from the
YAML declaration to its live data to its own top-bar action, needs
nothing beyond what is on this page.
Related
- Client manifest - the full
ui.workspaceblock - preview