/** * {{APP_NAME}} — process component entry point (persistent mode). * * This is a long-running HTTP server. The Kazzle platform routes triggers * (schedule and webhook) into this server at the paths declared in * `kazzle.config.ts` → `triggers[]`. Each request carries: * * - `Authorization: Bearer ${KAZZLE_TRIGGER_SECRET}` — validate this * before doing any work. Calls without the header are external traffic * and should be handled with your own auth (or rejected). * - `x-kazzle-trigger-name` — the trigger's `name` from the manifest. * - `x-kazzle-trigger-run-id` — opaque ID for log correlation. * - `x-kazzle-triggered-by` — 'cron' | 'webhook' | 'manual'. * * `PORT` and `HOST` are injected by `kazzle run`. Missing values throw so * the platform sees a fast, readable failure instead of binding to the * wrong port. * * If you want one-off ephemeral runs per trigger instead (Vercel cron * style), set `processMode: 'triggered'` in kazzle.config.ts and read * `TRIGGER_NAME` + `WEBHOOK_PAYLOAD` from process.env on startup; the * platform will spawn this file fresh per trigger and the body below * (`Bun.serve`) is never reached. */ import { SaveBookmarkInput } from '../../skills/tools/tools'; // ─── Triggered mode (one-off ephemeral run) ───────────────────────────── // // If TRIGGERED_BY is set, the platform launched us for a single trigger // (cron or webhook) and expects the process to do its job and exit. This // mirrors the `processMode: 'triggered'` lifecycle described above. if (process.env.TRIGGERED_BY) { const trigger = process.env.TRIGGERED_BY; const runId = process.env.RUN_ID ?? 'unknown'; const triggerName = process.env.TRIGGER_NAME ?? ''; const payload = process.env.WEBHOOK_PAYLOAD ?? ''; console.log(`[{{APP_NAME}}] Triggered by: ${trigger} (runId=${runId}, trigger=${triggerName})`); if (payload) console.log(`[{{APP_NAME}}] Payload: ${payload}`); console.log(`[{{APP_NAME}}] Job complete`); process.exit(0); } // ───────────────────────────────────────────────────────────────────────── // DO NOT CHANGE THE NEXT 4 LINES. DO NOT ADD FALLBACKS. DO NOT HARDCODE. // PORT and HOST are injected by `kazzle run`. Missing values must throw so // the platform sees a fast, readable failure instead of binding to the // wrong port. // ───────────────────────────────────────────────────────────────────────── if (!process.env.PORT || !process.env.HOST) { throw new Error('PORT and HOST must be set by "kazzle run" — never hardcode them.'); } const PORT = Number(process.env.PORT); const HOST = process.env.HOST; // KAZZLE_TRIGGER_SECRET is injected into the env when the migration that // adds `drive_items.trigger_secret` lands. Until then the platform skips // the Authorization header; treat a missing secret here as "auth not yet // available" and accept platform-tagged requests by header presence only. const TRIGGER_SECRET = process.env.KAZZLE_TRIGGER_SECRET ?? ''; function isPlatformTrigger(req: Request): boolean { if (!req.headers.get('x-kazzle-trigger-name')) return false; if (!TRIGGER_SECRET) return true; const auth = req.headers.get('authorization') ?? ''; return auth === `Bearer ${TRIGGER_SECRET}`; } const server = Bun.serve({ hostname: HOST, port: PORT, async fetch(req) { const url = new URL(req.url); // /health is the liveness probe. Keep it dependency-free so a flaky // database or downstream API doesn't take the whole process down. if (url.pathname === '/health') { return Response.json({ status: 'ok', pid: process.pid, uptime: process.uptime(), }); } // ── Platform-routed triggers ───────────────────────────────────────── // // Each trigger declared in kazzle.config.ts → triggers[] arrives here // at its declared `path`. The block below shows the canonical handler // shape — extend it as you add triggers. // Example: handle a daily cron declared as // { name: 'hourly-sync', kind: 'schedule', schedule: '0 * * * *', path: '/cron/hourly-sync' } if (req.method === 'POST' && url.pathname === '/cron/hourly-sync') { if (!isPlatformTrigger(req)) return new Response('Unauthorized', { status: 401 }); // Do the cron work here. console.log(`[{{APP_NAME}}] hourly sync fired (runId=${req.headers.get('x-kazzle-trigger-run-id')})`); return Response.json({ ok: true }); } // Example: handle a webhook declared as // { name: 'incoming', kind: 'webhook', path: '/webhook/incoming' } if (req.method === 'POST' && url.pathname === '/webhook/incoming') { if (!isPlatformTrigger(req)) return new Response('Unauthorized', { status: 401 }); const body = await req.json().catch(() => ({})) as Record; console.log(`[{{APP_NAME}}] webhook fired:`, JSON.stringify(body).slice(0, 256)); return Response.json({ ok: true, received: true }); } // ── AI tool handlers (the "kitchen") ───────────────────────────────── // // Each tool declared in skills/tools/tools.ts is backed by a route here. // When the AI calls `save_bookmark`, Kazzle POSTs the tool input to this path // with the app identity in the `X-Kazzle-Identity` header (your app's own // `Authorization` header is never touched). Return either plain text or JSON // { content } — that becomes the tool result the AI sees. // // To add a tool: add it to tools.ts (name + Zod input + path), then add // its matching `POST ` route below. if (req.method === 'POST' && url.pathname === '/tools/save-bookmark') { const input = await req.json().catch(() => ({})); const parsed = SaveBookmarkInput.safeParse(input); if (!parsed.success) { return Response.json({ content: parsed.error.issues.map(issue => `${issue.path.join('.') || 'input'}: ${issue.message}`).join('; ') }, { status: 400 }); } const toolInput = parsed.data; // Do the real work here (write to your DB, call an API, etc.). // // Per-install runtime secrets (e.g. a connected user's OAuth token) are // saved/read via KazzleInstallClient, which reads the `X-Kazzle-Identity` // header from this request and scopes the secret to this install. Use a // fixed name; never key by user yourself: // // import { KazzleInstallClient } from '@kazzle/app/client'; // const kazzle = new KazzleInstallClient(req); // await kazzle.setSecret('gmail_token', accessToken); // const token = await kazzle.getSecret('gmail_token'); // null → not connected yet console.log(`[{{APP_NAME}}] save_bookmark:`, toolInput.title ?? '(untitled)', toolInput.url); return Response.json({ content: `Saved bookmark: ${toolInput.title ?? toolInput.url}` }); } // ── Your own HTTP endpoints ────────────────────────────────────────── if (req.method === 'POST' && url.pathname === '/process') { const body = await req.json() as Record; return Response.json({ received: true, echo: body }); } return new Response('Hello from {{APP_NAME}}!'); }, }); console.log(`[{{APP_NAME}}] Server listening on port ${server.port}`);