Imperal Docs
SDK Reference

Validators reference

V14-V22 + V24 + V31 — every federal validator your extension must pass

When you run imperal validate ./extension (or imperal build), the SDK runs 11 federal validators on your manifest + code. Every one is ERROR severity — failing any blocks publishing. This page documents what each catches and how to fix common failures.

V23 was dropped

Earlier internal lists referenced V23. It was redundant with V19 and removed during the federal contract v4.0 sprint. The current set is V14-V22 + V24 + V31.

Synthetic tools don't count (v4.2.5+)

Validator tool_count (used by V3 "at least one tool" and by marketplace-display tool counts) excludes any tool whose name matches a synthetic-prefix allowlist: __panel__*, __widget__*, __tray__*, __webhook__*. These are platform-provided (e.g. the always-present __panel__secrets introduced in v4.2.4) and shouldn't count as author-authored tools. If you have zero real @ext.tool / @chat.function handlers, the validator correctly fires V3 again — not masked by synthetics.

V14 — manifest schema v3 conformance

Catches: Manifest doesn't match the strict v3 schema (missing required fields, unknown fields, wrong types).

✗ V14: Manifest invalid: 'tools[2].action_type' missing.

Fix: Re-run imperal build ./extension to regenerate from your code. Don't hand-edit imperal.json.

V15 — display name ≥ 3 chars, ≠ app_id

Catches: display_name is empty, shorter than 3 chars, or exactly matches app_id (case-sensitive).

✗ V15: display_name 'my' is too short (2 chars, min 3).
✗ V15: display_name must differ from app_id (case-sensitive).

Fix: Set a real display name:

ext = Extension(
    "tasks-mini",                 # app_id
    display_name="Tasks (Mini)",  # V15: ≥3 chars, ≠ app_id
    description="A task manager Webbee can drive in plain language. Supports create, complete, and delete.",
    icon="icon.svg",
    actions_explicit=True,
)

V16 — tool description ≥ 20 chars

Catches: @chat.function(description="...") is empty or < 20 chars. Synthetic __* tools are exempt.

✗ V16: Function 'create_task' description too short (4 chars): "TODO".

Fix: Write a real description of ≥20 chars that tells the LLM when to call this tool:

@chat.function(
    description="Add a task to the user's pending list.",
    ...
)

A good description explains when the LLM should call this tool, not just what it does.

V17 — Pydantic params model is module-scope

Catches: A @chat.function declares pydantic_param=SomeModel, but SomeModel is defined inside a function (function-local).

✗ V17: Pydantic model 'tasks_mini.handlers.MyParams' is function-local.
       Move it to module scope.

Why it matters: Function-local models silently disable the SDK's auto-detection that drives the retry feedback loop. Federal I-PYDANTIC-RETRY-SCOPE.

Fix: Move the class out of the function:

# ❌ DON'T
async def my_handler(ctx, **kwargs):
    class MyParams(BaseModel): ...   # function-local — V17 fails

# ✅ DO
class MyParams(BaseModel): ...       # module-scope

async def my_handler(ctx, params: MyParams): ...

V18 — Pydantic params model has no forward references

Catches: Pydantic model uses string-typed forward refs (from __future__ import annotations style without resolution).

✗ V18: 'tasks_mini.MyParams.assignee' is a forward reference.
       Resolve before declaration or use direct type.

Fix: Either drop from __future__ import annotations for the file, or call MyParams.model_rebuild() after the referent is defined.

V19 — actions_explicit=True AND chain_callable=True for write/destructive

Catches: Extension declares actions_explicit=False, or has a write/destructive tool with chain_callable=False.

✗ V19: Extension must set actions_explicit=True.
✗ V19: Function 'delete_task' is destructive but chain_callable=False.

Fix:

ext = Extension("my-app", ..., actions_explicit=True)  # required

@chat.function(
    description="...",
    action_type="write",
    chain_callable=True,   # required for write/destructive
    ...
)

V20 — effects declared for write/destructive (WARN v4, ERROR v5)

Catches: A write or destructive @chat.function has no effects= declared. Currently a WARNING in v4; will become ERROR in v5.

⚠ V20: Function 'send_email' is write but has no effects declared.
        Add effects=['email.send'] (or similar compliance tag).

Fix:

@chat.function(
    description="Send an email to the recipient.",
    action_type="write",
    effects=["email.send"],   # V20 compliance tag
)
async def send_email(ctx, params): ...

action_type is validated separately — it must be one of {'read', 'write', 'destructive'}:

ChooseWhen
readNo side effects (queries, lookups, formatting).
writeCreates/updates user data. Audited.
destructiveIrreversible (deletes, sends, charges). Triggers confirmation card.

V21 — required SVG marketplace icon

Catches: Your Extension(...) declaration is missing icon=, or the path is not .svg, or the SVG fails structural validation (no <svg> root, missing viewBox, embedded base64 raster, >100 KB, malformed XML).

✗ V21: Extension 'my-app' must declare icon='icon.svg'
       (federal V21 — required SVG marketplace icon)

✗ V21: Extension icon 'logo.png' must be SVG (.svg extension)

✗ V21: icon.svg root element must be <svg>

✗ V21: icon.svg viewBox attribute required for multi-size rendering

✗ V21: icon.svg contains embedded base64 raster — pure SVG only

Why it matters: The marketplace, left sidebar, and chat surfaces all render the extension icon at multiple sizes (16 px tray, 32 px sidebar, 64 px cards, 128 px detail). A valid SVG with a viewBox is the only format that scales cleanly. Raster icons pixelate; base64-embedded raster inside SVG bloats the payload and defeats the point. The 100 KB cap keeps the marketplace listing payload bounded.

Fix:

ext = Extension(
    "my-app",
    display_name="My App",
    description="...",
    icon="icon.svg",   # V21: required, points to a real file next to app.py
    actions_explicit=True,
)

Add an icon.svg file next to your app.py:

<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
     fill="none" stroke="currentColor" stroke-width="2"
     stroke-linecap="round" stroke-linejoin="round" width="24" height="24">
  <!-- your paths -->
</svg>

After you upload your packaged extension at panel.imperal.io/developer, the platform stores the icon.svg bytes in the marketplace registry and streams them back to every surface (sidebar, marketplace, chat) at request time. See Manifest reference §icon for the publish-time contract.

What about HTTP clients?

ctx.http is the recommended way to make outbound HTTP calls — it adds auth headers, audit logs, retries, and circuit breaking. Using requests/httpx/urllib directly is discouraged but not enforced by a federal validator. The federal ban on direct LLM-provider imports (anthropic, openai) is enforced by V7 — see your imperal validate output for the exact rule that fired.

V22 — lifecycle hook signatures match SDK contract

Catches: A lifecycle hook is missing required kwargs that the web-kernel passes when calling it.

✗ V22: Lifecycle hook 'on_refresh' missing required kwargs: ['message'].
       Add message=None to the hook signature, or accept **kwargs.

Why it matters: The web-kernel calls on_refresh(ctx, message=<text>). If your hook declares only async def on_refresh(ctx):, the web-kernel raises TypeError at runtime — closes the on_refresh(message=) TypeError class.

Fix:

# ❌ Don't — missing message= kwarg
@ext.on_refresh
async def on_refresh(ctx):
    ...

# ✅ Do — add message= or accept **kwargs
@ext.on_refresh
async def on_refresh(ctx, message=None, **kwargs):
    ...

Required kwargs per hook:

HookRequired kwargs
on_installnone
on_uninstallnone
on_refreshmessage
on_upgradefrom_version

V24 — @chat.function handlers must not access ctx.skeleton.*

Catches: AST scan finds ctx.skeleton.foo access inside @chat.function handler bodies.

✗ V24: tasks_mini/handlers.py:67 accesses ctx.skeleton.something inside @chat.function.
       Only @ext.skeleton tools may read ctx.skeleton. Use ctx.store or ctx.http.

Why it matters: ctx.skeleton is the LLM-facts snapshot consumed by the intent classifier. It is capped, sanitised, and eventually-consistent — not a live data source. Only @ext.skeleton refresh tools are allowed to read it. Accessing it from @chat.function violates invariant I-SKELETON-LLM-ONLY and will raise SkeletonAccessForbidden at runtime.

Fix:

# ❌ Don't — accessing skeleton from a chat function
@chat.function(description="List active monitors.")
async def list_monitors(ctx, params):
    data = await ctx.skeleton.get("monitors_summary")  # V24 fails

# ✅ Do — query the real store
@chat.function(description="List active monitors.")
async def list_monitors(ctx, params):
    page = await ctx.store.query("monitors", where={"status": "active"})
    return ActionResult.success({"monitors": [d.data for d in page.data]}, ...)

V31 — Extension(system=True) reserved for Imperal authors

Catches: Local validator inspects Extension(system=True). If IMPERAL_AUTHOR_ID env var is set and the value is not in the Imperal first-party allowlist, the validator fails with V31.

✗ V31: Extension(system=True) is reserved for first-party Imperal extensions
       (admin / billing / developer / automations). Author 'imp_u_dimas-...'
       is not in the Imperal author allowlist.

Why it matters: System apps (system=True) are auto-installed for every user on registration, never shown in marketplace listings, and cannot be uninstalled. Letting a third party set this flag would let them slip past discovery and claim platform-level trust they have not been granted.

Fix:

# ❌ Don't — third-party developer setting system=True
ext = Extension("my-cool-app", system=True, ...)  # V31 fails locally,
                                                  # Dev Portal rejects at publish

# ✅ Do — drop the flag, ship through the normal marketplace flow
ext = Extension("my-cool-app", ...)

Local dev without IMPERAL_AUTHOR_ID is not blocked so you can play with the flag in tests. The authoritative gate is the Dev Portal: when you upload your packaged extension at panel.imperal.io/developer, the portal looks up your developer record and rejects the publish if you're not in the Imperal author set. See /en/concepts/system-apps for the full contract.

V32 — IMPERAL_SECRET_DECLARED — coming in a future release

Catches: AST scanner walks every .py in your extension source looking for credential-like field access without a matching @ext.secret declaration:

  • os.environ.get("STRIPE_KEY"), os.environ["GITHUB_TOKEN"], and similar — key name matches regex (?i)(api[_-]?key|token|secret|password|client[_-]?secret|refresh[_-]?token|access[_-]?token|signing[_-]?secret|whsec)
  • redis.hget("user_settings", "api_token"), redis.get("imperal:byollm:...") — same regex on the field name
  • f-string interpolations of those env vars into URLs

For each detected pattern, V32 checks whether your manifest's secrets[] declares a name within Levenshtein distance ≤ 2 of the detected key. If yes → pass. If no → ERROR.

✗ V32: Credential-like access 'STRIPE_KEY' at line 17 has no matching
       @ext.secret declaration in manifest.

Why it matters: Federal EXT-SECRETS-V1 contract — credentials must flow through platform-KMS encryption (@ext.secret + ctx.secrets.get()), never as plaintext from os.environ or Redis. Plain-storage paths cannot satisfy I-SECRETS-NEVER-LOGGED or I-SECRETS-USER-SCOPED.

Fix:

# ❌ Don't — plaintext credential from environment
import os
sk = os.environ.get("STRIPE_KEY")

# ✅ Do — declare via @ext.secret, read via ctx.secrets
ext.secret(
    name="stripe_api_key",
    description="Your Stripe secret key (sk_live_...).",
    required=True, write_mode="user", max_bytes=200,
)(lambda: None)

@chat.function
async def create_charge(ctx, amount: int):
    sk = await ctx.secrets.get("stripe_api_key")
    ...

Bypass marker (for legitimate cases — test fixtures, legacy bootstrap migrating):

# imperal-allow-plaintext-credential: legacy bootstrap migrating in v1.5
sk = os.environ.get("STRIPE_KEY")

V32 demotes the violation to WARN severity and captures the reason in validator output. Use sparingly; the marker is audit-trail visible.

Not yet enforced

Implementation status: the V32 spec is locked, but publish-flow enforcement has not yet shipped. Until it lands, extensions that bypass @ext.secret are not auto-blocked at publish. Marketplace review can still flag them, and they will fail V32 automatically once enforcement turns on — write to the spec now so you don't get caught.

What V14-V22 + V24 + V31 don't catch

Validators are static checks. They can't see:

  • Whether your description matches what your handler actually does — you can lie. The marketplace review process (and user complaints) catch this.
  • Whether your code scopes state correctly by ctx.user.imperal_id — federal author-discipline.
  • Whether your handler is correct — semantic correctness needs tests + user feedback.

You're trusted but reviewed. Run the validators, but also run your tests and read your code.

Where to next

On this page