Cloud|

Dashboard widgets

Add application-owned information to the shared Cloud dashboard.

13 min read Updated 2026-10-13 #dashboard#widgets#authorization

A widget shows a small, current summary from an application on the shared dashboard.

The application owns one authenticated handler. Cloud discovers its declared widget, sends the user's session only to Core, and renders the shared widget blocks. Core exchanges that session for a 30-second invocation JWT bound to the target app and exact widget ID. The provider reloads the current actor and performs its normal authorization; it never receives the source cookie.

Cloud also forwards the Dashboard request's resolved locale in the x-cloud-locale header. Resolve it with getLocale(c) in the endpoint; do not rely on Accept-Language surviving the server-side fan-out.

Declare a widget

ts
export const app = defineApp({
  id: "inventory",
  // ...
  presentation: {
    baseLocale: "en",
    translations: {
      de: { widgets: { stock: { title: "Bestand", description: "Knappe Artikel in allen Lagern." } } },
    },
  },
  widgets: [
    {
      id: "stock",
      path: "/api/inventory/widget/stock",
      title: "Stock",
      description: "Low-stock items across all warehouses.",
      sizes: ["small", "medium"],
      defaultSize: "medium",
      suggest: true,
    },
  ],
});

The ID must be unique inside the application and contain 1–188 lowercase letters, digits, ., _, -, or :. Core invokes a widget as widget.read:<id>, which an invocation allows up to 200 characters, and @ separates a widget from the size the dashboard asks for. The path must be an absolute route served by that application.

Field Meaning
title Name in the dashboard and its gallery, up to 80 characters. Defaults to the app's name.
description One sentence for the gallery, up to 200 characters. Defaults to the app's description.
sizes Sizes the widget offers: small, medium, large. Defaults to ["large"].
defaultSize Size the widget starts in, one of sizes. Defaults to the largest offered size.
suggest Put the widget on the default board and under Suggested for you.
requiresRoles Offer the widget only to people with one of these roles, like nav.requiresRoles.
settings Up to four settings each board entry can set; see Declare settings.

Translate title and description under presentation.translations.<locale>.widgets.<id>. defineApp() rejects a duplicated, overlong, or otherwise invalid ID, an empty or overlong text, an unknown size or role, a defaultSize the widget does not offer, and a suggest that is not true or false.

requiresRoles only decides what the gallery and the default board offer. It is not authorization: the handler still checks every request and answers 403 to anyone who may not see the widget.

Suggest only widgets that are useful without any setup and that rarely have nothing to show. A suggested widget appears on the board of everyone who has not arranged their own board yet.

Choose sizes

The board has four columns on wider screens and two on phones. Every widget has one of three fixed sizes:

Size Four columns Phone Typical content
small one column, one row half the width one number, one status, or one hero
medium two columns, one row full width a status with its counts, or two list rows
large two columns, two rows full width, two rows a list of about six rows, or several blocks

A row is about 10.5rem high, so a small or medium widget has room for about 7rem of content below its header. Offer a size only when the handler fills it well. A widget without sizes offers only large, the height every widget had before sizes existed.

presentation.defaultZone and presentation.defaultSpan are deprecated: the dashboard has no zones or widths any more. Declare sizes and defaultSize instead, and keep an existing presentation for now. The dashboard reads it only to convert a board saved before sizes existed into the board the person saw.

Declare settings

A widget can declare up to four settings. Each board entry can ask the same widget with its own values. For example, Spaces declares an optional Space:

ts
settings: {
  space: { kind: "resource", type: "spaces.space", label: "Space" },
}
Kind Declaration Value
choice label, 1–6 options with label and value, optional default One declared option value as a string.
resource label, a namespaced resource type, such as spaces.space A CloudResourceRef: { type, id }.
toggle label, optional boolean default A boolean.
text label, optional placeholder and maxLength A trimmed string.

Names are camelCase identifiers matching /^[a-z][a-zA-Z0-9]{0,31}$/ and must not name a built-in object member such as toString or constructor. Labels, option labels, and placeholders are trimmed and must contain 1–80 characters. Option values must be distinct within a field, contain 1–100 characters, and contain no control characters. A choice default must be one of its values. Text maxLength is an integer from 1 to 200 and defaults to 200. defineApp() rejects invalid declarations at startup.

Translate setting presentation under presentation.translations.<locale>.widgets.<id>.settings.<name>:

ts
presentation: {
  baseLocale: "en",
  translations: {
    de: { widgets: { today: { settings: { space: { label: "Space" } } } } },
  },
}

A setting translation can supply label, placeholder, and options, an object of option labels keyed by option value. Regional overlays merge each setting and each option, so a de-CH label keeps other translations from de. Values and resource types never change with the locale.

Read values with getWidgetRequest(c).settings. Core validates them before forwarding, and the application validates them again against its declaration. Unknown names and invalid values are silently dropped, never reported as a settings error. Missing or dropped choice and toggle values use their declared defaults. Empty, overlong, or control-containing text and resource references of another type are dropped. Core sends the values in the request URL and never sends more than 8 KiB of them, URL-encoded, which keeps the request within an application's 16 KiB header limit. A board entry whose values encode longer, which only happens with very long resource IDs, ends as error instead of reaching the widget without its settings.

A widget must work without settings. Values can be missing, dropped, or stale. A valid resource reference grants no access: resolve its app-owned ID and use the normal service to authorize every chosen resource on every call. Answer 403 if the resource no longer exists or access is gone.

Register the handler

Export the Hono handler used by that route and register the same function with app.start():

ts
import { type AuthContext, getWidgetRequest } from "@k2b/cloud/server";
import type { Context } from "hono";

export const stockWidgetHandler = async (c: Context<AuthContext>) => {
  const { size } = getWidgetRequest(c);
  // Load and authorize c.get("accessSubject"), then return a WidgetResponse that fits `size`.
};

export default await app.start({
  fetch: router.fetch,
  widgets: { stock: stockWidgetHandler },
});

The declaration is the discovery contract; the app.start({ widgets }) map is the framework-owned internal invocation contract. Startup rejects an internal handler whose ID was not declared. Invocation JWTs are accepted only by the generated internal widget route, never by the public application route.

getWidgetRequest(c) returns { size, settings }. The size is always one the widget declares. Return the content that fits it, for example only the most important number in small and a longer list in large. Outside a dashboard invocation, such as a request to the handler's own public route, it returns { size: "large", settings: {} }. Settings are validated against the declaration; the handler still authorizes chosen resources and works without them.

Return widget data

ts
import type { WidgetResponse } from "@k2b/cloud/contracts";

const body: WidgetResponse = {
  title: "Inventory",
  icon: "ti ti-package",
  href: "/app/inventory",
  meta: "today",
  blocks: [
    {
      kind: "stat",
      value: lowStockCount,
      label: "Low-stock items",
      accent: { tone: "amber", icon: "ti ti-alert-triangle" },
    },
    {
      kind: "list",
      items: items.map((item) => ({
        label: item.name,
        meta: String(item.quantity),
        href: `/app/inventory/items/${item.id}`,
      })),
      emptyMessage: "Stock levels are healthy.",
    },
  ],
};

return c.json(body);

The top-level response requires title and blocks. It also accepts icon, href, and meta. The complete serialized response is limited to 128 KiB. Cloud validates and reserializes it before the dashboard sees it. Unknown fields are stripped for compatibility; oversized strings or collections, malformed JSON, and non-finite numbers are rejected.

Choose a block

Every block has one kind. Fields not listed for that kind are not part of the contract.

Kind Required fields Optional fields
stat value, label sub, valueClass, accent, grow
list items emptyMessage, grow
status tone, title message, icon, grow
pills pills grow
placeholder title description, icon
hero title subtitle, icon, tone

A stat accent requires tone and icon; it can also contain text.

Each list item requires label. It can contain icon, iconTone, sub, meta, and href.

Each pill requires label and value. It can contain tone and href.

Every href, including links inside list items and pills, must be a safe relative reference or an absolute HTTP(S) URL. Active schemes such as javascript:, backslashes, and control characters are rejected. Protocol-relative HTTP(S) links are accepted too.

WidgetResponse contains final display strings, never catalog keys. Numeric stat.value and pill.value fields are formatted automatically by @k2b/ui for the inherited locale; string values remain byte-for-byte unchanged. Return a string when the value is already deliberately composed. The application owns labels, dates, currency, relative time, plurals, list text, empty states, and error guidance. See Internationalize an application.

Use placeholder for a compact empty or unavailable state inside the widget.

Widget tones are emerald, amber, red, blue, or zinc. Status tones are ok, warn, error, or info.

Use only the fields defined by WidgetResponse. Cloud controls widget layout and visual styling.

Enforce access in the endpoint

Cloud authenticates the invocation, but it does not authorize application data. The handler must use the normal request identity and resource permission checks.

OAuth callers need read or admin at both the Core widget proxy and the internal widget route. Session and API-key requests keep their existing access rules; OAuth scopes never replace the handler's resource permission checks.

Framework-owned internal widget routes provide the same request runtime, settings, actor, access subject, and resolved locale as public application routes. Handlers can use the normal runtime context; they do not need a separate internal-route initialization path.

Return:

  • 200 with WidgetResponse when the user may see the content;
  • 403 when the user lacks the required access;
  • 204 when the widget has no content.

A widget keeps its place on the board whatever it answers. A 403 widget shows "No access any more" in its frame, and a 204 widget shows "Nothing to show right now". A timeout or another non-success response is logged with a bounded failure reason and shown inside that widget only.

Loading, timeouts, and failures

The dashboard page renders the board first, with a frame in the final size for every widget and a loading state inside it. The browser then asks Core for all widgets on the board through POST /api/widgets/v1, with each entry's id, size, and settings. Each widget fills its own frame as soon as its app answers; widgets that already arrived never wait for slower ones.

  • Concurrency: Core asks at most eight widgets at the same time and starts the rest as earlier ones finish.
  • Timeout: each widget has its own 8-second budget, counted from the moment Core starts it. Core signs every invocation of one dashboard together, so widgets do not wait on one another for authorization. A widget still loading after three seconds says in its frame that its app is taking longer than usual.
  • Stream deadline: one stream ends after 30 seconds, when the invocations Core signed for it expire. Every widget that has not answered by then reports timeout, including one still waiting for a free place that was never asked; Try again asks it in a new stream. Only a board with many slow widgets reaches this deadline: at eight at a time, 100 entries that each take three seconds need about 39 seconds.
  • Failure: a widget that times out, answers with an unexpected status, or returns invalid JSON shows a short message and Try again in its own frame. Retrying asks only that widget again. The rest of the board is never affected.
  • Layout: the frame's size comes from the board, not from the content. Longer content scrolls inside the widget, so put the most important information at the top.
  • Resizing: when a person changes a widget's size, the board asks the widget again in the new size and keeps the old content until the new one arrives.

Keep widget queries bounded and fast: a widget is a glanceable summary, and a slow one keeps showing its loading state until it answers or its budget ends. Link to the application for detailed work instead of turning the widget into a full page.

Each person has one board: an ordered list of widgets, each in one of its sizes, in the same reading order on every device. Until a person changes something, they follow the default board: every suggested widget they may see, in its default size, large ones first. Widgets an app suggests later appear on it by themselves. After the first change the board belongs to the person; new widgets then wait under Suggested for you in the gallery. Default in the edit mode returns to the default board.

Edit or a long press on a widget opens the edit mode. People drag widgets to move them, which selects no page text, choose Move forward or Move back, or focus one and use the arrow keys. They pick a size at the bottom of the widget and remove it with ×. Add widget opens the gallery, which lists the widgets the person may use, grouped by app, each with a live preview of the person's own data in the chosen size. The gallery loads a preview only when its card is on screen, at most eight at a time. Done saves the board without reloading the page.

A widget with declared settings offers Customize in edit mode. It opens a dialog, or a bottom sheet on phones, with the declared choices, toggles, text, and resource fields. Apply reloads that widget while keeping its current content until the new answer arrives. Reset clears the values so declared defaults apply again. Done saves the changes; Cancel in edit mode restores the previous board and settings.

The gallery can add a widget with settings several times. Each board entry has its own id and settings, stored separately, even when entries name the same widget. A widget without settings appears at most once: saving a board with a second entry of a running widget that declares no settings fails with 400. Core does not judge a key whose app is not running, or every key while the app registry cannot be read, so such a save still succeeds; the page then shows that widget once. The framework validates values against the current declaration on every call, and the handler authorizes every chosen resource. A stored resource title is only a display snapshot for the settings dialog; it is never sent to the widget and grants no access.

In edit mode, every label and announcement names an entry by its widget title and its first chosen resource, option, or text, for example "Today · Team Logistics", so screen reader users can tell entries apart. The widget's own frame shows what its handler returns, so a widget that can appear several times should name its chosen resource or option in its title or meta.

A board keeps the place and settings of a widget whose app is not running, and shows it again when the app returns. An entry keeps its settings while its app temporarily declares none.

Read widgets from a browser

The stream is GET /api/widgets/v1 with Accept: application/x-ndjson. Each line is a WidgetStreamLine from @k2b/cloud/contracts: start names the widgets in registry order, one widget line follows per widget with status ok, empty, forbidden, timeout, or error, and done ends the stream. Repeat widget=<appId>/<widgetId> to ask only some widgets, and append @small, @medium, or @large to ask a widget in that size, for example widget=inventory/stock@small. A widget asked without a size, or in one it does not offer, answers in its default size. A browser reads the stream with streamWidgets() from @k2b/cloud/browser/widgets, which takes the sizes as sizes, keyed by widget; aborting its signal stops every widget Core still waits for.

Use POST /api/widgets/v1 to ask once per board entry, including repeated widget keys with different settings. Send JSON with Content-Type: application/json and Accept: application/x-ndjson:

json
{
  "widgets": [
    { "id": "first", "key": "spaces/today", "size": "large", "settings": { "space": { "type": "spaces.space", "id": "abc123" } } },
    { "id": "second", "key": "spaces/today" }
  ]
}

The strict body accepts 1–100 entries. Each entry needs a unique id of 1–120 characters and a <appId>/<widgetId> key of 1–269 characters, the longest key Core can invoke. The byte limit is the largest valid body: 100 entries with every value at its maximum, in compact JSON, with each character of a free-form string at its longest escape, which is 1,426,513 bytes. size is an optional string, fitted to the widget's offered sizes. settings is an optional object with at most four values. Invalid JSON, a schema failure, duplicate IDs, or an oversized body returns 400 with { "message": "..." }.

Unknown or malformed widget keys are left out. start.widgets lists the requested entry IDs in request order. Every widget line includes that entry's id and its key; lines arrive as entries finish. GET keeps its existing lines without id and names keys in start.widgets.

Pass requests: [{ id, key, size?, settings? }] to streamWidgets() to use POST. This ignores keys and sizes. Both transports forward the locale and share the concurrency, cancellation, and timeout rules above.

GET /api/widgets/v1/<appId>/<widgetId> returns one widget as JSON with the same per-widget budget; append ?size=small to ask for a size. It answers 200 with the response, 204, 403, 504 for a timeout, or 502 for another failure.

See Request identity and Resource authorization.

Connect a coding agent

The Cloud skill provides compact working instructions. MCP supplies exact current documentation when details matter.

CLI command
bunx skills add https://docs.example.com

This command installs the working instructions published by this website in the selected coding agent.

For exact, current details, also connect the MCP server through one of the agent tabs.

Installation uses the open-source Vercel Skills CLI.

The skill and MCP complement each other: the skill describes workflows, while MCP supplies current documentation.