diff --git a/docs/agents/mcp-apps-spec-notes.md b/docs/agents/mcp-apps-spec-notes.md
index 51e61633f2..b481f69af9 100644
--- a/docs/agents/mcp-apps-spec-notes.md
+++ b/docs/agents/mcp-apps-spec-notes.md
@@ -15,6 +15,9 @@ over project-local conventions.
- The repo exposes a single generic shell via `open_generated_ui`.
- Saved apps are reopened by `app_id`; inline renders are ephemeral.
+- Saved apps are hidden from `search` by default; set
+ `hidden: false` in `ui_save_app` only for reusable apps that
+ should be discoverable.
- If an OAuth provider requires a callback URL, use a persisted hosted saved app
rather than an inline render.
- For secret-bearing requests and host approval policy, also read
diff --git a/packages/worker/migrations/0013-ui-artifact-search-visibility.sql b/packages/worker/migrations/0013-ui-artifact-search-visibility.sql
new file mode 100644
index 0000000000..400fa6ebeb
--- /dev/null
+++ b/packages/worker/migrations/0013-ui-artifact-search-visibility.sql
@@ -0,0 +1,2 @@
+ALTER TABLE ui_artifacts
+ADD COLUMN hidden INTEGER NOT NULL DEFAULT 1;
diff --git a/packages/worker/src/app/saved-ui-hosted-html.node.test.ts b/packages/worker/src/app/saved-ui-hosted-html.node.test.ts
index fa7186b840..b16c08c41c 100644
--- a/packages/worker/src/app/saved-ui-hosted-html.node.test.ts
+++ b/packages/worker/src/app/saved-ui-hosted-html.node.test.ts
@@ -23,6 +23,7 @@ test('renderHostedSavedUiHtml emits shared runtime assets for html apps', () =>
code: 'Hello',
runtime: 'html',
parameters: null,
+ hidden: true,
created_at: '2026-03-27T00:00:00.000Z',
updated_at: '2026-03-27T00:00:00.000Z',
},
@@ -80,6 +81,7 @@ test('renderHostedSavedUiHtml keeps user javascript separate from runtime bootst
code: 'document.querySelector("[data-generated-ui-root]")?.append("hello")',
runtime: 'javascript',
parameters: null,
+ hidden: true,
created_at: '2026-03-27T00:00:00.000Z',
updated_at: '2026-03-27T00:00:00.000Z',
},
diff --git a/packages/worker/src/mcp/capabilities/apps/ui-get-app.ts b/packages/worker/src/mcp/capabilities/apps/ui-get-app.ts
index c99de05700..3a8b10b17d 100644
--- a/packages/worker/src/mcp/capabilities/apps/ui-get-app.ts
+++ b/packages/worker/src/mcp/capabilities/apps/ui-get-app.ts
@@ -14,6 +14,7 @@ const outputSchema = z.object({
title: z.string(),
description: z.string(),
parameters: z.array(uiArtifactParameterSchema).nullable(),
+ hidden: z.boolean(),
code: z
.string()
.describe('Generated UI source code to render inside the generic shell.'),
@@ -54,6 +55,7 @@ export const uiGetAppCapability = defineDomainCapability(
title: row.title,
description: row.description,
parameters: parseUiArtifactParameters(row.parameters),
+ hidden: row.hidden,
code: row.code,
runtime: row.runtime,
created_at: row.created_at,
diff --git a/packages/worker/src/mcp/capabilities/apps/ui-list-apps.ts b/packages/worker/src/mcp/capabilities/apps/ui-list-apps.ts
index d9f077a31c..968f01ab45 100644
--- a/packages/worker/src/mcp/capabilities/apps/ui-list-apps.ts
+++ b/packages/worker/src/mcp/capabilities/apps/ui-list-apps.ts
@@ -17,6 +17,7 @@ const outputSchema = z.object({
description: z.string(),
runtime: z.string(),
parameters: z.array(uiArtifactParameterSchema).nullable(),
+ hidden: z.boolean(),
created_at: z.string(),
updated_at: z.string(),
}),
@@ -45,6 +46,7 @@ export const uiListAppsCapability = defineDomainCapability(
description: row.description,
runtime: row.runtime,
parameters: parseUiArtifactParameters(row.parameters),
+ hidden: row.hidden,
created_at: row.created_at,
updated_at: row.updated_at,
})),
diff --git a/packages/worker/src/mcp/capabilities/apps/ui-load-app-source.ts b/packages/worker/src/mcp/capabilities/apps/ui-load-app-source.ts
index 7849d786cc..657a786069 100644
--- a/packages/worker/src/mcp/capabilities/apps/ui-load-app-source.ts
+++ b/packages/worker/src/mcp/capabilities/apps/ui-load-app-source.ts
@@ -16,6 +16,7 @@ const outputSchema = z.object({
runtime: z.enum(['html', 'javascript']),
code: z.string(),
parameters: z.array(uiArtifactParameterSchema).nullable(),
+ hidden: z.boolean(),
})
export const uiLoadAppSourceCapability = defineDomainCapability(
@@ -52,6 +53,7 @@ export const uiLoadAppSourceCapability = defineDomainCapability(
runtime: row.runtime,
code: row.code,
parameters: parseUiArtifactParameters(row.parameters),
+ hidden: row.hidden,
}
},
},
diff --git a/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts b/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts
index f9b1ee9fc8..dd9e8973cf 100644
--- a/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts
+++ b/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts
@@ -6,11 +6,15 @@ import { type CapabilityContext } from '#mcp/capabilities/types.ts'
import { errorFields, logMcpEvent } from '#mcp/observability.ts'
import {
deleteUiArtifact,
+ getUiArtifactById,
insertUiArtifact,
updateUiArtifact,
} from '#mcp/ui-artifacts-repo.ts'
import { buildUiArtifactEmbedText } from '#mcp/ui-artifacts-embed.ts'
-import { upsertUiArtifactVector } from '#mcp/ui-artifacts-vectorize.ts'
+import {
+ deleteUiArtifactVector,
+ upsertUiArtifactVector,
+} from '#mcp/ui-artifacts-vectorize.ts'
import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts'
import {
normalizeUiArtifactParameters,
@@ -48,6 +52,12 @@ const inputSchema = z.object({
.describe(
'Optional parameter definitions for reusable saved apps. Resolved values are exposed at runtime on the imported `kodyWidget.params` helper from `@kody/ui-utils`.',
),
+ hidden: z
+ .boolean()
+ .optional()
+ .describe(
+ 'Whether this saved app should stay hidden from search results. Defaults to true so one-off apps stay private unless explicitly made discoverable.',
+ ),
})
const outputSchema = z.object({
@@ -55,6 +65,7 @@ const outputSchema = z.object({
runtime: z.enum(['html', 'javascript']),
hosted_url: z.string().url(),
parameters: z.array(uiArtifactParameterSchema).nullable(),
+ hidden: z.boolean(),
})
export const uiSaveAppCapability = defineDomainCapability(
@@ -77,6 +88,7 @@ export const uiSaveAppCapability = defineDomainCapability(
const serializedParameters = parameters
? JSON.stringify(parameters)
: null
+ let hidden: boolean
if (isUpdate) {
const updated = await updateUiArtifact(
@@ -89,12 +101,27 @@ export const uiSaveAppCapability = defineDomainCapability(
code: args.code,
runtime: args.runtime,
parameters: serializedParameters,
+ hidden: args.hidden,
},
)
if (!updated) {
throw new Error('Saved UI artifact not found for this user.')
}
+ if (args.hidden === undefined) {
+ const existing = await getUiArtifactById(
+ ctx.env.APP_DB,
+ user.userId,
+ appId,
+ )
+ if (!existing) {
+ throw new Error('Saved UI artifact not found for this user.')
+ }
+ hidden = existing.hidden
+ } else {
+ hidden = args.hidden
+ }
} else {
+ hidden = args.hidden ?? true
const now = new Date().toISOString()
await insertUiArtifact(ctx.env.APP_DB, {
id: appId,
@@ -104,23 +131,28 @@ export const uiSaveAppCapability = defineDomainCapability(
code: args.code,
runtime: args.runtime,
parameters: serializedParameters,
+ hidden,
created_at: now,
updated_at: now,
})
}
try {
- await upsertUiArtifactVector(ctx.env, {
- appId,
- userId: user.userId,
- embedText: buildUiArtifactEmbedText({
- title: args.title,
- description: args.description,
- code: args.code,
- runtime: args.runtime,
- parameters,
- }),
- })
+ if (!hidden) {
+ await upsertUiArtifactVector(ctx.env, {
+ appId,
+ userId: user.userId,
+ embedText: buildUiArtifactEmbedText({
+ title: args.title,
+ description: args.description,
+ code: args.code,
+ runtime: args.runtime,
+ parameters,
+ }),
+ })
+ } else if (isUpdate) {
+ await deleteUiArtifactVector(ctx.env, appId)
+ }
} catch (cause) {
if (!isUpdate) {
await deleteUiArtifact(ctx.env.APP_DB, user.userId, appId)
@@ -156,6 +188,7 @@ export const uiSaveAppCapability = defineDomainCapability(
runtime: args.runtime,
hosted_url: buildSavedUiUrl(ctx.callerContext.baseUrl, appId),
parameters,
+ hidden,
}
},
},
diff --git a/packages/worker/src/mcp/capabilities/unified-search.workers.test.ts b/packages/worker/src/mcp/capabilities/unified-search.workers.test.ts
index b88fef634a..b255692c6f 100644
--- a/packages/worker/src/mcp/capabilities/unified-search.workers.test.ts
+++ b/packages/worker/src/mcp/capabilities/unified-search.workers.test.ts
@@ -43,6 +43,7 @@ function createUiArtifactRow(appId: string): UiArtifactRow {
required: true,
},
]),
+ hidden: false,
created_at: '2026-03-20T00:00:00.000Z',
updated_at: '2026-03-20T00:00:00.000Z',
}
diff --git a/packages/worker/src/mcp/index.ts b/packages/worker/src/mcp/index.ts
index eb4ec888a4..f952d618ae 100644
--- a/packages/worker/src/mcp/index.ts
+++ b/packages/worker/src/mcp/index.ts
@@ -42,7 +42,7 @@ Quick start
- Never ask the user to paste secrets, tokens, API keys, passwords, OAuth codes, or client secrets into chat. Use saved secrets when available, or use 'open_generated_ui' to collect and save sensitive values instead.
- Use 'meta_save_skill' only for workflows that are reasonably repeatable—patterns you expect to run again with similar structure or inputs. Do not save one-off tasks, unique ad-hoc work, or highly bespoke requests as skills; run those with 'execute' instead. Use the optional 'collection' field to group related saved skills, and use 'meta_update_skill' to replace an existing skill's code in place.
- When a saved skill declares parameters, pass values via meta_run_skill params; the codemode can read them from the params variable.
-- Use 'ui_save_app' to persist reusable UI source for later reopening via 'app_id'. Saved apps are user-scoped UI artifacts, not codemode skills.
+ - Use 'ui_save_app' to persist reusable UI source for later reopening via 'app_id'. Saved apps are user-scoped UI artifacts, not codemode skills. They are hidden from search by default unless you explicitly set \`hidden: false\` for reusable apps.
- Use \`codemode.secret_list(args)\` during execute-time code to list secret metadata only; it does not return plaintext values.
- Use \`codemode.secret_set(args)\` only to persist secret values that are already available inside trusted execution, such as refreshed OAuth tokens. It returns metadata only and never returns plaintext values.
diff --git a/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts b/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts
index 6edbc5f9b9..2738f69ef1 100644
--- a/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts
+++ b/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts
@@ -232,6 +232,109 @@ test('mcp server executes user code against codemode and tracks execute context'
expect(executeResult?.hosted_url).toBe(
`${server.origin}/ui/${executeResult?.app_id}`,
)
+ expect(executeResult?.hidden).toBe(true)
+
+ const hiddenSearchResult = await mcpClient.client.callTool({
+ name: 'search',
+ arguments: {
+ query: 'Execute generated app',
+ detail: true,
+ },
+ })
+ const hiddenSearchStructured = (hiddenSearchResult as CallToolResult)
+ .structuredContent as
+ | {
+ result?: {
+ matches?: Array<{
+ type?: string
+ appId?: string
+ }>
+ }
+ }
+ | undefined
+ expect(
+ hiddenSearchStructured?.result?.matches?.some(
+ (match) =>
+ match.type === 'app' && match.appId === executeResult?.app_id,
+ ),
+ ).toBe(false)
+
+ const savedAppMetadata = await mcpClient.client.callTool({
+ name: 'execute',
+ arguments: {
+ code: `async () => {
+ return await codemode.ui_get_app({
+ app_id: ${JSON.stringify(executeResult?.app_id)},
+ })
+ }`,
+ },
+ })
+ const savedAppMetadataStructured = (savedAppMetadata as CallToolResult)
+ .structuredContent as
+ | {
+ result?: {
+ hidden?: boolean
+ }
+ }
+ | undefined
+ expect(savedAppMetadataStructured?.result?.hidden).toBe(true)
+
+ const searchableUpdateResult = await mcpClient.client.callTool({
+ name: 'execute',
+ arguments: {
+ code: `async () => {
+ return await codemode.ui_save_app({
+ app_id: ${JSON.stringify(executeResult?.app_id)},
+ title: 'Execute generated app',
+ description: 'Saved through execute.',
+ code: 'Execute App
',
+ hidden: false,
+ })
+ }`,
+ },
+ })
+ const searchableUpdateStructured = (
+ searchableUpdateResult as CallToolResult
+ ).structuredContent as
+ | {
+ result?: {
+ hidden?: boolean
+ }
+ }
+ | undefined
+ expect(searchableUpdateStructured?.result?.hidden).toBe(false)
+
+ const visibleSearchResult = await mcpClient.client.callTool({
+ name: 'search',
+ arguments: {
+ query: 'Execute generated app',
+ detail: true,
+ limit: 20,
+ maxResponseSize: 20_000,
+ },
+ })
+ const visibleSearchStructured = (visibleSearchResult as CallToolResult)
+ .structuredContent as
+ | {
+ result?: {
+ matches?: Array<{
+ type?: string
+ appId?: string
+ }>
+ }
+ }
+ | undefined
+ expect(
+ visibleSearchStructured?.result?.matches?.find(
+ (match) =>
+ match.type === 'app' && match.appId === executeResult?.app_id,
+ ),
+ ).toEqual(
+ expect.objectContaining({
+ type: 'app',
+ appId: executeResult?.app_id,
+ }),
+ )
const contextResult = await mcpClient.client.callTool({
name: 'execute',
diff --git a/packages/worker/src/mcp/observability.workers.test.ts b/packages/worker/src/mcp/observability.workers.test.ts
index b6a432f17b..dbc88ccc62 100644
--- a/packages/worker/src/mcp/observability.workers.test.ts
+++ b/packages/worker/src/mcp/observability.workers.test.ts
@@ -146,7 +146,6 @@ test('ui_save_app capability logs success for valid invocation', async () => {
{
title: 'Observed app',
description: 'Observation test app.',
- keywords: ['observability'],
code: 'document.querySelector("#app")!.innerHTML = "
Observed app
"',
},
{
@@ -175,6 +174,7 @@ test('ui_save_app capability logs success for valid invocation', async () => {
},
)
expect(typeof (result as { app_id: string }).app_id).toBe('string')
+ expect((result as { hidden: boolean }).hidden).toBe(true)
} finally {
console.info = originalInfo
}
@@ -201,6 +201,7 @@ test('ui_save_app logs vector refresh failure for in-place updates and still suc
title: 'Observed app',
description: 'Observation test app.',
code: 'Observed app
',
+ hidden: false,
},
{
env: {
diff --git a/packages/worker/src/mcp/tools/search.node.test.ts b/packages/worker/src/mcp/tools/search.node.test.ts
index 4a766783fa..559bbc796c 100644
--- a/packages/worker/src/mcp/tools/search.node.test.ts
+++ b/packages/worker/src/mcp/tools/search.node.test.ts
@@ -19,6 +19,7 @@ test('optional search rows fall back when saved skills lookup fails', async () =
code: '',
runtime: 'html',
parameters: null,
+ hidden: false,
created_at: '2026-03-24T00:00:00.000Z',
updated_at: '2026-03-24T00:00:00.000Z',
},
diff --git a/packages/worker/src/mcp/tools/search.ts b/packages/worker/src/mcp/tools/search.ts
index c4085c880a..092c8b372d 100644
--- a/packages/worker/src/mcp/tools/search.ts
+++ b/packages/worker/src/mcp/tools/search.ts
@@ -269,7 +269,9 @@ export async function registerSearchTool(agent: McpRegistrationAgent) {
loadSkills: () =>
listMcpSkillsByUserId(agent.getEnv().APP_DB, userId!),
loadUiArtifacts: () =>
- listUiArtifactsByUserId(agent.getEnv().APP_DB, userId!),
+ listUiArtifactsByUserId(agent.getEnv().APP_DB, userId!, {
+ hidden: false,
+ }),
loadUserSecrets: () =>
listUserSecretsForSearch({
env: agent.getEnv(),
diff --git a/packages/worker/src/mcp/ui-artifacts-repo.ts b/packages/worker/src/mcp/ui-artifacts-repo.ts
index c1e2cbdcd2..c872a649e8 100644
--- a/packages/worker/src/mcp/ui-artifacts-repo.ts
+++ b/packages/worker/src/mcp/ui-artifacts-repo.ts
@@ -16,8 +16,8 @@ export async function insertUiArtifact(
.prepare(
`INSERT INTO ui_artifacts (
id, user_id, title, description, source_code, source_type,
- parameters, created_at, updated_at
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ parameters, hidden, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
row.id,
@@ -27,6 +27,7 @@ export async function insertUiArtifact(
row.code,
row.runtime,
row.parameters ?? null,
+ row.hidden ? 1 : 0,
row.created_at ?? now,
row.updated_at ?? now,
)
@@ -41,7 +42,7 @@ export async function getUiArtifactById(
const result = await db
.prepare(
`SELECT id, user_id, title, description, source_code, source_type,
- parameters, created_at, updated_at
+ parameters, hidden, created_at, updated_at
FROM ui_artifacts WHERE id = ? AND user_id = ?`,
)
.bind(artifactId, userId)
@@ -62,7 +63,7 @@ export async function getUiArtifactByOwnerIds(
const result = await db
.prepare(
`SELECT id, user_id, title, description, source_code, source_type,
- parameters, created_at, updated_at
+ parameters, hidden, created_at, updated_at
FROM ui_artifacts
WHERE id = ? AND user_id IN (${placeholders})
LIMIT 1`,
@@ -92,7 +93,12 @@ export async function updateUiArtifact(
updates: Partial<
Pick<
UiArtifactRow,
- 'title' | 'description' | 'code' | 'runtime' | 'parameters'
+ | 'title'
+ | 'description'
+ | 'code'
+ | 'runtime'
+ | 'parameters'
+ | 'hidden'
>
>,
): Promise {
@@ -118,6 +124,9 @@ export async function updateUiArtifact(
if (updates.parameters !== undefined) {
addAssignment('parameters', updates.parameters ?? null)
}
+ if (updates.hidden !== undefined) {
+ addAssignment('hidden', updates.hidden ? 1 : 0)
+ }
addAssignment('updated_at', new Date().toISOString())
@@ -133,14 +142,21 @@ export async function updateUiArtifact(
export async function listUiArtifactsByUserId(
db: D1Database,
userId: string,
+ options?: { hidden?: boolean },
): Promise> {
+ const hidden = options?.hidden
const { results } = await db
.prepare(
`SELECT id, user_id, title, description, source_code, source_type,
- parameters, created_at, updated_at
- FROM ui_artifacts WHERE user_id = ?`,
+ parameters, hidden, created_at, updated_at
+ FROM ui_artifacts
+ WHERE user_id = ?
+ ${hidden === undefined ? '' : 'AND hidden = ?'}`,
+ )
+ .bind(
+ userId,
+ ...(hidden === undefined ? [] : [hidden ? 1 : 0]),
)
- .bind(userId)
.all>()
return (results ?? []).map(mapRow)
}
@@ -154,6 +170,10 @@ function mapRow(row: Record): UiArtifactRow {
code: String(row['source_code']),
runtime: String(row['source_type']) as UiArtifactRow['runtime'],
parameters: row['parameters'] == null ? null : String(row['parameters']),
+ hidden:
+ row['hidden'] === 1 ||
+ row['hidden'] === '1' ||
+ row['hidden'] === true,
created_at: String(row['created_at']),
updated_at: String(row['updated_at']),
}
diff --git a/packages/worker/src/mcp/ui-artifacts-types.ts b/packages/worker/src/mcp/ui-artifacts-types.ts
index e8d4640ad4..69c465c580 100644
--- a/packages/worker/src/mcp/ui-artifacts-types.ts
+++ b/packages/worker/src/mcp/ui-artifacts-types.ts
@@ -8,6 +8,7 @@ export type UiArtifactRow = {
code: string
runtime: UiArtifactRuntime
parameters: string | null
+ hidden: boolean
created_at: string
updated_at: string
}