Federal Extension Contract
The complete contract every published extension must satisfy — manifest schema v3, validators V14-V22 + V24 + V31, runtime invariants
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
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
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 type from
-> ActionResult→ emitsreturn_schema chain_callabledefaults toTruefor all action types since v4.2.10 — reads now type-dispatch in chains too, eliminating wrapper-LLM paraphrase drift onlist_*/search_*/get_*handlers
The 11 federal validators
All ERROR severity. Failing any blocks publishing.
V23 was dropped
v4.0.0 had V23 (scope pattern check). v4.0.1 dropped it as redundant — manifest_schema.SCOPE_PATTERN Pydantic validator catches the same thing. The current set is V14-V22 + V24 + V31.
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.
{
"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)" }
}
}See the full manifest reference for every field.
Web-kernel-side guarantees (Phase 2)
The contract is two-sided. You satisfy V14-V22+V24+V31 at build time. The web-kernel holds these runtime guarantees:
Typed dispatch in chains
When the classifier emits an action_plan and your tool has chain_callable=True, the web-kernel dispatches via Pydantic — no second LLM round in your extension.
Pre-flight anti-hallucination
I-AH-1..4 fire before your handler runs. Fabricated IDs, ungrounded narration, out-of-enum hints — caught upstream.
Confirmation chokepoint
Destructive actions intercepted, shown to user, run typed-iterate on accept. Federal I-CONFIRMATION-EXECUTES-WHAT-USER-SAW.
Audit chokepoint
Every action that reaches your handler also reaches the federal [action ledger](/en/reference/glossary/). No bypass.
Runtime invariants you can rely on
The contract isn't only build-time. Several invariants the web-kernel maintains at runtime affect how your extension is called:
| Invariant | Statement |
|---|---|
I-AH-1 | Tool args must not contain id-shaped values not seen in conversation history |
I-AH-2v2 | Narration must not claim data the tools didn't return |
I-AH-3 | Classifier hint must be in the closed enum |
I-AH-4 | Narrator factual claims require backing |
I-PYDANTIC-RETRY-BUDGET | Bounded retry — max 2 per tool_use on ValidationError |
I-CONFIRMATION-EXECUTES-WHAT-USER-SAW | Confirmation card args = byte-identical args on accept |
I-CHAIN-TYPED-PIPE | Chain step outputs flow as typed ctx.prior namespaces |
I-EXT-SYSTEM-TASK-NO-MESSAGE-KWARG | Skeleton/system tasks never receive message= kwarg |
I-EXT-SCHEMA-DESCRIPTION-STRING-DRAFT-2020-12 | Tool schemas use string descriptions (never tuples — typo killer) |
I-EXT-MODULE-ISOLATION | Each extension's Python modules load in an isolated namespace. You can name your modules app.py, handlers.py, config.py, models.py etc. without worrying about cross-extension collisions. Keep imports at the top of the file (no local from app import X inside handler bodies — the binding can freeze against another extension's module if it loaded first in the same worker) |
I-CHAT-FUNCTION-VERBATIM-PARAMS | Legacy ChatExtension(tool_name=...) wrappers MUST instruct the LLM in their system_prompt to pass the user's message verbatim. Otherwise the wrapper-LLM paraphrases and corrupts the typed-params contract. Prefer migrating to direct typed @chat.function dispatch — chain_callable=True is now the default for every action_type (v4.2.10+) so reads also typed-dispatch in chains |
I-CAPABILITY-LIST-MUST-BE-GROUNDED | Webbee's "what can you do" answer is grounded in installed-extension manifest display_name + description. As an extension author, your manifest description IS your capabilities answer — write it accurately, name the actual user-facing capability, and the platform paraphrases verbatim |
I-CAPABILITIES-USER-INSTALLED-ONLY | The platform never surfaces the platform-wide catalogue to a user's chat — only their installed apps (plus system apps). Public privacy/isolation contract |
I-CONTEXT-WINDOW-ADMIN-CONFIGURABLE | Tenant operators can tune the chat conversational-history window via Admin > LLM Config. Extension authors don't address this directly, but should not assume an unbounded history when designing skeletons or chained workflows |
I-LONGRUN-HTTP-CAP-180S | ctx.http(..., timeout=N) per-call kwarg is capped at 180 seconds federally. Larger timeouts raise ValueError pointing to ctx.background_task(). For ops legitimately exceeding 3 minutes, use the background-task primitive (v4.2.12+) |
I-LONGRUN-BG-CORO-RETURNS-ACTIONRESULT | The coroutine passed to ctx.background_task() MUST return ActionResult. Non-ActionResult return triggers a critical audit row and delivers a fallback error message to chat (v4.2.12+) |
I-LONGRUN-BG-USER-SCOPED | Every background task is bound to (ext_id, user_id) at creation. Cross-user cancel/status returns 403. Federal task-lifecycle invariant inherited from the web-kernel task manager (v4.2.12+) |
I-LONGRUN-CHAT-INJECT-USER-SCOPED | ctx.deliver_chat_message() is scoped to (ext_id, user_id); cross-user inject returns 403. Enforced server-side via the acting-user header check (v4.2.12+) |
I-LONGRUN-CHAT-INJECT-AUDIT-EVERY | Every ctx.deliver_chat_message() call writes an audit row. Enforced at the chokepoint, not by extension policy (v4.2.12+) |
I-SECRETS-USER-SCOPED | Every secret read/write is scoped to one user; cross-user access returns 403 |
I-SECRETS-NEVER-LOGGED | Plaintext never appears in action_ledger, journals, error responses, Temporal events, or backups |
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 secret operation writes retention_class='security_forever' (survives retention purges) |
I-SECRETS-HANDLER-SCOPE-MEMORY | Plaintext lives only inside one handler call's stack; SDK has no cache between calls |
I-SECRETS-CONTRACT-DECLARED | Manifest is the single source of truth; reading an undeclared name raises at runtime |
I-BILLING-ENFORCE-UNIFIED | Every billable code path dispatches through a single deduct_for_action() chokepoint; legacy direct-wallet writes are forbidden. BILLING_ENFORCE=true in production |
I-WALLET-PERSISTENT | All four wallet Lua scripts (CHECK_AND_DEDUCT, RESERVE, SETTLE, CREDIT) call PERSIST KEYS[1] atomically; wallet keys are TTL-free by construction |
I-WALLET-SWEEP-ACTIVE | Hourly Temporal WalletPersistSweepWorkflow (namespace imperal-billing) re-runs PERSIST on any wallet keys that regained a TTL. Defence-in-depth; no cron — Temporal only |
I-BYOLLM-PLATFORM-FEE-ZERO | When ctx.user.is_byollm == True, platform_fee → 0 at every deduct site. Resolved at deduct time via async user_llm_config lookup with 60s Redis cache |
I-NARRATOR-NO-FABRICATED-TOKEN-CLAIMS | Narrator MUST NOT emit cost claims to the user unless the claim is sourced from an actual deduct event. Regex+whitelist detector at delivery chokepoint strips fabricated phrases |
I-KAV-CONF-EXPIRES-AT-INT-SECONDS | KAV writer emits expires_at as int seconds (mirrors Redis expires_at_ts). ISO string forbidden |
I-CHAIN-PRIOR-RESULTS-STRUCTURED | Chain executor plumbs structured prior_step_results: list[dict] alongside the text summary; downstream LLMs receive verbatim ActionResult.data |
I-CHAIN-READ-BEFORE-WRITE-DEPENDENCIES | Classifier action_plans[] schema requires depends_on: list[str]; web-kernel applies stable Kahn's topological sort before iteration so reads precede dependent writes |
I-PANEL-RENDERING-CONTRACT | PANEL_SLOT_RENDERING_STATUS is the single source of truth for which slots the Imperal Panel host actually renders. CI gate tests/test_panel_rendering_contract.py enforces symmetry with ALLOWED_PANEL_SLOTS |
I-MANIFEST-EMITTER-SCHEMA-SYMMETRIC | tests/test_manifest_roundtrip_gate.py builds a canary Extension exercising every emitter code path and asserts every field round-trips through validate_manifest_dict with zero issues |
I-USER-ROLE-AUTHORITATIVE | _resolve_user_info cache TTL is bounded at 30s; never caches results with empty role; WARN-logs on auth-gw 200 with empty role. Closes silent admin-demote-for-5-minutes class |
I-PARAMS-NO-PLACEHOLDER-VALUES | SDK check_placeholder_args guard rejects any tool call whose arg values match LLM-placeholder pattern (<UNKNOWN>, <TODO>, <MISSING>, <EMAIL>, <PASSWORD>, <USER_ID>, ...) before write-arg-bleed + target-scope + 2-step confirmation. Tight regex ^<[A-Z][A-Z0-9_]*>$, full-anchored — substrings inside prose and lowercase HTML/XML tags do not trip the guard (v4.2.15+) |
I-CHAIN-PREFLIGHT-RESPECTS-USER-TOGGLE | Pre-flight confirmation cards consult kctx.confirmation_enabled + kctx.confirmation_actions on both the single-action hub.handle_hub_chat path and the multi-step session_workflow.py Phase 2B-2 path. Users with 2-step OFF see uniform behaviour across plan shapes |
Full list with statements and enforcement sites: Federal invariants.
What v4.1+ added on top
| Version | Added |
|---|---|
| v4.1.0 | Pydantic feedback loop — bounded retry on ValidationError with structured prose feedback |
| v4.1.1 | emit_narration audit-mode scope clarification (BYOLLM placeholder leak fix) |
| v4.1.2 | @chat.function(id_projection="...") for compound tool names |
| v4.2.0 | Extension(system=True) flag + V31 validator. Three new invariants: I-SYSTEM-APPS-NEVER-UNINSTALLABLE, I-MARKETPLACE-HIDES-SYSTEM, I-SYSTEM-FLAG-RESERVED-FOR-IMPERAL. See System Apps. |
| v4.2.1 | Validator 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.2 | EXT-SECRETS-V1 — @ext.secret decorator + ctx.secrets accessor + Manifest.secrets[] optional array. Seven new federal invariants (I-SECRETS-USER-SCOPED / NEVER-LOGGED / EXT-SCOPED / VAULT-DEPENDENCY / AUDIT-FOREVER / HANDLER-SCOPE-MEMORY / CONTRACT-DECLARED). Closes ARCH-D1 (Vault integration for per-user encrypted credentials). See @ext.secret reference for the full surface. |
| v4.2.3 | EXT-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.4 | EXT-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.5 | Validator 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.6 | New 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.7 | OAuth 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. New invariants: I-EXT-CACHE-APP-ID-WEB-KERNEL-AUTHORITATIVE, I-WEBHOOK-URL-CANONICAL. |
| v4.2.8 | SecretDecl added to Manifest Pydantic schema (manifest_schema.py). Closes I-MANIFEST-EMITTER-SCHEMA-SYMMETRIC for secrets[] — emitter wrote the field since v4.2.2 but the strict schema didn't declare it. Strict validation now rejects malformed secret entries at publish time. |
| v4.2.9 | Regenerated 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(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.11 | ui.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.12 | LONGRUN-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. Five new federal invariants (see I-LONGRUN-* rows in the runtime-invariants table above). |
| v4.2.13 | LONGRUN-V1 Component D — @chat.function(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.14 | Regenerated 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 / updatedYou 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-sdkRe-run build
imperal buildThis 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=Trueto writes - V21: provide a real SVG icon
Re-publish
imperal build .
imperal validate .
# Then upload the packaged extension via panel.imperal.io/developerThe migration is mostly mechanical — typically 1–2 hours per extension.