Secrets
Your app calls external APIs using a key the owner sets in App settings; the value never reaches your app's code or the model that built it.
What your app can connect to
Want your app to use another service, like a weather feed, Stripe, an LLM provider, or a notification service? The owner adds the key once, by name, in App settings → Secrets, and the app can call that service. Your agent never sees or handles the secret value itself.
Your app’s backend code references the key by name; Charming fills in the real value on its own infrastructure right before the request leaves, so the plaintext never sits in your app’s source, never shows up in a log, and never reaches the model that’s helping you build the app.
This works for any service that accepts a static API key. It doesn’t cover services that require you to sign in through their own login screen (OAuth).
Technical
The app owner sets, replaces, and deletes secret values in App settings → Secrets at /<owner-handle>/~/apps/<app-name>/settings/secrets; an agent never sees or types a value, only the NAME.
Copy this prompt for your agent
Connect my Charming app to an external service. Read
https://charm.ing/docs/capabilities/secrets.md first. Ask me which
service if it isn't obvious from the app, or propose one. Then tell
me the exact secret NAME to set in the app's Secrets section; I'll
add the value there myself.
If you haven't built a Charming app before, read
https://charm.ing/docs/build-mcp.md (or build-http.md for the HTTP API)
for the app skeleton first.
How an agent performs this job
Your agent declares charming:secrets/[email protected] in the app’s manifest.capabilities.imports (claimed apps only; denied on an anonymous, unclaimed app) and lists the exact HTTPS origin the request needs in manifest.permissions.server.fetch; a sealed fetch requires an explicit allowlist, so an empty or missing one denies every request. Inside a route handler it calls env.fetch(url, init) with {{secret:NAME}} inside a header value or a query-parameter value. The agent doesn’t set or read the value: it tells the owner which NAME to use, and the owner enters the value in App settings → Secrets. An owner (or anything holding their chrm_user_* token) can also manage secrets through the machine HTTP API: GET, POST, PUT, and DELETE /app/{id}/secrets.
The contract
env.fetch only exists if the app declared charming:secrets/[email protected]. Its shape:
env.fetch(url, init); // Promise<Response> - {{secret:NAME}} in a header or query-parameter value gets substituted
Header example:
env.fetch(url, { headers: { Authorization: `Bearer {{secret:OPENROUTER_KEY}}` } });
Query-string example, for APIs that authenticate by query parameter (511 SF Bay, OneBusAway, BART, most Socrata endpoints):
env.fetch(`https://api.example.com/data?api_key={{secret:EXAMPLE_KEY}}`);
Write the placeholder literally in the URL string, like the example above. Don’t build the query with URLSearchParams.set('api_key', '{{secret:EXAMPLE_KEY}}') or encodeURIComponent — either one percent-encodes the placeholder before Charming ever sees it, so it won’t resolve. This is deliberate: it’s what lets an app safely encodeURIComponent visitor-supplied text without that text ever being read as a secret reference.
The same declared capability grants env.secrets. Reading env.secrets.NAME returns the literal {{secret:NAME}} placeholder, never plaintext, so `Bearer ${env.secrets.KEY}` builds the same reference as the literal form. A valid name returns a placeholder whether or not the owner has set that secret, so a read cannot reveal which names exist. Names outside [A-Z][A-Z0-9_]* return undefined.
The proxy exposes no names through in, Object.keys, spread, JSON conversion, own-key reflection, or property descriptors. Those operations see an empty object, and attempts to add properties or change its prototype fail. String coercion returns [charming secrets], and the proxy has a null prototype. Apps cannot list secret names or recover values through reflection.
- Substitution happens in header values and query-parameter values, never a parameter name, the host, the path, the fragment, or the body, so a secret can’t change what the request targets. A query-string value is not log-safe the way a header is: query strings routinely land in the upstream’s access logs, proxy logs, and referrer headers. The plaintext never enters your app’s source, the sandbox, the model, or Charming’s own logs either way — but prefer a header when the API accepts one, and use the query string only when it’s the sole option.
- Secret names match
^[A-Z][A-Z0-9_]*$, the same charset a{{secret:NAME}}reference uses, so any settable name is always referenceable. - Each value is capped at 8 KiB (8192 bytes) of plaintext, checked before it’s encrypted for storage. Need a bigger value? Contact us.
env.fetchis HTTPS-only and enforces the samemanifest.permissions.server.fetchallowlist and public-address (SSRF) guard as plain backendfetch: private, loopback, and cloud-metadata addresses stay blocked even on an allowlisted host.- Values are stored encrypted and are never returned by any endpoint, including to the owner; listing secrets returns names only.
Limits, access, and deletion
- Setting values. Owner-only. Use App settings → Secrets, or use the machine HTTP API with the owner’s
chrm_user_*token:POST /app/{id}/secretsto create a new name (409 secret_already_existsif it’s already set; usePUTto replace it),PUTto replace an existing value,DELETE /app/{id}/secrets?name=NAMEto remove one. App and render tokens are rejected on all of these; only a user token works. - Bad name. A name outside
^[A-Z][A-Z0-9_]*$fails withinvalid_secret_name. - Oversize value. A value over 8 KiB fails with
secret_too_large. - Missing secret. If your code references a
{{secret:NAME}}that isn’t set, in a header or in the URL,env.fetchrejects the whole request. Catch that and handle the failure, or prompt the owner to add the secret, rather than sending the request with the placeholder still in it. - Deletion. Deleting a secret removes it immediately. Any code that still references its NAME fails on the next call until the owner sets it again or the code stops referencing it.