feat(mini-apps): real scoped Composio bridge + GitHub Dashboard

Phase 2 makes the Mini App bridge real instead of stubbed.

- New composio:execute-tool and composio:search-tools IPC channels
  (shared schema + main handlers reusing the agent's execute path)
- Bridge refactored to a scoped RPC: callAction/searchTools/isConnected/
  connect, enforced against each app's declared scope at the host
- Auth prompt: acting on a disconnected toolkit triggers Composio OAuth
- GitHub Dashboard sample: track repos, view their 20 most-recent open PRs
  with descriptions, plus the authenticated user's profile + contribution
  graph — all live via the bridge
- Twitter sample reverted to a local UI demo (X has no managed OAuth2)
This commit is contained in:
Gagan 2026-06-30 01:17:38 +05:30
parent 7acbf67cd4
commit 30a46178d1
9 changed files with 496 additions and 63 deletions

View file

@ -315,3 +315,50 @@ export async function listToolkits() {
totalItems: filtered.length,
};
}
/**
* Execute a Composio tool by slug on behalf of a Mini App. The toolkit must be
* connected (ACTIVE). Mirrors the agent's composio-execute-tool builtin.
*/
export async function executeTool(
toolkitSlug: string,
toolSlug: string,
args?: Record<string, unknown>,
): Promise<{ successful: boolean; data?: unknown; error?: string }> {
const account = composioAccountsRepo.getAccount(toolkitSlug);
if (!account || account.status !== 'ACTIVE') {
return { successful: false, error: `Toolkit "${toolkitSlug}" is not connected.` };
}
try {
const result = await composioClient.executeAction(toolSlug, {
connected_account_id: account.id,
user_id: 'rowboat-user',
version: 'latest',
arguments: args ?? {},
});
return { successful: result.successful, data: result.data, error: result.error ?? undefined };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[Composio] Mini App tool execution failed for ${toolSlug}:`, message);
return { successful: false, error: `Failed to execute ${toolSlug}: ${message}` };
}
}
/**
* Search Composio tools within a toolkit so a Mini App can discover the right
* tool slug + input schema at runtime (how generated apps will wire actions).
*/
export async function searchToolsInToolkit(
toolkitSlug: string,
query: string,
): Promise<{ tools: Array<{ slug: string; name: string; description?: string }>; error?: string }> {
try {
const { items } = await composioClient.searchTools(query, [toolkitSlug]);
return {
tools: items.map((t) => ({ slug: t.slug, name: t.name, description: t.description })),
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { tools: [], error: message };
}
}

View file

@ -1304,6 +1304,12 @@ export function setupIpcHandlers() {
'composio:list-toolkits': async () => {
return composioHandler.listToolkits();
},
'composio:execute-tool': async (_event, args) => {
return composioHandler.executeTool(args.toolkitSlug, args.toolSlug, args.arguments);
},
'composio:search-tools': async (_event, args) => {
return composioHandler.searchToolsInToolkit(args.toolkitSlug, args.query);
},
'migration:check-composio-google': async () => {
return qualifyAndDisconnectComposioGoogle();
},

View file

@ -5,37 +5,62 @@ import { MINI_APP_MESSAGE } from '@/mini-apps/types'
// Host side of the Mini App bridge. Renders the app's self-contained HTML in a
// sandboxed iframe and answers the postMessage protocol in mini-apps/types.ts.
//
// Phase 1: data is delivered from the static app.data, per-app state lives in
// memory only, and callAction is stubbed (returns a friendly demo result). Later
// phases replace the action/state handlers with real IPC to the agent + Composio.
// Phase 2: the bridge is real. App rpc calls (callAction/searchTools/
// isConnected/connect) are scope-checked against the app's declared scope and
// routed to Composio over IPC. Per-app state is still in-memory for now.
// Sandbox intentionally omits allow-same-origin: the app gets an opaque origin so
// it cannot reach host cookies/storage. The bridge is the only channel out.
const SANDBOX = 'allow-scripts allow-popups allow-popups-to-escape-sandbox allow-forms allow-modals allow-downloads'
// Phase 1 stub: pretend the Composio action succeeded.
function stubActionResult(action: string): { message: string } {
switch (action) {
case 'repost': return { message: 'Reposted (demo)' }
case 'reply': return { message: 'Reply sent (demo)' }
case 'mark_read': return { message: 'Dismissed' }
default: return { message: action + ' done (demo)' }
}
}
export function MiniAppFrame({ app }: { app: MiniApp }) {
const iframeRef = useRef<HTMLIFrameElement | null>(null)
// In-memory per-app state for Phase 1 (resets when the frame unmounts).
// In-memory per-app state for Phase 1/2 (resets when the frame unmounts).
const stateRef = useRef<Record<string, unknown>>({})
useEffect(() => {
// Reset state when switching apps.
stateRef.current = {}
function postToFrame(message: unknown) {
iframeRef.current?.contentWindow?.postMessage(message, '*')
}
// Dispatch an app rpc call to real Composio IPC. Throws on scope violation
// or failure; the caller turns that into an rpc-result error.
async function handleRpc(method: string, params: Record<string, unknown>): Promise<unknown> {
const scope = typeof params.scope === 'string' ? params.scope : ''
if (!scope || !app.scope.includes(scope)) {
throw new Error(`This app is not allowed to use "${scope || '(none)'}".`)
}
switch (method) {
case 'isConnected': {
const r = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: scope })
return r.isConnected
}
case 'connect': {
const r = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: scope })
if (!r.success && r.error) throw new Error(r.error)
return { started: r.success }
}
case 'searchTools': {
const query = typeof params.query === 'string' ? params.query : ''
const r = await window.ipc.invoke('composio:search-tools', { toolkitSlug: scope, query })
if (r.error) throw new Error(r.error)
return r.tools
}
case 'callAction': {
const tool = typeof params.tool === 'string' ? params.tool : ''
if (!tool) throw new Error('No tool specified.')
const args = (params.args && typeof params.args === 'object' ? params.args : {}) as Record<string, unknown>
const r = await window.ipc.invoke('composio:execute-tool', { toolkitSlug: scope, toolSlug: tool, arguments: args })
if (!r.successful) throw new Error(r.error || 'Action failed.')
return r.data
}
default:
throw new Error(`Unknown bridge method "${method}".`)
}
}
function handleMessage(event: MessageEvent) {
const frameWindow = iframeRef.current?.contentWindow
if (!frameWindow || event.source !== frameWindow) return
@ -49,27 +74,16 @@ export function MiniAppFrame({ app }: { app: MiniApp }) {
postToFrame({ type: MINI_APP_MESSAGE.state, state: stateRef.current })
break
}
case MINI_APP_MESSAGE.action: {
// Phase 1: stubbed. (Phase 2 enforces app.scope and calls Composio.)
const allowed = app.scope.includes(msg.scope)
if (!allowed) {
postToFrame({
type: MINI_APP_MESSAGE.actionResult,
id: msg.id,
case MINI_APP_MESSAGE.rpc: {
const { id, method, params } = msg
handleRpc(method, (params ?? {}) as Record<string, unknown>)
.then((result) => postToFrame({ type: MINI_APP_MESSAGE.rpcResult, id, ok: true, result }))
.catch((err) => postToFrame({
type: MINI_APP_MESSAGE.rpcResult,
id,
ok: false,
error: 'Action scope "' + msg.scope + '" not granted to this app',
})
break
}
// Small delay so the UI's busy state is visible.
window.setTimeout(() => {
postToFrame({
type: MINI_APP_MESSAGE.actionResult,
id: msg.id,
ok: true,
result: stubActionResult(msg.action),
})
}, 350)
error: err instanceof Error ? err.message : String(err),
}))
break
}
case MINI_APP_MESSAGE.setState: {

View file

@ -0,0 +1,314 @@
// Sample Mini App: GitHub Dashboard.
//
// A custom dashboard: pick repos to track, then view their open pull requests
// (title, author, description) — fetched LIVE from GitHub through the Phase 2
// bridge (searchTools -> callAction against a Composio managed-OAuth2 toolkit).
// Tracked repos persist via the per-app state store.
import { buildMiniAppHtml } from '../runtime'
import type { MiniApp } from '../types'
// Seed repos shown on first open (the user can add/remove their own).
const data = {
seedRepos: [
{ owner: 'vercel', repo: 'next.js' },
{ owner: 'rowboatlabs', repo: 'rowboat' },
],
}
const style = `
.app { height:100%; overflow:auto; background:#0d1117; color:#e6edf3; }
.wrap { max-width:760px; margin:0 auto; padding:24px 16px 64px; }
.h { font-size:22px; font-weight:600; letter-spacing:-0.02em; margin:0 0 4px; color:#f0f6fc; }
.sub { font-size:13px; color:#8b949e; margin:0 0 18px; }
.banner { display:flex; align-items:center; justify-content:space-between; gap:12px; margin:0 0 16px; padding:12px 14px; border:1px solid #1f6feb; background:rgba(31,111,235,0.12); border-radius:10px; }
.banner.ok { border-color:#238636; background:rgba(35,134,54,0.12); }
.banner-text { font-size:13px; color:#e6edf3; }
.banner-btn { background:#238636; color:#fff; border:0; border-radius:8px; padding:7px 14px; font-size:13px; font-weight:600; cursor:pointer; }
.banner-btn:disabled { opacity:.6; cursor:default; }
.profile { display:flex; gap:14px; align-items:center; margin-bottom:16px; }
.avatar { width:64px; height:64px; border-radius:50%; border:1px solid #30363d; flex:0 0 auto; object-fit:cover; }
.avatar-fb { width:64px; height:64px; border-radius:50%; background:#21262d; display:none; align-items:center; justify-content:center; font-size:24px; font-weight:600; color:#8b949e; flex:0 0 auto; }
.pname { font-size:17px; font-weight:600; color:#f0f6fc; }
.plogin { font-size:13px; color:#8b949e; }
.pbio { font-size:13px; color:#adbac7; margin-top:4px; }
.pstats { display:flex; gap:16px; margin-top:8px; font-size:12px; color:#8b949e; }
.pstats b { color:#e6edf3; }
.contrib { border:1px solid #30363d; background:#161b22; border-radius:10px; padding:14px; margin-bottom:18px; }
.contrib-title { font-size:13px; font-weight:600; color:#f0f6fc; margin-bottom:10px; }
.contrib-img { width:100%; height:auto; display:block; border-radius:6px; }
.contrib-note { font-size:12px; color:#6e7681; }
.adder { display:flex; gap:8px; margin-bottom:18px; }
.adder input { flex:1; background:#0d1117; border:1px solid #30363d; color:#e6edf3; border-radius:8px; padding:8px 11px; font-size:13px; outline:none; }
.adder input:focus { border-color:#1f6feb; }
.adder button { background:#21262d; border:1px solid #30363d; color:#e6edf3; border-radius:8px; padding:0 14px; font-size:13px; font-weight:600; cursor:pointer; }
.adder button:hover { background:#30363d; }
.repo { border:1px solid #30363d; background:#161b22; border-radius:10px; margin-bottom:14px; overflow:hidden; }
.repo-head { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:12px 14px; border-bottom:1px solid #21262d; }
.repo-name { font-size:14px; font-weight:600; color:#2f81f7; }
.repo-actions { display:flex; gap:8px; align-items:center; }
.repo-count { font-size:12px; color:#8b949e; }
.icon-btn { background:none; border:0; color:#8b949e; cursor:pointer; font-size:13px; padding:2px 6px; border-radius:6px; }
.icon-btn:hover { background:#21262d; color:#e6edf3; }
.pr { border-bottom:1px solid #21262d; }
.pr:last-child { border-bottom:0; }
.pr-row { display:flex; gap:10px; align-items:flex-start; padding:10px 14px; cursor:pointer; }
.pr-row:hover { background:#1c2128; }
.pr-icon { color:#3fb950; font-size:13px; line-height:1.5; flex:0 0 auto; }
.pr-main { min-width:0; flex:1; }
.pr-title { font-size:13px; color:#e6edf3; }
.pr-sub { font-size:12px; color:#8b949e; margin-top:2px; }
.pr-body { padding:0 14px 12px 38px; font-size:13px; line-height:1.5; color:#adbac7; white-space:pre-wrap; word-wrap:break-word; }
.pr-body.empty { color:#6e7681; font-style:italic; }
.state { padding:14px; font-size:13px; color:#8b949e; }
.state.err { color:#f85149; }
.toast { position:fixed; bottom:20px; left:50%; transform:translateX(-50%); background:#238636; color:#fff; font-size:14px; font-weight:600; padding:10px 18px; border-radius:8px; opacity:0; transition:opacity .2s; pointer-events:none; }
.toast.show { opacity:1; }
.toast.err { background:#da3633; }
.empty-state { padding:32px 14px; text-align:center; color:#6e7681; font-size:13px; }
.loading { padding:48px 16px; text-align:center; color:#8b949e; }
`
const script = `
var root = document.getElementById('root');
var seed = [];
var repos = null; // [{owner,repo}], persisted
var expanded = {}; // pr key -> bool
var prCache = {}; // 'owner/repo' -> { loading, error, prs }
var profile = null; // authenticated user profile
var profileState = null; // { loading, error }
var repoInput = '';
var connected = null;
var connecting = false;
var toastTimer = null;
function esc(s){ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function key(o,r){ return o + '/' + r; }
function persist(){ window.rowboat.setState({ repos: repos }); }
// ---- live GitHub reads via the bridge -------------------------------------
function asArray(d){
if (Array.isArray(d)) return d;
if (d && typeof d === 'object') {
var keys = ['details','data','items','pull_requests','pullRequests','response_data','result'];
for (var i=0;i<keys.length;i++){ if (Array.isArray(d[keys[i]])) return d[keys[i]]; }
for (var k in d){ if (Array.isArray(d[k]) && d[k].length && typeof d[k][0] === 'object') return d[k]; }
}
return [];
}
function normPRs(d){
return asArray(d).map(function(p){
return {
number: (p.number != null ? p.number : (p.id != null ? p.id : '?')),
title: p.title || '(untitled)',
body: (p.body || '').trim(),
author: (p.user && (p.user.login || p.user.name)) || p.author || '',
url: p.html_url || p.url || ''
};
});
}
// Pick the *list pull requests* tool, excluding review/comment/file/commit
// variants that happen to contain PULL + LIST but need pull_number/review_id.
function pickPRTool(tools){
var bad = ['REVIEW','COMMENT','FILE','COMMIT','REQUESTED'];
function ok(s){ for (var i=0;i<bad.length;i++) if (s.indexOf(bad[i])>=0) return false; return true; }
for (var i=0;i<tools.length;i++){ if ((tools[i].slug||'').toUpperCase() === 'GITHUB_LIST_PULL_REQUESTS') return tools[i]; }
for (var j=0;j<tools.length;j++){ var s=(tools[j].slug||'').toUpperCase(); if (/LIST_PULL_REQUESTS$/.test(s) && ok(s)) return tools[j]; }
for (var m=0;m<tools.length;m++){ var t=(tools[m].slug||'').toUpperCase(); if (t.indexOf('PULL_REQUEST')>=0 && t.indexOf('LIST')>=0 && ok(t)) return tools[m]; }
return null;
}
function fetchPRs(o, r){
var k = key(o,r);
prCache[k] = { loading:true }; render();
var args = { owner:o, repo:r, state:'open', sort:'updated', direction:'desc', per_page:20 };
// Canonical slug first; fall back to a strict search only if it's unknown.
window.rowboat.callAction('github', 'GITHUB_LIST_PULL_REQUESTS', args).catch(function(){
return window.rowboat.searchTools('github','list pull requests for a repository').then(function(tools){
var tool = pickPRTool(tools || []);
if (!tool) throw new Error('could not find a list-pull-requests tool');
return window.rowboat.callAction('github', tool.slug, args);
});
}).then(function(d){
prCache[k] = { loading:false, prs: normPRs(d) }; render();
}).catch(function(e){
prCache[k] = { loading:false, error: (e && e.message ? e.message : String(e)) }; render();
});
}
function fetchAll(){ if (!connected) return; loadProfile(); if (!repos) return; repos.forEach(function(rp){ var k=key(rp.owner,rp.repo); if (!prCache[k]) fetchPRs(rp.owner, rp.repo); }); }
// ---- authenticated user profile -------------------------------------------
function asObj(d){
if (d && typeof d === 'object'){
if (d.login) return d;
var ks = ['data','details','response_data','result','user'];
for (var i=0;i<ks.length;i++){ var v=d[ks[i]]; if (v && typeof v === 'object' && v.login) return v; }
}
return d || {};
}
function loadProfile(){
if (!connected || profile || (profileState && profileState.loading)) return;
profileState = { loading:true }; render();
window.rowboat.callAction('github','GITHUB_GET_THE_AUTHENTICATED_USER',{}).catch(function(){
return window.rowboat.searchTools('github','get the authenticated user').then(function(tools){
var pick = null;
for (var i=0;i<(tools||[]).length;i++){ if (/AUTHENTICATED_USER$/.test((tools[i].slug||'').toUpperCase())){ pick = tools[i]; break; } }
if (!pick && tools && tools.length) pick = tools[0];
if (!pick) throw new Error('no profile tool found');
return window.rowboat.callAction('github', pick.slug, {});
});
}).then(function(d){
var u = asObj(d);
profile = { login:u.login||'', name:u.name||u.login||'', avatar:u.avatar_url||'', bio:u.bio||'', repos:u.public_repos||0, followers:u.followers||0, following:u.following||0 };
profileState = { loading:false }; render();
}).catch(function(e){
profileState = { loading:false, error:(e && e.message ? e.message : String(e)) }; render();
});
}
// ---- connection -----------------------------------------------------------
function bannerHtml(){
if (connecting) return '<div class="banner"><span class="banner-text">Connecting GitHub — finish in your browser…</span><button class="banner-btn" disabled>Connecting…</button></div>';
if (connected === false) return '<div class="banner"><span class="banner-text">Connect GitHub to load pull requests.</span><button class="banner-btn" data-connect="1">Connect GitHub</button></div>';
if (connected === true) return '<div class="banner ok"><span class="banner-text">GitHub connected — pull requests are live.</span></div>';
return '';
}
function checkConn(){ window.rowboat.isConnected('github').then(function(c){ connected = c; render(); fetchAll(); }); }
function connect(){
connecting = true; render();
window.rowboat.connect('github').then(function(){
var tries = 0;
var iv = setInterval(function(){
tries++;
window.rowboat.isConnected('github').then(function(c){
if (c){ connected = true; connecting = false; clearInterval(iv); render(); flash('GitHub connected'); fetchAll(); }
else if (tries > 30){ connecting = false; clearInterval(iv); render(); }
});
}, 2000);
}).catch(function(e){ connecting = false; render(); flash('Connect failed: ' + (e && e.message ? e.message : e), true); });
}
// ---- repo management ------------------------------------------------------
function addRepo(){
var v = (repoInput || '').trim().replace(/^https?:\\/\\/github.com\\//i, '');
var m = v.match(/^([\\w.-]+)\\/([\\w.-]+)$/);
if (!m){ flash('Enter as owner/repo', true); return; }
var o = m[1], r = m[2];
if (repos.some(function(x){ return x.owner === o && x.repo === r; })){ flash('Already tracked'); return; }
repos.push({ owner:o, repo:r }); repoInput = ''; persist(); render();
if (connected) fetchPRs(o, r);
}
function removeRepo(o, r){
repos = repos.filter(function(x){ return !(x.owner === o && x.repo === r); });
delete prCache[key(o,r)]; persist(); render();
}
// ---- render ---------------------------------------------------------------
function prHtml(o, r, pr){
var k = key(o,r) + '#' + pr.number;
var open = !!expanded[k];
var body = open
? (pr.body ? '<div class="pr-body">' + esc(pr.body) + '</div>' : '<div class="pr-body empty">No description.</div>')
: '';
return '<div class="pr"><div class="pr-row" data-pr="' + esc(k) + '">'
+ '<span class="pr-icon">⌥</span><div class="pr-main">'
+ '<div class="pr-title">' + esc(pr.title) + '</div>'
+ '<div class="pr-sub">#' + esc(pr.number) + (pr.author ? ' · ' + esc(pr.author) : '') + '</div>'
+ '</div></div>' + body + '</div>';
}
function repoHtml(rp){
var k = key(rp.owner, rp.repo);
var c = prCache[k];
var inner;
if (!connected) inner = '<div class="state">Connect GitHub to load PRs.</div>';
else if (!c || c.loading) inner = '<div class="state">Loading pull requests…</div>';
else if (c.error) inner = '<div class="state err">' + esc(c.error) + '</div>';
else if (!c.prs.length) inner = '<div class="empty-state">No open pull requests 🎉</div>';
else inner = c.prs.map(function(pr){ return prHtml(rp.owner, rp.repo, pr); }).join('');
var count = (c && c.prs) ? '<span class="repo-count">' + c.prs.length + ' open</span>' : '';
return '<div class="repo"><div class="repo-head">'
+ '<span class="repo-name">' + esc(rp.owner) + '/' + esc(rp.repo) + '</span>'
+ '<span class="repo-actions">' + count
+ '<button class="icon-btn" data-refresh="' + esc(k) + '">↻</button>'
+ '<button class="icon-btn" data-remove="' + esc(k) + '">✕</button>'
+ '</span></div>' + inner + '</div>';
}
function profileHtml(){
if (!connected) return '';
if (!profile) return (profileState && profileState.loading) ? '<div class="state">Loading profile…</div>' : '';
var initials = (profile.name || profile.login || '?').slice(0,1).toUpperCase();
var img = profile.avatar ? '<img class="avatar" src="' + esc(profile.avatar) + '" alt="" />' : '';
var fbStyle = profile.avatar ? '' : ' style="display:flex"';
return '<div class="profile">' + img
+ '<div id="avfb" class="avatar-fb"' + fbStyle + '>' + esc(initials) + '</div>'
+ '<div><div class="pname">' + esc(profile.name) + '</div><div class="plogin">@' + esc(profile.login) + '</div>'
+ (profile.bio ? '<div class="pbio">' + esc(profile.bio) + '</div>' : '')
+ '<div class="pstats"><span><b>' + profile.repos + '</b> repos</span><span><b>' + profile.followers + '</b> followers</span><span><b>' + profile.following + '</b> following</span></div>'
+ '</div></div>'
+ '<div class="contrib"><div class="contrib-title">Contributions</div>'
+ '<img class="contrib-img" src="https://ghchart.rshah.org/' + encodeURIComponent(profile.login) + '" alt="contributions" />'
+ '<div id="contribnote" class="contrib-note" style="display:none">Contribution graph could not load.</div>'
+ '</div>';
}
function render(){
var list = repos
? (repos.length ? repos.map(repoHtml).join('') : '<div class="empty-state">No repos yet — add one above.</div>')
: '<div class="loading">Loading…</div>';
root.innerHTML = '<div class="app"><div class="wrap">'
+ '<h1 class="h">GitHub Dashboard</h1><p class="sub">Track repos and read their open pull requests.</p>'
+ bannerHtml()
+ profileHtml()
+ '<div class="adder"><input id="repo-input" placeholder="owner/repo (e.g. vercel/next.js)" /><button data-add="1">Add</button></div>'
+ list
+ '</div><div class="toast" id="toast"></div></div>';
var el = document.getElementById('repo-input');
if (el) el.value = repoInput;
var av = root.querySelector('.avatar');
if (av) av.onerror = function(){ this.style.display='none'; var f=document.getElementById('avfb'); if (f) f.style.display='flex'; };
var ci = root.querySelector('.contrib-img');
if (ci) ci.onerror = function(){ this.style.display='none'; var n=document.getElementById('contribnote'); if (n) n.style.display='block'; };
}
function flash(msg, isErr){
var el = document.getElementById('toast'); if (!el) return;
el.textContent = msg; el.className = 'toast show' + (isErr ? ' err' : '');
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(function(){ el.className = 'toast' + (isErr ? ' err' : ''); }, 2400);
}
// ---- events ---------------------------------------------------------------
root.addEventListener('input', function(e){ if (e.target && e.target.id === 'repo-input') repoInput = e.target.value; });
root.addEventListener('keydown', function(e){ if (e.target && e.target.id === 'repo-input' && e.key === 'Enter') addRepo(); });
root.addEventListener('click', function(e){
var t = e.target;
if (!t || !t.closest) return;
if (t.closest('[data-connect]')) { connect(); return; }
if (t.closest('[data-add]')) { addRepo(); return; }
var rm = t.closest('[data-remove]');
if (rm) { var p = rm.getAttribute('data-remove').split('/'); removeRepo(p[0], p[1]); return; }
var rf = t.closest('[data-refresh]');
if (rf) { var q = rf.getAttribute('data-refresh').split('/'); fetchPRs(q[0], q[1]); return; }
var pr = t.closest('[data-pr]');
if (pr) { var pk = pr.getAttribute('data-pr'); expanded[pk] = !expanded[pk]; render(); return; }
});
window.rowboat.onData(function(d){ seed = (d && d.seedRepos) || []; if (repos === null) { repos = seed.slice(); render(); } });
window.rowboat.onState(function(st){
if (st && st.repos) { repos = st.repos; render(); fetchAll(); }
else if (repos === null) { repos = seed.slice(); render(); }
});
render();
window.rowboat.ready();
checkConn();
`
export const githubRadarApp: MiniApp = {
id: 'github-radar',
name: 'GitHub Dashboard',
description: 'Track repos and read their open pull requests — live via GitHub.',
source: 'GitHub',
active: true,
lastRun: 'just now',
scope: ['github'],
data,
html: buildMiniAppHtml({ title: 'GitHub Dashboard', style, script }),
}

View file

@ -109,6 +109,10 @@ const style = `
.action.like.on { color:#f91880; }
.action.repost.on { color:#00ba7c; }
.empty { padding:48px 16px; text-align:center; color:#71767b; font-size:15px; }
.banner { display:flex; align-items:center; justify-content:space-between; gap:12px; margin:14px 16px 0; padding:12px 14px; border:1px solid #1d9bf0; background:rgba(29,155,240,0.12); border-radius:12px; }
.banner-text { font-size:13px; color:#e7e9ea; }
.banner-btn { background:#1d9bf0; color:#fff; border:0; border-radius:999px; padding:7px 16px; font-size:13px; font-weight:700; cursor:pointer; }
.banner-btn:disabled { opacity:.6; cursor:default; }
.toast { position:fixed; bottom:20px; left:50%; transform:translateX(-50%); background:#1d9bf0; color:#fff; font-size:14px; font-weight:600; padding:10px 18px; border-radius:999px; opacity:0; transition:opacity .2s; pointer-events:none; }
.toast.show { opacity:1; }
.loading { padding:48px 16px; text-align:center; color:#71767b; font-size:15px; }
@ -133,6 +137,17 @@ function persist() {
window.rowboat.setState({ topics: selected, liked: liked, reposted: reposted });
}
// X/Twitter has no Composio managed-OAuth2 flow, so this sample stays a local UI
// demo (see GitHub Radar for the real connect -> execute bridge).
function act(action, id) {
if (action === 'reply') { flash('Reply drafted (demo)'); return; }
if (action === 'like') liked[id] = !liked[id];
if (action === 'repost') reposted[id] = !reposted[id];
persist(); render();
if (action === 'repost') flash(reposted[id] ? 'Reposted (demo)' : 'Removed');
if (action === 'like') flash(liked[id] ? 'Liked (demo)' : 'Unliked');
}
function postHtml(p) {
var likeOn = liked[p.id] ? ' on' : '';
var repoOn = reposted[p.id] ? ' on' : '';
@ -193,18 +208,7 @@ root.addEventListener('click', function (e) {
}
var btn = t && t.closest ? t.closest('.action') : null;
if (!btn) return;
var action = btn.getAttribute('data-action');
var id = btn.getAttribute('data-id');
if (action === 'reply') { flash('Reply drafted (demo)'); return; }
if (action === 'like') { liked[id] = !liked[id]; }
if (action === 'repost') { reposted[id] = !reposted[id]; }
persist();
render();
window.rowboat.callAction('twitter', action, { id: id }).then(function (res) {
flash((res && res.message) || 'Done');
}).catch(function (err) {
flash('Failed: ' + (err && err.message ? err.message : err));
});
act(btn.getAttribute('data-action'), btn.getAttribute('data-id'));
});
window.rowboat.onData(function (d) {

View file

@ -4,10 +4,11 @@
// from ~/.rowboat/apps/<id>/ over IPC.
import type { MiniApp } from './types'
import { githubRadarApp } from './apps/github-radar'
import { twitterClientApp } from './apps/twitter-client'
import { newsletterDigestApp, competitorWatchApp } from './apps/digests'
export const MINI_APPS: MiniApp[] = [twitterClientApp, newsletterDigestApp, competitorWatchApp]
export const MINI_APPS: MiniApp[] = [githubRadarApp, twitterClientApp, newsletterDigestApp, competitorWatchApp]
export function getMiniApp(id: string): MiniApp | undefined {
return MINI_APPS.find((app) => app.id === id)

View file

@ -19,6 +19,13 @@ const BRIDGE_SHIM = /* js */ `
var dataCbs = [], stateCbs = [];
var pending = {}, seq = 0;
function post(msg) { parent.postMessage(msg, '*'); }
function rpc(method, params) {
var id = 'r' + (++seq);
return new Promise(function (resolve, reject) {
pending[id] = { resolve: resolve, reject: reject };
post({ type: 'rowboat:mini-app:rpc', id: id, method: method, params: params });
});
}
window.addEventListener('message', function (e) {
var m = e.data;
if (!m || typeof m !== 'object') return;
@ -28,11 +35,11 @@ const BRIDGE_SHIM = /* js */ `
} else 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:action-result') {
} else if (m.type === 'rowboat:mini-app:rpc-result') {
var p = pending[m.id];
if (p) {
delete pending[m.id];
if (m.ok) p.resolve(m.result); else p.reject(new Error(m.error || 'action failed'));
if (m.ok) p.resolve(m.result); else p.reject(new Error(m.error || 'request failed'));
}
}
});
@ -54,13 +61,15 @@ const BRIDGE_SHIM = /* js */ `
post({ type: 'rowboat:mini-app:setState', patch: patch });
stateCbs.forEach(function (cb) { try { cb(state); } catch (_) {} });
},
callAction: function (scope, action, args) {
var id = 'a' + (++seq);
return new Promise(function (resolve, reject) {
pending[id] = { resolve: resolve, reject: reject };
post({ type: 'rowboat:mini-app:action', id: id, scope: scope, action: action, args: args });
});
},
// Execute a Composio tool by slug within the app's scope. Resolves to the
// tool result, rejects with an Error (e.g. not connected / out of scope).
callAction: function (scope, tool, args) { return rpc('callAction', { scope: scope, tool: tool, args: args }); },
// Find tool slugs within a toolkit. Resolves to [{ slug, name, description }].
searchTools: function (scope, query) { return rpc('searchTools', { scope: scope, query: query }); },
// Resolve to true/false whether the toolkit is connected.
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 }); },
ready: function () { post({ type: 'rowboat:mini-app:ready' }); },
};
})();

View file

@ -46,12 +46,22 @@ export type MiniApp = {
// surface the app codes against and the security boundary.
// ---------------------------------------------------------------------------
/**
* Host RPC methods the app can call (all scope-checked against the app's
* declared integration scope by the host before they run):
* - callAction: execute a Composio tool by slug params: { scope, tool, args? }
* - searchTools: find tool slugs within a toolkit params: { scope, query }
* - isConnected: is the toolkit connected? params: { scope }
* - connect: trigger the Composio OAuth flow params: { scope }
*/
export type MiniAppRpcMethod = 'callAction' | 'searchTools' | 'isConnected' | 'connect'
/** Messages sent from the iframe (app) up to the host (renderer). */
export type MiniAppOutboundMessage =
/** Handshake: app is mounted and wants its initial data + state. */
| { type: 'rowboat:mini-app:ready' }
/** App requests a scoped Composio action. Host replies with action-result. */
| { type: 'rowboat:mini-app:action'; id: string; scope: string; action: string; args?: unknown }
/** A request/response call into the host, correlated by id. */
| { type: 'rowboat:mini-app:rpc'; id: string; method: MiniAppRpcMethod; params?: unknown }
/** App wants to persist a patch to its per-app state store. */
| { type: 'rowboat:mini-app:setState'; patch: unknown }
@ -61,14 +71,14 @@ export type MiniAppInboundMessage =
| { type: 'rowboat:mini-app:data'; data: unknown }
/** Current per-app state; sent on ready and after setState. */
| { type: 'rowboat:mini-app:state'; state: unknown }
/** Result of a previously requested action, correlated by id. */
| { type: 'rowboat:mini-app:action-result'; id: string; ok: boolean; result?: unknown; error?: string }
/** Result of a previously requested rpc call, correlated by id. */
| { type: 'rowboat:mini-app:rpc-result'; id: string; ok: boolean; result?: unknown; error?: string }
export const MINI_APP_MESSAGE = {
ready: 'rowboat:mini-app:ready',
action: 'rowboat:mini-app:action',
rpc: 'rowboat:mini-app:rpc',
setState: 'rowboat:mini-app:setState',
data: 'rowboat:mini-app:data',
state: 'rowboat:mini-app:state',
actionResult: 'rowboat:mini-app:action-result',
rpcResult: 'rowboat:mini-app:rpc-result',
} as const

View file

@ -1020,6 +1020,34 @@ const ipcSchemas = {
req: z.object({}),
res: ZListToolkitsResponse,
},
// Mini Apps: execute a Composio tool by slug (scoped to a connected toolkit).
'composio:execute-tool': {
req: z.object({
toolkitSlug: z.string(),
toolSlug: z.string(),
arguments: z.record(z.string(), z.unknown()).optional(),
}),
res: z.object({
successful: z.boolean(),
data: z.unknown().optional(),
error: z.string().optional(),
}),
},
// Mini Apps: search Composio tools within a toolkit (returns slugs + schemas).
'composio:search-tools': {
req: z.object({
toolkitSlug: z.string(),
query: z.string(),
}),
res: z.object({
tools: z.array(z.object({
slug: z.string(),
name: z.string(),
description: z.string().optional(),
})),
error: z.string().optional(),
}),
},
// Agent schedule channels
'agent-schedule:getConfig': {
req: z.null(),