Documentation · SDK

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';
Logical model names: when calling 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.

A real Postgres database for your project's structured data.
user mentions records, lists, accounts, history, anything that needs to be remembered between requests.
"Track my reading list" · "Customer support tickets" · "Daily journal entries"
Embedding storage + similarity search. Doubles as the substrate for "remember things across sessions" — index by user_id, query by meaning.
user wants "find things like X", RAG over their documents, or a chat agent that recalls past conversations.
Search past meeting notes by meaning · Q&A over uploaded PDFs · Journal companion that remembers prior goals
File uploads & downloads via a per-project CDN bucket.
user mentions files, photos, PDFs, attachments, generated documents.
Profile photo upload · PDF receipt attachments · Generated invoice PDFs · CSV imports

Identity & secrets

Who's calling, what keys they brought. Route access is declared per file and enforced at the platform edge; config is the single read API for any declared [[secret]] or [[config]] value, walking project → account → manifest default server-side.

Gate who can call a route, and read who's calling.
declare export const access per file; the edge authenticates the caller and enforces it before your handler runs. Read identity from req.member{ id, handle, … } or null.
Per-member data filtering · Owner-only admin endpoints · "Whose journal entry is this?"
Read declared values — config and secrets alike.
app needs a Stripe secret, a Slack URL. Declare via [[secret]] in hatchable.toml; read via config.get('KEY'). AI keys are never readable directly — ai.generateText resolves them server-side.
Stripe webhook secret · Slack URL · HMAC signing key

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.

LLM calls with logical-name model selection — single calls or hand-rolled multi-step loops.
user wants a summary, a classification, a generated paragraph, a structured extraction, or a multi-step agent. Default to provider-family aliases ('sonnet', 'gpt') so the app works on whichever provider key the user has.
Meeting-notes summarizer · Email-tone rewriter · Classify support tickets · Multi-step research assistant (use 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().

Cron-style recurring runs + one-shot future invocations + fire-and-forget background work (use '+0s').
user mentions "every day at 9am", "next Tuesday", "remind me in an hour", or anything slow that shouldn't block the request.
Daily summary email · Hourly stock-price fetch · One-shot launch announcement · Generate PDF after upload (scheduler.at('+0s', '/api/render-pdf', { payload }))

Communication

How the app reaches the world outside its own UI — humans (email) and other AIs (MCP).

Send transactional email — Hatchable handles SMTP, deliverability, bounces.
welcome emails, password resets, notifications, daily digests, anything not a marketing blast.
"Email me when X happens" · Welcome flow · Weekly summary · Password reset links

Web outside

Reaching out to or processing things from the broader internet — visiting pages, transforming uploads.

Headless Chromium — visit a page, extract data, take a screenshot, fill a form.
no API exists for the data you need; user wants scraping, screenshotting, or form automation.
Daily scrape of a public dashboard · Generate OG images for share cards · Test a competitor's landing page

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.

db.query(sql, params?) → { rows, rowCount }
Run one parameterized statement. Bindings use $1, $2, … positional placeholders.
db.transaction([{ sql, params }, …]) → { results }
Run an array inside 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. Schema lives in 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.

knowledge.base(name, opts) → handle
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.
handle.add(items, opts?)
Items: { id, text, metadata? }. SDK embeds the text and upserts. Idempotent by id — re-adding updates.
handle.addByVector(items)
Power-user write path when you already have embeddings. Items: { id, embedding, metadata? }.
handle.search(query, opts?) → results
Text query. SDK embeds for you. opts.topK (default 10), opts.filter for metadata-equality. Returns [{ id, similarity, metadata }] with metadata._text set to the original.
handle.searchByVector(embedding, opts?) → results
When you've embedded the query elsewhere.
handle.remove(ids)
Delete items by id.
handle.table() → { name, toLiteral }
Escape hatch — the underlying _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.

storage.put(key, buffer, contentType?, { metadata? }?) → url
Stores bytes; returns a presigned URL valid for ~1h. 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.
storage.url(key, { ttl? }) → url
Mint a fresh presigned URL for an existing key. ttl is seconds (default 3600, max 604800 = 7 days). Call before each browser-facing render for long-lived references.
storage.get(key) → { buffer, contentType }
Fetch the bytes + Content-Type in-handler. Use for streaming through auth-gated routes when even a signed URL would leak too much.
storage.list({ prefix?, cursor?, limit?, include_metadata? }) → { items, next_cursor }
Enumerate objects under a prefix in the project's namespace. Each item: { 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.
storage.head(key) → { size, content_type, last_modified, metadata }
Read size + content-type + last-modified + user metadata for one object without fetching its bytes. Use for lightbox details, on-demand metadata reads, existence checks.
storage.del(key)
Idempotent — deleting a missing key is not an error.
// 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)));

Route access & caller identity

You never build a login form, session, or users table — the platform owns identity. Every route declares export const access, and the platform edge authenticates the caller and enforces the gate before your handler runs. Inside the handler you read who's calling; you don't re-check it for authorization, the edge already did.

export const access = 'public' | 'member' | 'admin' | 'scheduler'
Required on every api/*.js and mcp/*.js file. 'public' = anyone (anonymous OK); 'member' = any signed-in collaborator; 'admin' = owner/admin only; 'scheduler' = platform scheduler only.
req.member → { id, handle, email?, display_name?, avatar_url?, role? } | null
The platform-resolved signed-in caller, set server-side by the edge (on gated routes, and on public routes when a signed-in collaborator calls). email is the caller's Hatchable account email, present for any authenticated caller. null for anonymous visitors. Use it to scope rows / stamp attribution.
req.rawBody → string | null
The raw request body, verbatim — the exact bytes received. Use for signature verification (webhooks/JWTs), where re-serializing the parsed req.body would break the signature. null for multipart/empty bodies. See the webhooks module.
// Require a signed-in collaborator, then scope to them:
export const access = 'member';

export default async function (req, res) {
  const member = req.member;   // guaranteed present on a gated route; the edge verified it
  const { rows } = await db.query('SELECT * FROM notes WHERE author_id = $1', [member.id]);
  res.json(rows);
}

On 'public' routes req.member may be null — treat that as an anonymous visitor and vary the response, don't throw. The platform sets req.member server-side (the browser can't forge it), so it's safe to trust. The /api/auth/* namespace is reserved by the platform — files under api/auth/ are rejected at deploy. 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. Distinct from access: 'member': a member gate admits any signed-in collaborator, while admin answers the narrower question "is this specifically the owner/operator?"

admin.check(req) → boolean
True iff the request carries a valid hatchable_session cookie for an account that owns this project.
admin.require(req, res) → boolean
Same as 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.
admin.profile(req) → { handle, email } | null
Returns the owner's account profile, or null if the request isn't from the owner. For greeting copy / display.
// 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.get(key, opts?) → any | null
Resolves the key against [[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.
config.expose(key, opts?) → string | null
Same as 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

// The [ai] capability is account-scoped — the buyer's own Anthropic /
// OpenAI / Google key is used regardless of which collaborator is signed in.
// asUser on ai.generateText is for usage-tracking (llm_calls.user_id) only.
const reply = await ai.generateText({ asUser: member.id, model, prompt });
Same call, two declaration paths. Whether a key resolves from [[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.
SetupRequired — when 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.

env.set(key, value, opts?)
Project-tier write. opts.isSecret (default true) controls whether the value is masked in audit logs / dashboard listings. Key is normalized to UPPER_SNAKE_CASE.
env.setForAccount(key, value, opts?)
Account-tier write — cascades across every project on the owner's account. Same options as set.
env.unset(key) · env.unsetForAccount(key)
Clear a value at the corresponding tier. No-op if the key wasn't set.
// Stripe Connect callback — capture the connected-account id at runtime
import { env } from 'hatchable';

export const access = 'admin';   // owner-only config flow

export default async function (req, res) {
  const { account_id } = await exchangeCodeForAccount(req.query.code);
  await env.set('STRIPE_CONNECTED_ACCOUNT', account_id);

  res.redirect('/dashboard?connected=1');
}
SDK capabilities are account-scoped. [ai] and other managed capabilities always use the buyer's own key, regardless of which collaborator is signed in. For third-party OAuth against a known provider (Notion, GitHub, etc.), declare [[api]] and let the platform run the connect flow — see the secrets reference.
Agents don't write secret values. The MCP toolset has no 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 keys are SDK-only — always. 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.
ai.generateText(opts) → { text, toolCalls, finishReason, usage, model, steps }
Pass either prompt (single user message) OR messages (full array). With tools + maxSteps > 1, runs the agent loop for you.
ai.streamText(opts) → AsyncIterator
Same options as generateText; yields normalized { type, ... } events (token, tool_call_start, tool_call_complete, final) across providers.
ai.embed(input, opts?) → { embedding, usage, model }
Single string or array; returns one or many vectors.
ai.fetch(opts) → { ok, status, headers, json(), text(), arrayBuffer() }
Provider-API escape hatch — call any path on Anthropic / OpenAI / Google with BYOK auth injected by the gateway. Use for image gen, audio, files, batches, prompt caching, anything generateText doesn't cover.
ai.usage({ window? }) → totals + breakdowns
Rolls every priced ai.* call into total spend plus by-model / by-operation / by-purpose / by-user breakdowns and a time series, scoped to this project. Drop behind admin.require() for a spend page.

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 caller (usage analytics)

const member = req.member;
await ai.generateText({
  model: 'sonnet',
  prompt,
  userId: member.id,    // labels the llm_calls row for per-caller usage / billing
  asUser: member.id,    // alias for userId (kept for back-compat). Both run against
                         // the project owner's key — SDK calls don't switch keys per caller.
  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.

scheduler.at(when, route, opts?) → task
when is a 5-field cron string OR a Date / ISO timestamp. A past/now timestamp is clamped to now and fires on the next minute tick (no longer an error). opts.payload goes to req.body; opts.name for idempotent arms.
scheduler.now(route, payload?) → task
Fire route immediately on the queue (seconds latency, ~310s budget). Use for "do this slow thing off the request thread now" — faster than at(new Date(), …), which waits for the minute tick.
scheduler.cancel(taskId) → boolean
// 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" });
Declarative cron. You can also declare recurring jobs in 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(...).

email.send({ to, subject, html, text? })
Returns when accepted by the SMTP relay.

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.

Owner-mode only in v1. The OAuth grant is tied to your Hatchable account; the AI calls tools as you. Multi-tenant (per-end-user) MCP grants ship later. For the full pattern — three worked examples, the filename rule, revocation UX — see skill 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.

browser.html(url) → string
Fully-rendered HTML of the page (post-hydration). Use for SPAs whose initial HTML is empty.
browser.screenshot(url, opts?) → Uint8Array
PNG (default) or JPEG ({ format: 'jpeg', quality }) of the rendered page. Pair with storage.put + storage.url to serve as OG images.
browser.pdf(url, opts?) → Uint8Array
PDF buffer; { format: 'Letter', printBackground: true } etc.
browser.session(async page => …) → result
Stateful flow with a Playwright-style page. Navigate, fill forms, click, extract.

api import { api } from 'hatchable'

Call third-party HTTP APIs declared in [[api]] (Reddit, Notion, …). The platform runs the OAuth/key connect flow, refreshes credentials, and proxies the request with auth attached — your handler never touches the token. See the config reference for declaring an [[api]].

api.call(name, { method, path, query?, headers?, body?, asUser? }) → { status, body, headers, contentType }
body is parsed JSON when the upstream is JSON, else raw text. Throws an error with code: 'SetupRequired' when the buyer hasn't connected the API yet — route them to /__hatchable/setup.
api.<name>.get/post/put/patch/delete(path, opts?)
Convenience over api.call — e.g. api.reddit.get('/r/popular?limit=10'). Any name declared in [[api]] works.

webhooks import { webhooks } from 'hatchable'

Verify the signature on an inbound webhook. Provider-agnostic by design: the platform owns the security floor (verifies against the raw body, constant-time compare, optional replay window); you supply the provider's header / signed-payload shape from a recipe. See skill integrations/verify-a-webhook for Stripe / GitHub / Shopify / Slack recipes.

webhooks.verifyHmac({ raw, signature, secret, algorithm?, encoding?, timestamp?, tolerance? }) → boolean
raw must be req.rawBody (the exact signed bytes — not req.body). algorithm: 'sha256' (default) | 'sha1' | 'sha512'; encoding: 'hex' (default) | 'base64'. Pass the provider's signed timestamp (unix seconds) to enforce a replay window (tolerance default 300s; 0 disables). Constant-time compare; throws if raw is missing.
// Stripe: header is t=<ts>,v1=<hex>, signs `t.rawBody`
const p = Object.fromEntries(
  (req.headers['stripe-signature'] || '').split(',').map((kv) => kv.split('='))
);
const ok = await webhooks.verifyHmac({
  raw: `${p.t}.${req.rawBody}`, signature: p.v1, secret, timestamp: p.t,
});
if (!ok) return res.status(400).json({ error: 'Invalid signature.' });
Keyed crypto. Need the primitive directly? crypto.subtle.importKey/sign/verify (HMAC) and require('crypto').createHmac / createVerify (RSA/ECDSA verify with a PEM) are available, bridged to host Node crypto. No createSign (private-key signing).