Developer docs  /  hatchable.toml
Documentation · Reference

hatchable.toml

The complete reference. Every block, every field. hatchable.toml is read at deploy time; 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.

FieldTypeDescription
namestringDisplay name. Shown in the gallery card, header of the auto-generated Setup page, 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"]
Identity is the platform's job. You don't build login forms or session handling. Gate routes with export const access (see the SDK reference) and read the caller from req.member; for apps with their own end users, declare [auth]. The /api/auth/* namespace is reserved unconditionally — files under api/auth/ are rejected at deploy. Reserved table names (users, sessions, accounts, verifications, passkeys, invited_users) 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, and each route carries exactly one schedule — if two blocks name the same route, the last one wins. To run the same work on two schedules, ship two route files with one [[cron]] block each.

FieldTypeDescription
path requiredstringAPI route to invoke. Path-relative; /api/ is prepended if absent. The key is path, not route — an entry written with route = is silently ignored and the job never fires.
schedule requiredstring5-field cron string. Minimum interval is 1 hour on every plan; anything more frequent is rejected at deploy.

The job fires as a POST with no body. Re-deploys reconcile by path, so re-deploying updates the existing schedule in place rather than creating a duplicate.

[[cron]]
path     = "/api/jobs/daily-digest"
schedule = "0 9 * * *"           # every day at 09:00 UTC

[[cron]]
path     = "/api/jobs/weekly-summary"
schedule = "0 13 * * 1"          # Monday at 13:00 UTC

[[fork.questions]]

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 Setup page + 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"]

[auth]

Turn on end-user accounts for your app — people who sign up on the app itself, a separate identity system from the collaborators who build it. The platform provides passwordless email sign-in, a hosted login page, session handling, and the auth.getUser / auth.requireUser SDK; you never build a login form or a users table.

[auth]
enabled = true
FieldTypeDescription
enabledboolDefault false. Turns on the end-user identity system and its reserved tables/routes.
providerslistSign-in methods. ["email"] (passwordless codes; the default when enabled) or ["email", "google"]. Declaring google never blocks a deploy — it reveals a card on the console Setup page, and the button appears once the owner completes it: a verified custom domain (Google's rule) plus their own Google OAuth client, so the consent screen shows their name and logo. Email stays mandatory.
signupstring"open" (default) — anyone can create an account. "invite" — sign-in is limited to existing users and invited emails. Pending invites are rows in the platform table invited_users: your admin routes INSERT INTO invited_users (email) VALUES (lower(...)), and the row is consumed when the account is created. There is no invite list in toml — email lists never belong in project files, where they'd leak into forks and exports.
hosted_loginboolDefault true. The platform serves a sign-in page and redirects signed-out users to it. Set false for SPAs with their own sign-in modal — gates then return JSON 401 instead of redirecting.
login_pathstringMove the hosted login page (and every redirect to it) to a custom path like "/signin". Not under /api/ or /__hatchable.

Handlers read the signed-in end user with await auth.getUser(req) or gate with await auth.requireUser(req, res); see the SDK reference and skill auth/enable-app-auth.

[[secret]]

The full secrets manifest. Templates declare every API key, token, or sensitive value the project needs; the platform handles storage, the Setup page, validation, and gateway-mediated access. See Secrets architecture for the conceptual overview.

Common fields

FieldTypeDescription
requiredboolDefault true. Every declared secret gates setup until it's satisfied — write required = false explicitly to opt out. Satisfied means resolvable: the owner pasted a project value, or granted the key from their account vault (see where values live).
allowedlistEnum constraint; renders a select in the override UI.
providerstringCatalog provider name (anthropic, openai, stripe, etc.). A paste-time hint: gets the buyer a proper form with format validation and a "get a key" link.
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 also injected into your app's process.env. This is enumerability, not access control — see the security model.
[[secret]]
key         = "STRIPE_SECRET_KEY"
provider    = "stripe"
required    = true
group       = "stripe"
description = "Charges customers. Your code calls Stripe's API with `fetch`."
Deprecated fields: tenancy is parsed and ignored — where a value comes from (project paste vs. vault grant) is decided by the owner in the console, never in toml. default on a [[secret]] still resolves for existing manifests, but a value with a safe default is a tunable — declare it as [[config]]. Both produce a dry-run deprecation warning.

AI / LLM keys live in the [ai] capability block, not [[secret]]

AI provider keys live in a dedicated single-table block. You declare "I use this capability" and the platform handles provider routing, account-scoped storage, and the picker UI. [ai] is currently the only capability block that does this: email.send needs no declaration, and there is no payments or sms SDK module. Keys for a service you call yourself belong in [[secret]] or [[api]].

# 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 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 — safe because calls are gateway-mediated, metered, and spend-capped; the raw key never enters app code). 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: there is no kind field and declaring one fails the deploy (AI keys go in [ai]); key must not be platform-reserved and must be unique. tenancy and default are deprecated (accepted with a dry-run warning; ignored / moved to [[config]] respectively).
  • [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 is optional and, when set, must be none, api_key, or oauth2 (omit it and the buyer picks on the Setup page); base_url is optional and must be https when present; there is no tenancy field on [[api]] and declaring one fails the deploy; [api.headers] can't set authorization, host, content-length, connection, cookie, or set-cookie (the proxy owns them).

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 with an sdk_helper are fully gateway-mediated (the raw key never enters app code). The rest are paste-time hints on a plain [[secret]].

[[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 project's owner or any collaborator with the admin role 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"     # lowercase, digits, underscores (no hyphens)
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 an owner or admin 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 whoever populates it 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."
fields      = [{ key = "title", type = "string", label = "Title" }, { key = "url", type = "url", label = "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 · image · 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.
fieldsarraytype = "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 through the console 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

Deploy-time validation

When you run deploy, the platform 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. Reserved tables. No migration may CREATE TABLE or DROP TABLE on reserved platform table names (users, sessions, accounts, verifications, passkeys).
  3. Reserved route namespace. No api/auth/* file may exist — the namespace is reserved by the platform unconditionally.
  4. Secrets manifest. Each [[secret]] entry validated against the rules above; deprecated fields (tenancy, default) surface dry-run warnings.
  5. Cron routes. Each [[cron]] path must match a deployed function. Schedules are cron-validated. A block whose key is anything other than path, schedule, or description surfaces a deploy warning, since unrecognized keys are ignored.

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"
required    = true
group       = "stripe"

[[secret]]
key         = "STRIPE_PUBLISHABLE_KEY"
provider    = "stripe"
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]]
path     = "/api/jobs/daily-run"
schedule = "0 6 * * *"

[[cron]]
path     = "/api/jobs/weekly-digest"
schedule = "0 13 * * 1"

Custom integration — raw key access via expose

name = "Acme Internal Reporter"

[[secret]]
key         = "ACME_INTERNAL_API_KEY"
provider    = "custom"
required    = true
expose      = true             # raw process.env access — 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 — expose is enumerability, not access control, which is why it defaults off.