Cookbook

Short, self-contained recipes for common tasks. Each runs as written once a provider key is set, and every output on this page is real.

Several are the standard use cases other agent frameworks demonstrate — structured extraction, routing, evaluator–optimizer loops, batch classification — written the Kaval.AI way. See How Kaval.AI compares for where that fit ends, and the linked tutorials for the reasoning behind each recipe.

Two longer walkthroughs have pages of their own — grading a chatbot and grading a workflow with side effects:

A chatbot that remembers

The trick is not the graph — it is reusing the session. Pass the same external_id (your own user, ticket or thread id) on every turn and the engine replays that conversation’s history into each llm node.

import asyncio

from pydantic import BaseModel

from kavalai.agent_service import AgentService
from kavalai.db import db_manager
from kavalai.workflow import WorkflowBuilder


class Message(BaseModel):
    user_message: str


class Reply(BaseModel):
    agent_response: str


async def main():
    await db_manager.init_sqlite()

    bot = (
        WorkflowBuilder("Village guide", llm_model="openai/gpt-5.6-luna")
        .data_model("input", Message)
        .data_model("output", Reply)
        .start("reply")
        .llm("reply", prompt="You are a warm, concise guide to Green Village.",
             inputs={"message": "input"}, output="output", next="end")
        .end()
        .build_engine(
            agent_service=AgentService(
                db_manager.get_sqlite_sessionmaker()
            )
        )
    )

    turns = [
        "I'm Agnes, visiting on Friday.",
        "What did I say my name was?",
    ]
    for turn in turns:
        state = await bot.run({"user_message": turn}, external_id="villager-42")
        print(f"> {turn}\n  {state.output_data['agent_response']}")


asyncio.run(main())
> I'm Agnes, visiting on Friday.
  Welcome, Agnes! We look forward to seeing you at Green Village on
  Friday. How can I help you prepare for your visit?
> What did I say my name was?
  You said your name was Agnes.

Drop the external_id and each call starts a fresh session — which is what you want for one-off, stateless invocations. See Observability & storage.

Answering from your own documents

Retrieval in a function node, generation in an llm node. The retrieval tool is an ordinary Python function, so it can query anything — here a portable SQLite index.

import asyncio

from pydantic import BaseModel

from kavalai import pythontool
from kavalai.agent_service import AgentService
from kavalai.db import db_manager
from kavalai.rag import SqliteRagService
from kavalai.workflow import WorkflowBuilder

FACTS = [
    "Green Village's oldest resident is Agnes Whitlow (born 02.06.1929).",
    "The village pond, Lake Miller, is 1.2 metres deep at its deepest point.",
    "Green Village's only pub, The Rusty Anchor, has been operating since 1923.",
    "The village library owns 1,847 books and is open on Tuesdays and Fridays.",
]

rag = SqliteRagService(":memory:", model="fastembed/BAAI/bge-small-en-v1.5")


class Passages(BaseModel):
    context: str


@pythontool
async def search_village(question: str) -> Passages:
    """Find the village facts most relevant to a question."""
    hits = await rag.query(question, top_k=3)
    return Passages(context="\n".join(f"- {hit.content}" for hit in hits))


class Question(BaseModel):
    user_message: str


class Answer(BaseModel):
    agent_response: str


async def main():
    await rag.index_batch(
        texts=FACTS,
        metadata_list=[{}] * len(FACTS),
        source_ids=[f"fact-{i}" for i in range(len(FACTS))],
    )
    await db_manager.init_sqlite()

    engine = (
        WorkflowBuilder("Village FAQ", llm_model="openai/gpt-5.6-luna")
        .data_model("input", Question)
        .data_model("passages", Passages)
        .data_model("output", Answer)
        .start("retrieve")
        .function(
            "retrieve",
            tool="python://search_village",
            inputs={"question": "input.user_message"},
            output="passages",
            next="answer",
        )
        .llm(
            "answer",
            prompt=(
                "Answer the villager's question using only these facts:\n"
                "{{ context.passages.context }}\n"
                "If they are not enough, say so."
            ),
            inputs={"question": "input"},
            output="output",
            next="end",
        )
        .end()
        .build_engine(
            agent_service=AgentService(
                db_manager.get_sqlite_sessionmaker()
            )
        )
    )
    engine.kernel.register_python_tool("search_village", search_village)

    state = await engine.run({"user_message": "When is the library open?"})
    print(state.output_data["agent_response"])


asyncio.run(main())
The village library is open on Tuesdays and Fridays.

Note {{ context.passages.context }} in the prompt: the retrieved passages are interpolated straight from the run context. See Retrieval-augmented generation (RAG).

A document that changes is re-indexed as a whole. Its chunks carry the document in their metadata, and replace exchanges them: the new texts are embedded first, then the old chunks are deleted and the new ones inserted in one transaction, so a failure at any step leaves the old chunks in place. The embedding call is recorded only when the call is given a stats_receiver:

from kavalai.workflow import StatsBridge
from kavalai.workflow.tasklog import MemoryTaskLogger

page = {"page": "opening-hours"}
await rag.index_batch(
    texts=[
        "The library is open on Tuesdays and Fridays.",
        "The Rusty Anchor opens at noon.",
    ],
    metadata_list=[page, page],
)

meter = MemoryTaskLogger()
await rag.replace(
    "default",
    texts=["The library is open on Tuesdays, Fridays and Saturdays."],
    metadata_list=[page],
    match=page,
    stats_receiver=StatsBridge(meter),
)
await meter.flush()

print(await rag.count_entries("default"))
for call in meter.model_calls:
    print(call.call_type, call.model, call.batch_size)
5
embedding fastembed/BAAI/bge-small-en-v1.5 1

The four facts and the one new chunk remain. In a deployment the logger is a PostgresTaskLogger, which writes the call to model_call_stats beside the calls the runs make.

Routing a request to the right handler

Classify with a cheap call, then branch. Each branch can use a different model, prompt, or even an agent with tools.

name: Council desk
llm_model: openai/gpt-5.6-luna
data_types:
  input:
    type: object
    properties:
      user_message: {type: string}
  classification:
    type: object
    properties:
      intent: {type: string}
  output:
    type: object
    properties:
      agent_response: {type: string}
nodes:
  - {name: start, type: start, next: classify}
  - name: classify
    type: llm
    prompt: |
      Classify the villager's message as exactly one of: repair, permit, other.
      Respond with that single lowercase word in the `intent` field.
    inputs: {input: {type: context, value: input}}
    output: classification
    next: route
  - name: route
    type: switch
    expr: classification.intent
    cases:
      repair: repair_reply
      permit: permit_reply
    default: general_reply
  - name: repair_reply
    type: llm
    prompt: "Acknowledge the repair request and name the next step."
    inputs: {input: {type: context, value: input}}
    output: output
    next: end
  - name: permit_reply
    type: llm
    prompt: "Explain briefly how to apply for this permit."
    inputs: {input: {type: context, value: input}}
    output: output
    next: end
  - name: general_reply
    type: llm
    prompt: "Answer the villager's question helpfully and briefly."
    inputs: {input: {type: context, value: input}}
    output: output
    next: end
  - {name: end, type: end, output: output}

Three messages, three traces:

start → classify → route → repair_reply → end
start → classify → route → permit_reply → end
start → classify → route → general_reply → end

Give the classifier a small, closed set of labels and tell it to answer with one word — that is what makes switch reliable. See Workflows.

Giving an agent only the tools it should have

One kernel often hosts more tools than any single agent should touch. allowed_tools is enforced, not advisory: excluded tools are neither described to the model nor callable.

from kavalai import Agent, FunctionKernel, make_client
from kavalai.tools.webtools.crawl4ai import crawl_url, web_search

kernel = FunctionKernel()
kernel.register_python_tool("web.search", web_search)
kernel.register_python_tool("web.crawl", crawl_url)
# Registered on the kernel, but withheld from the agent below.
kernel.register_python_tool("db.delete_customer", delete_customer)

researcher = Agent(
    llm_client=make_client("openai/gpt-5.6-luna"),
    kernel=kernel,
    allowed_tools=["python://web.search", "python://web.crawl"],
)

print(await researcher.prompt(
    "Find out what Kaval.AI does and summarise it in three sentences.",
    max_steps=6,
))

In YAML, set allowed_tools on the agent node. See Workflow YAML reference.

Testing a workflow without calling a model

kavalai.testing replaces the model and nothing else. A ScriptedLlmClient answers each call with the next reply from its list, and the engine accepts it as its client_factory. The test below drives the council desk workflow of the previous recipe, saved as council_desk.yaml, down its repair branch:

from kavalai import WorkflowEngine
from kavalai.testing import ScriptedLlmClient


async def test_repair_requests_route_to_the_repair_handler():
    model = ScriptedLlmClient(
        [
            {"intent": "repair"},
            {"agent_response": "A technician will visit on Monday."},
        ]
    )
    engine = WorkflowEngine.from_yaml_path(
        "council_desk.yaml", client_factory=model
    )

    state = await engine.run({"user_message": "The streetlight is out."})

    assert state.trace == [
        "start", "classify", "route", "repair_reply", "end"
    ]
    assert state.output_data == {
        "agent_response": "A technician will visit on Monday."
    }
    assert "streetlight" in model.calls[0].prompt

The replies are consumed in the order the nodes run: the first answers classify, the second repair_reply. Only the provider call is replaced. Each reply is streamed through the base client’s streamer and validated into the node’s data type by the engine, so a reply that does not fit the type fails the run as a model’s reply would, and every call reports a ModelCallStat with estimated token counts. model.calls records each request — its messages, response model and parameters — for assertions. The test is a coroutine, so run it with pytest-asyncio (asyncio_mode = "auto" or the @pytest.mark.asyncio marker).

A reply may also be an exception, raised in place of an answer, or an Interrupted, which streams part of an answer and then fails; when its error is a transient provider error, the stream carries the restart event a live retry produces. A function (messages, response_model) -> reply takes the place of the list when the answer depends on the prompt.

A test that does not construct the engine itself cannot pass a factory; the workflow names its model instead. fake_providers() registers the scripted client under a provider name for the duration of a with block and restores the registry on exit, so, registered as openai, it answers the unchanged workflow. Each block also registers a FakeEmbeddingClient under the same name. It embeds any text by hashing its words: the vectors are deterministic and lexical, and a query that shares words with a passage ranks it first, which suffices to test retrieval without downloading a model.

import asyncio

from kavalai import WorkflowEngine
from kavalai.rag import SqliteRagService
from kavalai.testing import ScriptedLlmClient, fake_providers

FACTS = [
    "Green Village has 104 residents.",
    "The village pond, Lake Miller, is 1.2 metres deep.",
    "The bakery opens at seven on weekdays.",
]


async def main():
    model = ScriptedLlmClient(
        [
            {"intent": "permit"},
            {"agent_response": "Apply at the parish office."},
        ]
    )
    with fake_providers(llm=model, name="openai"):
        engine = WorkflowEngine.from_yaml_path("council_desk.yaml")
        state = await engine.run({"user_message": "May I build a shed?"})

    print(state.trace)
    print([call.model for call in model.calls])

    with fake_providers() as fakes:
        index = SqliteRagService(":memory:", model="fake/hashing")
        await index.index_batch(FACTS, [{} for _ in FACTS])
        hits = await index.query("How deep is the pond?", top_k=1)

    print(hits[0].content, round(hits[0].similarity, 3))
    print(fakes.embedding.calls[-1])


asyncio.run(main())
['start', 'classify', 'route', 'permit_reply', 'end']
['openai/gpt-5.6-luna', 'openai/gpt-5.6-luna']
The village pond, Lake Miller, is 1.2 metres deep. 0.886
['How deep is the pond?']

See Testing API and Safety.

Using a provider Kaval.AI does not ship

The built-in provider names are a starting set, not a closed one. Registering a client gives it a name, and from that point it is indistinguishable from a built-in: mycorp/model-x works in make_client(), as a workflow’s llm_model, in a YAML node and in KAVALAI_DEFAULT_LLM_MODEL.

If the provider speaks the OpenAI wire format — DeepSeek, Groq, Together, Fireworks and OpenRouter all do — there is nothing to implement. Bind the base URL and the key to a name of your own:

import os

from kavalai import OpenAIClient, make_client, register_llm_provider

register_llm_provider(
    "deepseek",
    OpenAIClient,
    base_url="https://api.deepseek.com",
    api_key=os.environ["DEEPSEEK_API_KEY"],
)

client = make_client("deepseek/deepseek-chat")

Arguments given at registration are bound to the name and passed to the client on every use, so one class can serve several endpoints under different names. A registration may equally name a class that has not been imported yet — the dotted path is resolved on first use, which is how Kaval.AI registers its own clients, and why import kavalai costs nothing where no provider SDK is installed:

register_llm_provider("mycorp", "mycorp_sdk.client.MyCorpClient")

If the API has a protocol of its own, implement BaseLlmClient. The one method to write, _run_chat_completions, does three things: send the request, push text into the value streamer as it arrives, and report a ModelCallStat — which is what puts a custom provider’s token usage in the same tables as the built-ins, and so in the backoffice. LLM clients builds a complete one against a live API, structured output included.

Embeddings work the same way. An embedding client implements compute_embeddings, returning the vectors and a ModelCallStat. The one below calls no provider at all: it hashes words into a fixed number of dimensions, needing no key and no download. Be clear about what that buys — the vectors are lexical rather than semantic. They match shared words, which is useful for exact terms, offline runs and tests, and no substitute for an embedding model when the question is worded differently from the answer.

import time

from sklearn.feature_extraction.text import HashingVectorizer

from kavalai import (
    BaseEmbeddingClient,
    ModelCallStat,
    register_embedding_provider,
)
from kavalai.rag import SqliteRagService

FACTS = [
    "Green Village's oldest resident is Agnes Whitlow "
    "(born 02.06.1929).",
    "Green Village has 104 residents.",
    "The village pond, Lake Miller, is 1.2 metres deep at its "
    "deepest point.",
]


class LexicalEmbeddingClient(BaseEmbeddingClient):
    """Hashed bag-of-words vectors: no download, no key, no network."""

    def __init__(self, model, dimensions=512):
        super().__init__(model)
        self.vectorizer = HashingVectorizer(
            n_features=dimensions, norm="l2", alternate_sign=False
        )

    async def compute_embeddings(self, texts, normalize=False,
                                 normalizer=None, **kwargs):
        started = time.perf_counter()
        vectors = self.vectorizer.transform(texts).toarray().tolist()
        stats = ModelCallStat(
            call_type="embedding",
            model=f"lexical/{self.model}",
            batch_size=len(texts),
            total_tokens=0,
            duration_seconds=time.perf_counter() - started,
        )
        return vectors, stats


register_embedding_provider(
    "lexical", LexicalEmbeddingClient, dimensions=512
)

village_index = SqliteRagService(":memory:", model="lexical/word-hash")
await village_index.index_batch(
    texts=FACTS,
    metadata_list=[{}] * len(FACTS),
    source_ids=[f"fact-{i}" for i in range(len(FACTS))],
)

for hit in await village_index.query("How deep is Lake Miller?", top_k=2):
    print(f"{hit.similarity:.3f}  {hit.content}")
0.516  The village pond, Lake Miller, is 1.2 metres deep at its deepest point.
0.135  Green Village's oldest resident is Agnes Whitlow (born 02.06.1929).

The name was all the RAG service needed. It builds its embedding client through the same registry, so a backend registered in one line reaches indexing, retrieval and every rag_query node without either being told about it. register_rag_service() completes the set, naming a whole configured service so a workflow can ask for it by name:

register_rag_service(
    "handbook", SqliteRagService,
    filename="handbook.db", model="fastembed/BAAI/bge-small-en-v1.5",
)

Registering a name that already exists raises, as duplicate tool names do. Deliberately replacing one — pointing openai at an internal gateway, say — takes replace=True and is logged, because it changes what every later lookup means.

Two rules are worth knowing before you rely on this:

Register before you load. A workflow’s model names are checked when the graph is parsed, and a rag_query node resolves its service then too. An import two lines too late looks exactly like a typo.

A workflow names a registration, never a Python path. Workflow documents are served by GET /workflow and edited in the backoffice, so a dotted path in one would turn “edit a workflow” into “run code in the agent server”. Both that and connection strings in a rag_query node’s service are rejected when the workflow loads.

Under python -m kavalai.server nobody constructs the engine, so name the modules that do the registering and let the entry point import them first:

KAVALAI_PROVIDER_MODULES=mycorp.providers,mycorp.rag

Every dotted registration is resolved right after, so a bad path stops the container at boot instead of failing the first request that reaches that node.

Streaming a run to a UI

Turn on stream_output for the node whose text the user should watch, then forward the events. stream_delta sends only new text per chunk, which is what is wanted for long answers.

engine = (
    WorkflowBuilder("Village guide", llm_model="openai/gpt-5.6-luna")
    .data_model("input", Message)
    .data_model("output", Reply)
    .start("reply")
    .llm("reply", prompt="You are a warm guide to Green Village.",
         inputs={"message": "input"}, output="output", next="end",
         stream_output=True, stream_delta=True)
    .end()
    .build_engine(agent_service=service)
)

question = {"user_message": "Tell me about the tower."}
async for event in engine.run_stream(question):
    if event.type == "partial" and event.name == "reply":
        print(event.value, end="", flush=True)
    elif event.type == "workflow_failed":
        print("\nrun failed:", event.value)

Over HTTP, POST /stream_agent serves these same events as Server-Sent Events — see Serving a workflow over HTTP.

Extracting structured records from unstructured text

The most reliably useful operation a language model performs: turning prose into a typed record. Nested models and lists work, so one call can return a whole document.

import asyncio

from pydantic import BaseModel, Field

from kavalai import make_client

NOTES = """
Village council, 14 March. Present: Thomas Cook, Greta Lindqvist, Agnes Whitlow.
Greta will order 200kg of flour before the Turnip Festival. Thomas agreed to
get three quotes for repairing the church bell by 1 April. We rejected the
proposal to widen Cobbler's Path. Agnes will ask the library to open on
Saturdays during the festival week.
"""


class ActionItem(BaseModel):
    owner: str
    task: str
    due: str | None = Field(
        default=None, description="Deadline if one was stated."
    )


class Minutes(BaseModel):
    date: str
    attendees: list[str]
    actions: list[ActionItem]
    decisions: list[str]


async def main():
    client = make_client("openai/gpt-5.6-luna")
    minutes = await client.prompt(
        f"Extract the minutes from these notes:\n{NOTES}", response_model=Minutes
    )

    print("date     :", minutes.date)
    print("attendees:", ", ".join(minutes.attendees))
    for action in minutes.actions:
        due = f" (due {action.due})" if action.due else ""
        print(f"  - {action.owner}: {action.task}{due}")


asyncio.run(main())
date     : 14 March
attendees: Thomas Cook, Greta Lindqvist, Agnes Whitlow
  - Greta Lindqvist: Order 200kg of flour before the Turnip Festival
  - Thomas Cook: Get three quotes for repairing the church bell (due 1 April)
  - Agnes Whitlow: Ask the library to open on Saturdays during the festival week

Note due is str | None with a description: optional fields let the model say “not stated” without inventing a date, and the description is part of the schema it sees. See LLM clients.

A self-correcting draft (evaluator–optimizer)

A graph may contain cycles, which is what makes the classic write → critique → revise → critique loop expressible directly. The if node decides whether to go round again, and a counter keeps the loop finite.

name: Notice writer
description: Drafts a village notice, critiques it, and revises until it passes.
llm_model: openai/gpt-5.6-luna
data_types:
  input:
    type: object
    properties:
      topic: {type: string}
  draft:
    type: object
    properties:
      text: {type: string}
  review:
    type: object
    properties:
      approved: {type: boolean}
      feedback: {type: string}
  attempts:
    type: object
    properties:
      count: {type: integer}
  output:
    type: object
    properties:
      agent_response: {type: string}
nodes:
  - {name: start, type: start, next: write}
  - name: write
    type: llm
    prompt: |
      Write a short notice for the Green Village noticeboard about:
      {{ context.input.topic }}
      Keep it under 40 words.
    inputs: {input: {type: context, value: input}}
    output: draft
    next: critique
  - name: critique
    type: llm
    prompt: |
      You are a strict village clerk. Review this notice:
      {{ context.draft.text }}
      Approve only if it states what, when and where, and names
      Greta Lindqvist as the contact. Set approved and give feedback.
    inputs: {draft: {type: context, value: draft}}
    output: review
    next: count
  - name: count
    type: function
    tool: python://bump
    inputs: {current: {type: context, value: attempts.count}}
    output: attempts
    next: decide
  - name: decide
    type: if
    condition: "review.approved == True or attempts.count >= 3"
    then: finish
    else: revise
  - name: revise
    type: llm
    prompt: |
      Rewrite the notice addressing this feedback.
      Notice: {{ context.draft.text }}
      Feedback: {{ context.review.feedback }}
    inputs: {review: {type: context, value: review}}
    output: draft
    next: critique
  - name: finish
    type: llm
    prompt: >
      Return the final notice text unchanged in agent_response:
      {{ context.draft.text }}
    inputs: {draft: {type: context, value: draft}}
    output: output
    next: end
  - {name: end, type: end, output: output}

The counter is an ordinary tool. Note the Optional — on the first pass attempts.count does not exist yet and resolves to None:

from typing import Optional

from pydantic import BaseModel

from kavalai import pythontool


class Attempts(BaseModel):
    count: int


@pythontool
def bump(current: Optional[int] = None) -> Attempts:
    """Increment the revision counter."""
    return Attempts(count=(current or 0) + 1)

The clerk’s house rule — a named contact — is one the writer is not told, so the first draft is sent back once. The trace records the loop, and state.data holds every intermediate value:

engine = WorkflowEngine.from_yaml_path("notice_writer.yaml")
engine.kernel.register_python_tool("bump", bump)

state = await engine.run(
    {"topic": "the bake sale at the village hall, Saturday 18 October, 10 am"}
)
print("trace   :", " → ".join(state.trace))
print("attempts:", state.data["attempts"])
print("approved:", state.data["review"]["approved"])
print("notice  :", state.output_data["agent_response"])
trace   : start → write → critique → count → decide → revise
          → critique → count → decide → finish → end
attempts: {'count': 2}
approved: True
notice  : GREEN VILLAGE BAKE SALE

Join us at the Village Hall on Saturday 18 October from 10 am. Enjoy
delicious homemade treats and support our community!

Contact: Greta Lindqvist

Always bound the loop. max_node_visits (1000 by default) will stop a runaway graph, but a stuck critique burns real tokens until it does — the explicit counter is what makes the cost predictable.

Classifying a backlog concurrently

The engine executes one node at a time, but nothing stops you running many workflows at once. This is how you fan out today.

import asyncio

from pydantic import BaseModel

from kavalai.agent_service import AgentService
from kavalai.db import db_manager
from kavalai.workflow import WorkflowBuilder


class Note(BaseModel):
    user_message: str


class Tagged(BaseModel):
    agent_response: str
    topic: str
    urgent: bool


NOTES = [
    "The church bell has been stuck since Tuesday.",
    "May I put a beehive in my front garden?",
    "The pub sign fell down and nearly hit someone.",
    "When does the library open?",
]


def build(service):
    return (
        WorkflowBuilder("Noticeboard triage", llm_model="openai/gpt-5.6-luna")
        .data_model("input", Note)
        .data_model("output", Tagged)
        .start("tag")
        .llm(
            "tag",
            prompt=(
                "Tag this note from the Green Village noticeboard. "
                "topic is one of: repair, permit, other. "
                "urgent is true only if someone could get hurt. "
                "agent_response is a one-line acknowledgement."
            ),
            inputs={"note": "input"},
            output="output",
            next="end",
            use_history=False,
        )
        .end()
        .build_engine(agent_service=service)
    )


async def main():
    await db_manager.init_sqlite()
    service = AgentService(db_manager.get_sqlite_sessionmaker())
    engine = build(service)           # one engine, shared by every run

    async def classify(note: str):
        return await engine.run({"user_message": note})

    states = await asyncio.gather(*map(classify, NOTES))
    for note, state in zip(NOTES, states):
        out = state.output_data
        print(f"{out['topic']:<7} urgent={out['urgent']!s:<5} {note}")


asyncio.run(main())
repair  urgent=False The church bell has been stuck since Tuesday.
permit  urgent=False May I put a beehive in my front garden?
repair  urgent=True  The pub sign fell down and nearly hit someone.
other   urgent=False When does the library open?

``use_history=False`` is what makes it work. Classification is not a conversation: left on, each run would replay the session’s earlier turns into the prompt — more tokens, and one note’s wording nudging the next one’s label.

One engine serves all four runs. Each run keeps its own token accounting, so the figures stay per-run no matter how much they overlap:

for state in states:
    print(state.token_usage)
{'model_calls': 1, 'prompt_tokens': 119,
 'completion_tokens': 53, 'total_tokens': 172}
{'model_calls': 1, 'prompt_tokens': 122,
 'completion_tokens': 67, 'total_tokens': 189}
{'model_calls': 1, 'prompt_tokens': 120,
 'completion_tokens': 57, 'total_tokens': 177}
{'model_calls': 1, 'prompt_tokens': 116,
 'completion_tokens': 31, 'total_tokens': 147}

Sharing the engine is in fact the better shape: it parses the workflow once and keeps one set of tool-server connections, which matters when the workflow declares MCP servers, since those are subprocesses.

Pausing for a human

Kaval.AI has no interrupt-and-resume primitive: a run goes from start to end. The pattern that works is to make the pause a boundary between runs, with the session carrying the state.

# Run 1 — draft a reply and stop. Nothing is sent.
draft = await engine.run(
    {"user_message": "The church bell has been stuck since Tuesday."},
    external_id="ticket-91",
)
show_to_reviewer(draft.output_data["agent_response"])

# The reviewer decides here. That may take minutes or days; the
# process is free to exit in the meantime.

# Run 2 — same session, so the draft and its context are already in history.
final = await engine.run(
    {"user_message": "Approved, but mention the bell ringer's name."},
    external_id="ticket-91",
)

Because both runs share a session, the second one sees the first through chat history, and a node can read a specific earlier value with a history: input (see Workflow YAML reference). What you do not get is a suspended run resuming mid-graph — the second run starts at start again. For approval steps in the middle of a long graph, LangGraph or n8n do this natively; see How Kaval.AI compares.

Watching what a run used

Token usage is aggregated on the returned state. The individual calls are written to model_call_stats by a task logger, so pass one to the engine — the AgentService alone records sessions, runs and chat history, not calls. Each row carries the run and the session that made it, so run_id= selects one run’s calls and session_id= a whole conversation’s.

from uuid import UUID

from kavalai.workflow.tasklog import PostgresTaskLogger

tasklog = PostgresTaskLogger(service)
engine = (
    WorkflowBuilder("Village guide", llm_model="openai/gpt-5.6-luna")
    .data_model("input", Message)
    .data_model("output", Reply)
    .start("reply")
    .llm("reply", prompt="You are a warm, concise guide to Green Village.",
         inputs={"message": "input"}, output="output", next="end")
    .end()
    .build_engine(agent_service=service, task_logger=tasklog)
)

state = await engine.run({"user_message": "Is the pub open on Sundays?"})
print(state.token_usage)

await tasklog.flush()
for call in await service.get_model_call_stats(run_id=UUID(state.run_id)):
    print(call.model, call.total_tokens, f"{call.duration_seconds:.2f}s")
{'model_calls': 1, 'prompt_tokens': 77,
 'completion_tokens': 39, 'total_tokens': 116}
openai/gpt-5.6-luna 116 3.03s

The logger writes behind the run; flush() waits for the queue to drain, which a long-running server does in its shutdown hook instead.

Multiply by your provider’s published prices to turn tokens into money — and subtract cached_prompt_tokens from prompt_tokens first, because cached input is billed at a fraction of the rest. Observability explains why the runtime records usage rather than cost, and gives the query that adds up a run or a conversation in Cost per run and per conversation.