SDK reference
Everything the hatchable module exposes. Each runs in the V8 isolate that wraps your api/*.js functions; calls go to the platform gateway over a scoped HMAC token, never to user code.
Every API file imports from hatchable:
import { db, auth, admin, email, storage, scheduler,
ai, knowledge, browser, config } from 'hatchable';
ai.generateText, pass a provider-family alias like 'sonnet' / 'haiku' / 'opus' / 'gpt' / 'gemini' instead of a raw provider model id. The gateway resolves it server-side based on whichever provider key the user has set, and the platform updates the underlying model when a new generation ships without breaking your code. See Secrets architecture for the full story.When to reach for which
A purpose-and-popular-use-case guide. Match the user's prompt to the cluster, then drop into the alphabetical reference below for the full surface.
Data
Where the app's facts live. Every project has Postgres + a CDN-backed bucket; the rest are layers on top for speed and semantic search.
Identity & secrets
Who's logged in, what keys they brought. Auth covers your app's own end-users; config is the single read API for any declared [[secret]] or [[config]] value, walking user → project → account → manifest default server-side.
auth.getUser(req) when an anonymous fallback is fine; auth.requireUser(req, res) to gate the handler with a 401.[[secret]] in hatchable.toml; read via config.get('KEY'). AI keys are never readable directly — ai.generateText resolves them server-side.AI
When the app itself needs to call a model — summarize, classify, draft, decide. Always BYOK; the platform routes the request and never exposes the raw key to your code.
'sonnet', 'gpt') so the app works on whichever provider key the user has.ai.generateText({ tools, maxSteps }) — runtime drives the loop)
Scheduling
Anything that shouldn't happen on the request thread — recurring jobs, future-dated work, slow background tasks. One primitive covers all of it: scheduler.at().
'+0s').scheduler.at('+0s', '/api/render-pdf', { payload }))
Communication
How the app reaches the world outside its own UI — humans (email) and other AIs (MCP).
Web outside
Reaching out to or processing things from the broader internet — visiting pages, transforming uploads.
Module reference
db import { db } from 'hatchable'
Postgres for your project. Raw SQL — agents are good at it, the skills port everywhere. Each project gets its own database.
$1, $2, … positional placeholders.BEGIN/COMMIT. Any error rolls the whole batch back.import { db } from 'hatchable'; // SELECT const r = await db.query('SELECT id, email FROM users WHERE active = $1', [true]); // → { rows: [{ id, email }, …], rowCount: 12 } // INSERT … RETURNING — get the id back without a second round trip const ins = await db.query( 'INSERT INTO posts (title, body) VALUES ($1, $2) RETURNING id', [title, body], ); const postId = ins.rows[0].id; // Atomic multi-statement await db.transaction([ { sql: 'INSERT INTO orders (user_id, amount) VALUES ($1, $2) RETURNING id', params: [u.id, 99] }, { sql: 'INSERT INTO order_items (order_id, sku) VALUES ($1, $2)', params: [42, 'tee-l'] }, ]);
migrations/*.sql, applied in filename order on every deploy. Each runs once. Use seed.sql for first-deploy data; runs only on a fresh project.knowledge import { knowledge } from 'hatchable'
RAG-shaped storage on top of pgvector. Declare a knowledge base by name, add items with text + metadata, search by query. Embeddings are computed for you via the ai module's embed model. Backed by pgvector on this project's own Postgres DB.
opts.dimensions required (e.g. 1536 for OpenAI text-embedding-3-small), opts.metric optional (cosine | l2 | ip — default cosine). Idempotent declare on first use.{ id, text, metadata? }. SDK embeds the text and upserts. Idempotent by id — re-adding updates.{ id, embedding, metadata? }.opts.topK (default 10), opts.filter for metadata-equality. Returns [{ id, similarity, metadata }] with metadata._text set to the original._hv_<name> table for hybrid SQL via db.query.const docs = knowledge.base('articles', { dimensions: 1536 }); await docs.add([ { id: 'p1', text: 'How do I deploy?', metadata: { kind: 'help' } }, { id: 'p2', text: 'Setting up auth', metadata: { kind: 'guide' } }, ]); const hits = await docs.search('how do I ship to prod', { topK: 5 });
For builder-curated content (docs, FAQ, manuals), populate via the console's Knowledge tab — paste text or upload .txt / .md / .markdown files. Same _hv_* tables underneath; agent code's search() doesn't care which way the content got there. Declare what you expect with [[knowledge]] blocks in hatchable.toml.
storage import { storage } from 'hatchable'
Object storage backed by S3. The bucket is fully private — every read goes through a short-lived presigned URL minted by storage.put or storage.url, or via storage.get in your handler. There is no permanent public URL.
buffer can be Uint8Array, base64 string, or string. Optional opts.metadata is a flat string→string map applied as x-amz-meta-* on the object (ASCII printable, 2KB combined cap). Persist the key in your DB OR attach the fields you'd otherwise store as metadata — see storage.list.ttl is seconds (default 3600, max 604800 = 7 days). Call before each browser-facing render for long-lived references.{ key, size, content_type, last_modified, url, metadata? } — url is freshly minted (~1h TTL). include_metadata: true runs a parallel HeadObject per item, adding the x-amz-meta-* set at put time. Opaque cursor for pagination. Default limit 100, cap 1000.// Save an upload — store the key, hand the browser a fresh signed URL on render const bytes = new Uint8Array(await req.body.arrayBuffer()); const key = `uploads/${user.id}/${crypto.randomUUID()}.png`; await storage.put(key, bytes, 'image/png'); await db.query('INSERT INTO uploads (user_id, storage_key) VALUES ($1, $2)', [user.id, key]); // Later — render a list with fresh URLs every request const rows = (await db.query('SELECT storage_key FROM uploads WHERE user_id = $1', [user.id])).rows; const urls = await Promise.all(rows.map(r => storage.url(r.storage_key)));
auth import { auth } from 'hatchable'
App-level user identity. For projects that need their own users — multi-user apps, paid SaaS, anything with signup. Single-user/private projects without [auth] have no app-level users; platform access gating happens at the platform edge, not in app code.
users table via the session cookie, or null.getUser, plus auto-401 + null return when there's no user. Use in 90% of authed handlers.// The common shape: const user = await auth.requireUser(req, res); if (!user) return; // requireUser already wrote the 401 // … user is non-null past this point.
Both methods return null when the project has no [auth] section, when the visitor isn't signed in, or when the session has expired. requireUser also writes a 401 { error: "Not signed in." } in those cases — use it whenever the handler shouldn't proceed without a user.
Enabling auth
[auth] enabled = true providers = ["email"] // only "email" is wired today
The platform auto-mounts /api/auth/sign-up/email, /api/auth/sign-in/email, /api/auth/sign-out, /api/auth/get-session. Per-project users and sessions tables are created automatically — they live in your project's own Postgres, not in any central platform DB, so each project's user base is fully isolated.
The session cookie is hatchable_app_session (HttpOnly, Secure, SameSite=Lax, no Domain attribute — it scopes to the request host so custom domains work without operator config). Sessions slide: any get-session probe past the half-life pushes the expiry back, so active users stay logged in indefinitely.
Auth endpoints are rate-limited at the platform layer (signup 3/hour per IP; signin 10/min per IP plus 5/15min per (IP, email)). The /api/auth/* namespace is reserved by the platform — files under api/auth/ are rejected at deploy whether or not [auth] is enabled. See the config reference.
admin import { admin } from 'hatchable'
Project-owner recognition via the platform's hatchable_session cookie. Use this to gate /admin/* routes inside your app to the project owner without enabling app-tier [auth]. Distinct from auth: auth is about your app's end-users, admin is about the platform buyer (you).
hatchable_session cookie for an account that owns this project.check, plus writes a 302 to hatchable.com/console/login if no session, or 403 if signed in but not the owner. Returns false when it wrote a response; check the boolean before continuing.// Owner-only dashboard route import { admin, db } from 'hatchable'; export default async function (req, res) { const allowed = await admin.require(req, res); if (!allowed) return; // require() already wrote 302/403 const rows = await db.query('SELECT * FROM waivers ORDER BY signed_at DESC'); res.json({ waivers: rows.rows }); }
config import { config } from 'hatchable'
One read API for everything declared in hatchable.toml: buyer-editable settings from [[config]] blocks (Configure tab) and sensitive values from [[secret]] blocks (gate-pasted). Walks config → user → project → account → manifest default server-side and returns the first hit.
[[config]] first (the buyer-saved value from the Configure tab, else the schema default), then falls through to [[secret]] tier-walk (user → project → account → default). Pass { req } to scope user-tier reads to the request's authenticated app-user. Throws SetupRequired when a secret is declared+required+unsatisfied with no default.get, but also mirrors the resolved value into process.env[key]. For npm libraries that insist on process.env. Project-tier only.// [[config]] — buyer-editable, non-sensitive (lowercase snake_case keys) import { config } from 'hatchable'; const displayName = await config.get('display_name'); // "Welcome" (default) or the buyer's saved value const accent = await config.get('accent_color'); // "#f5b840" or the saved hex const links = await config.get('links'); // array — JSON round-trips through one column // [[secret]] — sensitive, gate-pasted (UPPER_SNAKE_CASE keys by convention) const model = await config.get('DEFAULT_MODEL'); // returns the manifest default if no override // User-tier RAW secret (an integration the APP calls directly, not the SDK) — // pass req so the gateway scopes to the signed-in app user. const calendarToken = await config.get('GOOGLE_CALENDAR_REFRESH_TOKEN', { req }); // The [ai] capability is account-scoped — the buyer's own Anthropic / // OpenAI / Google key is used regardless of which end-user is signed in. // asUser on ai.generateText is for usage-tracking (llm_calls.user_id), // NOT for swapping out the key per user. End-user BYOK for SDK calls // isn't supported by design. const reply = await ai.generateText({ asUser: user.id, model, prompt });
[[config]] (Configure tab edits, no redeploy needed) or [[secret]] (gate-pasted by humans, or written programmatically via env.set for OAuth-callback flows) is decided by which block the manifest declared. The runtime resolver checks config_manifest first, then secrets_manifest. See the [[config]] reference for customization fields, [[secret]] reference for API keys and sensitive values, and the env module below for programmatic secret writes.config.get throws with code: 'SetupRequired', a [[secret]] is declared as required, has no default, and no human has pasted it. The error includes setup_url; route the user there. The platform's auto-injected modal runtime catches these for you in browser-driven flows; only handle manually for non-interactive paths (cron, webhooks, etc.).env import { env } from 'hatchable'
Programmatic writes to project-tier and account-tier secret values from inside an authed app handler. Use this for OAuth callbacks ("user just authorized our Stripe Connect; save the connected-account id"), in-app setup wizards, and batch imports — anything where the value is computed at runtime rather than pasted by a human. Reads are still via config.get.
opts.isSecret (default true) controls whether the value is masked in audit logs / dashboard listings. Key is normalized to UPPER_SNAKE_CASE.set.// Stripe Connect callback — capture the connected-account id at runtime import { env, auth } from 'hatchable'; export default async function (req, res) { const user = await auth.requireUser(req, res); if (!user) return; const { account_id } = await exchangeCodeForAccount(req.query.code); await env.set('STRIPE_CONNECTED_ACCOUNT', account_id); res.redirect('/dashboard?connected=1'); }
env. When the app needs each end-user to paste their own credentials for an integration the app's own code calls (a private API, a third-party with no SDK helper), declare the value as [[secret]] tenancy = "user" in hatchable.toml. The platform's gate handles the paste — it stores into app_user_secrets scoped to (project_id, app_user_id, key), and your app reads it via config.get(key, { req }). For per-end-user OAuth against a known provider (Notion, GitHub, etc.) use [[api]] per_user = true instead — see the multi-tenant pattern. SDK capabilities ([ai], etc.) are always account-scoped — managed SDK calls use the buyer's own key.
set_env / list_env / delete_env. env.set only runs inside an authenticated app handler (the project's own deployed code), never from agent context. Build-time defaults go in hatchable.toml via [[secret]] with a default; pasted values go through the setup gate; runtime-computed values (OAuth, batch imports) use env.set from app code.
ai import { ai } from 'hatchable'
Provider-agnostic LLM access. Provider-family aliases, provider-prefixed names, and raw model ids all resolve to the same call shape. The gateway routes to whatever provider the project (or end-user) has a key for. Vercel-AI-SDK-shaped — same input shape, same return fields.
ai.generateText resolves keys server-side via the gateway; the raw provider key (the sk-ant-… / sk-… string) never enters the V8 isolate. There is no process.env.ANTHROPIC_API_KEY for user code to read — the [ai] capability block doesn't take an expose field, and the gateway never surfaces raw provider keys through the SDK. See SDK-only keys.prompt (single user message) OR messages (full array). With tools + maxSteps > 1, runs the agent loop for you.generateText; yields normalized { type, ... } events (token, tool_call_start, tool_call_complete, final) across providers.generateText doesn't cover.ai.fetch — escape hatch for any provider endpoint
When you need an endpoint generateText / streamText / embed doesn't cover — image generation, audio, files, batches, prompt-caching tuning, anything new — call ai.fetch. The gateway resolves the same [ai] capability key, injects auth, forwards the request, and logs the call to ai_raw_calls. Your code never sees the key.
// Generate an image (Gemini) const r = await ai.fetch({ provider: 'google', path: '/v1beta/models/gemini-2.5-flash-image-preview:generateContent', body: { contents: [{ parts: [{ text: prompt }] }], generationConfig: { responseModalities: ['IMAGE'] }, }, purpose: 'generate-image', }); const data = await r.json(); const inline = data.candidates[0].content.parts.find(p => p.inlineData).inlineData; // inline.data is the base64-encoded image. Stash in storage, return URL, etc.
Method gating: GET / POST work without ceremony. PUT / DELETE / PATCH require dangerous: true on the call — catches accidental destructive calls.
Two body shapes: pass body for JSON, or pass fields + files for multipart uploads (OpenAI Files API, Whisper, DALL-E edits, Anthropic Files API). The gateway assembles the multipart envelope server-side. files[i].data accepts Uint8Array, ArrayBuffer, or a base64 string. 50 MB total cap per call. Streaming responses still pending. Full skill: use a provider directly.
Two input shapes
// Single-user-message shortcut — most common case. const { text } = await ai.generateText({ model: 'haiku', prompt: 'Summarize this article: ...', system: 'Be concise.', purpose: 'summarize', // optional — auto-logs to llm_calls when set }); // Full multi-turn — pass when you have a conversation history. const { text } = await ai.generateText({ model: 'sonnet', messages: [ { role: 'user', content: 'Hi' }, { role: 'assistant', content: 'Hello!' }, { role: 'user', content: 'How are you?' }, ], });
Pass prompt OR messages, not both. Errors out otherwise.
Model resolution — three forms
// 1. Provider-family alias — RECOMMENDED. One mapping per branded // line; the platform updates the underlying model when a new // generation ships. Templates stay portable. await ai.generateText({ model: 'sonnet', prompt }); // Anthropic mid-tier (Sonnet) await ai.generateText({ model: 'haiku', prompt }); // Anthropic small/fast (Haiku) await ai.generateText({ model: 'opus', prompt }); // Anthropic strongest (Opus) await ai.generateText({ model: 'gpt', prompt }); // OpenAI general (GPT-4o) await ai.generateText({ model: 'gpt-mini', prompt }); // OpenAI small/fast await ai.generateText({ model: 'gemini', prompt }); // Google small/fast (default — Gemini Flash) await ai.generateText({ model: 'gemini-pro', prompt }); // Google flagship (Gemini Pro) // 2. Provider-prefixed — pin to a specific provider. Use when you // explicitly compare providers, or rely on a model's quirks. await ai.generateText({ model: 'anthropic.sonnet', prompt }); await ai.generateText({ model: 'openai.gpt', prompt }); await ai.generateText({ model: 'google.gemini', prompt }); // 3. Raw provider model id — passes through verbatim, locks you // to that exact version. Avoid unless you have a specific reason. await ai.generateText({ model: 'claude-sonnet-4-5-20250929', prompt }); // 4. No model field — uses AI_DEFAULT_MODEL configured in Setup, // or falls through to the family alias of whichever provider // the user has a key for. await ai.generateText({ prompt });
Tool calling — return tool calls for the caller to handle
const { toolCalls } = await ai.generateText({ model: 'sonnet', prompt: "What's the weather in Paris?", tools: { get_weather: { description: 'Returns current weather for a city', inputSchema: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'] }, }, }, }); for (const tc of toolCalls) { // tc = { id, name: 'get_weather', input: { city: 'Paris' } } // Run the tool, push the result back if you want a continuation. }
Auto-loop — runtime drives tool calls until the model is done
// Add `execute` functions and set maxSteps > 1. const { text, steps } = await ai.generateText({ model: 'sonnet', prompt: "What's the weather in SF and Tokyo?", tools: { get_weather: { description: 'Returns current weather for a city', inputSchema: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'] }, execute: async ({ city }) => { const r = await fetch(`https://api.weather.com/...?q=${city}`); return await r.json(); }, }, }, maxSteps: 10, purpose: 'weather', }); // text = "It's 65°F in SF and 50°F in Tokyo..." // steps = full per-step trace for observability
Tracking calls per end-user (multi-tenant analytics)
const user = await auth.getUser(req); await ai.generateText({ model: 'sonnet', prompt, userId: user.id, // labels the llm_calls row for per-user usage / billing asUser: user.id, // alias for userId (kept for back-compat). Both run against // the project owner's key — SDK calls don't switch keys per user. purpose: 'chat', });
Streaming
const stream = await ai.streamText({ model: 'sonnet', prompt, tools, // optional; same shape as generateText maxSteps: 10, // optional; agent loop in stream form purpose: 'chat-stream', }); for await (const ev of stream) { // ev.type: 'token' | 'tool_call_start' | 'tool_call_delta' | // 'tool_call_complete' | 'final' if (ev.type === 'token') res.write(ev.text); }
See Secrets architecture for the full provider catalog, tier-to-model mapping, and the gateway resolution flow.
scheduler import { scheduler } from 'hatchable'
Cron-style recurring jobs and one-shot future invocations. Both end up calling one of your api/* routes.
when is a 5-field cron string OR a Date / ISO timestamp. opts.payload goes to req.body; opts.name for idempotent arms.// Recurring — every hour await scheduler.at("0 * * * *", "/api/nightly-report"); // One-shot at a specific moment await scheduler.at("2026-05-01T07:00:00Z", "/api/book", { payload: { missionId: 42 }, }); // Idempotent named arm — repeated calls update in place await scheduler.at("0 9 * * *", "/api/daily-digest", { name: "daily-digest" });
hatchable.toml via [[cron]] blocks; same effect, lives in source. See the config reference.email import { email } from 'hatchable'
Transactional email. Routed through the platform's SMTP — no provider account, no API keys, no DNS. Just call email.send(...).
mcp tools mcp/<name>.js + [mcp] enabled = true
Expose your project's logic to AI clients (Claude, ChatGPT, Cursor) via the Model Context Protocol. The user authorizes once via OAuth on hatchable.com; their AI client then calls your tools directly. Works on private/personal projects — visibility doesn't matter, the OAuth grant is the access check.
Two pieces: opt in via hatchable.toml, then add one file per tool.
# hatchable.toml [mcp] enabled = true
// mcp/list_todos.js — filename MUST equal the exported `name` field. export default { name: 'list_todos', description: 'List todos, optionally filtered by status.', inputSchema: { type: 'object', properties: { status: { type: 'string', enum: ['active', 'done'] } }, }, async handler(args, ctx) { const { rows } = await ctx.db.query( 'SELECT id, title, status FROM todos WHERE ($1::text IS NULL OR status = $1)', [args.status ?? null], ); return { todos: rows }; }, };
The handler signature is (args, ctx), not (req, res). ctx exposes the full SDK — same db / ai / email / storage / scheduler / config / knowledge / browser / images / tasks that api/*.js handlers get. Return any JSON-serializable value; throw to surface an error.
The MCP endpoint lives at https://<slug>.hatchable.site/mcp. The OAuth flow lives at https://hatchable.com/oauth/<slug>/... and is discovered automatically by any compliant MCP client. Tokens are project-scoped (a grant for project A can't be replayed at project B even on the same account) and revocable from the console's MCP tab.
mcp/expose-an-mcp-tool.browser import { browser } from 'hatchable'
Managed Chromium pool. Render a page, screenshot it, generate a PDF, or open a stateful Playwright-shaped session for scraping + multi-step flows.
{ format: 'jpeg', quality }) of the rendered page. Pair with storage.put + storage.url to serve as OG images.{ format: 'Letter', printBackground: true } etc.page. Navigate, fill forms, click, extract.