@ext.secret reference
@ext.secret reference — declare and access per-user credentials safely: API keys, OAuth tokens, and webhook signing secrets, encrypted at rest and never logged.
@ext.secret is the federal EXT-SECRETS-V1 decorator (SDK v4.2.2+). It declares that your extension needs a credential the user supplies — a Spotify API key, an OpenAI key, a webhook signing secret, an OAuth refresh token — and lets the user enter or rotate that value from the Imperal Panel without ever exposing the plaintext to admins, logs, or backups.
The system is built so that:
- Plaintext exists only transiently — briefly inside the platform's encryption service while it encrypts or decrypts the value, and inside your handler for the duration of a single call. It is never persisted to storage, logs, the audit ledger, error responses, chat history, workflow event history, or backups.
- The user supplies the value through the Panel UI (
type="password"input, no echo, no clipboard, state cleared on submit), or — for OAuth-style flows where the extension itself writes the token after the user authorizes — the extension callsctx.secrets.set()if its manifest declaredwrite_mode="extension". - The Panel Secrets tab UI reads your manifest's
secrets[]to render one entry per declared name with status badges ("Set" / "Not set" / "required") and the value-entry form. The web-kernel-siderequired=Truedispatch gate (which would block handler invocation when a required secret is unset and emit asecret_missing_card) is in scope for the EXT-SECRETS-V1 spec but not yet implemented — handle theNonecase fromctx.secrets.get()in your handler.
Where it lives
@ext.secret is a method on the Extension instance (not on ChatExtension). Declare secrets at module scope alongside your other extension decorators.
from imperal_sdk import Extension
ext = Extension(
"spotify",
version="1.0.0",
display_name="Spotify",
description="Spotify integration — search, play, queue, manage your library.",
icon="icon.svg",
actions_explicit=True,
)
ext.secret(
name="spotify_api_key",
description="Your Spotify API key (from developer.spotify.com).",
required=True,
write_mode="user",
max_bytes=200,
)(lambda: None)The decorator returns an identity wrapper — the call itself registers the secret on the Extension. The trailing (lambda: None) is a syntactic anchor; you can also attach it to a marker class:
@ext.secret(
name="spotify_refresh_token",
description="OAuth refresh token written by extension after authorize.",
write_mode="extension",
rotation_hint_days=30,
)
class _SecretAnchor: passBoth forms are equivalent.
Signature
def secret(
self,
name: str,
description: str,
*,
required: bool = False,
write_mode: Literal["user", "extension", "both"] = "user",
max_bytes: int = 4096,
rotation_hint_days: int | None = None,
scope: Literal["user", "app"] = "user", # v5.8.0+
env_fallback: str | None = None, # v5.8.0+, scope="app" only
):name — required, snake_case
Matches regex ^[a-z][a-z0-9_]{0,62}$. The value is auto-scoped under your app_id in storage, so two extensions can both declare api_key without collision.
description — required, non-empty
Shown to the user in the Panel UI when they're entering the value. Write it as instructions the user can act on — "Your Spotify client secret from developer.spotify.com > your-app > Show Client Secret" rather than "Spotify secret".
required — default False
Currently a manifest-level metadata flag. It lands in imperal.json and the Panel Secrets tab UI uses it to highlight unset required secrets with a "Configure" prompt. The web-kernel-side dispatch gate is not yet implemented — a handler whose required secret is unset will still be invoked, and await ctx.secrets.get(name) returns None. Handle the None case in your handler until the gate ships (see Secrets concept — required).
write_mode — default "user"
Determines who can write the value:
"user"— only the Panel UI (an authenticated user session) can write. Your extension'sctx.secrets.set()will raiseSecretWriteForbidden. Use this for credentials the user pastes from somewhere else (API keys, app passwords)."extension"— onlyctx.secrets.set()(extension code) can write. The Panel UI shows the secret as read-only with helper text "the extension will write this after you authorize". Use this for OAuth refresh tokens that the extension acquires after the user goes through an authorize flow."both"— either side can write. Use sparingly; common for OAuth access tokens that the user can initially seed manually then the extension rotates programmatically.
max_bytes — default 4096, hard cap 65536
Byte-length limit on the value (UTF-8 encoded). Cap your declaration at the realistic upper bound — most API keys are under 256 bytes, OAuth refresh tokens under 2 KB. Auth-gateway enforces server-side; SDK enforces client-side as a defence-in-depth check.
rotation_hint_days — default None, optional positive integer
Informational only — the Panel UI shows a "Recommended to rotate every N days" hint next to the secret. There is no automatic rotation in v1; the user (or your extension) rotates manually via PUT / ctx.secrets.set(). Use this to encode provider-specific guidance (Stripe webhook secrets every 90 days, etc.).
scope — default "user" (v5.8.0+)
Decides whose credential this is:
"user"(default) — a per-user secret: each user has their own value, one user can never read another's. The user supplies it (or your extension writes it after the user authorizes). This is the original behaviour; omitscopeand nothing changes."app"— a developer-owned, shared secret: ONE value for the whole extension, set by you, read transparently by your handlers for every user. Use it for credentials you own — your OAuthclient_id/client_secret, a shared API key you pay for. The end user never sees or types it.
The read call is identical for both (await ctx.secrets.get(name)) — the platform routes to the per-user or the shared store based on this field. App-scope changes the rules around it: writes are owner-only (Developer Portal — not ctx.secrets.set(), not the user), and a user session cannot read it. write_mode is ignored for scope="app" (the owner-only write rule supersedes it). Pick the scope with the one-question rule.
⚠️ Common mistake — your OAuth
client_secretisscope="app", NOTwrite_mode="extension".
scopeandwrite_modeanswer different questions.scope= whose value (one shared / per-user).write_mode= who writes it (the Panel user / your extension code). They are not interchangeable, and the OAuth case needs both fields set deliberately:
credential what it is scopewrite_modeOAuth client_id/client_secretyours, one app registration for everyone "app"— (ignored) OAuth refresh / access token each user's, captured after they authorize "user""extension"an API key the user pastes from their own account the user's "user""user"(default)Declaring your shared
client_secretaswrite_mode="extension"(noscope) is the #1 OAuth-extension bug:write_mode="extension"defaults toscope="user", so the value is per-user and never set →ctx.secrets.get()returnsNone→ every user's token refresh 401s and the feature looks dead, and the owner can't even paste the value (the Secrets tab renderswrite_mode="extension"entries read-only). Rule of thumb: a credential you own and pay for isscope="app"; a token your code writes per user iswrite_mode="extension". The canonical OAuth split that gets both right is shown below under "OAuth login for every user".
env_fallback — default None, scope="app" only (v5.8.0+)
Optional migration bridge. On an app-scope read that misses Vault, the platform falls back to this environment variable — so an extension that used to read a shared key from env keeps working until you save the value in the Developer Portal. Once saved, the value comes from Vault and the env var is ignored.
Security: the name MUST be in your app's own IMPERAL_APPSECRET_<EXT>_<NAME> namespace. SecretSpec raises ValueError at build time for any other name (and for env_fallback on a scope="user" secret), and the platform ignores an out-of-namespace name at runtime — so a fallback can never reach another app's secret or a platform secret like STRIPE_SECRET_KEY.
Reading the value — ctx.secrets.get
Inside a handler, retrieve plaintext via the ctx.secrets accessor:
@chat.function
async def search_my_library(ctx, query: str) -> ActionResult:
api_key = await ctx.secrets.get("spotify_api_key")
if not api_key:
return ActionResult.error("Please configure your Spotify API key in settings.")
# Use api_key for the duration of this handler call only.
# SDK does NOT cache between calls — every get() is a fresh round-trip.
async with httpx.AsyncClient() as c:
r = await c.get(
"https://api.spotify.com/v1/search",
headers={"Authorization": f"Bearer {api_key}"},
params={"q": query, "type": "track"},
)
tracks = r.json().get("tracks", {}).get("items", [])
return ActionResult.ok(data={"tracks": tracks})Federal contract: plaintext exists only inside the handler call stack. The SDK keeps no module-level cache, no class-level cache, no @lru_cache. If you store the plaintext into an instance attribute or module dict, you're violating the handler-scoped-memory guarantee — plaintext must never outlive a single handler call — and your extension can be rejected at Dev Portal publish time once the validator for this rule lands.
If name is not in your manifest's secrets[], ctx.secrets.get(name) raises SecretNotDeclaredError — the manifest is the single source of truth for which secrets exist, and this is enforced at runtime, not just at publish time.
Writing the value — ctx.secrets.set
Only allowed when write_mode is "extension" or "both". Use this after a successful OAuth dance:
@chat.function
async def authorize_spotify(ctx) -> ActionResult:
# ...redirect user through OAuth, capture refresh_token from callback...
refresh_token = await _spotify_oauth_dance(ctx)
await ctx.secrets.set("spotify_refresh_token", refresh_token)
return ActionResult.ok(message="authorized")For write_mode="user" secrets, ctx.secrets.set(name, value) raises SecretWriteForbidden — the value must come from the Panel UI session, not from extension code.
Other accessor methods
# Cheap metadata read; does NOT decrypt, does NOT write an audit row.
is_set: bool = await ctx.secrets.is_set("spotify_api_key")
# List all declared secrets for this extension + their is_set state.
# Returns SecretStatus dataclasses with name, description, is_set,
# last_accessed_at. NEVER the value itself.
statuses = await ctx.secrets.list()
# Delete a value (write_mode='user' secrets can only be deleted via Panel).
was_set: bool = await ctx.secrets.delete("spotify_api_key")What lands in the manifest
Your imperal.json (after imperal build) gains an optional secrets[] array:
{
"manifest_schema_version": 3,
"sdk_version": "5.8.0",
"app_id": "spotify",
"secrets": [
{
"name": "spotify_client_secret",
"description": "Your Spotify app's client secret (from developer.spotify.com — shared by all users).",
"required": true,
"write_mode": "user",
"max_bytes": 200,
"scope": "app",
"env_fallback": "IMPERAL_APPSECRET_SPOTIFY_SPOTIFY_CLIENT_SECRET"
},
{
"name": "spotify_refresh_token",
"description": "OAuth refresh token written by extension after this user authorizes.",
"required": false,
"write_mode": "extension",
"max_bytes": 4096,
"rotation_hint_days": 30,
"scope": "user"
}
]
}The two entries are the canonical OAuth split: the scope:"app" spotify_client_secret is yours, set once for everyone; the scope:"user" spotify_refresh_token is each user's, written per user after they authorize. Manifest schema version stays at 3 — scope and env_fallback are additive optional fields (scope defaults to "user" and is now always emitted; env_fallback is emitted only when set), fully back-compatible with extensions that don't declare any secrets. write_mode is ignored for scope:"app" entries (app-scope writes are owner-only).
Auto-injected Secrets panel (v4.2.4+)
Every Extension instance unconditionally auto-registers a synthetic secrets panel in __init__:
- slot:
right - title:
Secrets - icon:
KeyRound - internal tool name:
__panel__secrets
Users land there to manage credentials without you writing any panel code. The panel reads declared secrets + live is_set state via ctx.secrets.list() and renders one card per declaration with status badges + a Manage button that routes to the dedicated /ext/{ext_id}/secrets page.
Slot choice rationale: the synthetic panel uses right defensively because most extensions occupy left (sidebar nav) and center (main content). If your extension already declares a right-slot panel, your panel wins — users still reach the Secrets UI via the direct /ext/{ext_id}/secrets route and via the Dev Portal Secrets tab.
Empty state: when an extension has zero declared secrets, the panel renders a developer-facing empty state with a @ext.secret(...) code example + link to this reference page. End users see "This extension does not declare any secrets" — a sane, discoverable affordance.
Validator note: __panel__secrets (and the other __panel__*, __widget__*, __tray__*, __webhook__* synthetic prefixes) are excluded from validator tool_count since v4.2.5 — they don't count toward V3 "at least one tool" and aren't displayed in marketplace tool counts. See Validators reference.
Write surfaces must use ui.Password (v4.2.6+) — Panel UI input fields capturing the value MUST use ui.Password (or ui.Input(type="password")) so the browser renders <input type="password" autocomplete="new-password" spellcheck="false">. The Dev Portal Secrets tab and imperal-panel's SecretManagerCard both already comply. See ui.Password.
Dev-mode and pytest
When IMPERAL_DEV_MODE=true is set in your shell, ctx.secrets.get(name) reads IMPERAL_SECRET_<UPPER_NAME> from the environment instead of calling the auth-gateway. ctx.secrets.set/delete become no-ops with a WARN log. The manifest contract is still enforced — undeclared names still raise.
export IMPERAL_DEV_MODE=true
export IMPERAL_SECRET_SPOTIFY_API_KEY="sk-dev-test-key"
imperal devIn pytest, inject MockSecretStore via a fixture:
import pytest
from imperal_sdk.testing import MockSecretStore
@pytest.fixture
def secrets():
return MockSecretStore({"spotify_api_key": "sk-test"})
async def test_search(ctx_factory, secrets):
ctx = ctx_factory(secrets=secrets)
result = await search_my_library(ctx, query="test")
assert "tracks" in resultPass declared={"spotify_api_key", "spotify_refresh_token"} to MockSecretStore if you want it to mirror the SecretNotDeclaredError behaviour.
Exceptions
| Exception | Raised when | Guarantee enforced |
|---|---|---|
SecretNotDeclaredError | Runtime ctx.secrets.* call with name not in manifest | The manifest is the single source of truth — reading an undeclared name always fails |
SecretWriteForbidden | ctx.secrets.set(name) where manifest write_mode='user' | — |
SecretVaultUnavailable | Platform key-management service unavailable | Secrets fail closed when the encryption service is down — no fallback decryption, no plaintext cache |
SecretValueTooLarge | Value bytes > manifest max_bytes (client-side check before HTTP) | — |
SecretDeclarationConflict | @ext.secret called twice with the same name on one Extension | — |
All exceptions are exported from imperal_sdk at the top level.
Common patterns
User pastes API key once
ext.secret(
name="openai_api_key",
description="Your personal OpenAI API key (sk-proj-... format).",
required=True,
write_mode="user",
max_bytes=200,
)(lambda: None)User goes to Panel → your extension's settings → secrets → enters key → done. Your handler reads it on every invocation.
OAuth refresh — extension writes after authorize
ext.secret(
name="github_refresh_token",
description="GitHub OAuth refresh token (written by extension after authorize flow).",
write_mode="extension",
rotation_hint_days=180,
)(lambda: None)
@chat.function
async def github_auth_complete(ctx, code: str):
tokens = await _exchange_code(code)
await ctx.secrets.set("github_refresh_token", tokens["refresh_token"])
await ctx.secrets.set("github_access_token", tokens["access_token"])OAuth login for every user — your client secret is scope="app"
The credential that makes "Connect Spotify" work for everyone is yours, shared — so it's app-scope. The per-user token your extension captures afterwards is user-scope. Declare both:
This example illustrates the scope split (shared client secret vs per-user token). For the actual connect flow, prefer the unified OAuth-connect flow — declare ext.oauth(...) and the platform does the code-exchange and token persistence for you, reading exactly the scope="app" client credentials shown below. You rarely need to call _exchange_code by hand.
# Your app's client secret — ONE value, all users. You set it in the Dev Portal.
ext.secret(
name="spotify_client_secret",
description="Spotify app client secret (developer.spotify.com > your app). Shared by all users.",
scope="app",
required=True,
)(lambda: None)
# Each user's OAuth token — written per user after they authorize.
ext.secret(
name="spotify_refresh_token",
description="Spotify OAuth refresh token (written by the extension after this user authorizes).",
scope="user",
write_mode="extension",
rotation_hint_days=30,
)(lambda: None)
@chat.function
async def connect_spotify(ctx, code: str) -> ActionResult:
client_secret = await ctx.secrets.get("spotify_client_secret") # app-scope: same for everyone
tokens = await _exchange_code(code, client_secret) # exchange THIS user's code
await ctx.secrets.set("spotify_refresh_token", tokens["refresh_token"]) # user-scope: this user only
return ActionResult.ok(message="Spotify connected")You never call ctx.secrets.set("spotify_client_secret", …) — app-scope is owner-write-only (Dev Portal). You set it once; every user's connect_spotify reads the same value.
Webhook signing secret with rotation hint
ext.secret(
name="stripe_webhook_signing_secret",
description="Stripe webhook signing secret (whsec_...). Get it from Stripe Dashboard > Developers > Webhooks > Signing secret.",
required=False,
write_mode="user",
max_bytes=200,
rotation_hint_days=90,
)(lambda: None)Federal contract — seven guarantees
The platform makes seven binding guarantees about how secrets behave:
- User-scoped — every read, write, and delete is scoped to a single user; one user can never reach another user's secrets.
- Never logged — plaintext never appears in audit records, journals, error responses, durable background workflow history, or backups; audit records store only the value's length and a short non-reversible fingerprint.
- Extension-scoped — one extension cannot read another extension's secrets, even for the same user.
- Fail-closed on dependency loss — if the encryption service is unavailable, secret operations fail closed; there is no fallback decryption and no plaintext cache.
- Audited permanently — every operation writes a security audit record that survives normal retention purges.
- Handler-scoped in memory — plaintext lives only inside a single handler call; the SDK keeps no cache between calls.
- Manifest-declared — the manifest is the single source of truth for which secrets exist; reading an undeclared name fails at runtime.
These describe scope="user". A scope="app" secret is shared by design (so #1 doesn't apply) and is instead bound by: writes owner-only (Developer Portal), plaintext reads kernel-only (never an end user, never the LLM). Guarantees #2–#6 hold for both.
See also
ctx.secretsAPI surface — full method reference- Manifest reference —
secrets[]field — JSON schema - Federal contract — all 7 EXT-SECRETS invariants
ui.Passwordprimitive — required write surface (v4.2.6+)- Changelog v4.2.2 → v4.2.6 — release notes for the full EXT-SECRETS-V1 sweep
Lifecycle hooks reference
Lifecycle hooks reference — install, enable, disable, uninstall, upgrade, and health-check hook signatures for the platform-driven extension lifecycle.
ext.oauth reference
ext.oauth + ctx.oauth_authorize_url — let the platform run the whole OAuth connect flow: declare a provider, build the authorize URL, save accounts automatically.