From 8c92b428c67c7292d25a3653b6c7d7aada83e2ff Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Wed, 15 Jul 2026 20:25:19 +0000 Subject: [PATCH 1/5] Add extension data store read/write actions for agents --- packages/core/src/extensions/actions.ts | 210 ++++++++++++++++++ .../.agents/skills/extensions/SKILL.md | 19 ++ 2 files changed, 229 insertions(+) diff --git a/packages/core/src/extensions/actions.ts b/packages/core/src/extensions/actions.ts index baead73556..4c0b972f4d 100644 --- a/packages/core/src/extensions/actions.ts +++ b/packages/core/src/extensions/actions.ts @@ -1,8 +1,11 @@ +import { randomUUID } from "node:crypto"; + import { ACTION_CHAT_UI_INLINE_EXTENSION_RENDERER } from "../action-ui.js"; import type { ActionRunContext } from "../action.js"; import type { ActionEntry } from "../agent/production-agent.js"; import type { AgentChatAttachment } from "../agent/types.js"; import { writeAppState } from "../application-state/script-helpers.js"; +import { getDbExec, isPostgres } from "../db/client.js"; import { readResource } from "../resources/script-helpers.js"; import { getRequestOrgId, @@ -35,6 +38,7 @@ import { import { createExtension, deleteExtension, + ensureExtensionsTables, findRecentDuplicateExtension, getHiddenExtensionIdsForCurrentUser, getExtension, @@ -749,6 +753,212 @@ export function createExtensionActionEntries(): Record { }, }, + "extension-data-set": { + tool: { + description: + "Write a value to an extension's extensionData store (the tool_data table) from the agent side. Use this when the agent needs to update or seed data that an extension reads via extensionData.get() at render time. Requires editor access to the extension.", + parameters: { + type: "object", + properties: { + extensionId: { + type: "string", + description: "Extension id whose data store to write to.", + }, + collection: { + type: "string", + description: "Collection name within the extension's data store.", + }, + itemId: { + type: "string", + description: + "Item id within the collection. Upserts if it already exists.", + }, + data: { + description: + "The data value to store. Objects are JSON-serialized automatically.", + }, + scope: { + type: "string", + enum: ["user", "org"], + description: + "Storage scope. 'user' (default) is private to the current user; 'org' is shared across the organization.", + }, + }, + required: ["extensionId", "collection", "itemId", "data"], + }, + }, + run: async (args) => { + const extensionId = String(args?.extensionId ?? "").trim(); + const collection = String(args?.collection ?? "").trim(); + const itemId = String(args?.itemId ?? "").trim(); + if (!extensionId || !collection || !itemId) + return "Error: extensionId, collection, and itemId are required."; + if (args?.data === undefined) return "Error: data is required."; + + await ensureExtensionsTables(); + const access = await resolveAccess("extension", extensionId); + if (!access || access.role === "viewer") + return `Error: editor access required for extension ${extensionId}.`; + + const userEmail = getRequestUserEmail()?.toLowerCase() ?? ""; + const scope = args?.scope === "org" ? "org" : "user"; + const orgId = getRequestOrgId(); + if (scope === "org" && !orgId) + return "Error: org context required for scope=org."; + + const data = + typeof args.data === "string" ? args.data : JSON.stringify(args.data); + const MAX_BYTES = 1024 * 1024; + if (Buffer.byteLength(data, "utf8") > MAX_BYTES) + return `Error: data exceeds ${MAX_BYTES} byte limit. Store large payloads in file storage instead.`; + + const now = new Date().toISOString(); + const scopeKey = scope === "org" ? `org:${orgId}` : userEmail; + const client = getDbExec(); + const pg = isPostgres(); + const conflictClause = pg + ? `ON CONFLICT (tool_id, collection, scope_key, item_id) + DO UPDATE SET data = EXCLUDED.data, updated_at = EXCLUDED.updated_at` + : `ON CONFLICT (tool_id, collection, scope_key, item_id) + DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`; + + await client.execute({ + sql: `INSERT INTO tool_data (id, tool_id, collection, item_id, data, owner_email, scope, org_id, scope_key, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ${conflictClause}`, + args: [ + randomUUID(), + extensionId, + collection, + itemId, + data, + userEmail, + scope, + scope === "org" ? orgId! : null, + scopeKey, + now, + now, + ], + }); + return { + ok: true, + id: itemId, + extensionId, + collection, + scope, + updatedAt: now, + }; + }, + }, + + "extension-data-get": { + tool: { + description: + "Read items from an extension's extensionData store (the tool_data table) from the agent side. Use this to inspect what data an extension currently has stored, or to verify a write succeeded. Requires viewer access to the extension.", + parameters: { + type: "object", + properties: { + extensionId: { + type: "string", + description: "Extension id whose data store to read from.", + }, + collection: { + type: "string", + description: "Collection name within the extension's data store.", + }, + itemId: { + type: "string", + description: + "Optional specific item id. If omitted, lists all items in the collection.", + }, + scope: { + type: "string", + enum: ["user", "org", "all"], + description: + "Storage scope to read. 'user' (default) reads the current user's items; 'org' reads organization-shared items; 'all' reads both.", + }, + limit: { + type: "number", + description: + "Maximum items to return when listing (default 100, max 1000).", + }, + }, + required: ["extensionId", "collection"], + }, + }, + run: async (args) => { + const extensionId = String(args?.extensionId ?? "").trim(); + const collection = String(args?.collection ?? "").trim(); + if (!extensionId || !collection) + return "Error: extensionId and collection are required."; + + await ensureExtensionsTables(); + const access = await resolveAccess("extension", extensionId); + if (!access) return `Error: no access to extension ${extensionId}.`; + + const userEmail = getRequestUserEmail()?.toLowerCase() ?? ""; + const scope = args?.scope ?? "user"; + const orgId = getRequestOrgId(); + const itemId = args?.itemId ? String(args.itemId).trim() : null; + const limit = Math.min(Math.max(1, Number(args?.limit) || 100), 1000); + const client = getDbExec(); + + if (itemId) { + const scopeClause = + scope === "org" + ? `AND scope = 'org' AND org_id = ?` + : scope === "all" + ? `AND ((scope = 'user' AND lower(owner_email) = ?) OR (scope = 'org' AND org_id = ?))` + : `AND scope = 'user' AND lower(owner_email) = ?`; + const scopeArgs = + scope === "org" + ? [orgId ?? ""] + : scope === "all" + ? [userEmail, orgId ?? ""] + : [userEmail]; + + const result = await client.execute({ + sql: `SELECT COALESCE(item_id, id) AS id, data, scope, owner_email, updated_at + FROM tool_data + WHERE tool_id = ? AND collection = ? AND COALESCE(item_id, id) = ? ${scopeClause}`, + args: [extensionId, collection, itemId, ...scopeArgs], + }); + const row = result.rows?.[0]; + if (!row) return { found: false, extensionId, collection, itemId }; + return { found: true, ...row }; + } + + const scopeClause = + scope === "org" + ? `AND scope = 'org' AND org_id = ?` + : scope === "all" + ? `AND ((scope = 'user' AND lower(owner_email) = ?) OR (scope = 'org' AND org_id = ?))` + : `AND scope = 'user' AND lower(owner_email) = ?`; + const scopeArgs = + scope === "org" + ? [orgId ?? ""] + : scope === "all" + ? [userEmail, orgId ?? ""] + : [userEmail]; + + const result = await client.execute({ + sql: `SELECT COALESCE(item_id, id) AS id, data, scope, owner_email, updated_at + FROM tool_data + WHERE tool_id = ? AND collection = ? ${scopeClause} + ORDER BY updated_at DESC + LIMIT ?`, + args: [extensionId, collection, ...scopeArgs, limit], + }); + return { + extensionId, + collection, + scope, + count: result.rows?.length ?? 0, + items: result.rows ?? [], + }; + }, + }, + "delete-extension": { tool: { description: diff --git a/packages/core/src/templates/workspace-core/.agents/skills/extensions/SKILL.md b/packages/core/src/templates/workspace-core/.agents/skills/extensions/SKILL.md index 3ce035f4bf..2cd7cb9523 100644 --- a/packages/core/src/templates/workspace-core/.agents/skills/extensions/SKILL.md +++ b/packages/core/src/templates/workspace-core/.agents/skills/extensions/SKILL.md @@ -144,6 +144,25 @@ persistence** — it handles everything automatically. Only use `dbQuery`/`dbExec` when querying the app's existing tables. See `references/api.md` for the full `get`/`remove`/scope reference. +### Agent-side extension data access + +The agent can read and write `extensionData` directly using two dedicated +actions — no need to go through the iframe bridge or raw SQL: + +| Action | Purpose | +| --------------------- | ------------------------------------------------- | +| `extension-data-set` | Upsert an item in an extension's data store | +| `extension-data-get` | Read items from an extension's data store | + +Use `extension-data-set` when the agent needs to seed, refresh, or update +data that an extension reads at render time via `extensionData.get()`. This +is the correct path for agent-driven dashboard refreshes — the agent +fetches fresh data from providers, then writes the merged result with +`extension-data-set`, and the extension picks it up on next load. + +Use `extension-data-get` to inspect what data an extension currently stores, +or to verify a write succeeded. + ## What extensions are Extensions are mini Alpine.js apps that run inside sandboxed iframes. They From 0387582d203caceea7d63c2d79e15c6c410976fb Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Mon, 20 Jul 2026 20:28:04 +0000 Subject: [PATCH 2/5] Notify extension changes to trigger iframe remount on updates --- packages/core/src/extensions/actions.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/core/src/extensions/actions.ts b/packages/core/src/extensions/actions.ts index 4c0b972f4d..2827889cf6 100644 --- a/packages/core/src/extensions/actions.ts +++ b/packages/core/src/extensions/actions.ts @@ -48,6 +48,7 @@ import { hideExtension, listExtensionHistory, listExtensions, + notifyExtensionChangeForResource, restoreExtensionHistoryVersion, unhideExtension, updateExtension, @@ -840,6 +841,15 @@ export function createExtensionActionEntries(): Record { now, ], }); + + // Bump the extension record's updated_at so mounted iframes + // (keyed by updatedAt) remount and re-fetch extensionData. + await client.execute({ + sql: `UPDATE tools SET updated_at = ? WHERE id = ?`, + args: [now, extensionId], + }); + await notifyExtensionChangeForResource(extensionId); + return { ok: true, id: itemId, From b47a1b31855fee7ffb3ff0a2b3076d37c8bc6b3a Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Mon, 20 Jul 2026 22:49:45 +0000 Subject: [PATCH 3/5] Add extension-data agent actions for reading and writing data --- .changeset/extension-data-agent-actions.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/extension-data-agent-actions.md diff --git a/.changeset/extension-data-agent-actions.md b/.changeset/extension-data-agent-actions.md new file mode 100644 index 0000000000..fb7918327c --- /dev/null +++ b/.changeset/extension-data-agent-actions.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Add extension-data-set and extension-data-get agent actions for reading and writing extensionData from the agent side, with auto-refresh of mounted extension iframes after writes. From a78226f809587f26a7fc93e2d277e84abf964f81 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Mon, 20 Jul 2026 22:54:07 +0000 Subject: [PATCH 4/5] Add agent-side extension data access and validate org scope --- .agents/skills/extensions/SKILL.md | 19 +++++++++++++++++++ packages/core/src/extensions/actions.ts | 2 ++ 2 files changed, 21 insertions(+) diff --git a/.agents/skills/extensions/SKILL.md b/.agents/skills/extensions/SKILL.md index 3ce035f4bf..2cd7cb9523 100644 --- a/.agents/skills/extensions/SKILL.md +++ b/.agents/skills/extensions/SKILL.md @@ -144,6 +144,25 @@ persistence** — it handles everything automatically. Only use `dbQuery`/`dbExec` when querying the app's existing tables. See `references/api.md` for the full `get`/`remove`/scope reference. +### Agent-side extension data access + +The agent can read and write `extensionData` directly using two dedicated +actions — no need to go through the iframe bridge or raw SQL: + +| Action | Purpose | +| --------------------- | ------------------------------------------------- | +| `extension-data-set` | Upsert an item in an extension's data store | +| `extension-data-get` | Read items from an extension's data store | + +Use `extension-data-set` when the agent needs to seed, refresh, or update +data that an extension reads at render time via `extensionData.get()`. This +is the correct path for agent-driven dashboard refreshes — the agent +fetches fresh data from providers, then writes the merged result with +`extension-data-set`, and the extension picks it up on next load. + +Use `extension-data-get` to inspect what data an extension currently stores, +or to verify a write succeeded. + ## What extensions are Extensions are mini Alpine.js apps that run inside sandboxed iframes. They diff --git a/packages/core/src/extensions/actions.ts b/packages/core/src/extensions/actions.ts index 2827889cf6..202e94efc6 100644 --- a/packages/core/src/extensions/actions.ts +++ b/packages/core/src/extensions/actions.ts @@ -909,6 +909,8 @@ export function createExtensionActionEntries(): Record { const userEmail = getRequestUserEmail()?.toLowerCase() ?? ""; const scope = args?.scope ?? "user"; const orgId = getRequestOrgId(); + if ((scope === "org" || scope === "all") && !orgId) + return "Error: org context required for scope=org or scope=all."; const itemId = args?.itemId ? String(args.itemId).trim() : null; const limit = Math.min(Math.max(1, Number(args?.limit) || 100), 1000); const client = getDbExec(); From e9b7b30c5ff439bb272db3464c58d0629f0ad44a Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Tue, 21 Jul 2026 00:08:15 +0000 Subject: [PATCH 5/5] Fix: move tools update before data insert in extension actions --- packages/core/src/extensions/actions.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/core/src/extensions/actions.ts b/packages/core/src/extensions/actions.ts index 202e94efc6..dd0d1a4cf0 100644 --- a/packages/core/src/extensions/actions.ts +++ b/packages/core/src/extensions/actions.ts @@ -823,6 +823,10 @@ export function createExtensionActionEntries(): Record { : `ON CONFLICT (tool_id, collection, scope_key, item_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`; + await client.execute({ + sql: `UPDATE tools SET updated_at = ? WHERE id = ?`, + args: [now, extensionId], + }); await client.execute({ sql: `INSERT INTO tool_data (id, tool_id, collection, item_id, data, owner_email, scope, org_id, scope_key, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) @@ -841,13 +845,6 @@ export function createExtensionActionEntries(): Record { now, ], }); - - // Bump the extension record's updated_at so mounted iframes - // (keyed by updatedAt) remount and re-fetch extensionData. - await client.execute({ - sql: `UPDATE tools SET updated_at = ? WHERE id = ?`, - args: [now, extensionId], - }); await notifyExtensionChangeForResource(extensionId); return {