hatchable.toml
The complete reference. Every block, every field. hatchable.toml is read at deploy time by DeployService; values surface to runtime as project metadata, auth config, cron schedules, fork-question prompts, and secret manifests.
hatchable.toml sits at the root of every project. It's optional — a project without one runs fine — but it's how you opt into auth, schedule cron jobs, declare fork inputs, and configure secrets. The parser supports a small subset of TOML (described below) sufficient for everything templates need.
# Minimal example name = "My App" tagline = "A short blurb" description = "Longer description for the gallery card" category = "Productivity" tags = ["productivity", "ai"] [ai] required = true providers = ["anthropic", "openai"]
Project metadata
Top-level keys describe the project for the platform's gallery, deploy preview, and admin surfaces.
| Field | Type | Description |
|---|---|---|
| name | string | Display name. Shown in the gallery card, header of the auto-generated setup gate, and Hatchable console. |
| tagline | string | One-line summary. Up to ~80 chars. Shown under the name on gallery cards. |
| description | string | Longer description (1-3 paragraphs). Up to ~2000 chars. Shown on the template detail page. |
| category | string | Free-text category for gallery filtering (e.g. "Education", "Marketing", "Developer Tools"). |
| tags | list | Free-text tags for search/filter. Lowercase, hyphen-separated. |
name = "Worksheet Studio" tagline = "AI worksheets, the way teachers want them" description = "AI-powered worksheet and quiz generator for K–12 teachers. Multiple choice, short answer, fill-in-the-blank, true/false, reading comprehension." category = "Education" tags = ["education", "teaching", "worksheets", "k12"]
export const access (see the SDK reference) and read the caller from req.member. The /api/auth/* namespace is reserved unconditionally — files under api/auth/ are rejected at deploy. Reserved table names (users, sessions, accounts, verifications, passkeys) can't be created or dropped in migrations.
[[cron]]
Declarative recurring jobs. Equivalent to calling scheduler.at() with a cron string at deploy time, but lives in source instead of code. Each block targets one route. Multiple [[cron]] blocks can target the same route with different schedules.
| Field | Type | Description |
|---|---|---|
| route required | string | API route to invoke. Must match a deployed handler. Path-relative, starts with /. |
| schedule required | string | 5-field cron string. Minimum granularity is 1 hour for free-tier projects. |
| name | string | Stable identifier so re-deploys update in place rather than creating duplicates. |
| payload | table | JSON-style table sent as the request body when the cron fires. |
[[cron]] route = "/api/jobs/daily-digest" schedule = "0 9 * * *" # every day at 09:00 UTC name = "daily-digest" [[cron]] route = "/api/jobs/weekly-summary" schedule = "0 13 * * 1" # Monday at 13:00 UTC name = "weekly-summary" payload = { digest_type = "executive" }
[[fork.questions]] legacy-ish
Pre-secrets-architecture pattern: prompts shown at fork time to populate non-secret config. Still useful for things that aren't credentials — brand name, default email recipient, time zone, etc. For API keys and tokens, use [[secret]] instead, which integrates with the platform's gate + storage tiers.
| Field | Type | Description |
|---|---|---|
| key required | string | Env var name to write. Uppercased automatically. |
| label required | string | Human-readable label for the prompt UI. |
| default | string | Pre-filled value. |
| type | string | "string" (default) or "select". |
| options | list | For type = "select": allowed values. |
| required | bool | Default false. If true, fork can't proceed without a value. |
[[fork.questions]] key = "BRAND_NAME" label = "What's your brand name?" required = true [[fork.questions]] key = "DEFAULT_TIMEZONE" label = "Default timezone" default = "America/Los_Angeles" type = "select" options = ["America/Los_Angeles", "America/New_York", "Europe/London", "UTC"]
[[secret]]
The full secrets manifest. Templates declare every API key, token, or sensitive value the project needs; the platform handles storage, the setup gate, validation, and gateway-mediated access. See Secrets architecture for the conceptual overview.
Common fields
| Field | Type | Description |
|---|---|---|
| kind | string | One of raw (default — single env var) · ai (any AI/LLM provider; auto-expands or accepts pin = "<provider>" for one specific provider). |
| tenancy | string | One of project (default) · account. Controls storage table and gate behavior. See secret tenancies. |
| required | bool | Default false. If true, the platform setup gate fires for the project owner when this isn't set. |
| provider | string | Catalog provider name (anthropic, openai, stripe, etc.). Required at tenancy = "account". |
| description | string | Shown on the gate page card. |
| group | string | UI grouping for related secrets (e.g. Stripe's secret + publishable + webhook). |
| unlocks | list | Free-form tags surfaced in catalog UI ("setting this unlocks payments / vision / …"). |
Raw secrets — kind = "raw" (the default)
For a single concrete env-var key. Use this for non-LLM providers (Stripe, GitHub, Twilio, custom) or when you want to pin to one specific LLM provider.
| Field | Type | Description |
|---|---|---|
| key required | string | Env var name. Uppercased automatically. Must not be platform-reserved (HATCHABLE_*, NODE_ENV, PATH, …). |
| expose | bool | Default false. When true, the raw value is injected into the isolate's process.env. Only allowed on tenancy = "project" — see security model. |
[[secret]] key = "STRIPE_SECRET_KEY" provider = "stripe" tenancy = "project" required = true group = "stripe" description = "Used by `payments.*` SDK to charge customers."
AI / LLM keys live in the [ai] capability block, not [[secret]]
The legacy form [[secret]] kind = "ai" is no longer accepted. SDK capability credentials (AI keys, plus the email / payments / SMS keys when those SDK helpers ship) live in a dedicated single-table block — the agent declares "I use this capability" and the platform handles provider routing, account-scoped storage, and the picker UI.
# Default — buyer picks any provider they have a key for [ai] required = true description = "AI summarization for daily digests." # Narrow to specific providers (only when the app legitimately can't accept all) [ai] required = true providers = ["anthropic"] # Pin hard to one provider (Claude-only feature, Gemini-only grounding, etc.) [ai] required = true pin = "google"
The [ai] block has no key or tenancy field — env-var name comes from the provider catalog (ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY) and storage is always account-scoped (the buyer connects once, all their projects share it). For multi-provider apps where you specifically want all three keys pasted (e.g. an LLM-comparison app that calls each side-by-side), declare providers = ["anthropic", "openai", "google"]; the picker still asks for one key but your runtime can address each via the model alias.
Validation rules (deploy-time, hard-fail)
[[secret]]entries:tenancymust be one ofproject | account;kindfield is rejected (legacy carrier for AI keys);expose = trueonly legal at project tenancy;keymust not be platform-reserved and must be unique.[ai]:pin(if given) must be a catalog LLM provider; every name inproviders(if given) must be a catalog LLM provider.[[api]]:namemust match^[a-z][a-z0-9_]{0,63}$;authmust beapi_keyoroauth2;base_urlmust be https;tenancyfield is rejected (legacy carrier); theAuthorizationheader can't be set via[api.headers](the proxy sets it).
Provider catalog
Allowed values for provider:
| Provider | SDK helper | Primary env key |
|---|---|---|
| anthropic | ai | ANTHROPIC_API_KEY |
| openai | ai | OPENAI_API_KEY |
| ai | GOOGLE_API_KEY | |
| stripe | (planned) | STRIPE_SECRET_KEY (+ publishable + webhook) |
| twilio | (planned) | TWILIO_ACCOUNT_SID + TWILIO_AUTH_TOKEN |
| slack | (planned) | SLACK_BOT_TOKEN + SLACK_SIGNING_SECRET |
| github | (planned) | GITHUB_TOKEN |
| custom | n/a | declared by the template |
Providers without an sdk_helper can only be declared at tenancy = "project". Once their helpers ship, they become eligible for shared tiers.
[[knowledge]]
Declare the knowledge bases your project expects to query at runtime. Each block becomes a card on the console's Knowledge tab where the owner can populate it (paste text, upload .txt/.md files), or your app code can fill it in via knowledge.add(). Both paths write to the same _hv_<name> pgvector table on this project's own Postgres.
[[knowledge]] name = "company-docs" # slug — matches the SDK identifier description = "Product manuals + FAQ for the support agent." dimensions = 1536 # default — matches OpenAI text-embedding-3-small metric = "cosine" # cosine | l2 | ip (default cosine) required = true # gate the console with a "needs population" banner populated_by = "console" # console | app (default 'console')
populated_by = "console" means the owner uploads content via the Knowledge tab — the typical case for static, builder-curated content (docs, FAQ, manuals). Required-and-console entries that haven't been populated yet show as a yellow banner at the top of the Knowledge tab with a one-click "Populate" button.
populated_by = "app" means the project's own code calls knowledge.add() at runtime to fill the collection — typical for user-generated content (notes, tickets, products). The platform doesn't gate on these because the gate would deadlock on first deploy.
Field reference
| Field | Type | Notes |
|---|---|---|
name | string, required | Lowercase slug matching ^[a-z][a-z0-9_]{0,62}$ — same identifier you pass to knowledge.base(name) in code. Underlying pgvector table is _hv_<name>. |
description | string | Shown on the console card so the owner knows what to upload. |
dimensions | integer (1..16000) | Default 1536. Match your embedding model. Mismatched dimensions fail at query time and can't be changed without re-embedding everything. |
metric | cosine | l2 | ip | Default cosine. Hardcoded into the index — pick once. |
required | bool | Default true for console-populated, false for app-populated. When true and unpopulated, the console renders a "needs action" banner at the top of the Knowledge tab. |
populated_by | console | app | Default console. Controls who's expected to fill it. |
The console persists everything declared here on every successful deploy in projects.knowledge_manifest. The manifest is the canonical "what does this project need?" list — see the knowledge SDK reference for how the agent's code reads from these bases.
[[config]]
Declare buyer-editable, non-sensitive settings — the kind of thing the buyer wants to change without redeploying: a display name, a brand color, a list of links, a feature flag. Each block becomes a field on the console's Configure tab where the buyer edits the value. The agent's code reads with config.get(key) at request time; saves are live with no redeploy.
Distinct from [[secret]]: secrets are sensitive (API keys, credentials), gate-pasted through /__hatchable/setup, and never visible in AGENTS.md. Config is buyer-editable through the Configure tab and may be public-facing — bio text on a link-in-bio page, a brand color, a list of social links. Use [[config]] for "what the buyer wants to customize" and [[secret]] for "what I need to keep out of the agent's hands."
[[config]] key = "display_name" type = "string" label = "Display name" help = "Shown in the header and OG title." default = "Welcome" required = true [[config]] key = "accent_color" type = "color" label = "Accent color" default = "#f5b840" [[config]] key = "links" type = "list" label = "Featured links" help = "Up to 8 links shown on the home page." fields = [{ key = "title", type = "string", label = "Title" }, { key = "url", type = "url", label = "URL" }]
Field reference
| Field | Type | Notes |
|---|---|---|
key | string, required | Lowercase snake_case — same identifier you pass to config.get(key) in code. Distinct namespace from [[secret]] keys (which are UPPER_SNAKE_CASE). |
type | string | One of: string · text (multi-line) · number · boolean · email · url · color · image · select · list. Controls the form input the console renders and how the value round-trips through JSON storage. |
label | string | Human-readable label rendered next to the input. |
help | string | Optional hint shown below the input. |
default | any | The value config.get(key) returns when the buyer hasn't saved anything. Type must match type. Pre-populates the form input. |
required | bool | Default false. When true, the Configure tab marks the field as required; the value is still default until the buyer saves something else. |
options | array | type = "select" only. Each entry is { value, label } for the dropdown. |
fields | array | type = "list" only. An array of per-column field definitions { key, type, label } (same fields as a top-level entry); the Configure tab renders one row of these inputs per list item. Optional min_items / max_items / item_label alongside. |
Values live in the project_config table as JSON, so scalars, lists, and objects round-trip identically — no per-type unwrapping in the SDK. Buyer edits land via Console/ProjectConfigController and are visible to the next request (no caching, no redeploy). The schema (every [[config]] block) is persisted in projects.config_manifest on deploy so the gateway resolver can match keys without re-parsing TOML on each read.
See the config SDK reference for the runtime read API. Skills: config/declare-configurable-fields (this page) and config/read-config-values (the runtime side).
TOML support
Hatchable's TOML parser supports the surface templates actually use. It is not a full TOML 1.0 parser. If you need something exotic, the deploy will reject it instead of mis-parsing.
Supported
[section]— table headers[[section]]— arrays of tables (used by[[cron]],[[secret]],[[fork.questions]])[section.subsection]— dotted table headerskey = "value"— strings (double-quoted)key = 42— integerskey = true/false— booleanskey = ["a", "b"]— arrays of primitiveskey = { sub = "value" }— inline tables (used in[[cron]] payload)# comment— line comments
Not supported
- Multi-line strings (triple-quoted) — keep values on one line
- Floats — integers only
- Single-quoted strings — use double quotes
- Nested arrays of arrays
Deploy-time validation
When you run deploy, DeployService reads hatchable.toml and runs these checks before anything else happens. A validation failure aborts the deploy with a clear error message — no half-deployed state.
- TOML parse. Syntax errors fail with line numbers.
- Reserved tables. No migration may
CREATE TABLEorDROP TABLEon reserved platform table names (users,sessions,accounts,verifications,passkeys). - Reserved route namespace. No
api/auth/*file may exist — the namespace is reserved by the platform unconditionally. - Secrets manifest. Each
[[secret]]entry validated against the rules above. The most common failures:tenancy = "account"withexpose = true, custom provider at shared tier. - Cron routes. Each
[[cron]] routemust match a deployed function. Schedules are cron-validated.
Any failure surfaces in the deploy output and on the deploy preview page in the Hatchable console. The agent can dry-run validation before pushing files via the MCP tool dry_run_deploy.
Complete examples
Personal AI tool — logical alias, single user
name = "Worksheet Studio" tagline = "AI worksheets, the way teachers want them" description = "Generate K-12 worksheets across 6 question types..." category = "Education" tags = ["education", "k12", "ai"] [ai] required = true providers = ["anthropic", "openai", "google"] description = "Pick any AI provider. Templates use the 'sonnet' alias by default."
SaaS with payments — owner-paid Stripe + account-scoped AI
name = "Worksheets for Schools" tagline = "AI worksheets, billed to ACME" [ai] required = true providers = ["anthropic", "openai", "google"] description = "AI worksheet generation. The buyer connects one key at the account level." [[secret]] key = "STRIPE_SECRET_KEY" provider = "stripe" tenancy = "project" required = true group = "stripe" [[secret]] key = "STRIPE_PUBLISHABLE_KEY" provider = "stripe" tenancy = "project" required = true group = "stripe"
LLM monitoring tool — multi-provider comparison
name = "LLM Brand Monitor" # Declare [ai] once and list every provider you want to compare. The buyer # connects whichever they have a key for; the 'sonnet' / 'gpt' / 'gemini' # aliases route to whichever is set — one key for low cost, all three for # full brand coverage across LLMs. [ai] required = true providers = ["anthropic", "openai", "google"] description = "Per-LLM brand monitoring — connect one or more providers." [[cron]] route = "/api/jobs/daily-run" schedule = "0 6 * * *" name = "daily-monitor" [[cron]] route = "/api/jobs/weekly-digest" schedule = "0 13 * * 1" name = "weekly-digest"
Custom integration — raw key access via expose
name = "Acme Internal Reporter" [[secret]] key = "ACME_INTERNAL_API_KEY" provider = "custom" tenancy = "project" required = true expose = true # raw | aiaccess — needed for custom HTTP calls description = "Internal API for the reporter — no SDK helper available."
Then in handler code:
const resp = await fetch('https://acme-internal.example.com/api', { headers: { 'X-Api-Key': process.env.ACME_INTERNAL_API_KEY }, });
The owner accepts the risk that process.env is readable to any code in their project; that's why expose is forbidden at shared tiers.