Integrate Flapjack with an external app platform
Auto-provision a Flapjack agent with a persistent Linux computer whenever a user creates an app in your platform.
If you run an app platform (your app, a project manager, an internal tool catalog) you can wire it to Flapjack so every app gets a dedicated agent with a persistent Linux computer. The agent runs tests, hosts dev servers, performs CI tasks — all scoped to that one app and shared across every conversation with the agent.
This guide walks through the your app v0 integration pattern.
Prerequisites
- A Flapjack API key with
sandbox:adminscope (create in Settings → API keys). - A webhook receiver on your platform (any public https URL).
- Optional: a GitHub App installation if you want to clone private repos into the sandbox.
The flow
your app UI Flapjack
──────── ─────────
User creates app ──POST /api/agents/from-template──▶ create agent
enable persistent sandbox
store bootstrap config
kick off background bootstrap
◀──── 201 { agent, bootstrapRunId } ───
bootstrap runs in Heyo VM
◀──── POST webhookUrl (bootstrap.succeeded)
your app marks app ready
User clicks "Run tests" ──POST /api/agents/{id}/computer/exec──▶ exec in VM
◀── SSE stdout/stderr/exit ───
App detail page ── GET /api/agents/{id}/computer/status ──▶ cached pills
◀── JSON ───
Step 1 — Create the agent when the app is created
import { FlapjackClient } from '@maats/flapjack';
const flapjack = new FlapjackClient({ apiKey: process.env.FLAPJACK_API_KEY! });
export async function createApp(input: { name: string; repoUrl?: string }) {
const app = await db.apps.insert({ name: input.name, /* ... */ });
const result = await flapjack.createAgentFromTemplate({
name: input.name,
template: 'nextjs-fullstack', // pick based on detected stack
repo: input.repoUrl ? { url: input.repoUrl, installCmd: 'pnpm install' } : undefined,
envVars: [{ key: 'NODE_ENV', value: 'development' }],
sizeClass: 'medium',
webhookUrl: `${process.env.PUBLIC_URL}/api/flapjack/webhook`,
externalAppId: app.id, // idempotency: safe to retry
});
await db.apps.update(app.id, {
flapjackAgentId: result.agent.id,
flapjackBootstrapRunId: result.bootstrapRunId,
});
return app;
}
The call returns in < 1 s — bootstrap (package install, repo clone, deps) runs in the background. Your UI can show "Provisioning…" and unlock the "Run" button on the webhook event.
Step 2 — Verify the webhook
Flapjack POSTs lifecycle events to your webhookUrl, signed with
HMAC-SHA256 over the raw body.
// app/api/flapjack/webhook/route.ts
import { verifyWebhookSignature } from '@maats/flapjack';
export async function POST(req: Request) {
const body = await req.text();
const sig = req.headers.get('x-flapjack-signature') ?? '';
const ok = await verifyWebhookSignature(
body, sig, process.env.FLAPJACK_WEBHOOK_SECRET!,
);
if (!ok) return new Response('invalid signature', { status: 401 });
const event = JSON.parse(body);
switch (event.event) {
case 'bootstrap.succeeded':
await db.apps.updateByDassieAppId(event.externalAppId, { status: 'ready' });
break;
case 'bootstrap.failed':
await db.apps.updateByDassieAppId(event.externalAppId, { status: 'failed' });
break;
case 'computer.idled':
// informational — next exec resumes automatically
break;
case 'agent.deleted':
await db.apps.updateByDassieAppId(event.externalAppId, { flapjackAgentId: null });
break;
}
return new Response('ok');
}
You get the signing secret from Flapjack org settings → Webhooks.
Step 3 — Run commands on demand
export async function runTests(appId: string) {
const app = await db.apps.get(appId);
if (!app.flapjackAgentId) throw new Error('agent not provisioned yet');
const events = flapjack.execSandbox(app.flapjackAgentId, {
command: 'pnpm test',
timeoutSec: 300,
workingDir: '/workspace/app',
});
let output = '';
for await (const ev of events) {
if (ev.type === 'stdout' || ev.type === 'stderr') output += ev.chunk;
if (ev.type === 'exit') {
await db.apps.recordTestRun(appId, { ok: ev.ok, output });
return ev;
}
}
}
execSandbox is an async iterator. Each yielded event is one of
exec_started / stdout / stderr / exit / error. Forward the
chunks directly to your UI for a live terminal feel.
Step 4 — Show live status in the app page
import { useEffect, useState } from 'react';
import type { SandboxStatus } from '@maats/flapjack';
function useSandboxStatus(agentId: string) {
const [s, setS] = useState<SandboxStatus | null>(null);
useEffect(() => {
let t: NodeJS.Timeout;
const tick = async () => {
const r = await fetch(`/api/flapjack/status/${agentId}`);
if (r.ok) setS(await r.json());
t = setTimeout(tick, 10_000);
};
tick();
return () => clearTimeout(t);
}, [agentId]);
return s;
}
Or skip the custom UI entirely and embed Flapjack's status widget:
<iframe
src={`https://app.flapjack.dev/embed/agents/${agentId}/status?token=${embedToken}`}
style={{ border: 0, height: 180, width: '100%' }}
/>
Step 5 — Delete in concert
When a user deletes the your app app, delete the Flapjack agent to tear down the sandbox and stop the meter.
await flapjack.deleteAgent(app.flapjackAgentId);
await db.apps.delete(appId);
Pricing / idle
Persistent sandboxes auto-stop after 2 h of inactivity. The next exec
call resumes them (status goes idle → waking → ready). You pay for
running VMs only.
Rate limits
exec: 60/minute per agent, 600/minute per org, 3 concurrent per agentstatus: excluded from exec quota (server-cached 10 s)
Failure modes
| Symptom | Meaning | What to do |
|---|---|---|
409 SANDBOX_NOT_READY on exec | Bootstrap hasn't finished | Wait for bootstrap.succeeded webhook |
410 SANDBOX_DESTROYED on exec | VM gone (idle-stop + external destroy, or failed) | Call /from-template again; it's idempotent |
bootstrap.failed webhook | Package / repo install errored | Read stderr_tail from bootstrap_runs |
429 RATE_LIMITED | Over quota | Honour Retry-After header |