diff --git a/.agents/skills/homarr-custom-widget/references/runtime.md b/.agents/skills/homarr-custom-widget/references/runtime.md
index 3473a7305..421391c09 100644
--- a/.agents/skills/homarr-custom-widget/references/runtime.md
+++ b/.agents/skills/homarr-custom-widget/references/runtime.md
@@ -8,7 +8,7 @@ Templates read `data.requestId`, `status.requestId`, `options.name`, and tempora
- {(items) => {(items ?? []).map(item => {item.name})}}
+ {(result) => {(result.results ?? []).map(item => {item.name})}}
```
diff --git a/apps/nextjs/src/app/api/assistant/chat/custom-widget-authoring-context.spec.ts b/apps/nextjs/src/app/api/assistant/chat/custom-widget-authoring-context.spec.ts
index 093e3ce8e..a45d26001 100644
--- a/apps/nextjs/src/app/api/assistant/chat/custom-widget-authoring-context.spec.ts
+++ b/apps/nextjs/src/app/api/assistant/chat/custom-widget-authoring-context.spec.ts
@@ -15,6 +15,15 @@ const userMessage = (text: string): UIMessage => ({
});
describe("Custom Widget authoring context", () => {
+ test("refunds a focused discovery claim when the tool call fails", () => {
+ const controller = createCustomWidgetDiscoveryPhaseController();
+
+ expect(controller.claim("customWidget_getComponents")).toBe(true);
+ expect(controller.claim("customWidget_getComponents")).toBe(false);
+ controller.observeFailure("customWidget_getComponents");
+ expect(controller.claim("customWidget_getComponents")).toBe(true);
+ });
+
test.each([
"Create a custom widget for these fixtures",
"Create custom widgets for these services",
diff --git a/apps/nextjs/src/app/api/assistant/chat/custom-widget-authoring-context.ts b/apps/nextjs/src/app/api/assistant/chat/custom-widget-authoring-context.ts
index a99ab821e..f3aa4bb8a 100644
--- a/apps/nextjs/src/app/api/assistant/chat/custom-widget-authoring-context.ts
+++ b/apps/nextjs/src/app/api/assistant/chat/custom-widget-authoring-context.ts
@@ -42,7 +42,17 @@ export const createCustomWidgetDiscoveryPhaseController = (limit = maxFocusedCom
if (toolName === "customWidget_createFromPreview" && typeof result?.id === "string") reset();
},
observeFailure(toolName: string) {
- if (toolName === "customWidget_validateTemplate" || toolName === "customWidget_previewCreate") reset();
+ if (toolName === "customWidget_validateTemplate" || toolName === "customWidget_previewCreate") {
+ reset();
+ return;
+ }
+ if (customWidgetContextToolBudgets[toolName] === undefined) return;
+ const used = calls.get(toolName) ?? 0;
+ if (used <= 1) {
+ calls.delete(toolName);
+ return;
+ }
+ calls.set(toolName, used - 1);
},
};
};
diff --git a/packages/api/src/router/custom-widget/creation-procedures.ts b/packages/api/src/router/custom-widget/creation-procedures.ts
index 66e27c738..c962d9f7a 100644
--- a/packages/api/src/router/custom-widget/creation-procedures.ts
+++ b/packages/api/src/router/custom-widget/creation-procedures.ts
@@ -11,7 +11,7 @@ import {
import { permissionRequiredProcedure } from "../../trpc";
import { parseCustomWidgetAuthoringInput } from "./authoring-validation";
import { insertCustomWidgetDefinition } from "./definition-insert";
-import { getPreviewJournal, getPreviewSession, getPreviewSessionSecrets } from "./preview-sessions";
+import { getPreviewEvidence, getPreviewSession, getPreviewSessionSecrets } from "./preview-sessions";
import { assertSecretSources } from "./secret-policy";
const logger = createLogger({ module: "custom-widget" });
@@ -37,7 +37,7 @@ export const creationProcedures = {
mcp: {
enabled: true,
description:
- "Create a Custom JSX widget without reusing a preview. Prefer customWidget_createFromPreview after full preview validation and query checks so the definition is not streamed again.",
+ "Requires administrator permission. Create a Custom JSX widget without reusing a preview. Prefer customWidget_createFromPreview after full preview validation and query checks so the definition is not streamed again.",
},
})
.input(customWidgetCreateSchema)
@@ -56,16 +56,16 @@ export const creationProcedures = {
mcp: {
enabled: true,
description:
- "Persist the exact tested definition from a customWidget_previewCreate or customWidget_previewReviseTemplate session. Prefer this over resending a large widget to customWidget_create. Every query and action in the final preview revision must have current evidence.",
+ "Requires administrator permission. Persist the exact tested definition from a customWidget_previewCreate or customWidget_previewReviseTemplate session. Prefer this over resending a large widget to customWidget_create. Every query and action in the final preview revision must have current evidence.",
},
})
.input(z.object({ previewSessionId: z.string().min(1), targetBoardId: z.string().min(1).optional() }))
.mutation(async ({ ctx, input }) => {
const session = await getPreviewSession(input.previewSessionId, ctx.session.user.id);
- const journal = await getPreviewJournal(session.id, ctx.session.user.id);
+ const evidence = await getPreviewEvidence(session.id, ctx.session.user.id);
const unverifiedQueryIds = Object.entries(session.requests).flatMap(([requestId, request]) => {
if (request.kind !== "query") return [];
- const verified = journal.some(
+ const verified = evidence.some(
(entry) =>
entry.kind === "query" &&
entry.requestId === requestId &&
@@ -84,12 +84,8 @@ export const creationProcedures = {
}
const unverifiedActionIds = Object.entries(session.requests).flatMap(([requestId, request]) => {
if (request.kind !== "action") return [];
- const verified = journal.some((entry) => {
- if (
- entry.kind !== "action" ||
- entry.requestId !== requestId ||
- entry.sessionRevision !== session.revision
- ) {
+ const verified = evidence.some((entry) => {
+ if (entry.kind !== "action" || entry.requestId !== requestId || entry.sessionRevision !== session.revision) {
return false;
}
if (entry.simulated) return true;
diff --git a/packages/api/src/router/custom-widget/preview-sessions.ts b/packages/api/src/router/custom-widget/preview-sessions.ts
index 45ec56a40..509a61e35 100644
--- a/packages/api/src/router/custom-widget/preview-sessions.ts
+++ b/packages/api/src/router/custom-widget/preview-sessions.ts
@@ -14,6 +14,7 @@ import { toTrpcError } from "./domain-error";
const SESSION_PREFIX = "custom-widget:preview-session:";
const JOURNAL_PREFIX = "custom-widget:preview-journal:";
+const EVIDENCE_PREFIX = "custom-widget:preview-evidence:";
const COMPARE_AND_SWAP_SESSION_SCRIPT = `
local current = redis.call('GET', KEYS[1])
if not current then return 0 end
@@ -64,6 +65,13 @@ class RedisPreviewSessionStore implements PreviewSessionStore {
public getJournal(id: string, maxEntries: number) {
return this.redis.lrange(`${JOURNAL_PREFIX}${id}`, 0, maxEntries - 1);
}
+ public async saveEvidence(id: string, key: string, value: unknown, ttlMs: number) {
+ const evidenceKey = `${EVIDENCE_PREFIX}${id}`;
+ await this.redis.multi().hset(evidenceKey, key, JSON.stringify(value)).pexpire(evidenceKey, ttlMs).exec();
+ }
+ public getEvidence(id: string) {
+ return this.redis.hvals(`${EVIDENCE_PREFIX}${id}`);
+ }
}
let service: CustomWidgetPreviewSessionService | undefined;
@@ -104,6 +112,7 @@ export const appendPreviewJournal = (
entry: Omit,
) => call(() => getService().appendJournal(session, { ...entry, sessionRevision: session.revision }));
export const getPreviewJournal = (id: string, userId: string) => call(() => getService().getJournal(id, userId));
+export const getPreviewEvidence = (id: string, userId: string) => call(() => getService().getEvidence(id, userId));
export const getPreviewSessionSecrets = (session: CustomWidgetPreviewSession, sourceId: string) =>
getService().getSecrets(session, sourceId);
export const setPreviewSessionSecrets = (id: string, userId: string, secrets: CreatePreviewSessionInput["secrets"]) =>
diff --git a/packages/api/src/router/test/custom-widget/definition-persistence.spec.ts b/packages/api/src/router/test/custom-widget/definition-persistence.spec.ts
index dc25b64fd..eb5e56c3a 100644
--- a/packages/api/src/router/test/custom-widget/definition-persistence.spec.ts
+++ b/packages/api/src/router/test/custom-widget/definition-persistence.spec.ts
@@ -18,6 +18,7 @@ import { configureCustomWidgetSourceFromRequest } from "../../custom-widget/secr
import {
appendPreviewJournal,
configurePreviewSessionSource,
+ getPreviewJournal,
getPreviewSession,
} from "../../custom-widget/preview-sessions";
import { serializeCustomWidgetDefinition } from "../../custom-widget/stored-definition";
@@ -294,6 +295,43 @@ describe("custom widget definition persistence", () => {
await expect(caller.get({ id: created.id })).resolves.toMatchObject({ template: revisedTemplate });
});
+ test("retains persistence evidence for every request beyond the bounded journal", async () => {
+ const db = await prepareDatabase();
+ const caller = createCaller(db);
+ const requests = Object.fromEntries(
+ Array.from({ length: 64 }, (_, index) => [`query-${index}`, { path: `/queries/${index}` }]),
+ );
+ const definition = customWidgetDefinitionSchema.parse({
+ $schema: "homarr-custom-widget-v2",
+ name: "Large request set",
+ sources: {
+ default: { baseUrl: "https://example.com", networkScope: "public", auth: "none" },
+ },
+ requests,
+ options: {},
+ template: "All requests tested",
+ });
+ const preview = await caller.previewCreate({ definition, secrets: [] });
+ const previewSession = await getPreviewSession(preview.previewSession.id, userId);
+
+ for (const [requestId, request] of Object.entries(previewSession.requests)) {
+ await appendPreviewJournal(previewSession, {
+ requestId,
+ kind: "query",
+ method: request.method,
+ path: request.path,
+ status: 200,
+ durationMs: 1,
+ simulated: false,
+ });
+ }
+
+ expect(await getPreviewJournal(previewSession.id, userId)).toHaveLength(50);
+ await expect(caller.createFromPreview({ previewSessionId: previewSession.id })).resolves.toMatchObject({
+ managementPath: expect.stringContaining("/manage/custom-widgets/edit/"),
+ });
+ });
+
test("updates a saved widget with MCP-friendly multiline templateLines", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
diff --git a/packages/api/src/test/mcp.spec.ts b/packages/api/src/test/mcp.spec.ts
index 7a894fc46..804a0ccfd 100644
--- a/packages/api/src/test/mcp.spec.ts
+++ b/packages/api/src/test/mcp.spec.ts
@@ -205,6 +205,17 @@ test("MCP tools are deterministically ordered and retain executable schemas", ()
expect(tools.every((tool) => tool.inputValidator instanceof z.ZodObject)).toBe(true);
});
+test("publishes both Custom Widget template input formats", () => {
+ const tool = extractMcpToolsFromProcedures(mcpRouter).find(
+ (candidate) => candidate.name === "customWidget_validateTemplate",
+ );
+
+ expect(tool?.inputSchema.properties).toMatchObject({
+ template: expect.any(Object),
+ templateLines: expect.any(Object),
+ });
+});
+
describe("custom widget authoring procedure access", () => {
const unauthenticatedCaller = mcpRouter.createCaller({
db: null as never,
diff --git a/packages/custom-widgets/src/core/authoring-resources.ts b/packages/custom-widgets/src/core/authoring-resources.ts
index 709870e58..d5348587a 100644
--- a/packages/custom-widgets/src/core/authoring-resources.ts
+++ b/packages/custom-widgets/src/core/authoring-resources.ts
@@ -96,7 +96,7 @@ Templates read \`data.requestId\`, \`status.requestId\`, \`options.name\`, and t
- {(items) => {(items ?? []).map(item => {item.name})}}
+ {(result) => {(result.results ?? []).map(item => {item.name})}}
\`\`\`
diff --git a/packages/custom-widgets/src/runtime/request-capabilities.ts b/packages/custom-widgets/src/runtime/request-capabilities.ts
index 2848ea9aa..aa444d9b3 100644
--- a/packages/custom-widgets/src/runtime/request-capabilities.ts
+++ b/packages/custom-widgets/src/runtime/request-capabilities.ts
@@ -12,6 +12,22 @@ const methods = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]);
const kinds = new Set(["query", "action"]);
const permissions = new Set(["view", "modify", "full"]);
+const parseConfirmation = (value: unknown): CustomJsxRequestCapability["confirmation"] => {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
+ const record = value as Record;
+ if (typeof record.title !== "string" || typeof record.message !== "string") return undefined;
+ if (record.confirmLabel !== undefined && typeof record.confirmLabel !== "string") return undefined;
+ if (record.destructive !== undefined && typeof record.destructive !== "boolean") return undefined;
+
+ const confirmation: NonNullable = {
+ title: record.title,
+ message: record.message,
+ };
+ if (typeof record.confirmLabel === "string") confirmation.confirmLabel = record.confirmLabel;
+ if (typeof record.destructive === "boolean") confirmation.destructive = record.destructive;
+ return confirmation;
+};
+
export function parseRequestCapabilities(value: unknown): CustomJsxRequestCapability[] {
if (!Array.isArray(value)) return [];
return value.flatMap((candidate) => {
@@ -34,10 +50,7 @@ export function parseRequestCapabilities(value: unknown): CustomJsxRequestCapabi
method: record.method,
trigger: record.trigger === "load" ? "load" : "manual",
minimumBoardPermission: record.minimumBoardPermission,
- confirmation:
- record.confirmation && typeof record.confirmation === "object"
- ? (record.confirmation as CustomJsxRequestCapability["confirmation"])
- : undefined,
+ confirmation: parseConfirmation(record.confirmation),
invalidates: Array.isArray(record.invalidates)
? record.invalidates.filter((entry): entry is string => typeof entry === "string")
: [],
diff --git a/packages/custom-widgets/src/server/preview-sessions.ts b/packages/custom-widgets/src/server/preview-sessions.ts
index 10fa6293f..929bf3b54 100644
--- a/packages/custom-widgets/src/server/preview-sessions.ts
+++ b/packages/custom-widgets/src/server/preview-sessions.ts
@@ -75,6 +75,8 @@ export interface PreviewSessionStore {
deleteSession(id: string): Promise;
appendJournal(id: string, value: unknown, maxEntries: number, ttlMs: number): Promise;
getJournal(id: string, maxEntries: number): Promise;
+ saveEvidence(id: string, key: string, value: unknown, ttlMs: number): Promise;
+ getEvidence(id: string): Promise;
}
export interface PreviewSessionServiceOptions {
@@ -88,6 +90,7 @@ export interface PreviewSessionServiceOptions {
export class CustomWidgetPreviewSessionService {
private readonly sessions = new Map();
private readonly journals = new Map();
+ private readonly evidence = new Map>();
private readonly now: () => number;
public constructor(private readonly options: PreviewSessionServiceOptions) {
@@ -128,7 +131,11 @@ export class CustomWidgetPreviewSessionService {
const parsed = sessionSchema.safeParse(parseStoredValue(candidate));
if (!parsed.success || parsed.data.expiresAt <= this.now()) {
if (this.options.store) await this.options.store.deleteSession(id);
- else this.sessions.delete(id);
+ else {
+ this.sessions.delete(id);
+ this.journals.delete(id);
+ this.evidence.delete(id);
+ }
throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview session expired or was not found" });
}
if (parsed.data.userId !== userId) {
@@ -164,8 +171,23 @@ export class CustomWidgetPreviewSessionService {
): Promise {
const value = journalEntrySchema.parse({ ...input, id: this.options.createId(), timestamp: this.now() });
const ttlMs = Math.max(1, session.expiresAt - this.now());
- if (this.options.store) await this.options.store.appendJournal(session.id, value, MAX_JOURNAL_ENTRIES, ttlMs);
- else this.journals.set(session.id, [value, ...(this.journals.get(session.id) ?? [])].slice(0, MAX_JOURNAL_ENTRIES));
+ const isVerified =
+ (value.kind === "query" && value.status !== null && value.status >= 200 && value.status < 300) ||
+ (value.kind === "action" &&
+ (value.simulated || (value.status !== null && value.status >= 200 && value.status < 300)));
+ const evidenceKey = `${value.sessionRevision}:${value.kind}:${value.requestId}`;
+ if (this.options.store) {
+ const writes = [this.options.store.appendJournal(session.id, value, MAX_JOURNAL_ENTRIES, ttlMs)];
+ if (isVerified) writes.push(this.options.store.saveEvidence(session.id, evidenceKey, value, ttlMs));
+ await Promise.all(writes);
+ return;
+ }
+
+ this.journals.set(session.id, [value, ...(this.journals.get(session.id) ?? [])].slice(0, MAX_JOURNAL_ENTRIES));
+ if (!isVerified) return;
+ const evidence = this.evidence.get(session.id) ?? new Map();
+ evidence.set(evidenceKey, value);
+ this.evidence.set(session.id, evidence);
}
public async getJournal(id: string, userId: string): Promise {
@@ -179,6 +201,17 @@ export class CustomWidgetPreviewSessionService {
});
}
+ public async getEvidence(id: string, userId: string): Promise {
+ await this.get(id, userId);
+ const entries = this.options.store
+ ? await this.options.store.getEvidence(id)
+ : [...(this.evidence.get(id)?.values() ?? [])];
+ return entries.flatMap((entry) => {
+ const parsed = journalEntrySchema.safeParse(parseStoredValue(entry));
+ return parsed.success ? [parsed.data] : [];
+ });
+ }
+
public getSecrets(session: CustomWidgetPreviewSession, sourceId: string) {
return session.secrets
.filter((secret) => secret.sourceId === sourceId)
diff --git a/packages/custom-widgets/src/test/preview-sessions.spec.ts b/packages/custom-widgets/src/test/preview-sessions.spec.ts
index a3fb56915..44366b220 100644
--- a/packages/custom-widgets/src/test/preview-sessions.spec.ts
+++ b/packages/custom-widgets/src/test/preview-sessions.spec.ts
@@ -134,6 +134,8 @@ describe("preview sessions", () => {
},
appendJournal: async () => undefined,
getJournal: async () => [],
+ saveEvidence: async () => undefined,
+ getEvidence: async () => [],
};
const service = new CustomWidgetPreviewSessionService({
createId: () => "session",
diff --git a/packages/custom-widgets/src/test/runtime.spec.tsx b/packages/custom-widgets/src/test/runtime.spec.tsx
index f44bd8a72..1d5aa7f39 100644
--- a/packages/custom-widgets/src/test/runtime.spec.tsx
+++ b/packages/custom-widgets/src/test/runtime.spec.tsx
@@ -64,6 +64,13 @@ it("normalizes request capabilities and drops malformed entries", () => {
confirmation: { title: "Approve", message: "Approve this request?" },
invalidates: ["queue", 3],
},
+ {
+ id: "malformed-confirmation",
+ kind: "action",
+ method: "POST",
+ minimumBoardPermission: "modify",
+ confirmation: { title: 3, message: "Approve this request?" },
+ },
{ id: "unsafe", kind: "action", method: "TRACE", minimumBoardPermission: "full" },
]),
).toEqual([
@@ -76,6 +83,15 @@ it("normalizes request capabilities and drops malformed entries", () => {
confirmation: { title: "Approve", message: "Approve this request?" },
invalidates: ["queue"],
},
+ {
+ id: "malformed-confirmation",
+ kind: "action",
+ method: "POST",
+ trigger: "manual",
+ minimumBoardPermission: "modify",
+ confirmation: undefined,
+ invalidates: [],
+ },
]);
});