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) => {
return miniAppsHandler.getAppData(args.id);
},
'mini-apps:fetch': async (_event, args) => {
return miniAppsHandler.proxyFetch(args);
},
// Agent schedule handlers
'agent-schedule:getConfig': async () => {
const repo = container.resolve<IAgentScheduleRepo>('agentScheduleRepo');

View file

@ -24,7 +24,7 @@ function appDir(id: string): string {
*/
export const MINIAPP_BRIDGE_JS = `
(function () {
var data = null, state = null;
var data = null, dataLoaded = false, state = null;
var dataCbs = [], stateCbs = [];
var pending = {}, seq = 0;
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 });
});
}
// 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) {
var m = e.data;
if (!m || typeof m !== 'object') return;
if (m.type === 'rowboat:mini-app:data') {
data = m.data;
dataCbs.forEach(function (cb) { try { cb(data); } catch (_) {} });
} else if (m.type === 'rowboat:mini-app:state') {
if (m.type === 'rowboat:mini-app:state') {
state = m.state;
stateCbs.forEach(function (cb) { try { cb(state); } catch (_) {} });
} else if (m.type === 'rowboat:mini-app:rpc-result') {
@ -51,7 +56,8 @@ export const MINIAPP_BRIDGE_JS = `
});
window.rowboat = {
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; },
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 (_) {} }); },
@ -59,8 +65,15 @@ export const MINIAPP_BRIDGE_JS = `
searchTools: function (scope, query) { return rpc('searchTools', { scope: scope, query: query }); },
isConnected: function (scope) { return rpc('isConnected', { 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' }); },
};
loadData();
})();
`;
@ -108,6 +121,35 @@ export function listApps(): { manifests: Manifest[] } {
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. */
export function getAppData(id: string): { data: unknown | null } {
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> {
// 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 : ''
if (!s || !scope.includes(s)) {
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() {
let data: unknown = null
try {
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 })
// Apps load their own data.json (served sibling) via a relative fetch; the
// host only provides per-app UI state on ready.
function handleReady() {
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.
const CARD_CSS = `
/* 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-bg:#f6f6f7;
--ma-card-from:#ffffff; --ma-card-to:#fbfbfc;
--ma-card-hover-from:#ffffff; --ma-card-hover-to:#f5f5f7;
--ma-border:rgba(0,0,0,0.08); --ma-border-hover:rgba(0,0,0,0.14);
--ma-shadow:0 1px 2px rgba(0,0,0,0.06);
--ma-title:#101013; --ma-desc:rgba(0,0,0,0.62);
--ma-h1:#101013; --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-off-dot:rgba(0,0,0,0.4);
container-type: inline-size;
--ma-bg:#eceef1;
/* metallic silver */
--ma-card-from:#ffffff; --ma-card-mid:#f2f3f6; --ma-card-to:#e6e8ee;
--ma-card-hover-from:#ffffff; --ma-card-hover-mid:#f5f6f9; --ma-card-hover-to:#eaecf1;
--ma-sheen:rgba(255,255,255,0.55); --ma-top-highlight:rgba(255,255,255,0.9);
--ma-border:rgba(0,0,0,0.09); --ma-border-hover:rgba(0,0,0,0.15);
--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-pat-opacity:0.16; --ma-glow-opacity:0.22; --ma-glow-hover-opacity:0.30;
--ma-badge-mix:22%; --ma-pill-mix:18%;
--ma-pat-opacity:0.10; --ma-glow-opacity:0.16; --ma-glow-hover-opacity:0.24;
--ma-badge-mix:20%; --ma-pill-mix:16%; --ma-tint:16%; --ma-tint-hover:22%;
height:100%; overflow:auto; background:var(--ma-bg);
}
.dark .ma-page {
--ma-bg:#0c0c0e;
--ma-card-from:#17171B; --ma-card-to:#111114;
--ma-card-hover-from:#19191e; --ma-card-hover-to:#121215;
--ma-border:rgba(255,255,255,0.06); --ma-border-hover:rgba(255,255,255,0.09);
--ma-shadow:0 1px 2px rgba(0,0,0,0.18);
--ma-title:#f5f5f5; --ma-desc:rgba(255,255,255,0.68);
--ma-h1:#f5f5f5; --ma-sub:rgba(255,255,255,0.55); --ma-lastrun:rgba(255,255,255,0.4);
--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-bg:#0b0b0d;
/* metallic gunmetal */
--ma-card-from:#262930; --ma-card-mid:#191b21; --ma-card-to:#101116;
--ma-card-hover-from:#2b2e36; --ma-card-hover-mid:#1c1e25; --ma-card-hover-to:#131419;
--ma-sheen:rgba(255,255,255,0.07); --ma-top-highlight:rgba(255,255,255,0.09);
--ma-border:rgba(255,255,255,0.07); --ma-border-hover:rgba(255,255,255,0.12);
--ma-shadow:0 1px 2px rgba(0,0,0,0.35);
--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-pat-opacity:0.07; --ma-glow-opacity:0.10; --ma-glow-hover-opacity:0.13;
--ma-badge-mix:15%; --ma-pill-mix: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-tint:20%; --ma-tint-hover:26%;
}
.ma-inner { max-width:1080px; margin:0 auto; padding:32px 28px 48px; }
.ma-h1 { font-size:24px; font-weight:600; 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-grid { display:grid; grid-template-columns:repeat(3, 1fr); gap:28px; }
@media (max-width:1040px) { .ma-grid { grid-template-columns:repeat(2, 1fr); } }
@media (max-width:700px) { .ma-grid { grid-template-columns:1fr; } }
.ma-inner { max-width:1120px; margin:0 auto; padding:clamp(20px,3.5cqw,34px) clamp(16px,3cqw,30px) 48px; }
.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:clamp(13px,1.5cqw,14px); color:var(--ma-sub); margin:0 0 clamp(18px,2.5cqw,28px); }
/* Fluid columns: as many ~250px cards as fit the pane; single column when narrow. */
.ma-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(min(100%,248px),1fr)); gap:clamp(14px,2cqw,24px); }
.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);
background:linear-gradient(160deg, var(--ma-card-from) 0%, var(--ma-card-to) 100%);
padding:22px; text-align:left; cursor:pointer; overflow:hidden;
background:
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;
box-shadow: var(--ma-shadow), 0 6px 18px -20px var(--glow);
transition: box-shadow .22s ease, border-color .22s ease, background .22s ease;
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, transform .22s ease;
}
.ma-card: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) */
.ma-card::before {
@ -110,22 +122,27 @@ const CARD_CSS = `
}
.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 {
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;
}
.ma-footer { margin-top:auto; padding-top:22px; display:flex; align-items:center; justify-content:space-between; gap:12px; }
.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-lastrun { font-size:12px; color:var(--ma-lastrun); }
.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: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:11.5px; color:var(--ma-lastrun); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.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;
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-hint { font-size:12px; color:var(--ma-new-hint); }
.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); 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 }) {

View file

@ -15,7 +15,7 @@
*/
const BRIDGE_SHIM = /* js */ `
(function () {
var data = null, state = null;
var data = null, dataLoaded = false, state = null;
var dataCbs = [], stateCbs = [];
var pending = {}, seq = 0;
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 });
});
}
// 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) {
var m = e.data;
if (!m || typeof m !== 'object') return;
if (m.type === 'rowboat:mini-app:data') {
data = m.data;
dataCbs.forEach(function (cb) { try { cb(data); } catch (_) {} });
} else if (m.type === 'rowboat:mini-app:state') {
if (m.type === 'rowboat:mini-app:state') {
state = m.state;
stateCbs.forEach(function (cb) { try { cb(state); } catch (_) {} });
} else if (m.type === 'rowboat:mini-app:rpc-result') {
@ -45,9 +49,10 @@ const BRIDGE_SHIM = /* js */ `
});
window.rowboat = {
getData: function () { return data; },
refreshData: function () { return loadData(); },
onData: function (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); };
},
getState: function () { return state; },
@ -70,8 +75,15 @@ const BRIDGE_SHIM = /* js */ `
isConnected: function (scope) { return rpc('isConnected', { scope: scope }); },
// Trigger the Composio OAuth flow for the toolkit. Resolves when started.
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' }); },
};
loadData();
})();
`

View file

@ -54,7 +54,7 @@ export type MiniApp = {
* - isConnected: is the toolkit connected? 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). */
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) {
if (part.type === "tool-call") {
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") {
loopLogger.log('emitting ask-human-request, toolCallId:', part.toolCallId);
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:
- \`getData()\` / \`onData(cb)\` — the app's data (host serves \`data.json\`). Register
\`onData\` then call \`ready()\`.
- \`getData()\` / \`onData(cb)\` / \`refreshData()\` — the app's data. \`data.json\` is a
**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.
- \`isConnected(scope)\` / \`connect(scope)\` — connection status / start OAuth.
- \`searchTools(scope, query)\` → [{slug,name,description}], and
\`callAction(scope, toolSlug, args)\` → tool result (rejects on error). Scope is
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.
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)
Create a background task with \`create-background-task\` whose instructions:
- fetch the data via Composio (or browse via the embedded browser for social
feeds), and
- call **\`mini-app-set-data\`** with \`{ appId: "<id>", data: <payload> }\`.
- fetch the data via **Composio** if a toolkit exists, otherwise via the
builtin **\`fetch-url\`** tool (server-side HTTP, no CORS). **The bg-task agent
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\`
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
(cron/window) from the background-task skill.
manifest's \`agent\` field to the task's slug, and give the task a **capable model**
(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
@ -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
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)
\`\`\`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(dist, 'index.html'), html);
if (data !== undefined) {
const dataPath = path.join(dir, 'data.json');
try { await fs.access(dataPath); } catch { await fs.writeFile(dataPath, JSON.stringify(data, null, 2)); }
let payload: unknown = data;
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` };
} catch (e) {
@ -1545,11 +1550,45 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
}),
execute: async ({ appId, data }: { appId: string; data: unknown }) => {
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);
await fs.mkdir(dir, { recursive: true });
const dataPath = path.join(dir, 'data.json');
// The app must already exist — never create a stray folder, and load
// 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`;
await fs.writeFile(tmp, JSON.stringify(data, null, 2));
await fs.writeFile(tmp, JSON.stringify(payload, null, 2));
await fs.rename(tmp, dataPath);
return { success: true, appId };
} 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': {
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({

View file

@ -1033,6 +1033,23 @@ const ipcSchemas = {
req: z.object({ id: z.string() }),
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': {
req: z.object({
toolkitSlug: z.string(),

View file

@ -26,6 +26,17 @@ export const MiniAppManifest = z.object({
entry: z.string().default('dist/index.html'),
/** Optional associated background-task slug that produces data.json. */
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>;