fix(mini-apps): reliability fixes + self-contained data loading

From two rounds of copilot build feedback:

- runtime: null-check hallucinated tool names (was crashing the whole run)
- add window.rowboat.fetch + builtin fetch-url: CORS-safe HTTP for UI and
  bg-task agents (no shell needed for plain HTTP)
- mini-app-set-data/install: reject stringified JSON; require object
- manifest dataContract (requiredKeys/nonEmptyArrayKeys) enforced on writes so
  a stray/legacy task can't corrupt an app's data or wipe series
- self-contained data: data.json is a served sibling (dist/), apps fetch it via
  relative URL; removed host-side data injection
- build-mini-app skill: CORS/fetch-url/no-shell/capable-model/dataContract gotchas
This commit is contained in:
Gagan 2026-07-02 01:06:05 +05:30
parent 6d7fd7b013
commit c424976926
11 changed files with 288 additions and 75 deletions

View file

@ -1324,6 +1324,9 @@ export function setupIpcHandlers() {
'mini-apps:get-data': async (_event, args) => { 'mini-apps:get-data': async (_event, args) => {
return miniAppsHandler.getAppData(args.id); return miniAppsHandler.getAppData(args.id);
}, },
'mini-apps:fetch': async (_event, args) => {
return miniAppsHandler.proxyFetch(args);
},
// Agent schedule handlers // Agent schedule handlers
'agent-schedule:getConfig': async () => { 'agent-schedule:getConfig': async () => {
const repo = container.resolve<IAgentScheduleRepo>('agentScheduleRepo'); const repo = container.resolve<IAgentScheduleRepo>('agentScheduleRepo');

View file

@ -24,7 +24,7 @@ function appDir(id: string): string {
*/ */
export const MINIAPP_BRIDGE_JS = ` export const MINIAPP_BRIDGE_JS = `
(function () { (function () {
var data = null, state = null; var data = null, dataLoaded = false, state = null;
var dataCbs = [], stateCbs = []; var dataCbs = [], stateCbs = [];
var pending = {}, seq = 0; var pending = {}, seq = 0;
function post(msg) { parent.postMessage(msg, '*'); } function post(msg) { parent.postMessage(msg, '*'); }
@ -35,13 +35,18 @@ export const MINIAPP_BRIDGE_JS = `
post({ type: 'rowboat:mini-app:rpc', id: id, method: method, params: params }); post({ type: 'rowboat:mini-app:rpc', id: id, method: method, params: params });
}); });
} }
// Apps are self-contained: data.json is a served sibling of index.html, loaded
// via a relative fetch (same origin, no CORS). Rowboat does not inject it.
function loadData() {
return fetch('data.json', { cache: 'no-store' })
.then(function (r) { return r && r.ok ? r.json() : null; })
.then(function (d) { data = d; dataLoaded = true; dataCbs.forEach(function (cb) { try { cb(data); } catch (_) {} }); return data; })
.catch(function () { dataLoaded = true; dataCbs.forEach(function (cb) { try { cb(null); } catch (_) {} }); return null; });
}
window.addEventListener('message', function (e) { window.addEventListener('message', function (e) {
var m = e.data; var m = e.data;
if (!m || typeof m !== 'object') return; if (!m || typeof m !== 'object') return;
if (m.type === 'rowboat:mini-app:data') { if (m.type === 'rowboat:mini-app:state') {
data = m.data;
dataCbs.forEach(function (cb) { try { cb(data); } catch (_) {} });
} else if (m.type === 'rowboat:mini-app:state') {
state = m.state; state = m.state;
stateCbs.forEach(function (cb) { try { cb(state); } catch (_) {} }); stateCbs.forEach(function (cb) { try { cb(state); } catch (_) {} });
} else if (m.type === 'rowboat:mini-app:rpc-result') { } else if (m.type === 'rowboat:mini-app:rpc-result') {
@ -51,7 +56,8 @@ export const MINIAPP_BRIDGE_JS = `
}); });
window.rowboat = { window.rowboat = {
getData: function () { return data; }, getData: function () { return data; },
onData: function (cb) { dataCbs.push(cb); if (data !== null) { try { cb(data); } catch (_) {} } return function () { var i = dataCbs.indexOf(cb); if (i >= 0) dataCbs.splice(i, 1); }; }, onData: function (cb) { dataCbs.push(cb); if (dataLoaded) { try { cb(data); } catch (_) {} } return function () { var i = dataCbs.indexOf(cb); if (i >= 0) dataCbs.splice(i, 1); }; },
refreshData: function () { return loadData(); },
getState: function () { return state; }, getState: function () { return state; },
onState: function (cb) { stateCbs.push(cb); if (state !== null) { try { cb(state); } catch (_) {} } return function () { var i = stateCbs.indexOf(cb); if (i >= 0) stateCbs.splice(i, 1); }; }, onState: function (cb) { stateCbs.push(cb); if (state !== null) { try { cb(state); } catch (_) {} } return function () { var i = stateCbs.indexOf(cb); if (i >= 0) stateCbs.splice(i, 1); }; },
setState: function (patch) { state = Object.assign({}, state || {}, patch); post({ type: 'rowboat:mini-app:setState', patch: patch }); stateCbs.forEach(function (cb) { try { cb(state); } catch (_) {} }); }, setState: function (patch) { state = Object.assign({}, state || {}, patch); post({ type: 'rowboat:mini-app:setState', patch: patch }); stateCbs.forEach(function (cb) { try { cb(state); } catch (_) {} }); },
@ -59,8 +65,15 @@ export const MINIAPP_BRIDGE_JS = `
searchTools: function (scope, query) { return rpc('searchTools', { scope: scope, query: query }); }, searchTools: function (scope, query) { return rpc('searchTools', { scope: scope, query: query }); },
isConnected: function (scope) { return rpc('isConnected', { scope: scope }); }, isConnected: function (scope) { return rpc('isConnected', { scope: scope }); },
connect: function (scope) { return rpc('connect', { scope: scope }); }, connect: function (scope) { return rpc('connect', { scope: scope }); },
// CORS-safe HTTP via the main process. Resolves to { ok, status, text, json }
// (json is the parsed body when it's valid JSON, else null).
fetch: function (url, opts) {
return rpc('fetch', { url: url, method: (opts && opts.method), headers: (opts && opts.headers), body: (opts && opts.body) })
.then(function (r) { var j = null; try { j = JSON.parse(r.text); } catch (_) {} r.json = j; return r; });
},
ready: function () { post({ type: 'rowboat:mini-app:ready' }); }, ready: function () { post({ type: 'rowboat:mini-app:ready' }); },
}; };
loadData();
})(); })();
`; `;
@ -108,6 +121,35 @@ export function listApps(): { manifests: Manifest[] } {
return { manifests }; return { manifests };
} }
/**
* Proxy an HTTP request through the main process so Mini Apps can call
* third-party APIs that don't send CORS headers (the sandboxed app:// origin
* can't fetch them directly). GET/POST over http(s) only.
*/
export async function proxyFetch(input: {
url: string;
method?: string;
headers?: Record<string, string>;
body?: string;
}): Promise<{ ok: boolean; status: number; statusText: string; text: string; error?: string }> {
try {
const parsed = new URL(input.url);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return { ok: false, status: 0, statusText: '', text: '', error: 'Only http(s) URLs are allowed.' };
}
const method = (input.method || 'GET').toUpperCase();
const res = await fetch(input.url, {
method,
headers: input.headers,
body: method === 'GET' || method === 'HEAD' ? undefined : input.body,
});
const text = await res.text();
return { ok: res.ok, status: res.status, statusText: res.statusText, text };
} catch (e) {
return { ok: false, status: 0, statusText: '', text: '', error: e instanceof Error ? e.message : String(e) };
}
}
/** Read an app's latest data.json (agent output), or null if absent/invalid. */ /** Read an app's latest data.json (agent output), or null if absent/invalid. */
export function getAppData(id: string): { data: unknown | null } { export function getAppData(id: string): { data: unknown | null } {
try { try {

View file

@ -29,6 +29,17 @@ export function MiniAppFrame({ manifest }: { manifest: miniApp.MiniAppManifest }
} }
async function handleRpc(method: string, params: Record<string, unknown>): Promise<unknown> { async function handleRpc(method: string, params: Record<string, unknown>): Promise<unknown> {
// fetch is a network proxy, not toolkit-scoped — handle before the scope gate.
if (method === 'fetch') {
const url = typeof params.url === 'string' ? params.url : ''
if (!url) throw new Error('No url specified.')
return window.ipc.invoke('mini-apps:fetch', {
url,
method: typeof params.method === 'string' ? params.method : undefined,
headers: (params.headers && typeof params.headers === 'object' ? params.headers : undefined) as Record<string, string> | undefined,
body: typeof params.body === 'string' ? params.body : undefined,
})
}
const s = typeof params.scope === 'string' ? params.scope : '' const s = typeof params.scope === 'string' ? params.scope : ''
if (!s || !scope.includes(s)) { if (!s || !scope.includes(s)) {
throw new Error(`This app is not allowed to use "${s || '(none)'}".`) throw new Error(`This app is not allowed to use "${s || '(none)'}".`)
@ -62,15 +73,9 @@ export function MiniAppFrame({ manifest }: { manifest: miniApp.MiniAppManifest }
} }
} }
async function handleReady() { // Apps load their own data.json (served sibling) via a relative fetch; the
let data: unknown = null // host only provides per-app UI state on ready.
try { function handleReady() {
const r = await window.ipc.invoke('mini-apps:get-data', { id: manifest.id })
data = r.data
} catch {
data = null
}
postToFrame({ type: MINI_APP_MESSAGE.data, data })
postToFrame({ type: MINI_APP_MESSAGE.state, state: stateRef.current }) postToFrame({ type: MINI_APP_MESSAGE.state, state: stateRef.current })
} }

View file

@ -35,53 +35,65 @@ const patternFor = (id: string): string => PATTERNS[hash(id + '·pat') % PATTERN
// Tailwind tokens). Injected once; per-card accent is passed via CSS variables. // Tailwind tokens). Injected once; per-card accent is passed via CSS variables.
const CARD_CSS = ` const CARD_CSS = `
/* Light is the baseline; .dark (set on <html>) overrides the surface tokens. /* Light is the baseline; .dark (set on <html>) overrides the surface tokens.
The accent system (badge/pill/glow/pattern via --accent) is identical in both. */ The accent system (badge/pill/glow/pattern via --accent) is identical in both.
Surfaces are brushed-metal: a base gradient + a diagonal sheen + a hairline
top highlight. Sizing responds to the PANE width via container queries. */
.ma-page { .ma-page {
--ma-bg:#f6f6f7; container-type: inline-size;
--ma-card-from:#ffffff; --ma-card-to:#fbfbfc; --ma-bg:#eceef1;
--ma-card-hover-from:#ffffff; --ma-card-hover-to:#f5f5f7; /* metallic silver */
--ma-border:rgba(0,0,0,0.08); --ma-border-hover:rgba(0,0,0,0.14); --ma-card-from:#ffffff; --ma-card-mid:#f2f3f6; --ma-card-to:#e6e8ee;
--ma-shadow:0 1px 2px rgba(0,0,0,0.06); --ma-card-hover-from:#ffffff; --ma-card-hover-mid:#f5f6f9; --ma-card-hover-to:#eaecf1;
--ma-title:#101013; --ma-desc:rgba(0,0,0,0.62); --ma-sheen:rgba(255,255,255,0.55); --ma-top-highlight:rgba(255,255,255,0.9);
--ma-h1:#101013; --ma-sub:rgba(0,0,0,0.5); --ma-lastrun:rgba(0,0,0,0.42); --ma-border:rgba(0,0,0,0.09); --ma-border-hover:rgba(0,0,0,0.15);
--ma-off-bg:rgba(0,0,0,0.05); --ma-off-fg:rgba(0,0,0,0.5); --ma-off-dot:rgba(0,0,0,0.4); --ma-shadow:0 1px 2px rgba(0,0,0,0.08);
--ma-title:#0d0e11; --ma-desc:rgba(0,0,0,0.6);
--ma-h1:#0d0e11; --ma-sub:rgba(0,0,0,0.5); --ma-lastrun:rgba(0,0,0,0.42);
--ma-off-bg:rgba(0,0,0,0.05); --ma-off-fg:rgba(0,0,0,0.5);
--ma-new-border:rgba(0,0,0,0.14); --ma-new-title:rgba(0,0,0,0.6); --ma-new-hint:rgba(0,0,0,0.4); --ma-new-border:rgba(0,0,0,0.14); --ma-new-title:rgba(0,0,0,0.6); --ma-new-hint:rgba(0,0,0,0.4);
--ma-pat-opacity:0.16; --ma-glow-opacity:0.22; --ma-glow-hover-opacity:0.30; --ma-pat-opacity:0.10; --ma-glow-opacity:0.16; --ma-glow-hover-opacity:0.24;
--ma-badge-mix:22%; --ma-pill-mix:18%; --ma-badge-mix:20%; --ma-pill-mix:16%; --ma-tint:16%; --ma-tint-hover:22%;
height:100%; overflow:auto; background:var(--ma-bg); height:100%; overflow:auto; background:var(--ma-bg);
} }
.dark .ma-page { .dark .ma-page {
--ma-bg:#0c0c0e; --ma-bg:#0b0b0d;
--ma-card-from:#17171B; --ma-card-to:#111114; /* metallic gunmetal */
--ma-card-hover-from:#19191e; --ma-card-hover-to:#121215; --ma-card-from:#262930; --ma-card-mid:#191b21; --ma-card-to:#101116;
--ma-border:rgba(255,255,255,0.06); --ma-border-hover:rgba(255,255,255,0.09); --ma-card-hover-from:#2b2e36; --ma-card-hover-mid:#1c1e25; --ma-card-hover-to:#131419;
--ma-shadow:0 1px 2px rgba(0,0,0,0.18); --ma-sheen:rgba(255,255,255,0.07); --ma-top-highlight:rgba(255,255,255,0.09);
--ma-title:#f5f5f5; --ma-desc:rgba(255,255,255,0.68); --ma-border:rgba(255,255,255,0.07); --ma-border-hover:rgba(255,255,255,0.12);
--ma-h1:#f5f5f5; --ma-sub:rgba(255,255,255,0.55); --ma-lastrun:rgba(255,255,255,0.4); --ma-shadow:0 1px 2px rgba(0,0,0,0.35);
--ma-off-bg:rgba(255,255,255,0.06); --ma-off-fg:rgba(255,255,255,0.5); --ma-off-dot:rgba(255,255,255,0.45); --ma-title:#f4f5f7; --ma-desc:rgba(255,255,255,0.66);
--ma-h1:#f4f5f7; --ma-sub:rgba(255,255,255,0.52); --ma-lastrun:rgba(255,255,255,0.38);
--ma-off-bg:rgba(255,255,255,0.06); --ma-off-fg:rgba(255,255,255,0.5);
--ma-new-border:rgba(255,255,255,0.12); --ma-new-title:rgba(255,255,255,0.6); --ma-new-hint:rgba(255,255,255,0.38); --ma-new-border:rgba(255,255,255,0.12); --ma-new-title:rgba(255,255,255,0.6); --ma-new-hint:rgba(255,255,255,0.38);
--ma-pat-opacity:0.07; --ma-glow-opacity:0.10; --ma-glow-hover-opacity:0.13; --ma-pat-opacity:0.05; --ma-glow-opacity:0.10; --ma-glow-hover-opacity:0.16;
--ma-badge-mix:15%; --ma-pill-mix:13%; --ma-badge-mix:15%; --ma-pill-mix:13%; --ma-tint:20%; --ma-tint-hover:26%;
} }
.ma-inner { max-width:1080px; margin:0 auto; padding:32px 28px 48px; } .ma-inner { max-width:1120px; margin:0 auto; padding:clamp(20px,3.5cqw,34px) clamp(16px,3cqw,30px) 48px; }
.ma-h1 { font-size:24px; font-weight:600; letter-spacing:-0.02em; color:var(--ma-h1); margin:0 0 4px; } .ma-h1 { font-size:clamp(19px,2.6cqw,24px); font-weight:650; letter-spacing:-0.02em; color:var(--ma-h1); margin:0 0 4px; }
.ma-sub { font-size:14px; color:var(--ma-sub); margin:0 0 28px; } .ma-sub { font-size:clamp(13px,1.5cqw,14px); color:var(--ma-sub); margin:0 0 clamp(18px,2.5cqw,28px); }
.ma-grid { display:grid; grid-template-columns:repeat(3, 1fr); gap:28px; } /* Fluid columns: as many ~250px cards as fit the pane; single column when narrow. */
@media (max-width:1040px) { .ma-grid { grid-template-columns:repeat(2, 1fr); } } .ma-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(min(100%,248px),1fr)); gap:clamp(14px,2cqw,24px); }
@media (max-width:700px) { .ma-grid { grid-template-columns:1fr; } }
.ma-card { .ma-card {
position:relative; min-height:252px; border-radius:20px; position:relative; min-height:clamp(190px,24cqw,244px); border-radius:18px;
border:1px solid var(--ma-border); border:1px solid var(--ma-border);
background:linear-gradient(160deg, var(--ma-card-from) 0%, var(--ma-card-to) 100%); background:
padding:22px; text-align:left; cursor:pointer; overflow:hidden; linear-gradient(135deg, var(--ma-sheen) 0%, transparent 34%),
linear-gradient(158deg, color-mix(in srgb, var(--accent) var(--ma-tint), transparent) 0%, transparent 62%),
linear-gradient(158deg, var(--ma-card-from) 0%, var(--ma-card-mid) 52%, var(--ma-card-to) 100%);
padding:clamp(15px,2cqw,22px); text-align:left; cursor:pointer; overflow:hidden;
display:flex; flex-direction:column; isolation:isolate; display:flex; flex-direction:column; isolation:isolate;
box-shadow: var(--ma-shadow), 0 6px 18px -20px var(--glow); box-shadow: var(--ma-shadow), inset 0 1px 0 var(--ma-top-highlight), 0 8px 22px -20px var(--glow);
transition: box-shadow .22s ease, border-color .22s ease, background .22s ease; transition: box-shadow .22s ease, border-color .22s ease, background .22s ease, transform .22s ease;
} }
.ma-card:hover { .ma-card:hover {
border-color: var(--ma-border-hover); border-color: var(--ma-border-hover);
background:linear-gradient(160deg, var(--ma-card-hover-from) 0%, var(--ma-card-hover-to) 100%); background:
linear-gradient(135deg, var(--ma-sheen) 0%, transparent 36%),
linear-gradient(158deg, color-mix(in srgb, var(--accent) var(--ma-tint-hover), transparent) 0%, transparent 64%),
linear-gradient(158deg, var(--ma-card-hover-from) 0%, var(--ma-card-hover-mid) 52%, var(--ma-card-hover-to) 100%);
} }
/* decorative pattern layer (accent-tinted, very low opacity) */ /* decorative pattern layer (accent-tinted, very low opacity) */
.ma-card::before { .ma-card::before {
@ -110,22 +122,27 @@ const CARD_CSS = `
} }
.ma-badge.off { color: var(--ma-off-fg); background: var(--ma-off-bg); } .ma-badge.off { color: var(--ma-off-fg); background: var(--ma-off-bg); }
.ma-title { font-size:22px; font-weight:600; letter-spacing:-0.02em; color:var(--ma-title); margin:18px 0 8px; } .ma-title { font-size:clamp(17px,2.3cqw,21px); font-weight:600; letter-spacing:-0.02em; color:var(--ma-title); margin:clamp(12px,2cqw,18px) 0 8px; }
.ma-desc { .ma-desc {
font-size:15px; font-weight:400; line-height:1.45; color:var(--ma-desc); margin:0; font-size:clamp(13px,1.5cqw,14.5px); font-weight:400; line-height:1.45; color:var(--ma-desc); margin:0;
display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;
} }
.ma-footer { margin-top:auto; padding-top:22px; display:flex; align-items:center; justify-content:space-between; gap:12px; } .ma-footer { margin-top:auto; padding-top:clamp(14px,2cqw,22px); display:flex; align-items:center; justify-content:space-between; gap:10px; }
.ma-source { font-size:12px; font-weight:600; color:var(--accent); background: color-mix(in srgb, var(--accent) var(--ma-pill-mix), transparent); padding:5px 11px; border-radius:999px; } .ma-source { font-size:11.5px; font-weight:600; color:var(--accent); background: color-mix(in srgb, var(--accent) var(--ma-pill-mix), transparent); padding:5px 10px; border-radius:999px; white-space:nowrap; }
.ma-lastrun { font-size:12px; color:var(--ma-lastrun); } .ma-lastrun { font-size:11.5px; color:var(--ma-lastrun); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.ma-new { .ma-new {
min-height:252px; border-radius:20px; border:1px dashed var(--ma-new-border); min-height:clamp(190px,24cqw,244px); border-radius:18px; border:1px dashed var(--ma-new-border);
background:transparent; display:flex; flex-direction:column; align-items:center; justify-content:center; background:transparent; display:flex; flex-direction:column; align-items:center; justify-content:center;
gap:8px; color:var(--ma-new-title); cursor:default; transition: border-color .2s ease, color .2s ease; gap:8px; color:var(--ma-new-title); cursor:default; transition: border-color .2s ease, color .2s ease;
} }
.ma-new-title { font-size:15px; font-weight:600; color:var(--ma-new-title); } .ma-new-title { font-size:14.5px; font-weight:600; color:var(--ma-new-title); }
.ma-new-hint { font-size:12px; color:var(--ma-new-hint); } .ma-new-hint { font-size:12px; color:var(--ma-new-hint); text-align:center; padding:0 12px; }
/* Very narrow pane: tighten footer so source + last-run don't collide. */
@container (max-width: 380px) {
.ma-footer { flex-direction:column; align-items:flex-start; gap:6px; }
}
` `
function Card({ app, index, onOpen }: { app: miniApp.MiniAppManifest; index: number; onOpen: () => void }) { function Card({ app, index, onOpen }: { app: miniApp.MiniAppManifest; index: number; onOpen: () => void }) {

View file

@ -15,7 +15,7 @@
*/ */
const BRIDGE_SHIM = /* js */ ` const BRIDGE_SHIM = /* js */ `
(function () { (function () {
var data = null, state = null; var data = null, dataLoaded = false, state = null;
var dataCbs = [], stateCbs = []; var dataCbs = [], stateCbs = [];
var pending = {}, seq = 0; var pending = {}, seq = 0;
function post(msg) { parent.postMessage(msg, '*'); } function post(msg) { parent.postMessage(msg, '*'); }
@ -26,13 +26,17 @@ const BRIDGE_SHIM = /* js */ `
post({ type: 'rowboat:mini-app:rpc', id: id, method: method, params: params }); post({ type: 'rowboat:mini-app:rpc', id: id, method: method, params: params });
}); });
} }
// data.json is a served sibling of index.html — apps load it themselves.
function loadData() {
return fetch('data.json', { cache: 'no-store' })
.then(function (r) { return r && r.ok ? r.json() : null; })
.then(function (d) { data = d; dataLoaded = true; dataCbs.forEach(function (cb) { try { cb(data); } catch (_) {} }); return data; })
.catch(function () { dataLoaded = true; dataCbs.forEach(function (cb) { try { cb(null); } catch (_) {} }); return null; });
}
window.addEventListener('message', function (e) { window.addEventListener('message', function (e) {
var m = e.data; var m = e.data;
if (!m || typeof m !== 'object') return; if (!m || typeof m !== 'object') return;
if (m.type === 'rowboat:mini-app:data') { if (m.type === 'rowboat:mini-app:state') {
data = m.data;
dataCbs.forEach(function (cb) { try { cb(data); } catch (_) {} });
} else if (m.type === 'rowboat:mini-app:state') {
state = m.state; state = m.state;
stateCbs.forEach(function (cb) { try { cb(state); } catch (_) {} }); stateCbs.forEach(function (cb) { try { cb(state); } catch (_) {} });
} else if (m.type === 'rowboat:mini-app:rpc-result') { } else if (m.type === 'rowboat:mini-app:rpc-result') {
@ -45,9 +49,10 @@ const BRIDGE_SHIM = /* js */ `
}); });
window.rowboat = { window.rowboat = {
getData: function () { return data; }, getData: function () { return data; },
refreshData: function () { return loadData(); },
onData: function (cb) { onData: function (cb) {
dataCbs.push(cb); dataCbs.push(cb);
if (data !== null) { try { cb(data); } catch (_) {} } if (dataLoaded) { try { cb(data); } catch (_) {} }
return function () { var i = dataCbs.indexOf(cb); if (i >= 0) dataCbs.splice(i, 1); }; return function () { var i = dataCbs.indexOf(cb); if (i >= 0) dataCbs.splice(i, 1); };
}, },
getState: function () { return state; }, getState: function () { return state; },
@ -70,8 +75,15 @@ const BRIDGE_SHIM = /* js */ `
isConnected: function (scope) { return rpc('isConnected', { scope: scope }); }, isConnected: function (scope) { return rpc('isConnected', { scope: scope }); },
// Trigger the Composio OAuth flow for the toolkit. Resolves when started. // Trigger the Composio OAuth flow for the toolkit. Resolves when started.
connect: function (scope) { return rpc('connect', { scope: scope }); }, connect: function (scope) { return rpc('connect', { scope: scope }); },
// CORS-safe HTTP via the main process (for third-party APIs without CORS).
// Resolves to { ok, status, text, json } (json = parsed body or null).
fetch: function (url, opts) {
return rpc('fetch', { url: url, method: (opts && opts.method), headers: (opts && opts.headers), body: (opts && opts.body) })
.then(function (r) { var j = null; try { j = JSON.parse(r.text); } catch (_) {} r.json = j; return r; });
},
ready: function () { post({ type: 'rowboat:mini-app:ready' }); }, ready: function () { post({ type: 'rowboat:mini-app:ready' }); },
}; };
loadData();
})(); })();
` `

View file

@ -54,7 +54,7 @@ export type MiniApp = {
* - isConnected: is the toolkit connected? params: { scope } * - isConnected: is the toolkit connected? params: { scope }
* - connect: trigger the Composio OAuth flow params: { scope } * - connect: trigger the Composio OAuth flow params: { scope }
*/ */
export type MiniAppRpcMethod = 'callAction' | 'searchTools' | 'isConnected' | 'connect' export type MiniAppRpcMethod = 'callAction' | 'searchTools' | 'isConnected' | 'connect' | 'fetch'
/** Messages sent from the iframe (app) up to the host (renderer). */ /** Messages sent from the iframe (app) up to the host (renderer). */
export type MiniAppOutboundMessage = export type MiniAppOutboundMessage =

View file

@ -1541,6 +1541,14 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated
for (const part of message.content) { for (const part of message.content) {
if (part.type === "tool-call") { if (part.type === "tool-call") {
const underlyingTool = agent.tools![part.toolName]; const underlyingTool = agent.tools![part.toolName];
// The model can hallucinate a tool name that isn't declared.
// Skip it here instead of dereferencing undefined (which would
// crash the whole run); the SDK returns an error tool-result
// for the unknown call so the model can self-correct.
if (!underlyingTool) {
loopLogger.log('model called unknown tool, skipping:', part.toolName);
continue;
}
if (underlyingTool.type === "builtin" && underlyingTool.name === "ask-human") { if (underlyingTool.type === "builtin" && underlyingTool.name === "ask-human") {
loopLogger.log('emitting ask-human-request, toolCallId:', part.toolCallId); loopLogger.log('emitting ask-human-request, toolCallId:', part.toolCallId);
const rawOptions = (part.arguments as { options?: unknown }).options; const rawOptions = (part.arguments as { options?: unknown }).options;

View file

@ -63,13 +63,19 @@ bridge: include the shim and code against \`window.rowboat\`:
\`\`\` \`\`\`
\`window.rowboat\` API: \`window.rowboat\` API:
- \`getData()\` / \`onData(cb)\` — the app's data (host serves \`data.json\`). Register - \`getData()\` / \`onData(cb)\` / \`refreshData()\` — the app's data. \`data.json\` is a
\`onData\` then call \`ready()\`. **served sibling of index.html**, so the app is self-contained: \`onData\`
fetches \`data.json\` via a relative URL (you can also just \`fetch('data.json')\`
yourself). \`refreshData()\` re-fetches. Rowboat does NOT inject data.
- \`getState()\` / \`setState(patch)\` — per-app persistent UI state. - \`getState()\` / \`setState(patch)\` — per-app persistent UI state.
- \`isConnected(scope)\` / \`connect(scope)\` — connection status / start OAuth. - \`isConnected(scope)\` / \`connect(scope)\` — connection status / start OAuth.
- \`searchTools(scope, query)\` → [{slug,name,description}], and - \`searchTools(scope, query)\` → [{slug,name,description}], and
\`callAction(scope, toolSlug, args)\` → tool result (rejects on error). Scope is \`callAction(scope, toolSlug, args)\` → tool result (rejects on error). Scope is
enforced against the manifest, so only declared toolkits work. enforced against the manifest, so only declared toolkits work.
- \`fetch(url, opts?)\` → { ok, status, text, json } — a CORS-safe HTTP proxy
through the main process. **Use this for any third-party API, never the
browser's \`fetch\`** (public APIs rarely send CORS headers, so direct fetch
from the app origin fails with "Failed to fetch").
- \`ready()\` — call once after registering callbacks to receive initial data/state. - \`ready()\` — call once after registering callbacks to receive initial data/state.
Keep it dependency-free (no remote CDNs unless truly needed; the app:// origin Keep it dependency-free (no remote CDNs unless truly needed; the app:// origin
@ -84,14 +90,25 @@ allows them but offline-safe is better). Render loading / empty / error states.
## 4. Data pipeline (agent-backed apps only) ## 4. Data pipeline (agent-backed apps only)
Create a background task with \`create-background-task\` whose instructions: Create a background task with \`create-background-task\` whose instructions:
- fetch the data via Composio (or browse via the embedded browser for social - fetch the data via **Composio** if a toolkit exists, otherwise via the
feeds), and builtin **\`fetch-url\`** tool (server-side HTTP, no CORS). **The bg-task agent
- call **\`mini-app-set-data\`** with \`{ appId: "<id>", data: <payload> }\`. has NO shell** (\`executeCommand\` is disabled headlessly) and \`rowboat.fetch\`
is frontend-only never tell it to run a \`refresh.sh\`/script; it fetches via
\`fetch-url\` or Composio, and
- call **\`mini-app-set-data\`** with \`{ appId: "<id>", data: <object> }\`
pass the **object directly, never \`JSON.stringify\`** it. If a fetch fails,
**keep the last good data** (don't overwrite good series with empty ones).
Set the manifest's **\`dataContract\`** (\`requiredKeys\` + \`nonEmptyArrayKeys\`)
to the shape the UI needs \`mini-app-set-data\` enforces it, so a stale or
buggy run can't corrupt the app with the wrong shape or wipe series with empties.
The agent only RETURNS the data; \`mini-app-set-data\` writes \`data.json\` The agent only RETURNS the data; \`mini-app-set-data\` writes \`data.json\`
atomically (deterministic path + write the agent never touches files). Set the atomically (deterministic path + write the agent never touches files). Set the
manifest's \`agent\` field to the task's slug. Give the task a sensible trigger manifest's \`agent\` field to the task's slug, and give the task a **capable model**
(cron/window) from the background-task skill. (e.g. a Claude Sonnet / GPT-class model) via its model override the default
light model fabricates output and hallucinates tool names on side-effect tasks.
Give it a sensible trigger (cron/window) from the background-task skill.
## 5. Finalize ## 5. Finalize
@ -104,6 +121,22 @@ Then **open it for the user**: call \`app-navigation\` with
(under Mini Apps / its title) and shows an "Opened <app>" card in the chat. Only (under Mini Apps / its title) and shows an "Opened <app>" card in the chat. Only
do this once the app is installed AND its data is populated, so it renders ready. do this once the app is installed AND its data is populated, so it renders ready.
## Common gotchas (read before building)
- **CORS:** third-party APIs usually block browser fetches. From the app UI use
\`rowboat.fetch(url)\`, not \`fetch(url)\`. If you don't, it fails with
"Failed to fetch" even though curl works.
- **No Composio toolkit for the data?** That's fine use \`rowboat.fetch\` from a
live app, or a bg-task that fetches with **\`fetch-url\`** and calls
\`mini-app-set-data\`. Don't force a toolkit that doesn't exist (e.g. there's no
FX/currency toolkit), and don't reach for a shell or MCP server for plain HTTP.
- **data.json shape:** pass a plain **object** to \`mini-app-set-data\`; never
\`JSON.stringify\` it first (double-encoding breaks the UI silently).
- **bg-task has no shell:** don't generate \`refresh.sh\` / \`executeCommand\`
steps for the data agent it can't run them headlessly.
- **model:** set a capable model on any data/side-effect bg-task; the default is
too weak and will fabricate results.
## Manifest schema (manifest.json) ## Manifest schema (manifest.json)
\`\`\`yaml \`\`\`yaml

View file

@ -1528,8 +1528,13 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
await fs.writeFile(path.join(dir, 'manifest.json'), JSON.stringify(m, null, 2)); await fs.writeFile(path.join(dir, 'manifest.json'), JSON.stringify(m, null, 2));
await fs.writeFile(path.join(dist, 'index.html'), html); await fs.writeFile(path.join(dist, 'index.html'), html);
if (data !== undefined) { if (data !== undefined) {
const dataPath = path.join(dir, 'data.json'); let payload: unknown = data;
try { await fs.access(dataPath); } catch { await fs.writeFile(dataPath, JSON.stringify(data, null, 2)); } if (typeof payload === 'string') { try { payload = JSON.parse(payload); } catch { payload = undefined; } }
if (payload !== undefined && payload !== null && typeof payload === 'object') {
// sibling of index.html so the app can fetch('data.json')
const dataPath = path.join(dist, 'data.json');
try { await fs.access(dataPath); } catch { await fs.writeFile(dataPath, JSON.stringify(payload, null, 2)); }
}
} }
return { success: true, id: m.id, url: `app://miniapp/${m.id}/index.html` }; return { success: true, id: m.id, url: `app://miniapp/${m.id}/index.html` };
} catch (e) { } catch (e) {
@ -1545,11 +1550,45 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
}), }),
execute: async ({ appId, data }: { appId: string; data: unknown }) => { execute: async ({ appId, data }: { appId: string; data: unknown }) => {
try { try {
// Guard the #1 mistake: passing a JSON *string* (JSON.stringify'd)
// instead of the object. Auto-parse a string; reject non-objects so
// the UI never gets a double-encoded blob it can't read.
let payload: unknown = data;
if (typeof payload === 'string') {
try { payload = JSON.parse(payload); }
catch { return { success: false, error: 'data must be a JSON object/array — pass the object directly, do NOT JSON.stringify it.' }; }
}
if (payload === null || typeof payload !== 'object') {
return { success: false, error: 'data must be a JSON object or array.' };
}
const dir = path.join(WorkDir, 'apps', appId); const dir = path.join(WorkDir, 'apps', appId);
await fs.mkdir(dir, { recursive: true }); // The app must already exist — never create a stray folder, and load
const dataPath = path.join(dir, 'data.json'); // its dataContract to guard the write.
let manifest: z.infer<typeof MiniAppManifest>;
try {
manifest = MiniAppManifest.parse(JSON.parse(await fs.readFile(path.join(dir, 'manifest.json'), 'utf-8')));
} catch {
return { success: false, error: `No installed Mini App "${appId}". Install it first (mini-app-install).` };
}
const contract = manifest.dataContract;
if (contract && !Array.isArray(payload)) {
const obj = payload as Record<string, unknown>;
const missing = (contract.requiredKeys ?? []).filter((k) => obj[k] === undefined || obj[k] === null);
if (missing.length) {
return { success: false, error: `data is missing required key(s): ${missing.join(', ')}. Match the app's data shape — do NOT write a different shape; keep the last good data.` };
}
const badArrays = (contract.nonEmptyArrayKeys ?? []).filter((k) => !Array.isArray(obj[k]) || (obj[k] as unknown[]).length === 0);
if (badArrays.length) {
return { success: false, error: `these key(s) must be non-empty arrays: ${badArrays.join(', ')}. Don't overwrite good series with empty ones — keep the last good data.` };
}
}
// data.json is a served sibling of index.html so apps fetch it
// via a relative URL (app://miniapp/<id>/data.json).
const distDir = path.join(dir, 'dist');
await fs.mkdir(distDir, { recursive: true });
const dataPath = path.join(distDir, 'data.json');
const tmp = `${dataPath}.tmp`; const tmp = `${dataPath}.tmp`;
await fs.writeFile(tmp, JSON.stringify(data, null, 2)); await fs.writeFile(tmp, JSON.stringify(payload, null, 2));
await fs.rename(tmp, dataPath); await fs.rename(tmp, dataPath);
return { success: true, appId }; return { success: true, appId };
} catch (e) { } catch (e) {
@ -1557,6 +1596,32 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
} }
}, },
}, },
'fetch-url': {
description: "Fetch an HTTP(S) URL and return the response body as text. Use this to pull data from web APIs or pages (e.g. a JSON endpoint) — especially in background tasks, which have no shell. GET by default; supports POST with a body. Returns { ok, status, statusText, body } (body truncated if very large). For JSON, parse the returned body.",
inputSchema: z.object({
url: z.string().describe('The http(s) URL to fetch.'),
method: z.enum(['GET', 'POST']).optional().describe('HTTP method (default GET).'),
headers: z.record(z.string(), z.string()).optional().describe('Optional request headers.'),
body: z.string().optional().describe('Request body (for POST).'),
}),
execute: async ({ url, method, headers, body }: { url: string; method?: string; headers?: Record<string, string>; body?: string }) => {
try {
const parsed = new URL(url);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return { ok: false, status: 0, error: 'Only http(s) URLs are allowed.' };
}
const m = (method || 'GET').toUpperCase();
const res = await fetch(url, { method: m, headers, body: m === 'GET' || m === 'HEAD' ? undefined : body });
let text = await res.text();
const MAX = 200_000;
const truncated = text.length > MAX;
if (truncated) text = text.slice(0, MAX);
return { ok: res.ok, status: res.status, statusText: res.statusText, body: text, truncated };
} catch (e) {
return { ok: false, status: 0, error: e instanceof Error ? e.message : String(e) };
}
},
},
'run-live-note-agent': { 'run-live-note-agent': {
description: "Manually trigger the live-note agent to run now on a note. Equivalent to the user clicking the Run button in the live-note sidebar, but you can pass extra `context` to bias what the agent does this run — most useful for backfills (e.g. seeding a newly-made-live note from existing synced emails) or focused refreshes. Returns the action taken, summary, and the new note body.", description: "Manually trigger the live-note agent to run now on a note. Equivalent to the user clicking the Run button in the live-note sidebar, but you can pass extra `context` to bias what the agent does this run — most useful for backfills (e.g. seeding a newly-made-live note from existing synced emails) or focused refreshes. Returns the action taken, summary, and the new note body.",
inputSchema: z.object({ inputSchema: z.object({

View file

@ -1033,6 +1033,23 @@ const ipcSchemas = {
req: z.object({ id: z.string() }), req: z.object({ id: z.string() }),
res: z.object({ data: z.unknown().nullable() }), res: z.object({ data: z.unknown().nullable() }),
}, },
// Mini Apps: proxy an HTTP request through main (bypasses browser CORS for the
// sandboxed app origin). GET/POST to http(s) only.
'mini-apps:fetch': {
req: z.object({
url: z.string(),
method: z.string().optional(),
headers: z.record(z.string(), z.string()).optional(),
body: z.string().optional(),
}),
res: z.object({
ok: z.boolean(),
status: z.number(),
statusText: z.string(),
text: z.string(),
error: z.string().optional(),
}),
},
'composio:didConnect': { 'composio:didConnect': {
req: z.object({ req: z.object({
toolkitSlug: z.string(), toolkitSlug: z.string(),

View file

@ -26,6 +26,17 @@ export const MiniAppManifest = z.object({
entry: z.string().default('dist/index.html'), entry: z.string().default('dist/index.html'),
/** Optional associated background-task slug that produces data.json. */ /** Optional associated background-task slug that produces data.json. */
agent: z.string().optional(), agent: z.string().optional(),
/**
* Optional guard on what may be written to data.json (via mini-app-set-data).
* Prevents a stray/legacy task from corrupting the app with the wrong shape or
* wiping good series with empty ones.
*/
dataContract: z.object({
/** Top-level keys that must be present and non-null. */
requiredKeys: z.array(z.string()).default([]),
/** Top-level keys that must be non-empty arrays. */
nonEmptyArrayKeys: z.array(z.string()).default([]),
}).optional(),
}); });
export type MiniAppManifest = z.infer<typeof MiniAppManifest>; export type MiniAppManifest = z.infer<typeof MiniAppManifest>;