Skip to main content

@digitornai/sdk

@digitornai/sdk is the React library any app renders behind, wherever Digitorn embeds a live iframe against a running agent session: a custom_views tab, an Excalidraw/drawdb-style full-app preview, or a Craft-style dev-server preview. One provider plus a set of hooks give the view a live socket connection to the session - the agent's chat, its workdir, its status - and a way to add itself into the host's own chrome.

There is no separate build pipeline. The view is a normal React app (Vite, or any bundler) whose dist/ output ships inside the Digitorn app's own {app}/web/dist/ bundle - the exact same static folder Excalidraw, drawdb, and Craft already use for their whole UI.

Install​

@digitornai/sdk is a public npm package. In your view's React project:

bash
npm install @digitornai/sdk socket.io-client

Install socket.io-client alongside it — it is a peer dependency (the live session runs over Socket.IO), so the SDK does not pull it in for you and the hooks fail without it. react and react-dom (the SDK's other peers) come from your React app already. Then render <Digitorn> at the root and every hook below is a normal React hook.

Setup​

tsx
import { Digitorn } from "@digitornai/sdk";
import { createRoot } from "react-dom/client";

createRoot(document.getElementById("root")!).render(
<Digitorn>
<App />
</Digitorn>,
);

Digitorn resolves the session from the URL the host already put there - app (or app_id), session (or session_id), t (preview token) or token (JWT), daemon (an explicit daemon origin, otherwise the page's own window.location.origin). Nothing to wire by hand: the same query string a custom_views entry or an embedded preview receives on load already carries all of this.

Everything it exports​

ExportWhat it does
<Digitorn>Root provider. Opens the session's live socket, everything else reads from it.
useDigitorn()Raw context (state, session, send, abort, resolveApproval, onEvent) - an escape hatch, most apps never need it directly.
useTheme()The host's current theme (mode, accent), live.
useThumbnailMode()true when rendered for a gallery-thumbnail capture (?thumbnail=1) - render clean, no chrome, no interaction.
useConnection(){ connected, error } for the live socket.
useChat(){ messages, send, abort, busy } - the full conversation.
useStream()The assistant's current in-flight message (content, reasoning, toolCalls, streaming).
useAgentStatus()"idle" | "thinking" | "tool_use" | "streaming" | "error".
useApprovals(){ pending, approve, reject } - tool-call approvals waiting on a decision.
useEvents(filter?)The raw envelope stream - every event type the session emits, live.
useWorkspace()Imperative workdir access: readFile, writeFile, writeBinary, readDir.
useWatch(match, handler)Fires handler when matching workdir files change - the core "react to what the agent did" primitive.
useFile(path)A file's text, live - re-reads itself on every matching change.
useFileJson<T>(path)Same, parsed as JSON.
useSharedDoc<T>(path, initial)[value, setValue] - a two-way JSON document synced with the agent's workdir.
useWorkspaceEndpoint()Low-level URL/auth builder useWorkspace is built from - for a view that wants to make its own fetch calls.
useAgentSnapshot(capture, opts?)Publishes a real PNG of the view on the agent's request, for multimodal vision.
useTopBarActions(actions)Adds buttons/menus to the host's top bar while this view is the active tab.
switchView(id)Asks the host to switch the workspace to another view.
usePreviewAttach()The agent's build/dev-server preview - checked once on mount, then live. undefined until something is attached.

Live chat and turn state​

A custom view sees the same live conversation the main chat panel does - messages, streaming content, tool calls, status - and can build its own read-only board from it:

tsx
import { useChat, useStream, useAgentStatus } from "@digitornai/sdk";

function StatusBoard() {
const { messages, busy } = useChat();
const { content, streaming, toolCalls } = useStream();
const status = useAgentStatus();

return (
<div>
<div>status: {status}{busy ? " (working)" : ""}</div>
{messages.map((m, i) => <p key={i}>{m.role}: {m.content}</p>)}
{streaming && <p>assistant (typing): {content}</p>}
{toolCalls.map((c) => <p key={c.callId}>{c.name} - {c.status}{c.result ? `: ${c.result}` : ""}</p>)}
</div>
);
}

send, abort, and useApprovals()'s approve/reject are part of the same hooks but do NOT work from inside a custom view - the daemon rejects them with "session belongs to another user". A view's preview token identifies it as its own read-only identity, deliberately unable to act as the real account owner; only the host app (its own JWT) can drive the conversation or resolve approvals. A view can watch a tool call happen and show its result, but the turn itself has to come from the user typing in the actual chat.

useEvents(filter?) is the raw feed underneath all of the above - every envelope the session emits, live, matched against an optional predicate:

tsx
import { useEvents } from "@digitornai/sdk";

const toolCalls = useEvents((e) => e.type === "message_started");

Theme​

tsx
import { useTheme } from "@digitornai/sdk";

function Widget() {
const theme = useTheme(); // { mode: "light" | "dark", accent, locale }
return <span>{theme.mode}</span>;
}

Most apps never call this directly - <Digitorn> already applies data-theme and --digitorn-accent on the document root, so plain CSS (:root[data-theme="dark"] { … }) reacts on its own. Reach for the hook only when the app has its own theming API to drive (Excalidraw's appState.theme, a charting library's own dark-mode flag).

The agent's workdir​

The core idea: the agent's files ARE the state. A view reads them, watches them for live updates, and writes back to hand state to the agent for its next turn.

tsx
import { useWorkspace, useWatch, useFile, useSharedDoc } from "@digitornai/sdk";

// Imperative - read/write/list on demand.
function FileBrowser() {
const { readDir, readFile, writeFile } = useWorkspace();
const [entries, setEntries] = React.useState<{ name: string; path: string; type: string }[]>([]);
React.useEffect(() => { void readDir(".").then(setEntries); }, [readDir]);
return <ul>{entries.map((e) => <li key={e.path}>{e.name} ({e.type})</li>)}</ul>;
}

// Reactive - re-renders itself whenever the agent edits the file.
function Notes() {
const notes = useFile("notes.md");
return <pre>{notes ?? "(nothing yet)"}</pre>;
}

// Two-way - the agent edits it, the view re-renders; the view edits it,
// the agent sees it on its next turn.
function Settings() {
const [config, setConfig] = useSharedDoc("config.json", { priority: "normal" });
return (
<select value={config?.priority} onChange={(e) => setConfig({ ...config!, priority: e.target.value })}>
<option value="low">Low</option>
<option value="normal">Normal</option>
<option value="high">High</option>
</select>
);
}

useWatch is what all of the above are built from - the one primitive for "react to what changed":

tsx
useWatch("*", (changes) => console.log(changes));         // everything
useWatch("scene.json", () => reloadScene()); // one file
useWatch("pages/", async () => setPages(await readDir("pages"))); // a whole folder
useWatch((path) => path.endsWith(".png"), () => refreshGallery()); // a predicate

It is push-based, not polling: the daemon watches the workdir and sends a debounced workspace_changes event over the same socket the chat uses. The only time it falls back to polling is while the socket itself is not connected (a sandboxed iframe that briefly failed to open it) - and it stops the instant the socket comes back up.

Agent vision​

An agent that wants to SEE what a view is currently showing - not just read its files - can request a real screenshot:

tsx
import { useAgentSnapshot } from "@digitornai/sdk";

function Canvas({ exportToBlob }: { exportToBlob: () => Promise<Blob> }) {
useAgentSnapshot(async () => new Uint8Array(await (await exportToBlob()).arrayBuffer()));
return <canvas />;
}

The agent writes any non-empty value to snapshot.request in its workdir (or calls the preview.snapshot platform tool, which is the simpler default path and works for every app without wiring this hook at all). This hook exists for the higher-quality case: an app that can export its own canvas directly (Excalidraw's exportToBlob) gets a cleaner capture than a generic headless screenshot of the rendered page.

Adding to the host's chrome​

A view can put its own buttons - or a small dropdown - into the host's top bar, right where Digitorn renders its Code/Preview tabs and the GitHub/Publish buttons. They only exist while this view is the active tab; switching away removes them automatically.

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") },
],
},
]);

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, and the view decides what it means. switchView accepts a built-in view (code, preview, changes, activity, documents, project) or another custom_views id. Up to 6 top-level entries and 12 items per dropdown render; past that, extra entries are dropped rather than crowding the rest of the bar.

Set href instead of onClick for an entry that opens a new tab - a relayed click that calls window.open gets silently popup-blocked (the browser no longer treats it as tied to the original click by the time the postMessage round-trip completes), so the host opens href itself, synchronously, in the click it actually received:

tsx
useTopBarActions([
{ id: "open-live", label: "Open live site", icon: "external-link", href: deployedUrl },
]);

style gives a button its own size/weight/shape/variant/tone - a bounded enum, not raw CSS, so an action can read as the tab's primary CTA (a solid accent "Deploy" button, say) while staying visually native to the bar:

tsx
useTopBarActions([
{ id: "deploy", label: "Deploy", icon: "rocket", onClick: deploy,
style: { variant: "solid", tone: "accent", shape: "pill" } },
]);

Full reference, including the bundler-free plain-HTML wire protocol, in Custom workspace views.

Advanced: building a Lovable-style build → preview flow​

usePreviewAttach() is the one primitive a custom view needs to render its own welcome/empty state until the agent has built something, then switch itself to showing the build - and keep switching every time a new one lands:

tsx
import { usePreviewAttach } from "@digitornai/sdk";

function LiveBuildPreview() {
const attach = usePreviewAttach();
if (!attach) return <Welcome />; // your own branded empty state - anything at all
return <iframe src={attach.url} title="build" style={{ width: "100%", height: "100%", border: 0 }} />;
}

attach.url is a fully authenticated, session-scoped URL - drop it straight into an <iframe src>. It fires whenever the daemon detects a built index.html (also dist/index.html, build/index.html, out/index.html, public/index.html) written into the workdir, or a dev server the agent started on its own port - no polling, no extra tool call required from the agent beyond writing its build output where it normally would. It also checks once on mount, so reopening a session that already has a build shows it immediately - not just builds that happen while the view is open.

attach.kind is static (a built index.html), devserver (a running dev server) or web (the app's own bundled web/dist/ UI). This is the SDK live build's own kind — unrelated to ui.workspace.render_mode and to a template's preview_path extension. See Which "preview" is which?.

A view isn't limited to swapping what it renders in place - the same trigger can drive a full page navigation instead:

tsx
const attach = usePreviewAttach();
React.useEffect(() => {
if (attach) window.location.href = "/built.html";
}, [attach]);

Advanced: a live multi-file dashboard​

Combining readDir with a wildcard useWatch gives a view a live file tree without re-implementing any polling of its own:

tsx
import { useWorkspace, useWatch } from "@digitornai/sdk";

function LiveTree() {
const { readDir } = useWorkspace();
const [entries, setEntries] = React.useState<{ name: string; path: string; type: string }[]>([]);
const refresh = React.useCallback(() => void readDir(".").then(setEntries), [readDir]);
React.useEffect(refresh, [refresh]);
useWatch("*", refresh);
return (
<ul>
{entries.map((e) => <li key={e.path}>{e.type === "dir" ? "[dir]" : "[file]"} {e.path}</li>)}
</ul>
);
}

Every file the agent adds, edits, or removes updates this list the moment it happens - the same live explorer behind Digitorn's own Code tab, built from public primitives.

Advanced: a Lovable-style builder, no native primitives at all​

A full "clone a repo, watch it build, deploy it" app is entirely reachable with the primitives above - no source_control/deploy_target YAML flags, no native Preview tab, every view hand-built as a custom_views entry:

  • Clone: called straight from the view's own code, no agent turn needed. GitHub's Git Trees API (GET /repos/{owner}/{repo}/git/trees/{branch}?recursive=1, CORS-open) lists every path in one call; raw.githubusercontent.com (also CORS-open) serves each file's bytes. The view writes each one to the workdir with useWorkspace().writeFile/writeBinary - the same primitives every other recipe on this page uses, nothing repo-specific about them.
  • Build: the agent's own job, from the real chat - the view has no way to start a turn (see Live chat and turn state). Once the agent runs its build with its own tools, usePreviewAttach() picks it up exactly like the build → preview recipe above.
  • Deploy: also called straight from the view's own code. Read the built files back out of the workdir with useWorkspace().readDir/readFile (or useWorkspaceEndpoint().fileUrl directly, for binary assets), then POST them to Vercel's POST /v13/deployments with a personal access token the user pastes in - inlining file content directly in the request body needs no separate upload step.

Nothing here is a new primitive - it's the same handful (writeFile, writeBinary, readDir, usePreviewAttach, useTopBarActions) composed around two external REST APIs called directly from the view. That composability is the point: the SDK doesn't special-case "a repo" or "a deploy" - it gives a view real workdir access and a live build signal, and the view does whatever it wants with them.

Auth model​

Two modes, transparent to every hook above:

  • Embedded / custom view (the normal case): the URL carries a short-lived, session-scoped preview token (t=). The view can read and write that session's workdir files and use the live socket; it cannot reach anything outside that session, and it is not the user's own account credential.
  • Host app (a JWT is present instead): full workspace/* routes under the user's own auth.

useWorkspaceEndpoint() exposes which mode is active (preview: boolean) for a view that wants to build its own requests instead of going through useWorkspace().