Imperal Docs
Core Concepts

Action types

read / write / destructive action types — how Imperal Cloud gates side-effects, audit, and confirmations for every chat function declared in your extension.

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.

Each tool also declares its return shape with data_model= — an SDL type (an sdl.Entity for a single record, a concrete sdl.EntityList[T] for a list). Read tools require it (V23); write/destructive tools should return the resulting sdl.Entity (V24). The examples below show that shape alongside each action type.

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 True — like every action type, a read tool is dispatchable as a direct typed call so the web-kernel can execute it deterministically. Set chain_callable=False only when you want the read routed through the ChatExtension's internal LLM router to format or filter results before returning them.
  • Freely chainable as a dependency for write steps.
from imperal_sdk import Extension, ChatExtension, ActionResult, sdl
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 Note(sdl.Entity):
    pass


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


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",
    data_model=NoteList,   # ← required for reads (V23)
)
async def search_notes(ctx, params: SearchNotesParams) -> ActionResult:
    results = await ctx.store.query(
        "notes",
        where={"query": params.query},
        limit=params.limit,
    )
    items = [Note(id=d.id, title=d.data.get("title", "")) for d in results.data]
    return ActionResult.success(
        NoteList(items=items, total=results.total),
        summary=f"Found {len(items)} 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, sdl
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 Note(sdl.Entity, sdl.Bodied):
    pass


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"],
    data_model=Note,   # ← recommended for writes (V24)
)
async def create_note(ctx, params: CreateNoteParams) -> ActionResult:
    doc = await ctx.store.create("notes", {"title": params.title, "content": params.content})
    return ActionResult.success(
        Note(id=doc.id, title=params.title, body=params.content),
        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, sdl
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 Note(sdl.Entity):
    pass


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"],
    data_model=Note,   # ← recommended for writes/destructive (V24)
)
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(
        Note(id=params.note_id, title=note.data.get("title", "")),
        summary="Note permanently deleted.",
    )

Comparison table

readwritedestructive
Confirmation gate (KAV)NoNoYes — user must confirm
Audit ledger entryNoYesYes
chain_callable defaultTrueTrueTrue
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.

Confirm-what-you-saw guarantee: the action that executes after confirmation is identical to what was shown on the card. The platform does not re-derive params after confirmation — it executes the exact call you approved. 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 is declared-intent metadata that describes, in a stable machine-readable form, what the tool changes. It is emitted into your manifest and reserved for future platform use; it does not currently drive any runtime behavior.

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 declared-intent metadata describing what a tool changes, emitted into your manifest and reserved for future platform use. It does not drive runtime behavior today, but declaring it on every write and destructive tool keeps your tools' intent accurate and forward-compatible — and clears the V20 validator warning.


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
V23WARN (env-promotable to ERROR)action_type="read" tool has no data_model= (an sdl.Entity / sdl.EntityList[T])
V24WARNaction_type="write"/"destructive" tool has no data_model= returning the resulting sdl.Entity

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