fix(custom-widgets): refine v2 authoring

This commit is contained in:
Thomas Camlong committed 2026-07-20 14:14:53 +02:00
1 parent b050c267b6
commit f9863b8402
124 files changed
+2089 -913

No files matched your search

+1 -1
View File
@@ -89,6 +89,6 @@ Use the interactive API reference to inspect the exact request body for each end
Custom JSX v2 has two stable HTTP discovery endpoints:
- `GET /api/custom-widgets/authoring-prompt` returns the concise current authoring prompt and live resource identifiers.
- `GET /api/custom-widgets/skill` returns the Agent Skill name, version, `SKILL.md`, archive URL, and SHA-256.
- `GET /api/custom-widgets/skill` returns the Agent Skill name, version, live `SKILL.md`, skills.sh URL, repository source URL, and install command.
The short-lived credential entry route is generated by `customWidget_secretRequestUser`; do not construct or persist those URLs yourself. Definition CRUD, validation, previews, template patches, and secret writes are exposed through authenticated tRPC and MCP procedures. Exported definitions never include credentials.
@@ -1,12 +1,18 @@
# Agents and downloadable skill
# Agents and skills.sh
An external agent creates widgets one at a time through Homarr's MCP server. The harness owns any multi-widget task list; Homarr does not create packs or multi-widget drafts.
<a className="button button--primary" href="/downloads/homarr-custom-widget-2.0.0.zip" download>
Download Custom Widget Skill
<a className="button button--primary" href="https://www.skills.sh/homarr-labs/homarr/homarr-custom-widget">
View the Custom Widget Skill on skills.sh
</a>
The archive follows the Agent Skills format and includes `SKILL.md` plus schema, runtime, and security references. Its SHA-256 is `5571b74b2faafe8a9e230231c1915193e889c7afa95acef38a1f70befe14a9e1`.
Install the Agent Skill directly from the verified Homarr repository:
```sh
npx skills add https://github.com/homarr-labs/homarr --skill homarr-custom-widget
```
The source lives at [`.agents/skills/homarr-custom-widget`](https://github.com/homarr-labs/homarr/tree/v2/.agents/skills/homarr-custom-widget). There is no ZIP archive. After the skill exists on the public `v2` branch, run the command once to register installation activity, verify the skills.sh page, and request official maker attribution for `homarr-labs` through skills.sh. Repository code cannot grant that attribution itself.
## MCP discovery
@@ -16,6 +22,21 @@ The archive follows the Agent Skills format and includes `SKILL.md` plus schema,
The authoring-prompt tool description intentionally contains only one sentence. Workflow instructions live in the prompt and link to resources.
`GET /api/custom-widgets/skill`, the MCP tool, and the MCP resource return the live `SKILL.md` with:
```ts
{
name: "homarr-custom-widget";
version: string;
skillMd: string;
skillsShUrl: string;
sourceUrl: string;
installCommand: string;
}
```
They do not return an archive URL or hash.
## Agent loop
1. Fetch the prompt, live schema, and component catalog.
@@ -1,16 +1,20 @@
# Workbench and ChatGPT
import { CanonicalCustomWidgetExample } from "@site/src/components/custom-widget-code";
The desktop workbench keeps code and preview side by side. Mobile uses Configure and Preview tabs. Save and preview actions stay available while the form scrolls.
CodeMirror edits requests, options, state, and JSX. The editor supplies installed Mantine components, request/source IDs, option/state names, runtime components, and icons. The request toolbar jumps to a declaration and atomically renames its invalidation, dynamic-option, and JSX references. Diagnostics are copyable and secret-free.
CodeMirror edits requests, options, and JSX. The editor supplies installed Mantine components, request/source IDs, option names, temporary input names, runtime components, and icons. The request toolbar jumps to a declaration and atomically renames its invalidation, dynamic-option, and JSX references. Diagnostics are copyable and secret-free.
The **Schema** action beside Undo, Redo, and Format expands the generated JSON Schema plus minimal and full examples. These references are generated from the same validators used by imports, MCP, Workshop, and saved definitions.
## Manual workflow
1. Add the API source and select its network scope and authentication mode.
2. Enter credentials. Existing values are shown only as configured; plaintext is never revealed.
3. Declare named load/manual queries and actions.
4. Define item options and local state.
5. Write JSX using `data`, `status`, `options`, and `state`.
4. Define persisted item options.
5. Write JSX using `data`, `status`, `options`, and temporary `inputs`.
6. Test requests, preview compact/standard/wide layouts, and inspect runtime and accessibility diagnostics.
7. Save, then select the definition and its options in the board widget picker.
@@ -32,3 +36,32 @@ Options use a restricted object JSON Schema. Supported controls include text, te
Credentials are not widget options. Credential-like option names and static default values are rejected; configure authentication under the widget's API source so values stay encrypted and out of exports and board configuration.
Dynamic selects use `x-homarr.optionsSource` and a named query. Add `itemsPath` when the response wraps its result array, then use `valuePath` and `labelPath` relative to each item. These requests run only while configuring the item and use the same protected server executor as widget requests.
### Named requests and parameter bindings
Request parameters are a map from parameter name to `string`, `number`, or `boolean`. Query and body templates reference them with `{ "$param": "name" }`. Load queries receive matching widget options automatically; manual queries and actions receive `params` from their runtime component.
<CanonicalCustomWidgetExample
id="named-widget-requests"
label="Named queries and actions"
example="requests"
height="420px"
/>
### Options schema and defaults
Defaults must validate against the options schema. Keep secrets out of both documents.
<CanonicalCustomWidgetExample
id="widget-options-schema"
label="Widget options schema"
example="optionsSchema"
height="380px"
/>
<CanonicalCustomWidgetExample
id="widget-default-options"
label="Default option values"
example="defaultOptions"
height="160px"
/>
@@ -7,7 +7,7 @@ Templates are declarative JSX interpreted inside a widget-local error boundary.
```jsx
<Stack>
<TextInput bind="search" label="Search TV shows" />
<SubFetch requestId="search-series" params={{ query: state.search }} />
<SubFetch requestId="search-series" params={{ query: inputs.search }} />
{data["search-series"]?.map((series) => (
<Group key={series.tvdbId}>
<Text>{series.title}</Text>
@@ -22,7 +22,7 @@ Templates are declarative JSX interpreted inside a widget-local error boundary.
- `data.<requestId>` contains sanitized JSON or text results.
- `status.<requestId>` contains loading, success, HTTP, and error state.
- `options` contains the validated board-item configuration.
- `state` contains browser-session local state.
- `inputs` contains temporary values from controls using `bind`.
Load-triggered queries populate data automatically and follow the widget refresh interval. `SubFetch` runs a manual query. `ActionButton` and `ToggleSwitch` invoke actions.
@@ -34,18 +34,21 @@ Use safe Tabler icons through the resolver, for example `<TablerIcon name="serve
The runtime blocks imports, hooks, refs, callbacks, polymorphic roots, provider replacement, arbitrary portal targets, browser networking, npm packages, evaluation, prototype-pollution keys, unsafe URLs, global selectors, and escaping CSS.
## Bindings
## Temporary inputs
Declare state types and defaults, then bind controls:
Bind a control to a literal name and read the value from `inputs`:
```jsx
<TextInput bind="search" />
<TextInput bind="search" defaultValue="" />
<Calendar bind="selectedDate" />
<Switch bind="enabled" />
<Switch bind="enabled" defaultChecked={false} />
<MultiSelect bind="selectedEntities" data={options.entities} />
<SubFetch requestId="search" params={{ query: inputs.search }} />
```
Homarr adapts ordinary `value/onChange`, `checked/onChange`, `active/onChange`, and `opened/onChange` patterns plus documented unusual component signatures. State supports primitive values and `string[]`, `number[]`, or `date[]` for multi-value controls. Authored callbacks are not allowed.
These values exist only in React memory while the widget is mounted. They are never written to the definition, board options, database, browser storage, or `localStorage`; reloading or remounting resets them. Use widget options for persisted user configuration.
Homarr infers the adapter type and handles ordinary `value/onChange`, `checked/onChange`, `active/onChange`, and `opened/onChange` patterns plus unusual Mantine signatures. `defaultValue` and `defaultChecked` initialize the temporary value and are not forwarded beside the controlled prop. Multiple components may share a name only when their adapter types match. Authored callbacks are not allowed.
## Diagnostics
@@ -54,6 +57,7 @@ Homarr adapts ordinary `value/onChange`, `checked/onChange`, `active/onChange`,
- `UNKNOWN_MANTINE_PROP`: advisory; the prop is passed through, not discarded.
- `INVALID_PROP_VALUE`: warning or error depending on safety.
- `BINDING_UNAVAILABLE`: the component renders, but `bind` is unavailable.
- `BINDING_TYPE_CONFLICT`: components sharing one input name require incompatible value types.
- `RUNTIME_RENDER_ERROR`: rendering failed inside this tile only.
Homarr never silently drops an ordinary prop.
@@ -2,9 +2,38 @@
tags: [Custom Widgets, API, JSX, MCP]
---
import { BundledCustomWidgetGallery, CustomWidgetCodeExample } from "@site/src/components/custom-widget-code";
export const completeManifest = `{
"$schema": "homarr-custom-widget-v2",
"name": "Service status",
"description": "Shows the current service status.",
"sources": [{
"id": "default",
"name": "Service API",
"baseUrl": "https://status.example.com",
"networkScope": "public",
"auth": { "type": "none" }
}],
"requests": [{
"id": "status",
"sourceId": "default",
"kind": "query",
"method": "GET",
"pathTemplate": "/api/status",
"parameters": {},
"auth": "inherit",
"minimumBoardPermission": "view",
"trigger": "load"
}],
"optionsSchema": { "type": "object", "properties": {}, "additionalProperties": false },
"defaultOptions": {},
"template": "<Badge color={data.status?.ok ? 'green' : 'red'}>{data.status?.label ?? 'Unknown'}</Badge>"
}`;
# Custom Widgets
Custom JSX v2 is Homarr's only custom-widget format. A definition owns its metadata, inline API sources, named requests, options, local state, and safe JSX template. Credentials belong to a source but are encrypted separately and never exported.
Custom JSX v2 is Homarr's only custom-widget format. A definition owns its metadata, inline API sources, named requests, options, and safe JSX template. Credentials belong to a source but are encrypted separately and never exported.
There are no legacy display types, reusable connections, packs, or compatibility modes.
@@ -13,7 +42,7 @@ There are no legacy display types, reusable connections, packs, or compatibility
- [Workbench and ChatGPT](./authoring): create widgets manually or use the copy/paste AI loop.
- [JSX runtime](./custom-jsx): data roots, Mantine components, bindings, and diagnostics.
- [Requests and security](./requests-and-security): sources, actions, permissions, and network protections.
- [Agents and downloadable skill](./agent-authoring): MCP prompt/resources/tools and the portable Agent Skill.
- [Agents and skills.sh](./agent-authoring): MCP prompt/resources/tools and the portable Agent Skill.
- [Troubleshooting](./troubleshooting): fix validation, request, configuration, and runtime failures.
## One definition
@@ -28,17 +57,22 @@ interface HomarrCustomWidgetV2 {
requests: CustomWidgetRequest[];
optionsSchema: CustomWidgetOptionsSchema;
defaultOptions: Record<string, unknown>;
stateSchema?: Record<string, "string" | "number" | "boolean" | "date" | "string[]" | "number[]" | "date[]">;
defaultState?: Record<string, unknown>;
template: string;
}
```
<CustomWidgetCodeExample
id="complete-widget-manifest"
label="Complete Custom Widget manifest"
code={completeManifest}
height="420px"
/>
Exports, imports, the UI, MCP, and Workshop all validate this schema. Old files are rejected. If a board references a missing definition, only that tile shows **Custom widget unavailable**; the board and sibling widgets continue rendering.
## Create and configure
Open **Management → Custom Widgets → Add custom widget**. The continuous workbench has sections for general information, API sources and credentials, requests, options, state, JSX, and preview. Use the sticky navigation to jump between sections; there are no artificial “next” steps.
Open **Management → Custom Widgets → Add custom widget**. The continuous workbench has sections for general information, API sources and credentials, requests, options, JSX, and preview. Use the sticky navigation to jump between sections; there are no artificial “next” steps.
A board item selects a definition from a searchable picker and immediately renders the definition's options. Invalid options block insertion. If an updated definition makes existing configuration invalid, opening the item shows the normal repair form.
@@ -47,3 +81,9 @@ A board item selects a definition from a searchable picker and immediately rende
Export downloads one canonical JSON document without credentials. Import validates and shows the origins, auth modes, network scopes, methods, actions, and required board permissions before creating anything.
The [Workshop](/docs/management/workshop) publishes valid widgets immediately. Users can vote or report a submission. Workshop administrators may dismiss reports or delete any post; deletion does not remove installed local copies.
## Bundled examples
Homarr seeds four disabled examples. They use the same canonical schema as user widgets and are never overwritten after a user edits them. Select one to explore its manifest and JSX.
<BundledCustomWidgetGallery />
@@ -13,7 +13,7 @@ Queries and actions support GET, POST, PUT, PATCH, and DELETE. `kind`, not metho
Successful actions evict the declared queries from both the browser and server response cache before refetching them.
Path parameters use `{name}`. Structured query/body templates use `{ "$param": "name" }`. Runtime values can come from validated options, local state, and action input.
Path parameters use `{name}`. Structured query/body templates use `{ "$param": "name" }`. Runtime values can come from validated options, temporary `inputs`, and explicit action parameters.
## Protections
@@ -6,7 +6,7 @@ The definition was deleted or reset. Remove the broken tile from the board and i
## Validation fails
Use the first schema/JSX diagnostic and its path. Ensure source/request IDs are unique, every request references a source, parameters are declared, defaults match options/state schemas, and network components reference the correct request kind.
Use the first schema/JSX diagnostic and its path. Ensure source/request IDs are unique, every request references a source, parameters are declared, defaults match the options schema, and network components reference the correct request kind.
## Request fails
@@ -14,7 +14,7 @@ Check the source network scope, base URL, credentials, request method/path/body,
## Mantine prop warning
`UNKNOWN_MANTINE_PROP` is advisory and the prop is passed through. Confirm the prop against the installed Mantine version. `BLOCKED_CAPABILITY` is deliberate and must be expressed through safe options, state, bindings, or named requests instead.
`UNKNOWN_MANTINE_PROP` is advisory and the prop is passed through. Confirm the prop against the installed Mantine version. `BLOCKED_CAPABILITY` is deliberate and must be expressed through safe options, temporary bindings, or named requests instead.
## Existing item needs repair
+1 -1
View File
@@ -187,4 +187,4 @@ The complete tool surface is `customWidget_schema`, `customWidget_validate`, `cu
`custom-widget-manage` permits authoring. Supplying a secret directly requires the separate `custom-widget-secret-write` permission. Request execution retains Homarr's SSRF, DNS, redirect, response-size, timeout, rate, network-scope, and board-permission protections.
See [Agents and downloadable skill](./custom-widgets/agent-authoring) for the full loop and portable Agent Skill.
See [Agents and skills.sh](./custom-widgets/agent-authoring) for the full loop and portable Agent Skill.
@@ -9,3 +9,17 @@ Votes are unique per user and submission. Public listings show upvotes, downvote
Workshop administrators use a protected administration page to review reports grouped by submission, see reporter identities and explanations, inspect vote totals, dismiss reports, delete any submission, browse unreported posts, and view the audit trail. Workshop has no banning, account-state, moderator, approval, quarantine, badge, or remote-deletion system.
Deleting a Workshop post never deletes widgets already installed in a self-hosted Homarr instance.
## GitHub sign-in deployment
Workshop authentication is hosted by the central Workshop PocketBase service. Register this single callback in the GitHub OAuth application:
```text
https://<workshop-host>/api/oauth2-redirect
```
Self-hosted Homarr origins such as `http://localhost:3000`, `https://home.example.com`, or another custom domain do not need separate GitHub callback registrations. The popup starts directly from the user's click, authenticates against the Workshop host, and returns the result to the calling Homarr page.
PocketBase permits all origins by default. If the Workshop deployment uses PocketBase's `--origins` option or a restrictive reverse-proxy CORS policy, it must allow arbitrary self-hosted Homarr origins. Restricting it to `homarr.dev` breaks sign-in from private installations. See [PocketBase routing and CORS](https://pocketbase.io/docs/go-routing/).
Homarr reports provider misconfiguration, popup blocking, cancellation, and unavailable OAuth as visible notifications. Keep the Workshop host and GitHub provider configuration centralized; do not register every Homarr installation hostname.
+1 -1
View File
@@ -10,7 +10,7 @@ import { customApiWidget } from ".";
<WidgetHeader widget={customApiWidget} categories={["Custom", "API"]} />
The Custom API widget displays a Custom JSX v2 definition created in [Management → Custom Widgets](/docs/management/custom-widgets). A definition owns one or more inline API sources, named requests, an options schema, local state, and one safe JSX template. Source credentials are encrypted separately and never reach the browser or an export.
The Custom API widget displays a Custom JSX v2 definition created in [Management → Custom Widgets](/docs/management/custom-widgets). A definition owns one or more inline API sources, named requests, an options schema, and one safe JSX template. Temporary bound inputs exist only while the tile is mounted. Source credentials are encrypted separately and never reach the browser or an export.
### Add the widget
+2
View File
@@ -26,8 +26,10 @@
"@docusaurus/plugin-content-docs": "^3.10.1",
"@docusaurus/plugin-sitemap": "^3.10.1",
"@docusaurus/preset-classic": "^3.10.1",
"@homarr/custom-widgets": "workspace:^",
"@homarr/definitions": "workspace:^",
"@homarr/workshop": "workspace:^",
"@mantine/core": "catalog:",
"@mdx-js/react": "^3.1.1",
"@nivo/line": "^0.99.0",
"@scalar/api-reference-react": "catalog:",
@@ -0,0 +1,138 @@
import BrowserOnly from "@docusaurus/BrowserOnly";
import { useState } from "react";
import { MantineProvider, Select, Stack, Text } from "@mantine/core";
import "@mantine/core/styles.css";
import {
BUNDLED_CUSTOM_WIDGETS,
CUSTOM_WIDGET_OPTIONS_EXAMPLES,
CUSTOM_WIDGET_REQUEST_EXAMPLES,
} from "@homarr/custom-widgets/core";
import type { CustomWidgetEditorMessages } from "@homarr/custom-widgets/workbench";
import { ReadOnlyCustomWidgetCode } from "@homarr/custom-widgets/workbench";
const messages: CustomWidgetEditorMessages = {
languageJsx: "JSX",
languageJson: "JSON",
undo: "Undo",
redo: "Redo",
components: "Components",
componentSearch: "Search components",
componentEmpty: "No matching components",
componentCount: (count) => `${count} components`,
insertStarter: "Insert starter",
format: "Format",
copy: "Copy",
copied: "Copied",
schema: "Schema",
schemaTab: "JSON Schema",
minimalTab: "Minimal example",
fullTab: "Full example",
errors: (count) => `${count} errors`,
warnings: (count) => `${count} warnings`,
ready: "Read-only example",
position: ({ line, column }) => `Ln ${line}, Col ${column}`,
characters: (count, limit) => (limit ? `${count} / ${limit}` : `${count} characters`),
diagnosticsTitle: "Diagnostics",
diagnostic: (diagnostic) => diagnostic.value ?? diagnostic.code,
};
interface CustomWidgetCodeExampleProps {
id: string;
label: string;
code: string;
language?: "json" | "jsx";
height?: string;
}
export function CanonicalCustomWidgetExample({
id,
label,
example,
height,
}: {
id: string;
label: string;
example: "requests" | "optionsSchema" | "defaultOptions";
height?: string;
}) {
const value =
example === "requests"
? CUSTOM_WIDGET_REQUEST_EXAMPLES.full
: example === "optionsSchema"
? CUSTOM_WIDGET_OPTIONS_EXAMPLES.full.schema
: CUSTOM_WIDGET_OPTIONS_EXAMPLES.full.defaults;
return <CustomWidgetCodeExample id={id} label={label} code={JSON.stringify(value, null, 2)} height={height} />;
}
export function CustomWidgetCodeExample(props: CustomWidgetCodeExampleProps) {
return (
<BrowserOnly fallback={<div style={{ minHeight: props.height ?? "220px" }} />}>
{() => (
<MantineProvider forceColorScheme={getDocumentationColorScheme()}>
<ReadOnlyCustomWidgetCode
id={props.id}
label={props.label}
language={props.language ?? "json"}
value={props.code}
messages={messages}
height={props.height}
/>
</MantineProvider>
)}
</BrowserOnly>
);
}
export function BundledCustomWidgetGallery() {
return (
<BrowserOnly fallback={<div style={{ minHeight: "520px" }} />}>
{() => <BundledCustomWidgetGalleryClient />}
</BrowserOnly>
);
}
function BundledCustomWidgetGalleryClient() {
const [selectedId, setSelectedId] = useState(BUNDLED_CUSTOM_WIDGETS[0]?.id ?? null);
const selected = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === selectedId) ?? BUNDLED_CUSTOM_WIDGETS[0];
if (!selected) return null;
const manifest = { ...selected.widget, template: "__HOMARR_TEMPLATE__" };
return (
<MantineProvider forceColorScheme={getDocumentationColorScheme()}>
<Stack gap="md">
<Select
label="Bundled widget"
data={BUNDLED_CUSTOM_WIDGETS.map(({ id, widget }) => ({ value: id, label: widget.name }))}
value={selected.id}
onChange={(value) => {
if (value) setSelectedId(value);
}}
allowDeselect={false}
/>
<Text size="sm" c="dimmed">
{selected.widget.description}
</Text>
<ReadOnlyCustomWidgetCode
id={`${selected.id}-manifest`}
label="widget.json"
language="json"
value={JSON.stringify(manifest, null, 2)}
messages={messages}
height="340px"
/>
<ReadOnlyCustomWidgetCode
id={`${selected.id}-template`}
label="widget.jsx"
language="jsx"
value={selected.widget.template}
messages={messages}
height="340px"
/>
</Stack>
</MantineProvider>
);
}
function getDocumentationColorScheme() {
return document.documentElement.dataset.theme === "dark" ? "dark" : "light";
}
Binary file not shown.