feat(mini-apps): copilot builder — build-mini-app skill + install/data tools

Lets the copilot build a Mini App into ~/.rowboat/apps from chat.

- build-mini-app skill: intent gate (confirm when ambiguous), verify Composio
  wiring by actually calling tools before building, writer branch (Code Mode
  via code-with-agents vs Copilot), agent-backed data pipeline, install
- mini-app-install tool: write an app folder (manifest + dist/index.html)
- mini-app-set-data tool: atomic deterministic data.json write (agent returns
  content; path/write handled in code)
- serve canonical bridge shim at app://miniapp/__bridge__.js
This commit is contained in:
Gagan 2026-07-01 01:25:45 +05:30
parent 924a93fa4a
commit 4a4dcb1fa0
6 changed files with 298 additions and 1 deletions

View file

@ -0,0 +1,110 @@
import { z } from 'zod';
import { stringify as stringifyYaml } from 'yaml';
import { MiniAppManifest } from '@x/shared/dist/mini-app.js';
const manifestSchema = stringifyYaml(z.toJSONSchema(MiniAppManifest)).trimEnd();
export const skill = String.raw`
# Build a Mini App
A *Mini App* is a small app the user opens inside Rowboat its own UI, powered by
their integrations and (optionally) a background agent. Apps live on disk at
\`~/.rowboat/apps/<id>/\` and are served at \`app://miniapp/<id>/\`:
\`\`\`
~/.rowboat/apps/<id>/
manifest.json # see schema below
dist/index.html # the app UI (self-contained), served via app://miniapp/<id>/
data.json # data the UI reads (produced by the app's agent; optional)
\`\`\`
You do NOT hand-write these files with file tools. Use **\`mini-app-install\`** to
write the folder, and **\`create-background-task\`** for the optional agent.
## 0. Should this even be an app? (intent gate)
- **Strong build it:** "make/build/create an app · mini app · dashboard for …",
"turn this into an app".
- **Medium CONFIRM FIRST:** the request could be a one-off answer OR a recurring
app (e.g. "show me my open PRs", "track competitor launches"). Ask once:
*"Want this as a Mini App you can reopen, or just a one-time answer?"* Build only
if they say app. (Building installs a folder, maybe a background agent, and may
prompt an OAuth connection too heavy for a casual question.)
- **Anti don't build:** a clear one-off lookup/question just answer it.
## 1. Scope the app
Decide: \`id\` (kebab slug), \`title\`, \`description\`, \`source\` (e.g. "GitHub"),
the Composio \`scope\` (toolkits it may use), and whether it is:
- **live** calls Composio when the user interacts (no agent), or
- **agent-backed** a background agent refreshes \`data.json\` on a schedule and the
UI just reads it (keeps tokens low; best for feeds/digests/dashboards).
## 2. Verify the wiring BEFORE building (required do not speculate)
Load the \`composio-integration\` skill. Then for each toolkit in scope:
1. Ensure it is connected (\`composio-connect-toolkit\` → user authorizes if needed).
2. Actually call the tools you intend to use (\`composio-search-tools\`
\`composio-execute-tool\`) and **inspect the real returned JSON**.
3. Derive the app's **data shape from those real responses** never guess field
names. This shape is the contract between the agent (or live calls) and the UI.
If a toolkit doesn't support managed OAuth2 (e.g. X/Twitter), tell the user it
can't be connected this way and pick a different integration or a browser-based
agent.
## 3. Write the UI (dist/index.html)
The app is a self-contained HTML document. It talks to Rowboat ONLY through the
bridge: include the shim and code against \`window.rowboat\`:
\`\`\`html
<script src="/__bridge__.js"></script>
\`\`\`
\`window.rowboat\` API:
- \`getData()\` / \`onData(cb)\` — the app's data (host serves \`data.json\`). Register
\`onData\` then call \`ready()\`.
- \`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.
- \`ready()\` — call once after registering callbacks to receive initial data/state.
Keep it dependency-free (no remote CDNs unless truly needed; the app:// origin
allows them but offline-safe is better). Render loading / empty / error states.
**Who writes this HTML:**
- If a **coding agent is active** (user toggled the Code chip), delegate authoring
via the \`code-with-agents\` skill — run it with \`cwd\` = the app's
\`~/.rowboat/apps/<id>/\` folder so it can iterate and test, then install.
- Otherwise, author the HTML yourself and install it with \`mini-app-install\`.
## 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> }\`.
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.
## 5. Finalize
Call \`mini-app-install\` with the manifest + html (+ optional seed data). Then
confirm to the user, and ideally open it so they see it populate. For agent-backed
apps, trigger the agent once (\`run-background-task-agent\`) so \`data.json\` exists
immediately instead of waiting for the first scheduled run.
## Manifest schema (manifest.json)
\`\`\`yaml
` + manifestSchema + `
\`\`\`
`;
export default skill;

View file

@ -16,6 +16,7 @@ import composioIntegrationSkill from "./composio-integration/skill.js";
import liveNoteSkill from "./live-note/skill.js";
import backgroundTaskSkill from "./background-task/skill.js";
import notifyUserSkill from "./notify-user/skill.js";
import buildMiniAppSkill from "./build-mini-app/skill.js";
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
const CATALOG_PREFIX = "src/application/assistant/skills";
@ -102,6 +103,12 @@ const definitions: SkillDefinition[] = [
summary: "Write code, build projects, create scripts, or fix bugs by delegating to Claude Code or Codex.",
content: codeWithAgentsSkill,
},
{
id: "build-mini-app",
title: "Build a Mini App",
summary: "Build a Mini App the user opens inside Rowboat — its own UI powered by their integrations and an optional background agent. Use when the user asks to make/build/create an app, mini app, or dashboard; for ambiguous 'show me X' requests, confirm whether they want an app first.",
content: buildMiniAppSkill,
},
{
id: "background-task",
title: "Background Tasks",

View file

@ -15,6 +15,7 @@ import { WorkDir } from "../../config/config.js";
import { composioAccountsRepo } from "../../composio/repo.js";
import { executeAction as executeComposioAction, isConfigured as isComposioConfigured, searchTools as searchComposioTools } from "../../composio/client.js";
import { CURATED_TOOLKITS, CURATED_TOOLKIT_SLUGS } from "@x/shared/dist/composio.js";
import { MiniAppManifest } from "@x/shared/dist/mini-app.js";
import { BrowserControlInputSchema, type BrowserControlInput } from "@x/shared/dist/browser-control.js";
import { BackgroundTaskSchema, TriggersSchema } from "@x/shared/dist/background-task.js";
import type { CodeModeManager } from "../../code-mode/acp/manager.js";
@ -1495,6 +1496,51 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
},
isAvailable: async () => isComposioConfigured(),
},
'mini-app-install': {
description: "Install or update a Mini App on disk at ~/.rowboat/apps/<id>/. Writes manifest.json + dist/index.html (and optional initial data.json). Use this to materialize an app you built — do NOT hand-write these files with file tools. The HTML must be self-contained and include the bridge via <script src=\"/__bridge__.js\"></script>, coding against window.rowboat. After install, the app shows up in the Mini Apps gallery.",
inputSchema: z.object({
manifest: MiniAppManifest,
html: z.string().describe('Full self-contained HTML for dist/index.html.'),
data: z.unknown().optional().describe('Optional initial data.json content (only written if no data.json exists yet).'),
}),
execute: async ({ manifest, html, data }: { manifest: z.infer<typeof MiniAppManifest>; html: string; data?: unknown }) => {
try {
const m = MiniAppManifest.parse(manifest);
const dir = path.join(WorkDir, 'apps', m.id);
const dist = path.join(dir, 'dist');
await fs.mkdir(dist, { recursive: true });
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)); }
}
return { success: true, id: m.id, url: `app://miniapp/${m.id}/index.html` };
} catch (e) {
return { success: false, error: e instanceof Error ? e.message : String(e) };
}
},
},
'mini-app-set-data': {
description: "Write a Mini App's data.json — the JSON its frontend reads via rowboat.getData/onData. The path is derived from appId and the write is atomic (temp→rename), so you only supply the content. This is how a background task refreshes an app's data; the agent returns the data, the write is deterministic.",
inputSchema: z.object({
appId: z.string().describe('The app id (folder name under ~/.rowboat/apps).'),
data: z.unknown().describe('Full data payload to store as data.json (matching the app data schema).'),
}),
execute: async ({ appId, data }: { appId: string; data: unknown }) => {
try {
const dir = path.join(WorkDir, 'apps', appId);
await fs.mkdir(dir, { recursive: true });
const dataPath = path.join(dir, 'data.json');
const tmp = `${dataPath}.tmp`;
await fs.writeFile(tmp, JSON.stringify(data, null, 2));
await fs.rename(tmp, dataPath);
return { success: true, appId };
} catch (e) {
return { success: false, 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({