ReferenceAPI

Data services

Reference for dd.http fetch against host allowlists, dd.feeds for user-chosen news feeds, the dd.weather helpers, and dd.claudeUsage.

dd.http

Needs the http permission with the exact hostname allowlisted in the manifest (permissions.http.hosts). All traffic goes through the host's proxy; widgets have no direct network access at all.

dd.http.fetch()

dd.http.fetch(req: {
  url: string;
  method?: string;   // GET (default), POST, PUT, PATCH, DELETE, HEAD
  headers?: Record<string, string | { $secret: string }>;
  /** Extra query params merged into the URL host-side after allowlist
   *  validation. Values may be { $secret: name } like headers. */
  query?: Record<string, string | { $secret: string }>;
  body?: string;
  /** "dataUrl" returns the raw response bytes as a base64 data: URL
   *  (bodyText/json omitted), for images. Default "text". */
  as?: "text" | "dataUrl";
}): Promise<{
  status: number;
  headers: Record<string, string>;
  bodyText?: string;
  json?: unknown;    // parsed when the response is JSON
  dataUrl?: string;
}>

The rules, all enforced host-side:

  • Hosts. The URL's hostname must exactly match an entry in permissions.http.hosts. https only (http allowed for localhost). Redirects are followed up to 4 hops, each hop re-validated against the allowlist.
  • Methods. GET, POST, PUT, PATCH, DELETE, HEAD. Headers that would let a widget impersonate the browser session (host, cookie, origin, referer, connection-control headers) are refused; the response exposes a small safe set (content-type, content-length, date, cache-control, etag, last-modified).
  • Secrets. A header or query value of { "$secret": "settingKey" } names one of this widget's secretRef settings; the host injects the user's stored value after validation. The resolved value (and the merged URL) never appears in widget JS, logs, or error messages, and secret query params are never re-appended across redirects.
  • Sizes and pacing. Request body up to 256 KB; response up to 1 MB; query up to 32 pairs (keys 128 bytes, values 2048 bytes, final URL 8 KB); 30 second timeout; 2 concurrent requests per instance; 30 requests per minute by default, raised by the manifest's http.rateLimit up to 120.
  • Binary. as: "dataUrl" returns the bytes as a base64 data: URL (same 1 MB cap). The widget CSP allows img-src data:, so the result can feed an img or dd-icon directly.
const res = await dd.http.fetch({
  url: "https://api.example.com/v1/status",
  headers: { authorization: { $secret: "apiKey" } },
});
if (res.status === 200) render(res.json);

dd.feeds

Needs the feeds permission (app 0.3.9+, and 0.3.11+ for dd.feeds.open; the store sets minAppVersion). Reads a news feed from the web address the user typed into one of the widget's url settings, and opens its items in the browser. The host fetches and parses the feed and hands back only the parsed items, never the response, so the grant cannot fetch anything else.

dd.feeds.fetch()

dd.feeds.fetch(opts: { settingKey: string }): Promise<FeedResult>

interface FeedResult {
  /** The feed's own title (at most 120 characters), or null. */
  title: string | null;
  /** Up to 50 items, in the order the feed lists them. */
  items: FeedItem[];
  /** When the host last downloaded this feed (epoch ms). */
  fetchedAtMs: number;
}

interface FeedItem {
  /** Plain text on one line: tags removed, entities decoded, at most 300
   *  characters. Items without a title never arrive. */
  title: string;
  /** An absolute http(s) address of at most 1024 bytes, or null. Open it
   *  with dd.feeds.open. */
  link: string | null;
  /** The published time, else the updated time, in epoch ms; or null. */
  publishedMs: number | null;
}

settingKey names one of this widget's url settings. A url field never inherits its manifest default, so the widget shows a setup state until the user types an address; put an example in the field's placeholder. RSS 0.9, 1.0 and 2.0, Atom and JSON Feed all work.

The rules, all enforced host-side:

  • Address. https only (http allowed for localhost). Redirects are followed up to 4 hops, each held to the same rule, and a hop from a public address into loopback or a private network is refused.
  • Cache. One cache per address, shared by every widget that reads it. A call within 5 minutes of a download answers from memory; a later one revalidates with the ETag and Last-Modified the server sent.
  • Output. Only titles, links and dates cross the bridge. Put titles on screen with textContent, and open links with dd.feeds.open, which needs no links grant.
  • Errors. NOT_FOUND while the setting is blank or when the feed is gone (404 or 410); INVALID_PARAMS for an address that is not https; PERMISSION_DENIED when settingKey is not a url field; INTERNAL when the page is not a feed or the server fails. No error carries the address.
  • Sizes and pacing. Response up to 1 MB, 30 second timeout; 10 calls per rolling 10 seconds.
dd.poll({
  every: 15 * 60_000,
  refreshOn: ["feedUrl"],
  fetch: () => dd.feeds.fetch({ settingKey: "feedUrl" }),
  onData: (feed) => render(feed.items),
  onError: (err) => (err.code === "NOT_FOUND" ? showSetup() : showStale()),
});

dd.feeds.open()

dd.feeds.open(opts: { settingKey: string; link: string }): Promise<void>

App 0.3.11+. Opens an item link from dd.feeds.fetch in the default browser. The widget needs no links grant: the host opens only a link it parsed out of the feed at the address the user typed into that same url setting, so a widget can never pick another page.

  • Which links. The items of the newest answer for that address, plus those of earlier answers, up to 200 links. A headline that is still on screen after a refresh still opens. When the host no longer holds the address in its cache, the call reads the feed once before it decides.
  • Gesture. Honored only while the real cursor is inside the widget, and never from a desktop widget in edit mode. Call it from a click handler.
  • Errors. NOT_FOUND when the link is in none of those answers (the headline left the feed); PERMISSION_DENIED outside a click; plus the dd.feeds.fetch errors for the setting.
  • Pacing. 5 calls per rolling 10 seconds.
strip.addEventListener("click", (event) => {
  const item = event.target.closest("[data-link]");
  if (!item) return;
  dd.feeds
    .open({ settingKey: "feedUrl", link: item.dataset.link })
    .catch((err) => dd.log("warn", "headline open failed", err.code));
});

dd.weather

The shared weather stack, built on Open-Meteo. Convenience code, not a capability: everything routes through this widget's own grants. geocode needs geocoding-api.open-meteo.com in http.hosts, current and forecast need api.open-meteo.com, and the geocode cache uses your storage grant.

dd.weather.describe()

dd.weather.describe(code: number, isDay: number | boolean): {
  label: string;
  glyph: WeatherGlyph;
}

type WeatherGlyph =
  | "clearDay" | "clearNight" | "partlyDay" | "partlyNight" | "cloudy"
  | "fog" | "drizzle" | "rain" | "snow" | "thunder";

Maps a WMO weather code to a human label plus a glyph name for icon().

dd.weather.geocode()

dd.weather.geocode(query: string, opts?: { cacheKey?: string }): Promise<{
  query: string;
  lat: number;
  lon: number;
  label: string;
}>

Resolves a place name, cached in the widget's own storage (one entry per cacheKey; a changed query re-fetches).

dd.weather.current()

dd.weather.current(opts: {
  lat: number;
  lon: number;
  units?: "celsius" | "fahrenheit";
}): Promise<{
  temperature: number;  // in the requested unit
  code: number;         // WMO code; feed it to describe()
  isDay: boolean;
}>

dd.weather.forecast()

dd.weather.forecast(opts: {
  lat: number;
  lon: number;
  units?: "celsius" | "fahrenheit";
  scale: "hourly" | "daily";
  count?: number;       // slots to return, 1..24 (default 5)
  stepHours?: number;   // hourly only: hours between slots, 1..12 (default 1)
}): Promise<Array<{
  time: string;         // local ISO: "2026-08-28T15:00" hourly, "2026-08-29" daily
  code: number;         // WMO code; feed it to describe()
  isDay: boolean;       // daily slots are always true
  temperature: number;  // hourly: the temperature. daily: the high
  low?: number;         // daily only
}>>

The next slots of the forecast, hour by hour or day by day. Slot 0 is always the current period, this hour or today, so you can label it "Now" or "Today" without a second call; stepHours spaces the hourly slots, which lets five of them reach a day and a half ahead.

Unlike geocode, this is stateless: nothing is cached for you. Keep your own last-good snapshot in dd.storage if you want the widget to paint something after a remount or while the network is down.

The array is short rather than padded when the forecast window ends, and a malformed or empty response throws instead of returning partial slots.

dd.weather.icon()

dd.weather.icon(glyph: WeatherGlyph | string): string

A built-in line-art icon as a data: URI, ready for <dd-icon src=...> (the widget CSP allows img-src data:). Unknown names return "".

dd.claudeUsage

Needs the claudeUsage permission.

dd.claudeUsage.status()

dd.claudeUsage.status(opts: { dirKey: string }): Promise<ClaudeUsage>

Claude Code's own usage as one aggregated snapshot. dirKey names one of this widget's filePath settings; the host resolves the .claude directory the user configured there, reads the limit bars, today's tokens by model, and recently-active sessions, and refreshes the bars from Anthropic's usage endpoint in the background. The OAuth token, transcript contents, and raw paths never reach the widget. Poll it. 20 calls per rolling 10 seconds.

A blank setting means the host finds the folder (app 0.5.1+; before that, %USERPROFILE%\.claude): CLAUDE_CONFIG_DIR, the default install, and the ~/.claude folders of running WSL distros, the most recently used one winning. The host never starts a stopped WSL distro. A folder in one, found or typed, answers its last snapshot with wslStopped: true.

interface ClaudeUsage {
  /** False when the configured directory is not a readable Claude home,
   *  or a blank setting found none: show a "set Claude home dir" hint. */
  dirOk: boolean;
  /** True when the folder is in a WSL distro that is not running: the
   *  rest is the last snapshot read, with no sessions. With dirOk false,
   *  nothing was found and a stopped distro went unsearched. App 0.5.1+. */
  wslStopped?: boolean;
  claudeVersion: string | null;
  /** Where limits came from: "live" (Anthropic's usage endpoint), "cache"
   *  (Claude Code's own cache), or "none" (nothing available yet). */
  source: "live" | "cache" | "none";
  /** When limits was produced (epoch ms), for an "as of" readout. */
  fetchedAtMs: number | null;
  /** One bar per active limit window; percent is 0..100. A window whose
   *  reset time has passed reads 0 with resetsAtMs null (app 0.5.1+). */
  limits: {
    kind: string;
    group: string;
    label: string;
    percent: number;
    severity: string;
    resetsAtMs: number | null;
    isActive: boolean;
  }[];
  /** Today's usage summed from the transcripts (local day), by model. */
  today: {
    byModel: {
      model: string;
      input: number;
      output: number;
      cacheRead: number;
      cacheWrite: number;
      messages: number;
    }[];
    totals: {
      input: number;
      output: number;
      cacheRead: number;
      cacheWrite: number;
      messages: number;
    };
  };
  /** Recently-active Claude Code sessions. */
  sessions: {
    name: string | null;
    cwd: string;
    status: string;
    updatedAtMs: number;
  }[];
}