Imperal Docs
SDK Reference

Federal Extension Contract

The Federal Extension Contract every published Imperal extension satisfies: manifest schema v3, the federal validators, and runtime behaviour you can rely on.

The Federal Extension Contract is the binding agreement between your code and Imperal Cloud. It defines what a published extension MUST satisfy to ship and run. This page is the canonical reference.

The current contract is v4.0 (with v4.0.1 patches). Every published extension passes through it at build time, validate time, and publish time.

Why this contract exists

Before v4.0, the platform had a class of silent-failure bugs:

๐Ÿ”ด

Silent write failures

Extension says 'deleted' in chat, but nothing was actually deleted. Caused by chain-planner [BYOLLM](/en/reference/glossary/)-router gap.

๐Ÿ”ด

Hallucinated IDs

LLM fabricates id-shaped slugs (webhostmost-outlook-1) that look right but don't exist.

๐Ÿ”ด

Confirmation bypass

2-step destructive actions slipping through without user accept.

๐ŸŸ 

Lifecycle TypeError flood

Extensions on stale SDK signatures crashing skeleton refresh ~1166ร—/h.

v4.0 closed ~75% of these classes. The contract details follow.

The Extension declaration

app.py
from imperal_sdk import Extension

ext = Extension(
    "my-app",                                          # internal name
    version="1.0.0",
    display_name="My App",                             # V15: โ‰ฅ3 chars, โ‰  app_id
    description=(                                      # V14: โ‰ฅ40 chars
        "Sentence describing what the extension does, "
        "shown in Webbee's 'what I can do' capability list."
    ),
    icon="icon.svg",                                   # V21: required SVG
    actions_explicit=True,                             # V19: federal default
    capabilities=["my-app:read", "my-app:write"],
)

Prop

Type

The @chat.function contract

handlers.py
from imperal_sdk import ChatExtension, ActionResult
from .schemas import NoteIdParams

@chat.function(
    "delete_note",                                    # explicit name
    action_type="destructive",
    description="Delete a note (moves to trash, restorable for 30 days).",  # V16: โ‰ฅ20 chars
    chain_callable=True,                              # V19: required for write/destructive
    effects=["delete:note"],                          # V20: required for write/destructive
)
async def fn_delete_note(ctx, params: NoteIdParams) -> ActionResult:  # V17 + V18
    ...

The decorator auto-detects from your function annotations:

  • Params model from params: NoteIdParams โ†’ emits full JSON Schema in manifest (params_schema)
  • Return shape from data_model= โ€” an sdl.Entity or a concrete sdl.EntityList[T] โ†’ emits the SDL return_schema (with field roles) into the manifest. Required for reads (V23), recommended for writes (V24).
  • chain_callable defaults to True for all action types since v4.2.10 โ€” reads now type-dispatch in chains too, eliminating wrapper-LLM paraphrase drift on list_*/search_*/get_* handlers

The 11 federal validators

All ERROR severity. Failing any blocks publishing.

V23 / V24 โ€” SDL Typed Return Contract

Two related rules govern typed SDL return shapes. V23 requires data_model= โ€” an sdl.Entity or a concrete sdl.EntityList[T] โ€” on every action_type="read" tool, so the platform can read the entity's roles, validate $REF paths, and prevent input/output field-name drift. This is the live SDL enforcement: WARN today, env-promotable to ERROR. V24 recommends the same SDL data_model= on write/destructive tools (WARN-only). Note this typed-return V24 is distinct from the skeleton-access check (V24-AST) above โ€” see the SDL reference and Validators reference.

Manifest schema v3

generate_manifest(ext) walks every typed @chat.function and emits a tool entry. Pre-v3 manifests only contained the BYOLLM router โ€” chain planner was blind to typed surfaces. Now the web-kernel sees every tool.

imperal.json
{
  "manifest_schema_version": 3,
  "name": "notes",
  "display_name": "Notes",
  "description": "Personal notes assistant โ€” create, read, update, delete...",
  "icon": "icon.svg",
  "icon_size_bytes": 510,
  "actions_explicit": true,
  "capabilities": ["notes:read", "notes:write"],
  "tools": [
    {
      "name": "delete_note",
      "description": "Delete a note (moves to trash).",
      "action_type": "destructive",
      "chain_callable": true,
      "effects": ["delete:note"],
      "params_schema": { "type": "object", "properties": { "note_id": { "type": "string" } }, "required": ["note_id"] },
      "return_schema": { ... },
      "event": "deleted",
      "owner_chat_tool": "tool_notes_chat"
    }
  ],
  "lifecycle_hooks": {
    "on_install":  { "signature": "(ctx, message=None)" },
    "on_refresh":  { "signature": "(ctx, message=None)" }
  }
}

owner_chat_tool is a manifest reference field โ€” a back-pointer naming the ChatExtension's tool_name that owns this tool. It is descriptive metadata, not an independently registered, callable tool: the kernel dispatches the typed tools[] entries directly and does not resolve owner_chat_tool as a separate orchestrator tool.

See the full manifest reference for every field.

Platform-side guarantees

The contract is two-sided. You satisfy the build-time validators. The platform holds these runtime guarantees:

๐ŸŽฏ

Typed dispatch in chains

When your tool has chain_callable=True and a chain plan includes it, the platform dispatches it with Pydantic-validated args โ€” no extra LLM round inside your extension.

๐Ÿ›ก๏ธ

Pre-flight anti-hallucination

Fabricated IDs, ungrounded narration, and out-of-enum hints are caught before your handler runs.

๐Ÿ”

Confirmation chokepoint

Destructive actions are intercepted and shown to the user; on accept, exactly what the user saw is executed โ€” byte-identical args, no LLM rerun.

๐Ÿ“

Audit chokepoint

Every action that reaches your handler also reaches the federal [action ledger](/en/reference/glossary/). No bypass.

Runtime behaviour you can rely on

The contract isn't only build-time. The platform maintains these guarantees at runtime โ€” they affect how your extension is called:

GuaranteeWhat you can rely on
Typed chain dispatchWhen a chain step targets your chain_callable=True tool, the platform calls it with Pydantic-validated args from the prior step โ€” no extra LLM round in your extension. Since v4.2.10 this includes read tools (list_*/search_*/get_*).
Bounded validation retryIf the model passes args that fail Pydantic validation, it gets a small, bounded number of structured retries before the call fails.
Confirmation fidelityOn a confirmation card, accepting runs exactly what the user saw โ€” the same args, with no model rerun.
Structured chain hand-offA downstream chain step receives the prior step's structured ActionResult.data projected into its own typed params โ€” not a paraphrase.
Read-before-write orderingIn a multi-step plan, reads are ordered before the writes that depend on them.
System tasks get no message=Skeleton/system-initiated calls never receive a message= kwarg, so your handler can rely on its absence to detect that path.
String tool-schema descriptionsTool schema descriptions are always emitted as strings (never tuples).
Module isolationEach extension's Python modules load in an isolated namespace, so you can name modules app.py, handlers.py, config.py, models.py etc. without cross-extension collisions. Keep imports at the top of the file โ€” a local from app import X inside a handler body can bind against another extension's module if it loaded first in the same process.
Capabilities are groundedWebbee's "what can you do" answer is grounded in your installed extension's manifest display_name + description. Your manifest description IS your capabilities answer โ€” write it accurately and name the real user-facing capability.
Installed-apps onlyThe platform never surfaces the full platform-wide catalogue to a user's chat โ€” only their installed apps (plus system apps).
Tunable history windowTenant operators can tune the conversational-history window. Don't assume an unbounded history when designing skeletons or chained workflows.
HTTP timeout cap (180s)ctx.http(..., timeout=N) is capped at 180 seconds. Larger values raise ValueError pointing you at ctx.background_task() (v4.2.12+).
Background coroutine returns ActionResultA coroutine passed to ctx.background_task() MUST return ActionResult; anything else delivers a fallback error message to chat and records a critical audit row (v4.2.12+).
Background tasks are user-scopedEach background task is bound to (your extension, user); cross-user cancel/status returns 403 (v4.2.12+).
Chat injection is user-scoped + auditedctx.deliver_chat_message() is scoped to (your extension, user) โ€” cross-user inject returns 403 โ€” and every call writes an audit row (v4.2.12+).
Secrets are user- and extension-scopedEvery secret read/write is scoped to one user (cross-user access returns 403), and one extension cannot read another's secrets for the same user.
Secrets never loggedSecret plaintext never appears in the audit ledger, journals, error responses, chat history, workflow event history, or backups.
Secrets fail-closedIf the platform key service is unavailable, secret reads fail closed โ€” no fallback decryption, no plaintext cache.
Secret audit survives purgesEvery secret operation writes an audit row that survives retention purges.
Secret plaintext is call-scopedSecret plaintext lives only inside one handler call; the SDK keeps no cache between calls.
Undeclared secret reads raiseYour manifest is the single source of truth โ€” reading a name you didn't declare raises at runtime.
Uniform billingEvery billable path is metered uniformly; what you see charged matches what was deducted.
Wallet balance is durableWallet balances are never lost, and a recurring platform-managed background job re-asserts that durability.
BYOLLM platform fee is zeroWhen the user is on BYOLLM, the platform fee is zeroed at every charge point โ€” transparently, with no accessor your code needs to read.
No fabricated cost claimsThe agent never tells the user a cost figure unless it came from a real charge event.
Placeholder args rejected pre-dispatchA tool call whose arg values look like placeholder sentinels (e.g. <UNKNOWN>, <TODO>) is rejected before it reaches your handler โ€” no billing, no audit pollution (v4.2.15+).
Confirmation toggle respected uniformlyThe user's 2-step-confirmation setting is honoured consistently across single-action and multi-step plans.

Author-facing behaviour guarantees in plain language: Federal invariants.

What v4.1+ added on top

VersionAdded
v4.1.0Pydantic feedback loop โ€” bounded retry on ValidationError with structured prose feedback
v4.1.1emit_narration audit-mode scope clarification (BYOLLM placeholder leak fix)
v4.1.2@chat.function("my_tool", id_projection="...") for compound tool names
v4.2.0Extension(system=True) flag + V31 validator. System apps are auto-installed for every user, hidden from the marketplace, can never be uninstalled, and the system=True flag is reserved for Imperal first-party authors. See System Apps.
v4.2.1Validator MANIFEST-SKELETON-1 false-positive fix โ€” no longer flags @ext.tool("skeleton_alert_*") (the documented, web-kernel-supported pattern). See @ext.skeleton reference for the canonical refresh + alert pairing.
v4.2.2EXT-SECRETS-V1 โ€” @ext.secret decorator + ctx.secrets accessor + Manifest.secrets[] optional array. Secret storage guarantees land in one release: per-user and per-extension scoping, plaintext never logged, fail-closed reads, audit rows that survive purges, call-scoped plaintext (no cross-call cache), and manifest-declared names enforced at runtime. Per-user encrypted credentials are stored in the platform's managed secret service. See @ext.secret reference for the full surface.
v4.2.3EXT-SECRETS-V1 UX polish โ€” synthetic secrets panel auto-injected on first @ext.secret(...) declaration (slot right, title Secrets, icon KeyRound). Superseded by v4.2.4.
v4.2.4EXT-SECRETS-V1 โ€” synthetic secrets panel registration becomes unconditional in Extension.__init__. Every extension gets the Secrets tab regardless of whether it declares any secrets.
v4.2.5Validator tool_count (V3 "at least one tool" + marketplace display counts) excludes synthetic-prefix tools: __panel__*, __widget__*, __tray__*, __webhook__*. Fixes V3 detection masked by the always-present __panel__secrets from v4.2.4.
v4.2.6New ui.Password primitive + ui.Input(type=) kwarg โ€” browser-blind credential-entry input (<input type="password" autocomplete="new-password" spellcheck="false">). Required for EXT-SECRETS-V1 write surfaces. See ui.Password.
v4.2.7OAuth callback infrastructure end-to-end: panel.imperal.io/v1/ext/* accepts both GET (OAuth 302 redirects, verification challenges) and POST (server-to-server hooks) on the same path; new Context.webhook_url(path) SDK helper; public marketplace endpoint for a tenant's webhook URLs; Panel Secrets-tab redirect-URI hint; Dev Portal Webhooks tab. The webhook URL returned by the helper is the single canonical URL for your extension, and your app's identity always resolves the same way across the platform.
v4.2.8SecretDecl added to the Manifest Pydantic schema. The manifest's secrets[] array is now part of the strict schema โ€” the emitter wrote the field since v4.2.2 but the strict schema didn't declare it, so the two are now in sync. Strict validation rejects malformed secret entries at publish time.
v4.2.9Regenerated static imperal.schema.json to match the v4.2.8 Pydantic schema; v4.2.8 missed the regen step and the CI gate test_static_schema_matches_runtime_export[imperal] caught the drift on the release commit.
v4.2.10@chat.function("my_tool", chain_callable=) default flipped to True for all action_type values (was write/destructive only). Closes wrapper-LLM paraphrase risk on typed read handlers (list_*/search_*/get_*); reads now typed-dispatch in chains automatically. Authors needing the catch-all wrapper-LLM loop set chain_callable=False explicitly. See @chat.function reference.
v4.2.11ui.Link(label=, text=) accepts either kwarg (alias) โ€” earlier signature accepted only label= and text= raised TypeError mid-render, breaking the right panel. See ui.Link.
v4.2.12LONGRUN-V1 Session 1 โ€” three SDK primitives for ops longer than the 30s ctx.http default: ctx.http(..., timeout=N) per-call kwarg (federal cap 180s); ctx.background_task(coro, long_running=) explicit fire-and-detached (cap 180/1800s); ctx.deliver_chat_message(text) extension-initiated bot-turn injection. The long-running guarantees these carry (HTTP timeout cap, background coroutine return contract, user-scoped background tasks, user-scoped + audited chat injection) are the long-running rows of the runtime-behaviour table above.
v4.2.13LONGRUN-V1 Component D โ€” @chat.function("my_tool", background=True, long_running=False) declarative sugar. SDK auto-wraps the call in ctx.background_task() under the hood; LLM receives an immediate ack with task_id; platform auto-delivers the handler's returned ActionResult as a fresh bot turn when done. Manifest emission adds tools[].background + tools[].long_running booleans. See @chat.function โ€” background sugar.
v4.2.14Regenerated imperal.schema.json static mirror to match v4.2.13 Pydantic schema; v4.2.13 missed the regen step and the same CI gate that caught v4.2.8 caught the drift on the v4.2.13 release commit. Pin >= 4.2.14 to skip the temporarily-drifted v4.2.13 sdist.

See the changelog for migration notes.

How publishing enforces this

You run: imperal build .   โ†’   imperal validate .
       โ†“
SDK runs all 11 validators locally
       โ†“
   Any ERROR? โ†’ publish refused, list shown
   All pass? โ†’ upload the packaged extension at
              panel.imperal.io/developer (drag-and-drop)
       โ†“
Dev Portal re-runs validators server-side (defense in depth)
       โ†“
Marketplace listing created / updated

You cannot ship a non-conformant extension. The contract is enforced at three layers โ€” your local CLI, the upload server, and the web-kernel reload step.

Migrating from v3.x

If you have an extension on SDK v3.x:

Bump the SDK pin

pip install --upgrade imperal-sdk

Re-run build

imperal build

This regenerates the manifest as v3. It will likely fail validators โ€” that's expected.

Fix validator failures one by one

Read the error list. Most common:

  • V14: lengthen description
  • V17: add Pydantic params model (module-scope)
  • V18: add return annotation
  • V19: add chain_callable=True to writes
  • V21: provide a real SVG icon

Re-publish

imperal build .
imperal validate .
# Then upload the packaged extension via panel.imperal.io/developer

The migration is mostly mechanical โ€” typically 1โ€“2 hours per extension.

Where to next

On this page