Documentation · Reference

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.

FieldTypeDescription
namestringDisplay name. Shown in the gallery card, header of the auto-generated setup gate, and Hatchable console.
taglinestringOne-line summary. Up to ~80 chars. Shown under the name on gallery cards.
descriptionstringLonger description (1-3 paragraphs). Up to ~2000 chars. Shown on the template detail page.
categorystringFree-text category for gallery filtering (e.g. "Education", "Marketing", "Developer Tools").
tagslistFree-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.

FieldTypeDescription
enabledboolDefault false. When true, the project owns its own users + sessions tables and serves auth routes on the project's host.
providerslistLogin 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"]
Reserved tables. When [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.
Reserved routes. The /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.
Cookie scope + sliding sessions. The session cookie is 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.
Rate limits. Auth endpoints are rate-limited at the platform layer: signup 3/hour per IP (kills email enumeration), signin 10/min per IP plus 5/15min per (IP, email), signout 30/min per IP, get-session 120/min per IP. No code change needed.

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

FieldTypeDescription
route requiredstringAPI route to invoke. Must match a deployed handler. Path-relative, starts with /.
schedule requiredstring5-field cron string. Minimum granularity is 1 hour for free-tier projects.
namestringStable identifier so re-deploys update in place rather than creating duplicates.
payloadtableJSON-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.

FieldTypeDescription
key requiredstringEnv var name to write. Uppercased automatically.
label requiredstringHuman-readable label for the prompt UI.
defaultstringPre-filled value.
typestring"string" (default) or "select".
optionslistFor type = "select": allowed values.
requiredboolDefault 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

FieldTypeDescription
kindstringOne of raw (default — single env var) · ai (any AI/LLM provider; auto-expands or accepts pin = "<provider>" for one specific provider).
tenancystringOne of project (default) · account · user. Controls storage table and gate behavior. See three tenancies.
requiredboolDefault 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.
providerstringCatalog provider name (anthropic, openai, stripe, etc.). Required at tenancy = "account" | "user".
descriptionstringShown on the gate page card.
groupstringUI grouping for related secrets (e.g. Stripe's secret + publishable + webhook).
unlockslistFree-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.

FieldTypeDescription
key requiredstringEnv var name. Uppercased automatically. Must not be platform-reserved (HATCHABLE_*, NODE_ENV, PATH, …).
exposeboolDefault 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: tenancy must be one of project | user; kind field is rejected (legacy carrier for AI keys); tenancy = "user" requires [auth] enabled = true; expose = true only legal at project tenancy; key must not be platform-reserved and must be unique.
  • [ai]: pin (if given) must be a catalog LLM provider; every name in providers (if given) must be a catalog LLM provider.
  • [[api]]: name must match ^[a-z][a-z0-9_]{0,63}$; auth must be api_key or oauth2; base_url must be https; tenancy field is rejected (legacy carrier — use per_user = true for per-end-user OAuth); the Authorization header can't be set via [api.headers] (the proxy sets it).

Provider catalog

Allowed values for provider:

ProviderSDK helperPrimary env key
anthropicaiANTHROPIC_API_KEY
openaiaiOPENAI_API_KEY
googleaiGOOGLE_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
customn/adeclared 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

FieldTypeNotes
namestring, requiredLowercase 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>.
descriptionstringShown on the console card so the owner knows what to upload.
dimensionsinteger (1..16000)Default 1536. Match your embedding model. Mismatched dimensions fail at query time and can't be changed without re-embedding everything.
metriccosine | l2 | ipDefault cosine. Hardcoded into the index — pick once.
requiredboolDefault 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_byconsole | appDefault 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

FieldTypeNotes
keystring, requiredLowercase snake_case — same identifier you pass to config.get(key) in code. Distinct namespace from [[secret]] keys (which are UPPER_SNAKE_CASE).
typestringOne 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.
labelstringHuman-readable label rendered next to the input.
helpstringOptional hint shown below the input.
defaultanyThe value config.get(key) returns when the buyer hasn't saved anything. Type must match type. Pre-populates the form input.
requiredboolDefault false. When true, the Configure tab marks the field as required; the value is still default until the buyer saves something else.
optionsarraytype = "select" only. Each entry is { value, label } for the dropdown.
item_schemaobjecttype = "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 headers
  • key = "value" — strings (double-quoted)
  • key = 42 — integers
  • key = true/false — booleans
  • key = ["a", "b"] — arrays of primitives
  • key = { 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.

  1. TOML parse. Syntax errors fail with line numbers.
  2. Auth providers. Each name in [auth] providers must be supported (currently only email).
  3. Reserved auth tables. If [auth] enabled = true, no migration may CREATE TABLE or DROP TABLE on the platform-managed names (users, sessions, accounts, verifications).
  4. Auth route collision. No api/auth/* file may exist (the namespace is reserved unconditionally — even with [auth] disabled).
  5. Secrets manifest. Each [[secret]] entry validated against the rules above. The most common failures: tenancy = "account" with expose = true, custom provider at shared tier, user-tier without [auth] enabled.
  6. Cron routes. Each [[cron]] route must 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.