Imperal Docs
SDK Reference

Validators reference

The Imperal SDK federal validators (V1-V24 + V24-AST + V31): what each one catches, its severity (most are ERROR; V10/V20/V23/V24 are WARN), why publishing fails, and how to fix the common failures.

When you run imperal validate ./extension (or imperal build), the SDK runs its federal validators over your manifest + code. The full set is V1-V24 + V24-AST + V31 (with V32 specced for a future release). Most are ERROR severity — failing any ERROR blocks publishing. A handful are advisory: V10, V20, V23, and V24 are WARN (recommendations, not publish blockers), and V12 is INFO. This page documents what the main contract validators (V14-V24, V24-AST, V31) catch and how to fix common failures.

Severity at a glance

ERROR (blocks publish): V1-V5, V7, V14-V19, V21, V22, V24-AST (skeleton access), V31. WARN (recommendation): V6, V8, V9, V10, V13, V20, V23 (read-tool data_model), V24 (write/destructive data_model). INFO: V12. V23 is not dropped — it is the live SDL read-tool rule (see V23 below) and is promotable to ERROR via IMPERAL_VALIDATOR_V23_SEVERITY=error.

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 — extension description ≥ 40 chars, ≠ app_id

Catches: Extension(description=...) is empty, shorter than 40 chars, or exactly matches app_id. (Manifest schema conformance — required fields, unknown fields, wrong types — is enforced separately by the M1-M8 manifest-schema rules, not V14.)

✗ V14: description 'a task app' is too short (10 chars, min 40).
✗ V14: description must differ from app_id.

Fix: Write a real extension description of ≥40 chars that differs from app_id:

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

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(
    "create_task",
    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. Keeping the model at module scope is what guarantees the loop stays active.

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 — @chat.function declares a typed return annotation

Catches: A @chat.function handler has no typed return annotation. Every handler must declare a return type that resolves to ActionResult (or a subclass) or a Pydantic BaseModel subclass. A bare handler (or -> dict) fails. The typed-return requirement is enforced primarily by V5 (ERROR) — the rule that fires first on a missing/untyped return — with V18 providing the broader enforcement across handler shapes. If your imperal validate output cites V5, it is the same contract described here.

✗ V18: @chat.function 'create_task' must declare a typed return
       annotation: -> ActionResult (or subclass / Pydantic model).

Why it matters: The platform reads the return schema from your manifest to drive typed dispatch. A typed return is what lets downstream chain steps consume your tool's output safely.

Fix: Annotate the handler return as -> ActionResult and return an SDL entity as the data model:

from imperal_sdk import sdl
from imperal_sdk.chat import ActionResult

class Task(sdl.Entity, sdl.Completable):
    pass

@chat.function(
    "create_task",
    description="Create a task in the user's pending list.",
    action_type="write",
    data_model=Task,
)
async def create_task(ctx, params: CreateTaskParams) -> ActionResult:
    row = await ctx.store.create("tasks", {"title": params.title})
    return ActionResult.success(
        Task(id=row.id, title=params.title),
        summary="Task created.",
    )

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(
    "update_task",
    description="...",
    action_type="write",
    chain_callable=True,   # required for write/destructive
    ...
)

V20 — effects declared for write/destructive (WARN)

Catches: A write or destructive @chat.function has no effects= declared. This is a WARN-level check — a recommendation, never a publish blocker.

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

effects is advisory metadata

effects= is advisory, declared-intent metadata. The kernel does not currently read it — the narrator and audit ledger do not consume it. Declare it for convention and forward-compatibility; it does not change runtime behavior today.

Fix:

@chat.function(
    "send_email",
    description="Send an email to the recipient.",
    action_type="write",
    effects=["email.send"],   # V20 — advisory 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

V23 — read tool must declare data_model (SDL)

Catches: A @chat.function(action_type="read", ...) handler has no data_model= (and no -> ActionResult[T] / -> SomeBaseModel return annotation). This is the SDL read-tool enforcement — read tools must declare a typed return shape so the platform can validate $REF paths and prevent input/output naming drift.

⚠ V23: @chat.function 'list_notes' (action_type=read) is missing data_model
       declaration. Read tools must declare typed return shape so the platform
       can validate $REF paths and prevent naming drift.

Severity: WARN by default — a recommendation, not a publish blocker. It is promotable to ERROR by setting the environment variable IMPERAL_VALIDATOR_V23_SEVERITY=error (the soak toggle; flipped to error after third-party adoption stabilises). V23 is live, not dropped.

Fix: point data_model= at an sdl.Entity subclass (single record) or a concrete sdl.EntityList[T] (list result):

from imperal_sdk import sdl
from imperal_sdk.chat import ActionResult

class Note(sdl.Entity, sdl.Bodied):
    pass

class NoteList(sdl.EntityList[Note]):
    pass

@chat.function(
    "list_notes",
    description="List the user's notes, most recent first.",
    action_type="read",
    data_model=NoteList,   # V23 — declares the typed return shape
)
async def list_notes(ctx, params) -> ActionResult:
    rows = await ctx.store.query("notes")
    notes = [Note(id=r.id, title=r.data["title"], body=r.data.get("body")) for r in rows.data]
    return ActionResult.success(
        NoteList(items=notes, total=len(notes), has_more=False),
        summary=f"{len(notes)} note(s).",
    )

V24 — write/destructive tool should declare data_model (SDL)

Catches: A @chat.function(action_type="write") or action_type="destructive" handler has no data_model=. Declaring it lets the chain narrator and audit ledger see the resulting entity shape.

⚠ V24: @chat.function 'create_note' (action_type=write) lacks data_model
       declaration. Recommended: typing write/destructive returns so narrator
       + audit ledger see the resulting entity shape.

Severity: WARN-only — always a recommendation, never a publish blocker (it is not env-promotable).

Fix: declare data_model= pointing at the sdl.Entity the handler returns:

from imperal_sdk import sdl
from imperal_sdk.chat import ActionResult

class Note(sdl.Entity, sdl.Bodied):
    pass

@chat.function(
    "create_note",
    description="Create a note in the user's notebook.",
    action_type="write",
    data_model=Note,   # V24 — typed return shape for narrator + audit
)
async def create_note(ctx, params) -> ActionResult:
    row = await ctx.store.create("notes", {"title": params.title, "body": params.body})
    return ActionResult.success(
        Note(id=row.id, title=params.title, body=params.body),
        summary="Note created.",
    )

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

Catches: AST scan finds ctx.skeleton.foo access inside @chat.function handler bodies. This rule is emitted as code V24-AST (distinct from the V24 data_model recommendation above) and is ERROR severity — it blocks publishing.

✗ V24-AST: tasks_mini/handlers.py:67 accesses ctx.skeleton.something inside @chat.function. {/* docs-guard: allow ctx.skeleton.something — intentional test/mock example */}
       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 is forbidden and will raise SkeletonAccessForbidden at runtime.

Fix:

from imperal_sdk import sdl
from imperal_sdk.chat import ActionResult

class Monitor(sdl.Entity, sdl.ServiceHealth):
    pass

class MonitorList(sdl.EntityList[Monitor]):
    pass

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

# ✅ Do — query the real store and return a typed sdl.EntityList
@chat.function("list_monitors", description="List active monitors.", action_type="read", data_model=MonitorList)
async def list_monitors(ctx, params) -> ActionResult:
    page = await ctx.store.query("monitors", where={"status": "active"})
    monitors = [Monitor(id=d.id, title=d.data["name"], health=d.data.get("health")) for d in page.data]
    return ActionResult.success(MonitorList(items=monitors, total=len(monitors)), summary=f"{len(monitors)} monitor(s).")

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("github_token") — 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: Credentials must flow through platform-managed encryption (@ext.secret + ctx.secrets.get()), never as plaintext from os.environ or Redis. Only the managed path keeps secrets out of logs and scoped to the individual user — plain-storage paths cannot offer those guarantees.

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 the validators 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