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 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.

Identify the current request — Hatchable account or app-user session.
any handler that needs to know who's calling. auth.getUser(req) when an anonymous fallback is fine; auth.requireUser(req, res) to gate the handler with a 401.
Per-user data filtering · Owner-only admin endpoints · "Whose journal entry is this?"
Read declared values — config and secrets alike, across every tenancy.
app needs a Stripe secret, a Slack URL, a per-user provider key. 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 · Each user's Notion token in a multi-tenant fork

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)));

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.

auth.getUser(req) → { id, email, name } | null
Resolves the per-app user from the project's users table via the session cookie, or null.
auth.requireUser(req, res) → { id, email, name } | null
Same as 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).

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

// 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 });
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, 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');
}
User-tier writes don't go through 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.
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.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.

scheduler.at(when, route, opts?) → task
when is a 5-field cron string OR a Date / ISO timestamp. opts.payload goes to req.body; opts.name for idempotent arms.
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.