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.