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

@ -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({