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 {
/** Per instance; a taskbar document (and its pane) carries the
* sentinel "__bar" on every display. */
instanceId: string;
/** "main" = the widget document; "pane" = a popout pane document;
* "bar" = the taskbar surface. Same grants either way. */
role: "main" | "pane" | "bar";
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 };
/** Bar documents (and a bar's pane): the screen edge the bar window
* docks to. Absent on hosts older than this field: treat as "bottom". */
bar?: { edge: "bottom" | "left" | "right" };
}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.
- A bar document (see bar widgets and
building a taskbar) has
role: "bar", real mouse input until it reports segments, no grid-style popouts (its pane opens beside the strip) andbar.edgeset;interactiveis always false for it.
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, staleness so a settings-driven refresh cancels the in-flight cycle, and the settings trigger itself.
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;
/** Setting keys whose VALUE changing should re-poll, coalesced over
* refreshDebounceMs. Keys left out never cost a request. */
refreshOn?: string[];
/** How long the watched keys must rest before the re-poll (default 400ms). */
refreshDebounceMs?: number;
}
interface PollHandle {
/** Cancel any in-flight or scheduled cycle and poll now. */
refresh: () => void;
stop: () => void;
}Polling starts immediately. The usual wiring, with refreshOn naming the
settings the fetch actually reads:
dd.settings.bind((next) => {
settings = next; // repaint on every change
});
dd.poll({
fetch: () => fetchForecast(settings),
onData: render,
every: () => Number(settings.refreshMinutes) * 60_000,
refreshOn: ["city", "units", "refreshMinutes"],
});Do not call refresh() from a settings callback. The dialog live-previews
your settings as the user types, one change per keystroke, so that version
re-fetches once per CHARACTER: a nine-letter city name is nine requests, and
dd.http.fetch allows 30 a minute. refreshOn coalesces the burst into one
poll once the values rest, and a setting you leave out of the list, a panel
restyle for instance, costs nothing at all.
refresh() stays for the cases it suits: a manual reload button, or a retry
after the user fixes something.
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.
The host sanitizes forwarded text before writing it: URL query strings are
replaced with ?..., home-directory paths collapse to ~, and each line is
capped at 8 KB. Users share their log file for support, so nothing a widget
logs should be able to leak a token or a username. Log identifiers and state,
not raw URLs or file paths.
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; for a bar, the bar window or its pane, app 0.3.7 or
newer) 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 |
games.* | games |
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.