diff --git a/.agents/skills/homarr-custom-widget/SKILL.md b/.agents/skills/homarr-custom-widget/SKILL.md
index 9869523fa..7643c4524 100644
--- a/.agents/skills/homarr-custom-widget/SKILL.md
+++ b/.agents/skills/homarr-custom-widget/SKILL.md
@@ -25,6 +25,7 @@ Create one widget at a time. Repeat the workflow when the user asks for several
- Use inline sources, named requests, definition-owned options, temporary bound inputs, and one safe JSX template.
- Use `kind`, not the HTTP method, to distinguish reads from user-triggered actions.
+- Give every declared request parameter an explicit source. Load queries use `optionsBinding` with an option reference or primitive literal; manual queries and actions receive values only from the invoking component's `params` prop. Never infer bindings from matching names.
- Never put a credential in JSX, options, headers, URLs, examples, exports, logs, or chat output.
- Use declarative `bind` controls and Homarr request components; never emit imports, hooks, callbacks, browser requests, or arbitrary JavaScript.
- Include responsive loading, empty, error, and success states.
diff --git a/.agents/skills/homarr-custom-widget/references/runtime.md b/.agents/skills/homarr-custom-widget/references/runtime.md
index 143f5d091..4755efc40 100644
--- a/.agents/skills/homarr-custom-widget/references/runtime.md
+++ b/.agents/skills/homarr-custom-widget/references/runtime.md
@@ -8,6 +8,27 @@ Render icons as ``. When connected, retrie
Use `SubFetch` for a manual query, `ActionButton` or `ToggleSwitch` for an action, and `RefreshButton` to refresh data. A query with `trigger: "load"` populates its data automatically and follows the widget refresh interval.
+Manual requests receive values only from the invoking component:
+
+```jsx
+
+Restart
+```
+
+Loop variables and other JSX expressions can flow into a request only through that `params` prop. A load query has no invoking component, so every declared parameter must have an `optionsBinding` entry:
+
+```json
+{
+ "parameters": { "endpointId": "string", "showAll": "boolean" },
+ "optionsBinding": {
+ "endpointId": { "$option": "endpointId" },
+ "showAll": false
+ }
+}
+```
+
+Do not rely on parameter and option names matching. For an invalid-placeholder or unresolved-parameter error on a load query, check missing `optionsBinding` entries before changing placeholder syntax.
+
Bind local controls declaratively:
```jsx
diff --git a/.agents/skills/homarr-custom-widget/references/schema.md b/.agents/skills/homarr-custom-widget/references/schema.md
index 213192283..2a22ff5f2 100644
--- a/.agents/skills/homarr-custom-widget/references/schema.md
+++ b/.agents/skills/homarr-custom-widget/references/schema.md
@@ -18,7 +18,11 @@ interface HomarrCustomWidgetV2 {
A source has a stable lowercase `id`, display `name`, `baseUrl`, `networkScope` (`public`, `private`, or `loopback`), and one auth mode: `none`, `bearer`, `basic`, `apiKeyHeader`, or `apiKeyQuery`.
-A request has `id`, `sourceId`, `kind`, any supported HTTP `method`, same-origin `pathTemplate`, declared primitive `parameters`, optional `queryTemplate` and `bodyTemplate`, optional safe `staticHeaders`, `auth`, `minimumBoardPermission`, optional `trigger`, caching, confirmation, and invalidation. Parameter names may use camelCase. Bind a declared parameter with `{name}` in a path or `{ "$param": "name" }` in structured query/body data.
+A request has `id`, `sourceId`, `kind`, any supported HTTP `method`, same-origin `pathTemplate`, declared primitive `parameters`, optional `optionsBinding`, `queryTemplate`, and `bodyTemplate`, optional safe `staticHeaders`, `auth`, `minimumBoardPermission`, optional `trigger`, caching, confirmation, and invalidation. Parameter names may use camelCase. Reference a declared parameter with `{name}` in a path or `{ "$param": "name" }` in structured query/body data.
+
+Every declared parameter needs an explicit source. For a load query, bind each parameter in `optionsBinding` with `{ "$option": "optionName" }` or a primitive literal of the declared type. The referenced option must exist in `optionsSchema`. For manual queries and actions, the invoking `SubFetch`, `ActionButton`, or `ToggleSwitch` supplies the complete parameter object through `params`. Never infer a source because a parameter, option, or temporary input has the same name.
+
+Dynamic option requests have no JSX invoker and therefore also require `optionsBinding` for every parameter they declare.
Options use a restricted object JSON Schema. Set `additionalProperties: false`. Presentation metadata lives under `x-homarr`; dynamic selects use `optionsSource.requestId`, optional `itemsPath` for wrapped arrays, `valuePath`, and `labelPath`. Default options must validate against the schema.
diff --git a/apps/docs/docs/management/custom-widgets/authoring.mdx b/apps/docs/docs/management/custom-widgets/authoring.mdx
index e061ee08c..8cc9f395b 100644
--- a/apps/docs/docs/management/custom-widgets/authoring.mdx
+++ b/apps/docs/docs/management/custom-widgets/authoring.mdx
@@ -39,7 +39,25 @@ Dynamic selects use `x-homarr.optionsSource` and a named query. Add `itemsPath`
### 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.
+Request parameters are a map from parameter name to `string`, `number`, or `boolean`. Query and body templates reference them with `{ "$param": "name" }`. Declaring a parameter does not give it a value, even when an option or temporary input has the same name.
+
+For a load query, bind every parameter explicitly in `optionsBinding`. Each entry is either `{ "$option": "optionName" }` or a primitive literal matching the declared parameter type:
+
+```json
+{
+ "pathTemplate": "/api/endpoints/{endpointId}/docker/containers/json",
+ "parameters": { "endpointId": "string", "showAll": "boolean" },
+ "queryTemplate": { "all": { "$param": "showAll" } },
+ "optionsBinding": {
+ "endpointId": { "$option": "endpointId" },
+ "showAll": false
+ }
+}
+```
+
+For manual queries and actions, `SubFetch`, `ActionButton`, or `ToggleSwitch` supplies the complete parameter object through its `params` prop. This is the only route from JSX values such as `inputs.search` or a loop variable into a request.
+
+Dynamic option requests also run without a JSX invoker. If they declare parameters, give them explicit `optionsBinding` entries so the configuration form can resolve those values.
= {};
const status: Record = {};
for (const request of definition.requests.filter((entry) => entry.kind === "query" && entry.trigger === "load")) {
- const params = Object.fromEntries(
- Object.entries(request.parameters).flatMap(([name, type]) => {
- const value = definition.defaultOptions[name];
- return typeof value === type ? [[name, value as string | number | boolean]] : [];
- }),
- );
try {
+ const params = resolveCustomWidgetOptionsBinding(request, definition.defaultOptions);
const result = await fetchApi.customWidget.previewQuery.query({ sessionId, requestId: request.id, params });
data[request.id] = result.data;
status[request.id] = { loading: false, ...result };
diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/preview/[id]/page.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/preview/[id]/page.tsx
index e94b48e86..e811abc75 100644
--- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/preview/[id]/page.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/preview/[id]/page.tsx
@@ -3,6 +3,7 @@ import { Badge, Box, Container, Group, Paper, Stack, Text, Title } from "@mantin
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
+import { resolveCustomWidgetOptionsBinding } from "@homarr/custom-widgets/core";
import CustomJsxDisplay from "@homarr/widgets/custom-api/custom-jsx-display";
export default async function CustomWidgetPreviewPage({ params }: { params: Promise<{ id: string }> }) {
@@ -15,14 +16,11 @@ export default async function CustomWidgetPreviewPage({ params }: { params: Prom
const loadRequests = preview.requests.filter((request) => request.kind === "query" && request.trigger === "load");
const results = await Promise.all(
loadRequests.map(async (request) => {
- const requestParams = Object.fromEntries(
- Object.entries(request.parameters).flatMap(([name, type]) => {
- const value = preview.defaultOptions[name];
- return typeof value === type ? [[name, value as string | number | boolean]] : [];
- }),
- );
- const result = await api.customWidget
- .previewQuery({ sessionId: id, requestId: request.id, params: requestParams })
+ const result = await Promise.resolve()
+ .then(() => resolveCustomWidgetOptionsBinding(request, preview.defaultOptions))
+ .then((requestParams) =>
+ api.customWidget.previewQuery({ sessionId: id, requestId: request.id, params: requestParams }),
+ )
.catch((cause: unknown) => ({
ok: false,
status: 0,
diff --git a/packages/api/src/router/custom-widget/management-queries.ts b/packages/api/src/router/custom-widget/management-queries.ts
index 2692661dc..4bb07469e 100644
--- a/packages/api/src/router/custom-widget/management-queries.ts
+++ b/packages/api/src/router/custom-widget/management-queries.ts
@@ -107,7 +107,7 @@ export const managementQueryProcedures = {
),
optionRequests: widget.requests
.filter((request) => request.kind === "query")
- .map(({ id, parameters }) => ({ id, parameters })),
+ .map(({ id, parameters, optionsBinding }) => ({ id, parameters, optionsBinding })),
updatedAt: definition.updatedAt,
};
});
diff --git a/packages/api/src/router/custom-widget/preview-base-procedures.ts b/packages/api/src/router/custom-widget/preview-base-procedures.ts
index 2393989aa..6c6d1b1ca 100644
--- a/packages/api/src/router/custom-widget/preview-base-procedures.ts
+++ b/packages/api/src/router/custom-widget/preview-base-procedures.ts
@@ -89,13 +89,24 @@ export const previewBaseProcedures = {
optionsSchema: session.optionsSchema,
defaultOptions: session.defaultOptions,
requests: session.requests.map(
- ({ id, kind, method, minimumBoardPermission, trigger, parameters, confirmation, invalidates }) => ({
+ ({
id,
kind,
method,
minimumBoardPermission,
trigger,
parameters,
+ optionsBinding,
+ confirmation,
+ invalidates,
+ }) => ({
+ id,
+ kind,
+ method,
+ minimumBoardPermission,
+ trigger,
+ parameters,
+ optionsBinding,
confirmation,
invalidates,
}),
diff --git a/packages/api/src/router/custom-widget/preview-query-procedures.ts b/packages/api/src/router/custom-widget/preview-query-procedures.ts
index d6d79e723..8e3c7e760 100644
--- a/packages/api/src/router/custom-widget/preview-query-procedures.ts
+++ b/packages/api/src/router/custom-widget/preview-query-procedures.ts
@@ -1,6 +1,8 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
+import { resolveCustomWidgetOptionsBinding } from "@homarr/custom-widgets/core";
+
import { permissionRequiredProcedure } from "../../trpc";
import {
getPreviewRequestSource,
@@ -31,8 +33,10 @@ export const previewQueryProcedures = {
if (!request) throw new TRPCError({ code: "NOT_FOUND", message: "Preview query was not found" });
const resolved = getPreviewRequestSource(session, request.sourceId);
if (!resolved) throw new TRPCError({ code: "NOT_FOUND", message: "Preview source was not found" });
- const targetUrl = renderRequestTarget(resolved.source.baseUrl, request, input.params);
- const body = renderRequestBody(request.bodyTemplate, input.params);
+ const params =
+ request.trigger === "load" ? resolveCustomWidgetOptionsBinding(request, session.defaultOptions) : input.params;
+ const targetUrl = renderRequestTarget(resolved.source.baseUrl, request, params);
+ const body = renderRequestBody(request.bodyTemplate, params);
const release = await acquireCustomWidgetRequestLimit({
category: "query",
userId: ctx.session.user.id,
@@ -50,7 +54,7 @@ export const previewQueryProcedures = {
auth: request.auth === "none" ? undefined : resolved.auth,
networkScope: resolved.source.networkScope,
kind: "query",
- cacheKey: `custom-jsx:preview:${session.id}:${request.id}:${hashRuntimeParams(input.params)}`,
+ cacheKey: `custom-jsx:preview:${session.id}:${request.id}:${hashRuntimeParams(params)}`,
cacheTtlSeconds: request.cacheTtlSeconds,
});
await recordPreviewJournal(session, {
diff --git a/packages/api/src/router/widgets/custom-api.ts b/packages/api/src/router/widgets/custom-api.ts
index 25bca9b49..85b191aee 100644
--- a/packages/api/src/router/widgets/custom-api.ts
+++ b/packages/api/src/router/widgets/custom-api.ts
@@ -7,6 +7,7 @@ import { eq } from "@homarr/db";
import { boards, customWidgetDefinitions, items } from "@homarr/db/schema";
import type { BoardPermission } from "@homarr/definitions";
import { validateCustomWidgetOptions } from "@homarr/custom-widgets/core";
+import { resolveCustomWidgetOptionsBinding } from "@homarr/custom-widgets/core";
import type { CustomJsxRequest, CustomWidgetSource } from "@homarr/custom-widgets/core";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
@@ -173,21 +174,6 @@ const executeRequest = async (
);
};
-const getLoadParams = (resolved: ResolvedDefinition, request: CustomJsxRequest) => {
- const params: Record = {};
- for (const [name, type] of Object.entries(request.parameters)) {
- const value = resolved.configuration[name];
- if (typeof value !== type) {
- throw new TRPCError({
- code: "PRECONDITION_FAILED",
- message: `Load request '${request.id}' needs option '${name}' (${type})`,
- });
- }
- params[name] = value as string | number | boolean;
- }
- return params;
-};
-
export const customApiRouter = createTRPCRouter({
getData: publicProcedure.input(itemInputSchema).query(async ({ ctx, input }) => {
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
@@ -197,7 +183,8 @@ export const customApiRouter = createTRPCRouter({
const entries = await Promise.all(
loadRequests.map(async (request) => {
try {
- const response = await executeRequest(ctx, resolved, request, getLoadParams(resolved, request));
+ const params = resolveCustomWidgetOptionsBinding(request, resolved.configuration);
+ const response = await executeRequest(ctx, resolved, request, params);
return [
request.id,
{
@@ -249,6 +236,9 @@ export const customApiRouter = createTRPCRouter({
queryRequest: publicProcedure.input(namedRequestInputSchema).query(async ({ ctx, input }) => {
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
const request = findRequest(resolved, input.requestId, "query");
+ if (request.trigger !== "manual") {
+ throw new TRPCError({ code: "BAD_REQUEST", message: "Load queries cannot be invoked manually" });
+ }
const response = await executeRequest(ctx, resolved, request, input.params);
return {
ok: response.ok,
diff --git a/packages/custom-widgets/src/core/ai-prompt.ts b/packages/custom-widgets/src/core/ai-prompt.ts
index d69cb39d0..8ea3d7ae8 100644
--- a/packages/custom-widgets/src/core/ai-prompt.ts
+++ b/packages/custom-widgets/src/core/ai-prompt.ts
@@ -8,7 +8,7 @@ Use $schema "homarr-custom-widget-v2". A widget contains metadata, inline API so
This Homarr release uses Mantine ${CUSTOM_WIDGET_MANTINE_VERSION}. When connected, read homarr://custom-widgets/schema and homarr://custom-widgets/components instead of guessing the installed surface.
-Queries and actions may use GET, POST, PUT, PATCH, or DELETE. Set kind to query for reads and action for user-triggered changes. Actions never use trigger "load". Declare every path, query, or body parameter. Use {name} in paths and {"$param":"name"} in query/body templates. Use SubFetch for manual queries, ActionButton or ToggleSwitch for actions, and load-triggered queries through data..
+Queries and actions may use GET, POST, PUT, PATCH, or DELETE. Set kind to query for reads and action for user-triggered changes. Actions never use trigger "load". Declare every path, query, or body parameter. Use {name} in paths and {"$param":"name"} in query/body templates. Every declared parameter needs an explicit value source: SubFetch, ActionButton, and ToggleSwitch supply manual request values through their params prop; every load-query parameter must have an optionsBinding entry containing {"$option":"optionName"} or a primitive literal. Never infer a binding from matching names. Use load-triggered query results through data..
Source authentication is exactly one of {"type":"none"}, {"type":"bearer"}, {"type":"basic"}, {"type":"apiKeyHeader","headerName":"X-API-Key"}, or {"type":"apiKeyQuery","parameterName":"api_key"}. Credentials are configured separately by the user.
@@ -17,7 +17,7 @@ Templates can read data, status, options, and inputs. Bind supported Mantine con
Options use a restricted object JSON Schema. x-homarr controls include text, textarea, number, switch, select, multi-select, slider, date, time, color, icon, url, duration, timeZone, and json. Dynamic selects use x-homarr.optionsSource with requestId, optional itemsPath for wrapped arrays, valuePath, and labelPath.
Widget shape:
-{ "$schema":"homarr-custom-widget-v2", "name":"...", "description":"...", "iconUrl":"https://...", "sources":[{ "id":"default", "name":"API", "baseUrl":"https://host", "networkScope":"public|private|loopback", "auth":{ "type":"none|bearer|basic|apiKeyHeader|apiKeyQuery" } }], "requests":[{ "id":"data", "sourceId":"default", "kind":"query|action", "method":"GET|POST|PUT|PATCH|DELETE", "pathTemplate":"/api/{id}", "parameters":{ "id":"string", "limit":"number" }, "queryTemplate":{ "limit":{ "$param":"limit" } }, "auth":"inherit|none", "minimumBoardPermission":"view|modify|full", "trigger":"load|manual", "confirmation":{ "title":"...", "message":"...", "destructive":false }, "invalidates":[] }], "optionsSchema":{ "type":"object", "properties":{}, "additionalProperties":false }, "defaultOptions":{}, "template":"__HOMARR_TEMPLATE__" }
+{ "$schema":"homarr-custom-widget-v2", "name":"...", "description":"...", "iconUrl":"https://...", "sources":[{ "id":"default", "name":"API", "baseUrl":"https://host", "networkScope":"public|private|loopback", "auth":{ "type":"none|bearer|basic|apiKeyHeader|apiKeyQuery" } }], "requests":[{ "id":"data", "sourceId":"default", "kind":"query", "method":"GET", "pathTemplate":"/api/{id}", "parameters":{ "id":"string", "limit":"number" }, "optionsBinding":{ "id":{ "$option":"resourceId" }, "limit":20 }, "queryTemplate":{ "limit":{ "$param":"limit" } }, "auth":"inherit", "minimumBoardPermission":"view", "trigger":"load" }], "optionsSchema":{ "type":"object", "properties":{ "resourceId":{ "type":"string" } }, "required":["resourceId"], "additionalProperties":false }, "defaultOptions":{ "resourceId":"example" }, "template":"__HOMARR_TEMPLATE__" }
Keep the result concise, accessible, responsive, and valid. Preserve unrelated fields when fixing an existing widget.`;
@@ -33,7 +33,7 @@ export const CUSTOM_WIDGET_MCP_AUTHORING_PROMPT = `Author one Homarr Custom JSX
6. Use customWidget_secretSet only for a credential the user supplied and only with dedicated permission. Otherwise call customWidget_secretRequestUser and poll its completion status; never receive or repeat the plaintext.
7. Call customWidget_create only after validation, request tests, action simulation, and visual inspection pass.
-Use inline sources, named queries/actions, restricted JSON Schema options, temporary declarative bind controls exposed through inputs, and safe Mantine JSX. Use kind, not method, to distinguish reads from user-triggered changes. Never include credentials, imports, hooks, refs, callbacks, browser requests, arbitrary JavaScript, npm packages, polymorphic roots, or arbitrary portals. Make the widget responsive, accessible, loading-aware, empty-aware, and error-aware. The live schema and component resources are authoritative for the installed Homarr release.`;
+Use inline sources, named queries/actions, restricted JSON Schema options, temporary declarative bind controls exposed through inputs, and safe Mantine JSX. Every load-query parameter needs an explicit optionsBinding option reference or literal; manual queries and actions receive all parameters from the invoking component's params prop. Never infer sources from matching names. Use kind, not method, to distinguish reads from user-triggered changes. Never include credentials, imports, hooks, refs, callbacks, browser requests, arbitrary JavaScript, npm packages, polymorphic roots, or arbitrary portals. Make the widget responsive, accessible, loading-aware, empty-aware, and error-aware. The live schema and component resources are authoritative for the installed Homarr release.`;
export function buildCustomWidgetMcpPrompt(request?: string | null, documentationUrl?: string | null) {
const sections = [CUSTOM_WIDGET_MCP_AUTHORING_PROMPT];
diff --git a/packages/custom-widgets/src/core/authoring-resources.ts b/packages/custom-widgets/src/core/authoring-resources.ts
index a4a1c7278..3ffe8e01b 100644
--- a/packages/custom-widgets/src/core/authoring-resources.ts
+++ b/packages/custom-widgets/src/core/authoring-resources.ts
@@ -40,6 +40,7 @@ Create one widget at a time. Repeat the workflow when the user asks for several
- Use inline sources, named requests, definition-owned options, temporary bound inputs, and one safe JSX template.
- Use \`kind\`, not the HTTP method, to distinguish reads from user-triggered actions.
+- Give every declared request parameter an explicit source. Load queries use \`optionsBinding\` with an option reference or primitive literal; manual queries and actions receive values only from the invoking component's \`params\` prop. Never infer bindings from matching names.
- Never put a credential in JSX, options, headers, URLs, examples, exports, logs, or chat output.
- Use declarative \`bind\` controls and Homarr request components; never emit imports, hooks, callbacks, browser requests, or arbitrary JavaScript.
- Include responsive loading, empty, error, and success states.
diff --git a/packages/custom-widgets/src/core/bundled-widgets.ts b/packages/custom-widgets/src/core/bundled-widgets.ts
index 0653bd04d..7a1b29bb9 100644
--- a/packages/custom-widgets/src/core/bundled-widgets.ts
+++ b/packages/custom-widgets/src/core/bundled-widgets.ts
@@ -71,6 +71,11 @@ export const BUNDLED_CUSTOM_WIDGETS: readonly BundledCustomWidget[] = [
method: "GET",
pathTemplate: "/v1/latest",
parameters: { from: "string", to: "string", amount: "number" },
+ optionsBinding: {
+ from: { $option: "from" },
+ to: { $option: "to" },
+ amount: { $option: "amount" },
+ },
queryTemplate: {
from: { $param: "from" },
to: { $param: "to" },
@@ -167,6 +172,7 @@ export const BUNDLED_CUSTOM_WIDGETS: readonly BundledCustomWidget[] = [
method: "GET",
pathTemplate: "/api/v2/pokemon",
parameters: { limit: "number" },
+ optionsBinding: { limit: { $option: "limit" } },
queryTemplate: { limit: { $param: "limit" }, offset: 0 },
auth: "inherit",
minimumBoardPermission: "view",
diff --git a/packages/custom-widgets/src/core/custom-jsx-schema.ts b/packages/custom-widgets/src/core/custom-jsx-schema.ts
index edb5656de..74331647d 100644
--- a/packages/custom-widgets/src/core/custom-jsx-schema.ts
+++ b/packages/custom-widgets/src/core/custom-jsx-schema.ts
@@ -121,6 +121,16 @@ function validateDynamicOptions(
path: [...path, "x-homarr", "optionsSource", "requestId"],
message: `Dynamic options require a query request named '${String(requestId)}'`,
});
+ } else {
+ for (const parameterName of Object.keys(request.parameters)) {
+ if (!Object.hasOwn(request.optionsBinding ?? {}, parameterName)) {
+ ctx.addIssue({
+ code: "custom",
+ path: [...path, "x-homarr", "optionsSource", "requestId"],
+ message: `Dynamic option request '${request.id}' parameter '${parameterName}' requires an explicit optionsBinding entry`,
+ });
+ }
+ }
}
}
}
@@ -167,6 +177,29 @@ function validateRequests(definition: Definition, sourceIds: Set, ctx: z
});
}
}
+ for (const [parameterName, binding] of Object.entries(request.optionsBinding ?? {})) {
+ if (typeof binding !== "object") continue;
+ const optionName = binding.$option;
+ const optionSchema = definition.optionsSchema.properties?.[optionName];
+ if (optionSchema === null || typeof optionSchema !== "object" || Array.isArray(optionSchema)) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["requests", index, "optionsBinding", parameterName, "$option"],
+ message: `Unknown widget option '${optionName}'`,
+ });
+ continue;
+ }
+ const parameterType = request.parameters[parameterName];
+ const declaredOptionType = (optionSchema as Record).type;
+ const optionType = declaredOptionType === "integer" ? "number" : declaredOptionType;
+ if (parameterType !== undefined && optionType !== parameterType) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["requests", index, "optionsBinding", parameterName, "$option"],
+ message: `Option '${optionName}' must be ${parameterType} to supply parameter '${parameterName}'`,
+ });
+ }
+ }
requests.set(request.id, request);
});
definition.requests.forEach((request, index) => {
@@ -207,6 +240,18 @@ function validateTemplateRequests(template: string, requests: Map 0 && !/\bparams\s*=/u.test(match[2] ?? "")) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["template"],
+ message: `${component} must supply params for request '${requestId}'`,
+ });
}
}
}
diff --git a/packages/custom-widgets/src/core/definition-security.ts b/packages/custom-widgets/src/core/definition-security.ts
index c68cc0ce6..6205c5b7a 100644
--- a/packages/custom-widgets/src/core/definition-security.ts
+++ b/packages/custom-widgets/src/core/definition-security.ts
@@ -27,6 +27,7 @@ export function validateCredentialFreeExport(definition: SecurityDefinition, ctx
}
definition.requests.forEach((request, index) => {
if (
+ containsCredentialLikeValue(request.optionsBinding) ||
containsCredentialLikeValue(request.queryTemplate) ||
containsCredentialLikeValue(request.bodyTemplate) ||
Object.entries(request.staticHeaders ?? {}).some(
@@ -66,6 +67,7 @@ export function validatePrototypeKeys(definition: SecurityDefinition, ctx: Refin
[["defaultOptions"], definition.defaultOptions],
];
definition.requests.forEach((request, index) => {
+ values.push([["requests", index, "optionsBinding"], request.optionsBinding]);
values.push([["requests", index, "queryTemplate"], request.queryTemplate]);
values.push([["requests", index, "bodyTemplate"], request.bodyTemplate]);
});
diff --git a/packages/custom-widgets/src/core/request-schema.ts b/packages/custom-widgets/src/core/request-schema.ts
index 93b485ed2..6f776ccd8 100644
--- a/packages/custom-widgets/src/core/request-schema.ts
+++ b/packages/custom-widgets/src/core/request-schema.ts
@@ -21,6 +21,11 @@ export const customWidgetParameterIdentifierSchema = z
const requestParametersSchema = z
.record(customWidgetParameterIdentifierSchema, z.enum(customJsxRequestParameterTypes))
.refine((parameters) => Object.keys(parameters).length <= 32, "A request can declare at most 32 parameters");
+const optionReferenceSchema = z.object({ $option: customWidgetParameterIdentifierSchema }).strict();
+const requestOptionBindingValueSchema = z.union([z.string(), z.number().finite(), z.boolean(), optionReferenceSchema]);
+const requestOptionsBindingSchema = z
+ .record(customWidgetParameterIdentifierSchema, requestOptionBindingValueSchema)
+ .refine((bindings) => Object.keys(bindings).length <= 32, "A request can bind at most 32 parameters");
const reservedRequestHeaders = new Set([
"authorization",
"connection",
@@ -154,6 +159,7 @@ export const customJsxRequestSchema = z
.refine((path) => !path.includes("\\"), "Path templates cannot contain backslashes")
.refine((path) => !path.includes("#"), "Path templates cannot contain URL fragments"),
parameters: requestParametersSchema,
+ optionsBinding: requestOptionsBindingSchema.optional(),
queryTemplate: z.record(z.string().min(1).max(256), z.json()).optional(),
bodyTemplate: z.json().optional(),
staticHeaders: staticHeadersSchema.optional(),
@@ -192,6 +198,34 @@ export const customJsxRequestSchema = z
});
}
const declared = new Set(Object.keys(request.parameters));
+ const optionBindings = request.optionsBinding ?? {};
+ for (const [name, binding] of Object.entries(optionBindings)) {
+ const parameterType = request.parameters[name];
+ if (!parameterType) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["optionsBinding", name],
+ message: `Option binding '${name}' does not match a declared parameter`,
+ });
+ } else if ((typeof binding !== "object" || binding === null) && typeof binding !== parameterType) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["optionsBinding", name],
+ message: `Literal binding for '${name}' must be ${parameterType}`,
+ });
+ }
+ }
+ if (request.kind === "query" && request.trigger === "load") {
+ for (const name of declared) {
+ if (!Object.hasOwn(optionBindings, name)) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["optionsBinding", name],
+ message: `Load request parameter '${name}' requires an explicit option reference or literal value`,
+ });
+ }
+ }
+ }
const used = [...request.pathTemplate.matchAll(/\{([^{}]+)\}/g)].map((match) => match[1] ?? "");
collectParameterNames(request.queryTemplate).forEach((name) => used.push(name));
collectParameterNames(request.bodyTemplate).forEach((name) => used.push(name));
@@ -202,3 +236,26 @@ export const customJsxRequestSchema = z
}
});
export type CustomJsxRequest = z.infer;
+export type CustomJsxRequestOptionsBinding = NonNullable;
+
+export function resolveCustomWidgetOptionsBinding(
+ request: Pick,
+ options: Record,
+): Record {
+ const bindings = request.optionsBinding ?? {};
+ const params: Record = {};
+ for (const [name, expectedType] of Object.entries(request.parameters)) {
+ const binding = bindings[name];
+ if (binding === undefined) {
+ throw new Error(`Request '${request.id}' parameter '${name}' has no explicit option binding`);
+ }
+ const optionName = typeof binding === "object" ? binding.$option : undefined;
+ const value = optionName === undefined ? binding : options[optionName];
+ if (typeof value !== expectedType) {
+ const source = optionName === undefined ? "literal binding" : `option '${optionName}'`;
+ throw new Error(`Request '${request.id}' parameter '${name}' requires ${source} to be ${expectedType}`);
+ }
+ params[name] = value as string | number | boolean;
+ }
+ return params;
+}
diff --git a/packages/custom-widgets/src/core/schema-references.ts b/packages/custom-widgets/src/core/schema-references.ts
index d9986c45d..84c7845f1 100644
--- a/packages/custom-widgets/src/core/schema-references.ts
+++ b/packages/custom-widgets/src/core/schema-references.ts
@@ -59,6 +59,23 @@ export const CUSTOM_WIDGET_REQUEST_EXAMPLES = {
trigger: "load",
cacheTtlSeconds: 60,
},
+ {
+ id: "summary",
+ sourceId: "default",
+ kind: "query",
+ method: "GET",
+ pathTemplate: "/api/environments/{environmentId}/summary",
+ parameters: { environmentId: "number", includeStopped: "boolean" },
+ optionsBinding: {
+ environmentId: { $option: "environmentId" },
+ includeStopped: false,
+ },
+ queryTemplate: { includeStopped: { $param: "includeStopped" } },
+ auth: "inherit",
+ minimumBoardPermission: "view",
+ trigger: "load",
+ cacheTtlSeconds: 60,
+ },
{
id: "search",
sourceId: "default",
diff --git a/packages/custom-widgets/src/test/ai-prompt.spec.ts b/packages/custom-widgets/src/test/ai-prompt.spec.ts
index 31bdb82fb..9a4e5b525 100644
--- a/packages/custom-widgets/src/test/ai-prompt.spec.ts
+++ b/packages/custom-widgets/src/test/ai-prompt.spec.ts
@@ -32,6 +32,9 @@ describe("buildCustomWidgetAiPrompt", () => {
expect(prompt).toContain("homarr-custom-widget-v2");
expect(prompt).toContain("Return exactly two fenced blocks");
expect(prompt).toContain("Never include credentials");
+ expect(prompt).toContain("Every declared parameter needs an explicit value source");
+ expect(prompt).toContain('"optionsBinding"');
+ expect(prompt).toContain("Never infer a binding from matching names");
expect(prompt).toContain("Create a Portainer widget");
expect(prompt.length).toBeLessThanOrEqual(8_000);
});
diff --git a/packages/custom-widgets/src/test/request-manifest.spec.ts b/packages/custom-widgets/src/test/request-manifest.spec.ts
index d716f905d..09f139cad 100644
--- a/packages/custom-widgets/src/test/request-manifest.spec.ts
+++ b/packages/custom-widgets/src/test/request-manifest.spec.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
+import { resolveCustomWidgetOptionsBinding } from "../core";
import type { CustomJsxRequest } from "../core";
import { hashRuntimeParams, renderRequestBody, renderRequestTarget, validateRuntimeParams } from "../server";
@@ -58,4 +59,28 @@ describe("named request manifest rendering", () => {
it("hashes parameters independently of object insertion order", () => {
expect(hashRuntimeParams({ a: 1, b: "two" })).toBe(hashRuntimeParams({ b: "two", a: 1 }));
});
+
+ it("resolves only explicit option and literal bindings for automatic requests", () => {
+ const loadRequest: CustomJsxRequest = {
+ ...request,
+ kind: "query",
+ trigger: "load",
+ optionsBinding: {
+ device: { $option: "selectedDevice" },
+ enabled: true,
+ level: { $option: "volume" },
+ },
+ };
+ expect(resolveCustomWidgetOptionsBinding(loadRequest, { selectedDevice: "lamp", volume: 5 })).toEqual({
+ device: "lamp",
+ enabled: true,
+ level: 5,
+ });
+ expect(() => resolveCustomWidgetOptionsBinding({ ...loadRequest, optionsBinding: undefined }, {})).toThrow(
+ "has no explicit option binding",
+ );
+ expect(() => resolveCustomWidgetOptionsBinding(loadRequest, { device: "lamp", level: 5 })).toThrow(
+ "option 'selectedDevice'",
+ );
+ });
});
diff --git a/packages/custom-widgets/src/test/schema.spec.ts b/packages/custom-widgets/src/test/schema.spec.ts
index 3f0cb7282..9a5df6de5 100644
--- a/packages/custom-widgets/src/test/schema.spec.ts
+++ b/packages/custom-widgets/src/test/schema.spec.ts
@@ -83,6 +83,116 @@ describe("Custom JSX v2 validation", () => {
).toBe(true);
});
+ test("requires explicit parameter sources for load queries", () => {
+ const optionsSchema = {
+ type: "object" as const,
+ properties: {
+ endpointId: { type: "string" },
+ wrongType: { type: "boolean" },
+ },
+ required: ["endpointId"],
+ additionalProperties: false as const,
+ };
+ const request = {
+ id: "containers",
+ sourceId: "default",
+ kind: "query" as const,
+ method: "GET" as const,
+ pathTemplate: "/endpoints/{endpointId}/containers",
+ parameters: { endpointId: "string" as const, showAll: "boolean" as const },
+ queryTemplate: { all: { $param: "showAll" } },
+ auth: "none" as const,
+ minimumBoardPermission: "view" as const,
+ trigger: "load" as const,
+ };
+
+ const missing = customWidgetDefinitionSchema.safeParse({
+ ...CUSTOM_WIDGET_STARTER,
+ requests: [request],
+ optionsSchema,
+ defaultOptions: { endpointId: "local" },
+ });
+ expect(missing.success).toBe(false);
+ if (missing.success) throw new Error("Expected missing bindings to fail");
+ expect(missing.error.issues).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ path: ["requests", 0, "optionsBinding", "endpointId"],
+ message: expect.stringContaining("explicit option reference or literal"),
+ }),
+ expect.objectContaining({ path: ["requests", 0, "optionsBinding", "showAll"] }),
+ ]),
+ );
+
+ expect(
+ customWidgetDefinitionSchema.safeParse({
+ ...CUSTOM_WIDGET_STARTER,
+ requests: [
+ {
+ ...request,
+ optionsBinding: { endpointId: { $option: "endpointId" }, showAll: false },
+ },
+ ],
+ optionsSchema,
+ defaultOptions: { endpointId: "local" },
+ }).success,
+ ).toBe(true);
+ expect(
+ customWidgetDefinitionSchema.safeParse({
+ ...CUSTOM_WIDGET_STARTER,
+ requests: [
+ {
+ ...request,
+ optionsBinding: { endpointId: { $option: "wrongType" }, showAll: false },
+ },
+ ],
+ optionsSchema,
+ defaultOptions: { endpointId: "local" },
+ }).success,
+ ).toBe(false);
+ });
+
+ test("requires invoking components to provide params for parameterized manual requests", () => {
+ const request = {
+ id: "detail",
+ sourceId: "default",
+ kind: "query" as const,
+ method: "GET" as const,
+ pathTemplate: "/items/{id}",
+ parameters: { id: "string" as const },
+ auth: "none" as const,
+ minimumBoardPermission: "view" as const,
+ trigger: "manual" as const,
+ };
+ expect(
+ customWidgetDefinitionSchema.safeParse({
+ ...CUSTOM_WIDGET_STARTER,
+ requests: [request],
+ template: '',
+ }).success,
+ ).toBe(false);
+ expect(
+ customWidgetDefinitionSchema.safeParse({
+ ...CUSTOM_WIDGET_STARTER,
+ requests: [request],
+ template: '',
+ }).success,
+ ).toBe(true);
+ expect(
+ customWidgetDefinitionSchema.safeParse({
+ ...CUSTOM_WIDGET_STARTER,
+ requests: [
+ {
+ ...request,
+ trigger: "load",
+ optionsBinding: { id: "fixed" },
+ },
+ ],
+ template: '',
+ }).success,
+ ).toBe(false);
+ });
+
test("rejects reserved headers, duplicate IDs, and unknown sources", () => {
const request = {
id: "status",
@@ -150,6 +260,7 @@ describe("Custom JSX v2 validation", () => {
method: "POST",
pathTemplate: "/api/v3/calendar",
parameters: { start: "string" },
+ optionsBinding: { start: { $option: "start" } },
bodyTemplate: { start: { $param: "start" } },
auth: "inherit",
minimumBoardPermission: "view",
diff --git a/packages/widgets/src/_inputs/widget-custom-widget-configuration-input.tsx b/packages/widgets/src/_inputs/widget-custom-widget-configuration-input.tsx
index 7ba1d8fe1..c97da7065 100644
--- a/packages/widgets/src/_inputs/widget-custom-widget-configuration-input.tsx
+++ b/packages/widgets/src/_inputs/widget-custom-widget-configuration-input.tsx
@@ -23,7 +23,7 @@ import { IconAlertTriangle, IconBraces } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useOptionalBoard } from "@homarr/boards/context";
-import { validateCustomWidgetOptions } from "@homarr/custom-widgets/core";
+import { resolveCustomWidgetOptionsBinding, validateCustomWidgetOptions } from "@homarr/custom-widgets/core";
import { IconPicker } from "@homarr/forms-collection";
import { useScopedI18n } from "@homarr/translation/client";
@@ -336,15 +336,17 @@ function DynamicOptionsSelect({
const definition = available.data?.find((candidate) => candidate.id === definitionId);
const requestId = typeof source.requestId === "string" ? source.requestId : "";
const request = definition?.optionRequests.find((candidate) => candidate.id === requestId);
- const params = Object.fromEntries(
- Object.entries(request?.parameters ?? {}).flatMap(([name, type]) => {
- const value = configuration[name];
- return typeof value === type ? [[name, value as string | number | boolean]] : [];
- }),
- );
+ const params = (() => {
+ if (!request) return null;
+ try {
+ return resolveCustomWidgetOptionsBinding(request, configuration);
+ } catch {
+ return null;
+ }
+ })();
const query = clientApi.customWidget.optionRequest.useQuery(
- { boardId: board?.id ?? "", definitionId, requestId, params },
- { enabled: Boolean(board?.id && definitionId && requestId && request) },
+ { boardId: board?.id ?? "", definitionId, requestId, params: params ?? {} },
+ { enabled: Boolean(board?.id && definitionId && requestId && request && params) },
);
const valuePath = typeof source.valuePath === "string" ? source.valuePath : "$.value";
const labelPath = typeof source.labelPath === "string" ? source.labelPath : "$.label";