Imperal Docs
Core Concepts

Chain dispatch

How the web-kernel orchestrates multi-step actions — chain_callable, id_projection, effects, depends_on

Chain dispatch is the web-kernel mechanism that executes multiple @chat.function calls from different extensions in a single user turn — each step informed by the structured results of the steps before it.

When a user says "query the database, save a note with the results, and email it to the team," the web-kernel does not call three tools and hope the LLM stitches the outputs together. Instead, it runs a typed orchestration: SQL step executes first, its structured data flows to the notes step, notes step result flows to the mail step. No data is lost to LLM paraphrase in between.


Single-step vs chain

The simplest interaction is a single-step call: one user message, one @chat.function, one result surfaced to the user. Most interactions work this way.

A chain triggers when the intent classifier determines that satisfying the user's request requires actions across two or more extensions — and that the results of some steps are inputs to others.

Single-stepChain
Extensions involvedOneTwo or more
StepsOneTwo or more, ordered
Step resultsReturned to userPassed forward to next step
LLM routingClassifier picks one toolClassifier picks a sequence with dependencies
Confirmation gatesPer destructive toolPer destructive step, independently gated

A chain is not a macro or a pipeline you define. The classifier builds it dynamically from the user's message and the available extensions' manifests.


Chain-adjacent mechanisms

@ext.schedule (cron jobs)

Scheduled tasks run on a timer, entirely outside any user chat session. They are not chains — there is no LLM involved, no user message, and no step-to-step data flow. A scheduled job that calls three store operations does so as a single execution unit, not as three independently routable steps.

Multi-turn conversation

A user can ask a follow-up question in the next message that references the previous message's output. That is a conversational multi-turn, not a chain. The LLM produces the next response from the conversation history. Chain dispatch is strictly intra-turn: everything executes within the handling of one user message.


chain_callable=True

The chain_callable flag on @chat.function tells the web-kernel that this tool should be called as a direct typed dispatch — app/func(args) — rather than going through the ChatExtension's internal LLM router.

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.",
    )

Default behavior: chain_callable defaults to True for any action_type of "write" or "destructive". For "read" tools, the default is False — the web-kernel delegates to the ChatExtension's LLM router because read tools often need an internal reasoning turn to format or filter results.

You can set chain_callable=True on a read tool explicitly when the tool produces structured output that downstream chain steps should consume directly.

Why this matters: When the web-kernel dispatches a chain step via typed call, the tool receives well-formed Pydantic params derived from the prior step's structured output — not from an LLM paraphrase of it. The difference between {"count": 248} and the string "found 248 rows" is the difference between deterministic data flow and probabilistic re-extraction.

Setting chain_callable=False on a write tool forces the web-kernel to go through the internal LLM router for that step. This is almost always wrong for writes. The router adds a round-trip LLM call and introduces the risk that the model paraphrases or loses structured data from prior steps. Under actions_explicit=True, the validator rule V19 rejects this pattern as an error.


id_projection="field_name"

When this tool runs as a downstream chain step, the web-kernel must identify which entity is being operated on — to verify that prior steps actually produced it. By default, the web-kernel uses a verb-prefix heuristic: delete_note implies the target field is note_id, update_folder implies folder_id.

The heuristic fails for compound tool names. delete_notes_from_folder would naively yield notes_from_folder_id — a field that does not exist. mark_emails_as_read would yield emails_as_read_id.

id_projection solves this by explicitly naming the params field that carries the resolved target ID:

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 DeleteNotesInFolderParams(BaseModel):
    folder_id: str


@chat.function(
    "delete_notes_from_folder",
    description="Delete all notes inside a specific folder.",
    action_type="destructive",
    chain_callable=True,
    id_projection="folder_id",
    effects=["trash:note", "delete:note", "delete:folder"],
)
async def delete_notes_from_folder(
    ctx, params: DeleteNotesInFolderParams
) -> ActionResult:
    folder = await ctx.store.get("folders", params.folder_id)
    if folder is None:
        return ActionResult.error("Folder not found.")
    notes = await ctx.store.query("notes", where={"folder_id": params.folder_id})
    for note in notes.data:
        await ctx.store.delete("notes", note.id)
    await ctx.store.delete("folders", params.folder_id)
    return ActionResult.success(
        data={"folder_id": params.folder_id},
        summary=f"Deleted {len(notes.data)} notes and the folder.",
    )

The id_projection value must match a field that exists in the Pydantic params model — the validator checks this at imperal validate time. Production examples from real extensions: "folder_id" for delete_notes_from_folder (notes extension handlers_folders.py), "project_id" for create_task_in_project (tasks extension handlers_crud.py), "task_id" for add_tag_to_task (tasks extension handlers_crud.py).


effects=["scope.action"]

The effects list declares the side-effect surface of a tool — what the tool creates, updates, or deletes. The format is "verb:resource".

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 SendParams(BaseModel):
    to: str
    subject: str
    body: str


@chat.function(
    "send",
    description="Send an email to a recipient.",
    action_type="write",
    event="sent",
    effects=["create:email"],
)
async def send_email(ctx, params: SendParams) -> ActionResult:
    # ... implementation
    return ActionResult.success(
        data={"to": params.to, "subject": params.subject},
        summary=f"Email sent to {params.to}.",
    )

Effects serve two purposes:

  1. Chain narrator: when the chain completes, the narrator component uses the effects list to describe exactly what changed across steps — "created a note, sent an email" — without re-deriving this from response text.
  2. Audit ledger: the action ledger records effects alongside the action log entry, providing structured provenance.

Common effect verbs: create, update, delete, trash, archive, send. The resource noun after the colon is arbitrary but should be stable across versions since it appears in audit records. Examples from production: "create:note", "update:folder", "delete:email", "create:email", "update:task", "delete:task", "create:folder", "delete:folder".

V20 validator: any write or destructive tool without effects emits a WARN at imperal validate time. In SDK v5.0.0 this becomes an ERROR. Declare effects on all write and destructive tools now.

Multiple effects are allowed when a single tool touches more than one resource type:

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 EmptyTrashParams(BaseModel):
    confirm: bool = True


@chat.function(
    "empty_trash",
    description="Permanently delete all notes in the trash.",
    action_type="destructive",
    chain_callable=True,
    effects=["trash:note", "delete:note"],
)
async def empty_trash(ctx, params: EmptyTrashParams) -> ActionResult:
    trashed = await ctx.store.query("notes", where={"is_trashed": True})
    for note in trashed.data:
        await ctx.store.delete("notes", note.id)
    return ActionResult.success(
        data={"deleted_count": len(trashed.data)},
        summary=f"Permanently deleted {len(trashed.data)} notes.",
    )

depends_on and step ordering

When the classifier builds a multi-step chain, it assigns each step an app_id and a depends_on list — the app_ids whose READ output this step needs before it can execute.

This field is part of the classifier's output (action_plans[].depends_on) — it is not something you declare on @chat.function. The classifier infers it from the user's message and the tools' descriptions. However, the quality of your tool descriptions and effects declarations directly influences how accurately the classifier can infer the dependency graph.

The web-kernel applies a stable topological sort (Kahn's algorithm) to the classifier-emitted plan before executing any step. Steps with no dependencies execute first; write steps that consume a prior read's output execute after that read completes.

What this means in practice:

A user who says "fetch my SQL query results, save them to a note, and send an email with the note's content" might type the steps in any order. The classifier emits dependency relationships: mail depends on notes (needs the note content), notes depends on sql-db (needs the query results). The web-kernel sorts the steps so sql-db runs first, notes second, mail third — regardless of the order they appeared in the user's message.

Federal invariant I-CHAIN-READ-BEFORE-WRITE-DEPENDENCIES: the web-kernel enforces this sort before iteration. If the classifier emits a dependency that would result in a write step executing before its read dependency, the web-kernel reorders it. The sort is stable — steps with no dependency relationship preserve their original classifier-emitted order.

User: "Query orders, write a note with the count, email me the note."

Classifier emits:
  [sql-db (depends_on=[]), notes (depends_on=["sql-db"]), mail (depends_on=["notes"])]

Web-kernel topological sort:
  sql-db → notes → mail   (already in correct order)

If user had typed: "Email me the summary, query orders, save a note"
Classifier emits:
  [mail (depends_on=["notes"]), sql-db (depends_on=[]), notes (depends_on=["sql-db"])]

Web-kernel topological sort:
  sql-db → notes → mail   (web-kernel reorders regardless of message order)

Structured data flow between steps

When a chain step completes, its ActionResult.data dict is preserved verbatim and injected into the next step's execution context as a structured block — alongside, not instead of, the text response.

The injection looks like this in the next step's LLM context:

PRIOR STEP STRUCTURED DATA (verbatim — use exact values, do NOT paraphrase):
[{"step_idx": 0, "app_id": "sql-db", "tool": "run_query", "ok": true,
  "data": {"rows": [{"count": 248}], "row_count": 1},
  "summary": "[sql-db ok] Found 248 rows in orders table"}]

The step's LLM receives both the text summary and the raw structured data. This eliminates the class of errors where a downstream step writes a literal placeholder string like <результат из предыдущего шага> because the model could not reliably extract the value from a prose description.

Federal invariant I-CHAIN-PRIOR-RESULTS-STRUCTURED: the chain executor is required to carry structured prior_step_results alongside the text summary. Steps must never receive text-only prior context.


KAV gating in chains

When a chain step involves a destructive action type, the web-kernel inserts a KAV (Pre-Authorized Action Execution) confirmation gate before that step executes. The user sees a confirmation card showing exactly what the step will do and must explicitly confirm before execution proceeds.

Each destructive step gates independently. A three-step chain with one destructive step presents one card. A chain with two destructive steps presents two cards — in order.

The confirmation card is built from the step's manifest declaration (tool description, effects list, and the resolved params). What the user confirms is exactly what executes — this is enforced by the federal invariant I-CONFIRMATION-EXECUTES-WHAT-USER-SAW.

If a chain step has chain_callable=True but action_type="destructive", the gate cannot be bypassed. Setting chain_callable=True enables typed dispatch; it does not bypass confirmation. The two are independent.


Failure semantics

When a chain step fails, the chain halts at that step. Subsequent steps do not execute.

The partial results of completed steps are returned to the user with a clear indication of which step failed and why. Completed steps are not rolled back — if step 1 created a note and step 2 (send email) failed, the note remains.

Extension handlers should be written with this in mind. If your step creates a resource as part of a chain and you want that resource to be visible before the chain completes, return it in ActionResult.data. If the chain fails downstream, the user can always see what was already committed.


Cross-extension chains

Chains are not limited to tools within a single extension. The most common production chains span multiple extensions — typically a read from one extension flowing to a write in another.

Examples:

  • sql-db → notes: query a database, write the results as a structured note.
  • sql-db → notes → mail-client: query, note, email the note.
  • web-tools → mail-client: check a monitor's status, email the report.

The structured data flow mechanism (prior_step_results) is how cross-extension data transfer works without re-deriving values from text. Each extension's step receives the prior steps' verbatim data dicts.

The web-kernel handles routing, authentication, and context injection for each step. From the extension handler's perspective, a step in a cross-extension chain looks identical to a standalone call — the same ctx, the same Pydantic params, the same ActionResult return type.


Production patterns

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

Write tool with effects (notes extension, handlers_notes.py)

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 UpdateNoteParams(BaseModel):
    note_id: str
    title: str | None = None
    content: str | None = None


@chat.function(
    "update_note",
    description="Update the title or content of an existing note.",
    action_type="write",
    chain_callable=True,
    effects=["update:note"],
)
async def update_note(ctx, params: UpdateNoteParams) -> ActionResult:
    note = await ctx.store.get("notes", params.note_id)
    if note is None:
        return ActionResult.error("Note not found.")
    patch: dict = {}
    if params.title is not None:
        patch["title"] = params.title
    if params.content is not None:
        patch["content"] = params.content
    await ctx.store.update("notes", params.note_id, {**note.data, **patch})
    return ActionResult.success(
        data={"note_id": params.note_id},
        summary="Note updated.",
    )

Destructive tool with id_projection (notes extension, handlers_folders.py)

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 DeleteFolderParams(BaseModel):
    folder_id: str


@chat.function(
    "delete_notes_from_folder",
    description="Delete all notes and the folder itself.",
    action_type="destructive",
    chain_callable=True,
    id_projection="folder_id",
    effects=["trash:note", "delete:note", "delete:folder"],
)
async def delete_folder(ctx, params: DeleteFolderParams) -> ActionResult:
    notes = await ctx.store.query("notes", where={"folder_id": params.folder_id})
    for note in notes.data:
        await ctx.store.delete("notes", note.id)
    await ctx.store.delete("folders", params.folder_id)
    return ActionResult.success(
        data={"folder_id": params.folder_id},
        summary=f"Folder and {len(notes.data)} notes deleted.",
    )

Task creation with id_projection (tasks extension, handlers_crud.py)

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 CreateTaskParams(BaseModel):
    project_id: str
    title: str
    description: str = ""


@chat.function(
    "create_task_in_project",
    description="Create a new task inside a specific project.",
    action_type="write",
    chain_callable=True,
    id_projection="project_id",
    effects=["create:task"],
)
async def create_task_in_project(ctx, params: CreateTaskParams) -> ActionResult:
    doc = await ctx.store.create("tasks", {
        "project_id": params.project_id,
        "title": params.title,
        "description": params.description,
    })
    return ActionResult.success(
        data={"task_id": doc.id, "project_id": params.project_id},
        summary=f"Task '{params.title}' created.",
    )

Common pitfalls

Omitting id_projection on compound tool names

If the verb-prefix heuristic cannot extract the correct field from your tool name, the web-kernel falls back to a best-effort guess. That guess may be wrong, causing chain step resolution to target the wrong entity or fail silently.

from imperal_sdk import Extension, ChatExtension, ActionResult

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.")


# WRONG — heuristic produces "notes_in_folder_id", not "folder_id"
@chat.function(
    "archive_notes_in_folder",
    description="Archive all notes in a folder.",
    action_type="write",
    chain_callable=True,
    effects=["update:note"],
    # id_projection not set — web-kernel guesses wrong
)
async def archive_notes_in_folder(ctx, params) -> ActionResult:  # type: ignore[type-arg]
    return ActionResult.error("not implemented")


# RIGHT — id_projection tells the web-kernel which field is the target id
@chat.function(
    "archive_notes_in_folder_v2",
    description="Archive all notes in a folder.",
    action_type="write",
    chain_callable=True,
    id_projection="folder_id",
    effects=["update:note"],
)
async def archive_notes_in_folder_right(ctx, params) -> ActionResult:  # type: ignore[type-arg]
    return ActionResult.error("not implemented")

The rule: if your tool name contains more than one noun (_notes_in_folder, _emails_as_read, _drafts_in_project), set id_projection explicitly.

Setting chain_callable=False on a write tool under actions_explicit=True

This is rejected by validator V19. Writes must be chain-callable so the web-kernel can issue typed dispatch. If you disable it, the web-kernel falls back to LLM delegation for that step — adding latency, consuming LLM context budget, and risking data loss through paraphrase.

from imperal_sdk import Extension, ChatExtension, ActionResult

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.")


# WRONG — V19 error at imperal validate: chain_callable=False on a write tool
@chat.function(
    "send_email",
    description="Send an email.",
    action_type="write",
    chain_callable=False,  # rejected under actions_explicit=True
    effects=["create:email"],
)
async def send_email_wrong(ctx, params) -> ActionResult:  # type: ignore[type-arg]
    return ActionResult.error("not implemented")

Relying on message-order for step sequencing

The web-kernel uses topological sort, not message order, to sequence chain steps. Do not write tool descriptions that assume execution order will match the order items were mentioned in the user's message. Describe what your tool does, not when it runs.

Accessing prior step data outside of chain context

The structured prior_step_results data is injected into the chain step's LLM context. Extension handler code does not have direct Python access to it — it is part of the prompt, not a Python variable. If your handler needs to read the output of a prior step (for example, to use a returned ID in its own store query), the classifier must include that ID in the params it generates for your tool call.


Validators

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

What's next

On this page