Skip to main content

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​

yaml
ui:
workspace:
position: right
custom_views:
- id: dashboard
label: Dashboard
icon: layout-dashboard
entry: dashboard.html
FieldRole
idStable identifier. Also works as default_view: dashboard to open the tab first.
labelTab name in the mode menu.
iconA lucide icon name. Falls back to layout-dashboard when omitted or unknown.
entryPath inside the app's own {app}/web/dist/ bundle, e.g. dashboard.html or custom/dashboard/index.html.
themedigitorn (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:

html
<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:

yaml
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.

tsx
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") },
],
},
]);
FieldRole
idStable identifier, relayed back on digi:topbar-click.
labelButton (or menu trigger) text.
iconA lucide icon name. Falls back to layout-dashboard when omitted or unknown, same as custom_views.
onClickRuns in the view's own code. Omit it on an entry that only opens a menu (items) or sets href.
hrefOpens this URL in a new tab, opened by the host itself instead of relayed - see below. Ignored if set alongside items.
styleSize, weight, shape, variant, tone - see Styling below.
itemsTurns 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:

tsx
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​

tsx
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" },
},
]);
FieldValuesDefault
sizesm | md | lgmd
weightnormal | medium | semibold | boldmedium
shapesquare | rounded | pillrounded
variantghost | outline | solidghost
tonedefault | accentdefault

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:

html
<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:

tsx
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):

html
<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​

yaml
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:

json
{ "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.