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_callabledefaults toFalse— 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_callabledefaults toTruefor write tools so the web-kernel can issue typed dispatch.effectsshould 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_callabledefaults toTrue. The typed dispatch is issued only after confirmation.effectsshould 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
read | write | destructive | |
|---|---|---|---|
| Confirmation gate (KAV) | No | No | Yes — user must confirm |
| Audit ledger entry | No | Yes | Yes |
chain_callable default | False | True | True |
effects required | No | WARN now, ERROR in v5.0.0 | WARN now, ERROR in v5.0.0 |
| Reversible? | N/A — no state change | Generally yes | Generally no |
| External side effect? | No | Sometimes | Always 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 — alwaysctx.user.imperal_id, never self-reported by the extension) - The
app_idand tool name - The
action_type - The
effectslist 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
| Rule | Level | What it checks |
|---|---|---|
V4 | ERROR | action_type is not "read", "write", or "destructive" |
V19 | ERROR | Write or destructive tool has chain_callable=False under actions_explicit=True |
V20 | WARN (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
Chain dispatch
How multi-step chains are orchestrated — chain_callable, id_projection, effects, and depends_on ordering.
@chat.function reference
Full kwarg documentation, [Pydantic feedback loop](/en/reference/glossary/), and all edge cases.
Federal invariants
KAV confirmation gate and action ledger invariants.
Skeletons
Ambient LLM context — the other side of @chat.function.