Core runtime
The core of the dd global: dd.ready and the init context, dd.request, dd.poll, dd.log, and how permission gating works on every call.
Every widget document gets one global, dd. The SDK script creates it:
<script src="/__sdk/widget-sdk.js"></script>On load the SDK connects your document to the host, applies the design tokens
to :root, and resolves dd.ready once the host has answered. Everything a
widget can do (read settings, fetch data, stream system state) goes through
dd; there is no other door to the system.
dd.ready
The init context, resolved once per document.
dd.ready: Promise<DdContext>
interface DdContext {
instanceId: string;
/** "main" = the widget document; "pane" = a popout pane document. */
role: "main" | "pane";
widget: { id: string; version: string };
/** What the user granted: every permission key, plus http.hosts and
* links.prefixes. Mirrors the manifest after install-time consent. */
grants: DdGrants;
/** The manifest's interactive flag. Always false for panes. */
interactive: boolean;
/** Manifest defaults merged with the user's stored values. */
settings: Record<string, unknown>;
/** The current --dd-* token map. The SDK applies it for you. */
tokens: Record<string, string>;
/** Bridge API info: feature-detect against version; deprecated maps a
* method name to its deprecation note. */
api?: { version: number; minSupported: number; deprecated: Record<string, string> };
/** Static for the whole app run; absent on older hosts (treat as off). */
perf?: { lowSpec: boolean };
/** Pane documents only: the applied side and the data open() passed. */
popout?: { side: "down" | "up" | "left" | "right"; data: unknown };
}Notes:
perf.lowSpecis true when the user enabled low performance mode. It never changes mid-run, so read it once. Widgets that animate should cap their draw loop around 30 fps and their canvas backing store at 1xdevicePixelRatio; the host already lowers its own side (spectrum frames arrive at 20 Hz instead of 30).- A pane document (see popouts) boots the SDK exactly like the main document and carries the same instance identity and grants.
dd.request()
The low-level escape hatch. Every namespaced method wraps it; call it directly only for something the typed surface does not cover.
dd.request<T>(method: string, params?: unknown, timeoutMs?: number): Promise<T>Requests reject with an Error carrying a code:
interface DdError extends Error {
code: string; // "PERMISSION_DENIED", "RATE_LIMITED", ...
}The full code list is on
Errors and versioning. The client rejects
with TIMEOUT after 15 seconds by default; timeoutMs: 0 disables the client
timer (the dialog pickers use this, since they wait on the user).
dd.poll()
The polling loop widgets keep re-implementing: an interval, error backoff, and
staleness, so a settings-driven refresh() cancels the in-flight cycle.
dd.poll<T>(opts: PollOptions<T>): PollHandle
interface PollOptions<T> {
/** Do the work; return the data (or throw to trigger onError + backoff). */
fetch: () => Promise<T>;
onData: (data: T) => void;
onError?: (err: unknown) => void;
/** Milliseconds until the next poll after a success: a number, or a getter
* read each cycle so it can follow a live setting. */
every: number | (() => number);
/** Exponential backoff from 1s on error, capped at every (default true). */
backoff?: boolean;
}
interface PollHandle {
/** Cancel any in-flight or scheduled cycle and poll now. */
refresh: () => void;
stop: () => void;
}Polling starts immediately. The usual wiring:
const poll = dd.poll({
fetch: () => fetchForecast(settings),
onData: render,
every: () => Number(settings.refreshMinutes) * 60_000,
});
dd.settings.onChange((next) => {
settings = next;
poll.refresh();
});dd.log()
dd.log(level: "debug" | "info" | "warn" | "error", ...args: unknown[]): voidWrites to the host's log, tagged with your widget and instance id. Uncaught errors and unhandled rejections in your document are forwarded there automatically, so the host log is the first place to look when a widget misbehaves.
dd.links.open()
The one way out of the widget: open something with the OS default handler.
dd.links.open(url: string): Promise<void>Needs the links permission, and url must start with one of the manifest's
links.prefixes entries (each a full scheme://... string, like
steam://run/ or https://github.com/). Dangerous schemes (file,
javascript, data, and friends) are refused regardless of what a manifest
declares. Honored only while the real cursor is inside the widget (call it
from a click handler) and never in edit mode. 5 calls per rolling 10
seconds.
How permission gating works
The host checks permissions before a method dispatches. Most methods map to the permission named by their prefix:
| Prefix | Needs |
|---|---|
settings.*, theme.*, widget.* | nothing, always available |
time.* | time |
storage.* | storage |
files.* | files |
notify.* | notifications |
http.* | http, with the exact host allowlisted |
links.* | links, with a matching prefix |
desktop.* | desktop |
folders.* | folders |
apps.* | systemApps |
system.* reads | system |
system.* writes | system + systemControls |
media.* reads | media |
media.* controls | media + mediaControls |
audio.* | audio |
widgets.* | control |
peers.* | peers |
claudeUsage.* | claudeUsage |
power.* | power |
dialogs.pickDesktopItems | desktop |
dialogs.pickFolderItems | folders |
popout.* | a manifest popout block (a capability, not a permission) |
input.* | the manifest interactive flag (SDK-managed) |
A call without its grant rejects PERMISSION_DENIED. See the
permissions reference for what each grant
means to the user.
Rules that apply everywhere:
- Every
on*subscription returns an unsubscribe function. - Rate limits are per instance over a rolling 10 second window unless a
method says otherwise; exceeding one rejects
RATE_LIMITED. - Serialized request params are capped at 256 KB.
How the connection works
Widget documents run in sandboxed iframes with an opaque origin. On load the
SDK performs a small handshake with the host and receives a dedicated message
port plus the init context; all further traffic flows over that port, and the
host ties every message to the frame it physically arrived from. None of this
is your concern day to day: the dd namespaces are the whole surface, and
nothing a widget puts inside a message can change which widget the host
believes it is talking to.