Skip to content
Charming Docs
Esc
navigateopen⌘Jpreview
On this page

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.fetch is HTTPS-only and enforces the same manifest.permissions.server.fetch allowlist and public-address (SSRF) guard as plain backend fetch: 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}/secrets to create a new name (409 secret_already_exists if it’s already set; use PUT to replace it), PUT to replace an existing value, DELETE /app/{id}/secrets?name=NAME to 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 with invalid_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.fetch rejects 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.

Was this page helpful?