Imperal Docs
Core Concepts

Action types

read / write / destructive — what each gates, audits, and confirms

Every @chat.function must declare an action_type. The value is one of three strings: "read", "write", or "destructive". This single field drives the confirmation gate, the audit ledger, the chain dispatch behavior, and the validator rules that check your extension at publish time.

Choosing the wrong action type does not cause an immediate error — it silently changes the platform's behavior around your tool. A write marked as read bypasses the audit ledger. A destructive action marked as write bypasses the KAV confirmation gate. Both have consequences in production.


The three types

"read"

A read tool fetches or computes data without modifying any external state. The user's data, the extension's state, and any external systems are all unchanged after the call.

  • No audit row is written by the platform for a read.
  • No confirmation gate is presented to the user.
  • chain_callable defaults to False — the web-kernel delegates to the ChatExtension's internal LLM router, which can format and filter results before returning them.
  • Freely chainable as a dependency for write steps.
from imperal_sdk import Extension, ChatExtension, ActionResult
from pydantic import BaseModel

ext = Extension(
    "notes",
    display_name="Notes",
    description="Notes extension — create, organize, and search your notes with AI.",
    icon="icon.svg",
    actions_explicit=True,
)
chat = ChatExtension(ext, "notes", "Create and manage notes.")


class SearchNotesParams(BaseModel):
    query: str
    limit: int = 10


@chat.function(
    "search_notes",
    description="Search notes by keyword. Returns matching titles and IDs.",
    action_type="read",
)
async def search_notes(ctx, params: SearchNotesParams) -> ActionResult:
    results = await ctx.store.query(
        "notes",
        where={"query": params.query},
        limit=params.limit,
    )
    return ActionResult.success(
        data={"notes": [{"id": d.id, "title": d.data.get("title", "")} for d in results.data]},
        summary=f"Found {len(results.data)} notes.",
    )

"write"

A write tool modifies state but in a reversible or non-destructive way. It creates, updates, or archives data. The user can undo the effect (edit the note back, restore from trash, update a setting).

  • An audit row is written to the action ledger when the call completes.
  • No confirmation gate is presented for write-only tools.
  • chain_callable defaults to True for write tools so the web-kernel can issue typed dispatch.
  • effects should be declared (V20 WARN now, ERROR in SDK v5.0.0).
from imperal_sdk import Extension, ChatExtension, ActionResult
from pydantic import BaseModel

ext = Extension(
    "notes",
    display_name="Notes",
    description="Notes extension — create, organize, and search your notes with AI.",
    icon="icon.svg",
    actions_explicit=True,
)
chat = ChatExtension(ext, "notes", "Create and manage notes.")


class CreateNoteParams(BaseModel):
    title: str
    content: str


@chat.function(
    "create_note",
    description="Create a new note with the given title and content.",
    action_type="write",
    chain_callable=True,
    effects=["create:note"],
)
async def create_note(ctx, params: CreateNoteParams) -> ActionResult:
    doc = await ctx.store.create("notes", {"title": params.title, "content": params.content})
    return ActionResult.success(
        data={"note_id": doc.id},
        summary=f"Note '{params.title}' created.",
    )

"destructive"

A destructive tool modifies state in a way that cannot be undone — or performs an irreversible external action such as sending an email, calling a third-party write API, permanently deleting a record, or purging a queue.

  • An audit row is written to the action ledger.
  • A KAV (Pre-Authorized Action Execution) confirmation card is presented to the user before execution. The user must explicitly confirm.
  • chain_callable defaults to True. The typed dispatch is issued only after confirmation.
  • effects should be declared (V20 WARN now, ERROR in SDK v5.0.0).
  • The confirmation card is built from the tool's description, effects list, and resolved params — what the user sees is exactly what will execute.
from imperal_sdk import Extension, ChatExtension, ActionResult
from pydantic import BaseModel

ext = Extension(
    "notes",
    display_name="Notes",
    description="Notes extension — create, organize, and search your notes with AI.",
    icon="icon.svg",
    actions_explicit=True,
)
chat = ChatExtension(ext, "notes", "Create and manage notes.")


class DeleteNoteParams(BaseModel):
    note_id: str


@chat.function(
    "delete_note",
    description="Permanently delete a note. This cannot be undone.",
    action_type="destructive",
    chain_callable=True,
    effects=["delete:note"],
)
async def delete_note(ctx, params: DeleteNoteParams) -> ActionResult:
    note = await ctx.store.get("notes", params.note_id)
    if note is None:
        return ActionResult.error("Note not found.")
    await ctx.store.delete("notes", params.note_id)
    return ActionResult.success(
        data={"note_id": params.note_id},
        summary="Note permanently deleted.",
    )

Comparison table

readwritedestructive
Confirmation gate (KAV)NoNoYes — user must confirm
Audit ledger entryNoYesYes
chain_callable defaultFalseTrueTrue
effects requiredNoWARN now, ERROR in v5.0.0WARN now, ERROR in v5.0.0
Reversible?N/A — no state changeGenerally yesGenerally no
External side effect?NoSometimesAlways or commonly

The KAV confirmation gate

KAV (Pre-Authorized Action Execution) is the web-kernel mechanism that presents a user-visible confirmation card before executing any destructive tool. The card shows:

  • The tool description (what will happen)
  • The effects list (what resources are affected)
  • The resolved params (the exact values — which note, which folder, which email address)

The user sees a Confirm and Cancel button. If the user confirms, the web-kernel executes the typed dispatch exactly as planned. If the user cancels, execution halts and no action is taken.

Federal invariant I-CONFIRMATION-EXECUTES-WHAT-USER-SAW: the action that executes after confirmation must be identical to what was shown on the card. The web-kernel does not re-derive params after confirmation — it executes the stored intercepted call verbatim. There is no gap between what the user approved and what runs.

In a chain with multiple destructive steps, each step gates independently. The user sees one card per destructive step, in execution order.


The action ledger

Every write and destructive action is recorded in the action ledger. The ledger entry includes:

  • The user_id (web-kernel-authoritative — always ctx.user.imperal_id, never self-reported by the extension)
  • The app_id and tool name
  • The action_type
  • The effects list from the function declaration
  • Timestamp
  • Success/failure status

The ledger is the platform's audit trail for all state-modifying actions. It is append-only from the extension's perspective — extensions cannot delete or modify ledger entries.

For destructive actions, the ledger entry is written after confirmation — if the user cancels, no entry is created.

Validator V4 enforces that every @chat.function has a valid action_type. An invalid or missing action_type is an ERROR at imperal validate time and will block publication to the Marketplace.


effects and action type

The effects list (effects=["create:note"], effects=["delete:folder"], etc.) is layered on top of action_type — it provides resource-level granularity within the broader category.

action_type controls platform behavior (confirmation gate, audit, chain dispatch defaults). effects provides descriptive labeling of what the tool changes — used by the chain narrator, the audit ledger, and the confirmation card text.

Effects do not change which type a tool belongs to. A tool with action_type="write" and effects=["create:note"] is still a write tool — no confirmation gate. A tool with action_type="destructive" and effects=["delete:note"] gates on confirmation regardless of the effects content.

from imperal_sdk import Extension, ChatExtension, ActionResult
from pydantic import BaseModel

ext = Extension(
    "mail-client",
    display_name="Mail",
    description="Mail extension — send, receive, and organize email.",
    icon="icon.svg",
    actions_explicit=True,
)
chat = ChatExtension(ext, "mail-client", "Read and send email.")


class ArchiveParams(BaseModel):
    email_id: str


class PurgeParams(BaseModel):
    email_id: str


# write — no confirmation gate, but audit row written
@chat.function(
    "archive",
    description="Move an email to the archive folder.",
    action_type="write",
    event="archived",
    effects=["update:email"],
)
async def archive(ctx, params: ArchiveParams) -> ActionResult:
    # ... implementation
    return ActionResult.success(
        data={"email_id": params.email_id},
        summary="Email archived.",
    )


# destructive — confirmation gate required; email cannot be recovered
@chat.function(
    "purge",
    description="Permanently delete an email. This cannot be undone.",
    action_type="destructive",
    event="purged",
    effects=["delete:email"],
)
async def purge(ctx, params: PurgeParams) -> ActionResult:
    # ... implementation
    return ActionResult.success(
        data={"email_id": params.email_id},
        summary="Email permanently deleted.",
    )

The mail-client extension (handlers_manage.py) uses this exact split: archive, delete, mark_read, mark_unread, star, move, bulk_archive, bulk_delete, bulk_mark_read, bulk_mark_unread are all action_type="write". Only purge is action_type="destructive" — because purge permanently deletes email with no recovery path.


Decision tree

Work through these questions in order:

1. Does the tool make any change to any data or external system?

  • No — it only reads, filters, or computes → use "read".
  • Yes → continue.

2. Can the change be undone without data loss?

The question is whether a user who regrets the action can recover fully:

  • The note can be restored from trash.
  • The setting can be toggled back.
  • The record can be recreated.

If yes → use "write".

3. Is the change irreversible — or does it trigger an external action with external consequences?

  • Permanent deletion (no trash, no recovery).
  • Sending an email (cannot unsend).
  • Posting to an external API that has real-world consequences (a payment, a deploy, a webhook call to an external system).
  • Purging a queue.
  • Writing to a billing record.

If yes → use "destructive".


Common mistakes

Write marked as read — bypasses audit

from imperal_sdk import Extension, ChatExtension, ActionResult
from pydantic import BaseModel

ext = Extension(
    "notes",
    display_name="Notes",
    description="Notes extension — create, organize, and search your notes with AI.",
    icon="icon.svg",
    actions_explicit=True,
)
chat = ChatExtension(ext, "notes", "Create and manage notes.")


class CreateNoteParams(BaseModel):
    title: str
    content: str


# WRONG — creates data but is marked read; no audit row, no effects validation
@chat.function(
    "create_note_wrong",
    description="Create a note.",
    action_type="read",  # wrong — this modifies state
)
async def create_note_wrong(ctx, params: CreateNoteParams) -> ActionResult:
    doc = await ctx.store.create("notes", {"title": params.title, "content": params.content})
    return ActionResult.success(data={"note_id": doc.id}, summary="Note created.")


# RIGHT
@chat.function(
    "create_note",
    description="Create a note.",
    action_type="write",
    chain_callable=True,
    effects=["create:note"],
)
async def create_note_right(ctx, params: CreateNoteParams) -> ActionResult:
    doc = await ctx.store.create("notes", {"title": params.title, "content": params.content})
    return ActionResult.success(data={"note_id": doc.id}, summary="Note created.")

Marking a write as read suppresses the audit ledger entry for every call. If something goes wrong, there is no record of what was created and when.

Destructive marked as write — bypasses confirmation gate

from imperal_sdk import Extension, ChatExtension, ActionResult
from pydantic import BaseModel

ext = Extension(
    "notes",
    display_name="Notes",
    description="Notes extension — create, organize, and search your notes with AI.",
    icon="icon.svg",
    actions_explicit=True,
)
chat = ChatExtension(ext, "notes", "Create and manage notes.")


class DeleteNoteParams(BaseModel):
    note_id: str


# WRONG — permanent deletion, but no KAV gate; user never confirms
@chat.function(
    "delete_note_wrong",
    description="Permanently delete a note.",
    action_type="write",  # wrong — this cannot be undone
    chain_callable=True,
    effects=["delete:note"],
)
async def delete_note_wrong(ctx, params: DeleteNoteParams) -> ActionResult:
    await ctx.store.delete("notes", params.note_id)
    return ActionResult.success(data={"note_id": params.note_id}, summary="Note deleted.")


# RIGHT
@chat.function(
    "delete_note",
    description="Permanently delete a note. This cannot be undone.",
    action_type="destructive",
    chain_callable=True,
    effects=["delete:note"],
)
async def delete_note_right(ctx, params: DeleteNoteParams) -> ActionResult:
    await ctx.store.delete("notes", params.note_id)
    return ActionResult.success(data={"note_id": params.note_id}, summary="Note permanently deleted.")

Marking a destructive action as write means the user never sees a confirmation card. Actions that cannot be undone execute immediately without explicit approval.

Treating action_type="read" as a performance shortcut

Some developers mark tools as read because they assume it makes execution faster (no ledger write, no gate). The correct criterion is whether the tool modifies state — not performance. A read that calls an external write API is still a write from the platform's perspective, regardless of how the result is returned.

Write tool without effects

from imperal_sdk import Extension, ChatExtension, ActionResult
from pydantic import BaseModel

ext = Extension(
    "tasks",
    display_name="Tasks",
    description="Tasks extension — manage and track your tasks with AI assistance.",
    icon="icon.svg",
    actions_explicit=True,
)
chat = ChatExtension(ext, "tasks", "Create and organize tasks.")


class UpdateTaskParams(BaseModel):
    task_id: str
    title: str


# WARN at imperal validate (ERROR in SDK v5.0.0)
@chat.function(
    "update_task_wrong",
    description="Update a task's title.",
    action_type="write",
    chain_callable=True,
    # effects= missing
)
async def update_task_wrong(ctx, params: UpdateTaskParams) -> ActionResult:
    await ctx.store.update("tasks", params.task_id, {"title": params.title})
    return ActionResult.success(data={"task_id": params.task_id}, summary="Task updated.")


# RIGHT
@chat.function(
    "update_task",
    description="Update a task's title.",
    action_type="write",
    chain_callable=True,
    effects=["update:task"],
)
async def update_task_right(ctx, params: UpdateTaskParams) -> ActionResult:
    await ctx.store.update("tasks", params.task_id, {"title": params.title})
    return ActionResult.success(data={"task_id": params.task_id}, summary="Task updated.")

The effects list is what the chain narrator and audit ledger use to describe what changed. Without it, the narrator falls back to generic "action completed" language and the ledger entry lacks resource-level provenance. Declare effects on every write and destructive tool.


Validator rules

RuleLevelWhat it checks
V4ERRORaction_type is not "read", "write", or "destructive"
V19ERRORWrite or destructive tool has chain_callable=False under actions_explicit=True
V20WARN (ERROR in v5.0.0)Write or destructive tool has no effects declaration

Production examples

The following patterns appear in production extensions. Citations are by extension and file only.

notes extension (handlers_notes.py): search, get, list, count are action_type="read". create_note, update_note, pin_note are action_type="write". trash_note, delete_note, empty_trash are action_type="destructive".

mail-client extension (handlers_inbox.py, handlers_manage.py): inbox, read_email, search, folder, get_thread are action_type="read". send, reply, forward, archive, mark_read, bulk_archive are action_type="write". purge is action_type="destructive".

tasks extension (handlers_crud.py): read tools for listing and querying. Write tools for create_task, update_task, assign_task, add_subtask. Destructive tool for delete_task.

web-tools extension (handlers_monitors.py): list_monitors is action_type="read". create_monitor, update_monitor are action_type="write". delete_monitor is action_type="destructive".


What's next

On this page