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"] [auth] enabled = true [[secret]] kind = "ai" tenancy = "account" 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"]
[auth]
Turns on app-level user identity for the project. When enabled, the platform auto-mounts /api/auth/sign-up/email, /api/auth/sign-in/email, /api/auth/sign-out, and /api/auth/get-session, and creates the users + sessions tables in the project's own Postgres. auth.getUser(req) then returns the project-scoped end-user from the hatchable_app_session cookie.
When [auth] is omitted (the default), auth.getUser(req) returns null — the project has no app-level users. Platform access gating (private projects, share links, owner-only previews) is enforced at the platform edge, independent of this config.
| Field | Type | Description |
|---|---|---|
| enabled | bool | Default false. When true, the project owns its own users + sessions tables and serves auth routes on the project's host. |
| providers | list | Login providers to enable. Currently only "email" is wired through to the platform auth executor. Defaults to ["email"] when enabled = true. |
[auth] enabled = true providers = ["email"]
[auth] is enabled, the platform manages users, sessions, accounts, and verifications in your DB. Migrations that CREATE TABLE or DROP TABLE on those names are rejected at deploy. You can ALTER TABLE to add columns.
/api/auth/* namespace is reserved by the platform unconditionally — files under api/auth/ are rejected at deploy whether or not [auth] is enabled. Use a different prefix (/api/account/, /api/identity/) for project-owned auth code.
hatchable_app_session (HttpOnly, Secure, SameSite=Lax, no Domain attribute — 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.
[[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 · user. Controls storage table and gate behavior. See three tenancies. |
| required | bool | Default false. If true, the platform setup gate fires for the project owner when this isn't set; user-tier required secrets surface as 412 errors at request time. |
| provider | string | Catalog provider name (anthropic, openai, stripe, etc.). Required at tenancy = "account" | "user". |
| 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 | user;kindfield is rejected (legacy carrier for AI keys);tenancy = "user"requires[auth] enabled = true;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 — useper_user = truefor per-end-user OAuth); 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." item_schema = { title = "string", url = "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 · 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. |
item_schema | object | type = "list" only. Shape of each list item; the Configure tab renders one row of inputs per item. |
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.
- Auth providers. Each name in
[auth] providersmust be supported (currently onlyemail). - Reserved auth tables. If
[auth] enabled = true, no migration mayCREATE TABLEorDROP TABLEon the platform-managed names (users,sessions,accounts,verifications). - Auth route collision. No
api/auth/*file may exist (the namespace is reserved unconditionally — even with[auth]disabled). - Secrets manifest. Each
[[secret]]entry validated against the rules above. The most common failures:tenancy = "account"withexpose = true, custom provider at shared tier, user-tier without[auth]enabled. - 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"] [[secret]] kind = "ai" tenancy = "account" required = true providers = ["anthropic", "openai", "google"] description = "Pick any AI provider. Templates use the 'sonnet' alias by default."
Multi-tenant SaaS — owner pays Stripe, end-users BYOK their AI key
name = "Worksheets for Schools" tagline = "Per-teacher AI worksheets, billed to ACME" [auth] enabled = true providers = ["email", "google"] [[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" [[secret]] kind = "ai" tenancy = "user" required = true providers = ["anthropic", "openai", "google"] description = "Each teacher brings their own AI key — billing goes to them, not us."
LLM monitoring tool — multi-provider comparison
name = "LLM Brand Monitor" # Declare one [[secret]] per provider with its own pin. # The 'sonnet' / 'gpt' / etc. aliases route to whichever has a key set; # the user picks how many providers to configure (one for low-cost, three+ # for proper brand coverage across LLMs). [[secret]] kind = "ai" pin = "anthropic" tenancy = "account" required = true description = "Anthropic key — for the 'sonnet' alias." [[secret]] kind = "ai" pin = "openai" tenancy = "account" required = false description = "OpenAI key — for the 'gpt' alias. Optional but recommended for brand coverage." [[secret]] kind = "ai" pin = "google" tenancy = "account" required = false description = "Google key — for Gemini access. Optional but recommended for brand coverage." [[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.