Imperal Docs
SDK Reference

@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 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,
):

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.).


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 SecretNotDeclaredErrorI-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 3secrets[] 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 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 whenFederal invariant
SecretNotDeclaredErrorRuntime ctx.secrets.* call with name not in manifestI-SECRETS-CONTRACT-DECLARED
SecretWriteForbiddenctx.secrets.set(name) where manifest write_mode='user'
SecretVaultUnavailablePlatform KMS endpoint unavailableI-SECRETS-VAULT-DEPENDENCY
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"])

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

  1. I-SECRETS-USER-SCOPED — every read/write/delete is scoped to one user; cross-user access returns 403.
  2. I-SECRETS-NEVER-LOGGED — plaintext never appears in the audit ledger, journals, error responses, workflow event history, or backups; audit rows store value_length + sha256_prefix8 only.
  3. I-SECRETS-EXT-SCOPED — extension A cannot read extension B's secrets for the same user.
  4. I-SECRETS-VAULT-DEPENDENCY — platform KMS downtime → fail-closed; no fallback decryption, no plaintext cache.
  5. I-SECRETS-AUDIT-FOREVER — every operation writes an audit row with retention_class='security_forever' (survives retention purges).
  6. I-SECRETS-HANDLER-SCOPE-MEMORY — plaintext lives only inside one handler call's stack; SDK keeps no cache between calls.
  7. I-SECRETS-CONTRACT-DECLARED — manifest is the single source of truth; reading an undeclared name raises at runtime.

See also

On this page