@ext.secret reference
Declarative per-user encrypted secret declarations — API keys, OAuth tokens, webhook signing secrets
@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 lives in exactly three transient places — Vault's transit-engine memory during encrypt/decrypt, the auth-gateway process memory between Vault response and HTTP response, and the SDK process memory inside a single handler call. Never in the database, Redis,
action_ledger, journals, chat history, Temporal events, 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,
):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.).
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):
api_key = await ctx.secrets.get("spotify_api_key")
if not api_key:
return {"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"},
)
return {"tracks": r.json().get("tracks", {}).get("items", [])}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 I-SECRETS-HANDLER-SCOPE-MEMORY and your extension can be rejected at Dev Portal publish time once the V32 validator lands.
If name is not in your manifest's secrets[], ctx.secrets.get(name) raises SecretNotDeclaredError — I-SECRETS-CONTRACT-DECLARED 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):
# ...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 {"status": "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": "4.2.2",
"app_id": "spotify",
"secrets": [
{
"name": "spotify_api_key",
"description": "Your Spotify API key (from developer.spotify.com).",
"required": true,
"write_mode": "user",
"max_bytes": 200
},
{
"name": "spotify_refresh_token",
"description": "OAuth refresh token written by extension after authorize.",
"required": false,
"write_mode": "extension",
"max_bytes": 4096,
"rotation_hint_days": 30
}
]
}Manifest schema version stays at 3 — secrets[] is an additive optional field, fully back-compatible with extensions that don't declare any secrets.
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 | Federal invariant |
|---|---|---|
SecretNotDeclaredError | Runtime ctx.secrets.* call with name not in manifest | I-SECRETS-CONTRACT-DECLARED |
SecretWriteForbidden | ctx.secrets.set(name) where manifest write_mode='user' | — |
SecretVaultUnavailable | Platform KMS endpoint unavailable | I-SECRETS-VAULT-DEPENDENCY |
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"])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 invariants
I-SECRETS-USER-SCOPED— every read/write/delete is scoped to one user; cross-user access returns 403.I-SECRETS-NEVER-LOGGED— plaintext never appears in the audit ledger, journals, error responses, workflow event history, or backups; audit rows storevalue_length+sha256_prefix8only.I-SECRETS-EXT-SCOPED— extension A cannot read extension B's secrets for the same user.I-SECRETS-VAULT-DEPENDENCY— platform KMS downtime → fail-closed; no fallback decryption, no plaintext cache.I-SECRETS-AUDIT-FOREVER— every operation writes an audit row withretention_class='security_forever'(survives retention purges).I-SECRETS-HANDLER-SCOPE-MEMORY— plaintext lives only inside one handler call's stack; SDK keeps no cache between calls.I-SECRETS-CONTRACT-DECLARED— manifest is the single source of truth; reading an undeclared name raises at runtime.
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