Imperal Docs
SDK Reference

@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 calls ctx.secrets.set() if its manifest declared write_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-side required=True dispatch gate (which would block handler invocation when a required secret is unset and emit a secret_missing_card) is in scope for the EXT-SECRETS-V1 spec but not yet implemented — handle the None case from ctx.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: pass

Both 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's ctx.secrets.set() will raise SecretWriteForbidden. Use this for credentials the user pastes from somewhere else (API keys, app passwords).
  • "extension" — only ctx.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; omit scope and 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 OAuth client_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_secret is scope="app", NOT write_mode="extension".

scope and write_mode answer 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:

credentialwhat it isscopewrite_mode
OAuth client_id / client_secretyours, one app registration for everyone"app"— (ignored)
OAuth refresh / access tokeneach user's, captured after they authorize"user""extension"
an API key the user pastes from their own accountthe user's"user""user" (default)

Declaring your shared client_secret as write_mode="extension" (no scope) is the #1 OAuth-extension bug: write_mode="extension" defaults to scope="user", so the value is per-user and never set → ctx.secrets.get() returns Noneevery user's token refresh 401s and the feature looks dead, and the owner can't even paste the value (the Secrets tab renders write_mode="extension" entries read-only). Rule of thumb: a credential you own and pay for is scope="app"; a token your code writes per user is write_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 3scope 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 dev

In 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 result

Pass declared={"spotify_api_key", "spotify_refresh_token"} to MockSecretStore if you want it to mirror the SecretNotDeclaredError behaviour.


Exceptions

ExceptionRaised whenGuarantee enforced
SecretNotDeclaredErrorRuntime ctx.secrets.* call with name not in manifestThe manifest is the single source of truth — reading an undeclared name always fails
SecretWriteForbiddenctx.secrets.set(name) where manifest write_mode='user'
SecretVaultUnavailablePlatform key-management service unavailableSecrets fail closed when the encryption service is down — no fallback decryption, no plaintext cache
SecretValueTooLargeValue 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:

  1. User-scoped — every read, write, and delete is scoped to a single user; one user can never reach another user's secrets.
  2. 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.
  3. Extension-scoped — one extension cannot read another extension's secrets, even for the same user.
  4. Fail-closed on dependency loss — if the encryption service is unavailable, secret operations fail closed; there is no fallback decryption and no plaintext cache.
  5. Audited permanently — every operation writes a security audit record that survives normal retention purges.
  6. Handler-scoped in memory — plaintext lives only inside a single handler call; the SDK keeps no cache between calls.
  7. 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

On this page