Dashboard widgets
Add application-owned information to the shared Cloud dashboard.
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
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:
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>:
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():
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
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:
200withWidgetResponsewhen the user may see the content;403when the user lacks the required access;204when 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.
Board and gallery
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:
{
"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.