Workflows

A workflow is a directed graph — a small state machine — that turns one input into one output by walking from a start node to an end node. It is the backbone of Kaval.AI, and it exists because agentic pipelines are otherwise very hard to reason about: every step is named, every edge is explicit, and every value crossing a node boundary is typed and validated.

You author a workflow two equivalent ways: declaratively in YAML, or in Python with the fluent WorkflowBuilder. Both compile to the same graph and run on the same engine.

Node

What it does

start / end

the boundary — input in, output out

llm

one structured LLM completion

agent

a multi-step, tool-using agent loop

function

a single tool call (python://, rest://, mcp://)

if / switch

branch on a safely-evaluated expression

This notebook builds those up one node at a time. Every field used here is listed in the Workflow YAML reference.

Setup

Runs are persisted through an AgentService, which wraps a sessionmaker pointed at a database. init_sqlite() creates the runtime tables in an in-memory SQLite database, which keeps this notebook self-contained; in production the same service points at Postgres and nothing else changes.

DEFAULT_LLM_MODEL is passed to every engine below as default_llm_model, so llm_model can be left out of the graphs. The engine reads no environment variable for it; that is what python -m kavalai.server does with KAVALAI_DEFAULT_LLM_MODEL.

import os
import sys

import dotenv
from loguru import logger

dotenv.load_dotenv("../.env")

DEFAULT_LLM_MODEL = "openai/gpt-5.6-luna"
assert os.getenv("OPENAI_API_KEY"), "Set OPENAI_API_KEY to run the LLM nodes."

logger.remove()
_ = logger.add(sys.stderr, level="WARNING")

from kavalai.agent_service import AgentService
from kavalai.db import db_manager
from kavalai.workflow import SqliteTaskLogger, WorkflowEngine

# Create the runtime tables in an in-memory SQLite database.
await db_manager.init_sqlite()


def new_agent_service() -> AgentService:
    """An AgentService over the in-memory SQLite database."""
    return AgentService(db_manager.get_sqlite_sessionmaker())

1. The smallest workflow: start → llm → end

A graph is name + data_types + nodes. Two conventions make the boundary ergonomic: the workflow’s input is the data type named input, and what the caller gets back is the output variable named on the end node.

data_types are JSON-schema fragments compiled to Pydantic models, so every node’s input and output is validated. Inside a prompt you interpolate the run context with {{ context.<path> }}.

greeter_yaml = """
name: Village greeter
description: Greets a visitor to Green Village.
data_types:
  input:
    type: object
    properties:
      user_message: {type: string}
  output:
    type: object
    properties:
      agent_response: {type: string}
nodes:
  - {name: start, type: start, next: reply}
  - name: reply
    type: llm
    prompt: |
      You are the warm, concise greeter of Green Village (104 residents, one pub,
      one very old grain tower).
      Reply to the visitor in one friendly sentence: {{ context.input.user_message }}
    inputs:
      input: {type: context, value: input}
    output: output
    next: end
  - {name: end, type: end, output: output}
"""

engine = WorkflowEngine.from_yaml(
    greeter_yaml,
    default_llm_model=DEFAULT_LLM_MODEL,
    agent_service=new_agent_service(),
)
state = await engine.run({"user_message": "Hi, I'm visiting from Tallinn!"})

import textwrap

print(textwrap.fill(state.output_data["agent_response"], 80))
print("path:", " → ".join(state.trace))
Welcome to Green Village from Tallinn—we’re delighted to have you here!
path: start → reply → end

2. What a run gives you back

engine.run(...) returns a WorkflowState: the status, the ordered trace of visited nodes, the full context data, the output_data, and an aggregate token_usage. It round-trips through JSON, and the run is recorded through the agent service, so it can be reloaded later.

invocation_id is worth knowing about: this 8-character id prefixes every log line the run emits, so you can grep one invocation out of a busy log.

print("status       :", state.status)
print("invocation_id:", state.invocation_id)
print("token_usage  :", state.token_usage)
print()
print("serialised   :", state.to_json()[:160], "…")
status       : completed
invocation_id: 2e08e463
token_usage  : {'model_calls': 1, 'prompt_tokens': 106, 'completion_tokens': 26, 'total_tokens': 132}

serialised   : {"workflow_name":"Village greeter","status":"completed","current_node":"end","trace":["start","reply","end"],"data":{"input":{"user_message":"Hi, I'm visiting f …

The conversation was recorded too. A run is one invocation; a session is the conversation those runs belong to. Both are rows in your own database: see The database and data model for the sessions, runs and chat_messages tables.

from uuid import UUID

history = await engine.agent_service.get_chat_history(
    UUID(state.session_id)
)
for message in history:
    print(textwrap.fill(f"{message.role:>9}: {message.content}", 80,
                        subsequent_indent=" " * 11))
     user: Hi, I'm visiting from Tallinn!
assistant: Welcome to Green Village from Tallinn—we’re delighted to have you
           here!

The run row itself holds the input, the output and the resolved context — what each step received:

from kavalai import crud
from kavalai.db import Run

async with engine.agent_service.session_maker() as session:
    run = await crud.get_one(session, Run, UUID(state.run_id))
    print("input_data :", run.input_data)
    print("output_data:", run.output_data)
    print("context    :", sorted(run.context))
input_data : {'user_message': "Hi, I'm visiting from Tallinn!"}
output_data: {'agent_response': 'Welcome to Green Village from Tallinn—we’re delighted to have you here!'}
context    : ['input', 'output']

3. Function nodes — calling one tool

A function node performs exactly one tool call through the FunctionKernel, addressed by URI. Register the tool on the engine’s kernel and name it in the node. The return value lands in the node’s output variable and stays in the context for later nodes to read.

Use a function node when you know which tool must run. Use an agent node (section 5) when the model should decide.

from pydantic import BaseModel

from kavalai import pythontool


class PondReading(BaseModel):
    depth_m: float
    water: str


@pythontool
def measure_pond(name: str) -> PondReading:
    """Measure a Green Village pond. A real tool would read a sensor."""
    return PondReading(depth_m=1.2, water="murky but calm")


pond_yaml = """
name: Pond reporter
description: >-
  Reads a pond gauge with a function node, then phrases it with an LLM.
data_types:
  input:
    type: object
    properties:
      pond: {type: string}
  reading:
    type: object
    properties:
      depth_m: {type: number}
      water: {type: string}
  output:
    type: object
    properties:
      agent_response: {type: string}
nodes:
  - {name: start, type: start, next: measure}
  - name: measure
    type: function
    tool: python://measure_pond
    inputs:
      name: {type: context, value: input.pond}
    output: reading
    next: phrase
  - name: phrase
    type: llm
    prompt: >-
      Turn this pond reading into one cheerful sentence for the
      village noticeboard.
    inputs:
      reading: {type: context, value: reading}
    output: output
    next: end
  - {name: end, type: end, output: output}
"""

engine = WorkflowEngine.from_yaml(
    pond_yaml,
    default_llm_model=DEFAULT_LLM_MODEL,
    agent_service=new_agent_service())
engine.kernel.register_python_tool("measure_pond", measure_pond)

state = await engine.run({"pond": "Lake Miller"})
print("tool output in context:", state.data["reading"])
print(textwrap.fill("final: " + state.output_data["agent_response"], 80))
tool output in context: {'depth_m': 1.2, 'water': 'murky but calm'}
final: Lake Miller is a calm, 1.2-metre-deep pond with pleasantly murky water
today!

4. Branching with if and switch

Routing nodes evaluate an expression against the run context.

  • switch evaluates expr, stringifies the result, and matches it against cases, falling back to default.

  • if branches on a boolean condition.

Below, an llm node classifies a message to the Green Village council desk and a switch routes it to a specialised reply. The same graph runs twice and takes a different path each time.

council_yaml = """
name: Council desk
description: Classifies a message to the village council and routes it.
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.
      One short paragraph.
    inputs: {input: {type: context, value: input}}
    output: output
    next: end
  - name: permit_reply
    type: llm
    prompt: >-
      Explain briefly how to apply for this permit in a village of
      104 people.
    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}
"""

for message in [
    "The church bell has been stuck since Tuesday.",
    "May I put a beehive in my front garden?",
]:
    engine = WorkflowEngine.from_yaml(
        council_yaml,
        default_llm_model=DEFAULT_LLM_MODEL,
        agent_service=new_agent_service(),
    )
    state = await engine.run({"user_message": message})
    print(repr(message))
    print("  intent:", state.data["classification"]["intent"])
    print("  path  :", " → ".join(state.trace))
    print("  reply :", state.output_data["agent_response"][:80], "…\n")
'The church bell has been stuck since Tuesday.'
  intent: repair
  path  : start → classify → route → repair_reply → end
  reply : I acknowledge your repair request for the church bell, which has been stuck sinc …
'May I put a beehive in my front garden?'
  intent: permit
  path  : start → classify → route → permit_reply → end
  reply : Possibly, but it depends on your local beekeeping and property bylaws—the villag …

Seeing the graph

A workflow is data, so it can be drawn. render_workflow_svg turns a WorkflowGraph — or the plain dict the backoffice stores for an agent — into a standalone SVG. Each node is drawn by name and type, and the arrows follow the transitions; a branch node labels each outgoing arrow with the case that selects it, so the routing above can be read straight off the picture.

The same renderer serves the SDK, the backoffice POST /workflows/render-svg endpoint and this documentation build, so a diagram never drifts from the workflow it describes.

from IPython.display import SVG, display

from kavalai.workflow import render_workflow_svg

display(SVG(render_workflow_svg(engine.graph)))
../_images/e5100359b48e5132607b6604595174fd812f72d5cfc1120816e11afd5f648af9.svg

The expression language

Expressions are evaluated safely — an AST whitelist, never Python eval. Comparisons, and / or / not, in, arithmetic, and dotted or indexed access into the context are allowed; nothing else is. Unknown names resolve to None, so a guard on a missing value degrades instead of crashing.

The evaluator can be called directly, which is useful when writing a condition:

from kavalai import evaluate_bool, evaluate_expression

ctx = {
    "bell": {"broken_days": 3, "status": "stuck"},
    "ponds": [{"depth_m": 1.2}],
}

print(evaluate_expression(
    "bell.broken_days >= 3 and bell.status == 'stuck'", ctx
))
print(evaluate_expression("ponds[0].depth_m > 0.8", ctx))
print(evaluate_bool("bell.status in ['stuck', 'silent']", ctx))
print(evaluate_expression("missing.value", ctx))  # unknown path -> None
True
True
True
None

5. Agent nodes — multi-step tool use

An agent node runs the full Agent loop inside the graph: it may call tools repeatedly, feeding results back into its own reasoning, before emitting its structured output. Give it a kernel of tools and a max_steps budget. allowed_tools narrows which of the kernel’s tools this node may use.

import datetime


@pythontool
def current_time() -> str:
    """The current UTC time, as a weekday and clock time."""
    return datetime.datetime.now(datetime.timezone.utc).strftime("%A %H:%M UTC")


agent_yaml = """
name: Time-aware greeter
description: An agent that checks the time before greeting the visitor.
data_types:
  input:
    type: object
    properties:
      user_message: {type: string}
  output:
    type: object
    properties:
      agent_response: {type: string}
nodes:
  - {name: start, type: start, next: assistant}
  - name: assistant
    type: agent
    prompt: |
      Greet the visitor to Green Village appropriately for the current time of
      day. Check the time with a tool first; mention it.
    inputs: {input: {type: context, value: input}}
    output: output
    allowed_tools: ["python://current_time"]
    max_steps: 4
    next: end
  - {name: end, type: end, output: output}
"""

engine = WorkflowEngine.from_yaml(
    agent_yaml,
    default_llm_model=DEFAULT_LLM_MODEL,
    agent_service=new_agent_service())
engine.kernel.register_python_tool("current_time", current_time)

state = await engine.run({"user_message": "Say hello!"})
print(textwrap.fill(state.output_data["agent_response"], 80))
Good afternoon, and welcome to Green Village! It’s currently Saturday, 14:21
UTC.

6. Watching a run as it happens

run() is a convenience wrapper: internally every run is a stream. run_stream() yields WorkflowStreamEvent objects as the graph executes, which is what the agent server turns into Server-Sent Events.

Lifecycle events always arrive; content events (partial / complete) only come from nodes that opted in with stream_output.

engine = WorkflowEngine.from_yaml(
    council_yaml,
    default_llm_model=DEFAULT_LLM_MODEL,
    agent_service=new_agent_service(),
)

report = {"user_message": "The pub sign fell down."}
async for event in engine.run_stream(report):
    detail = ""
    if event.type == "workflow_completed":
        detail = f"  tokens={event.token_usage['total_tokens']}"
    print(f"{event.type:<20} {event.name}{detail}")
workflow_started     Council desk
node_started         start
node_completed       start
node_started         classify
node_completed       classify
node_started         route
node_completed       route
node_started         repair_reply
node_completed       repair_reply
node_started         end
node_completed       end
workflow_completed   Council desk  tokens=226

7. Observability — the task logger

A TaskLogger captures per-node debug data and every model call. Writes are fire-and-forget so they never block a run; get_tasks() and get_model_calls() await the pending writes before reading, so you do not have to flush by hand.

SqliteTaskLogger writes the same tasks and model_call_stats shape as the production PostgresTaskLogger, so what you inspect locally is what the backoffice shows in production. Both tables are described column by column in The database and data model.

task_logger = SqliteTaskLogger()
engine = WorkflowEngine.from_yaml(
    council_yaml,
    default_llm_model=DEFAULT_LLM_MODEL,
    agent_service=new_agent_service(), task_logger=task_logger,
)
state = await engine.run({"user_message": "How do I book the village hall?"})
await task_logger.flush()  # await the fire-and-forget writes

print("path       :", " → ".join(state.trace))
print("token usage:", state.token_usage)
path       : start → classify → route → permit_reply → end
token usage: {'model_calls': 2, 'prompt_tokens': 181, 'completion_tokens': 143, 'total_tokens': 324}

Every node execution and every LLM call along the way was recorded:

for task in await task_logger.get_tasks():
    print(f"  {task['name']:<14} {task['node_type']:<8} "
          f"{task['duration_seconds']:.2f}s")

calls = await task_logger.get_model_calls()
tokens = sum(call["total_tokens"] or 0 for call in calls)
print(f"\nmodel calls: {len(calls)} · tokens: {tokens}")
  start          start    0.00s
  classify       llm      1.44s
  route          switch   0.00s
  permit_reply   llm      3.32s
  end            end      0.00s

model calls: 2 · tokens: 324

In production you point the same engine at Postgres — AgentService over a Postgres sessionmaker and PostgresTaskLogger — and browse all of it in the backoffice UI. See Observability & storage and Using the Backoffice UI.

8. Testing a workflow without a model

LLMs are non-deterministic, which makes them poor test fixtures. Inject a client_factory and the engine builds your client instead of a real one, so graph logic — branching, data flow, node wiring — becomes fully deterministic and free to test.

ScriptedLlmClient from kavalai.testing is such a client. Its replies come from a list or, as here, from a function of the messages and the requested response model. Each reply is streamed through the machinery a provider’s answer takes and validated by the node, and every call is recorded in calls:

from kavalai.testing import ScriptedLlmClient

scripted = ScriptedLlmClient(
    lambda messages, response_model: {
        name: ("repair" if name == "intent" else "Scripted reply.")
        for name in response_model.model_fields
    }
)

engine = WorkflowEngine.from_yaml(
    council_yaml,
    default_llm_model=DEFAULT_LLM_MODEL,
    agent_service=new_agent_service(),
    client_factory=scripted,
)
state = await engine.run({"user_message": "anything at all"})

print("deterministic path:", " → ".join(state.trace))
print("reply             :", state.output_data["agent_response"])
print("model calls       :", len(scripted.calls))
deterministic path: start → classify → route → repair_reply → end
reply             : Scripted reply.
model calls       : 2

Every run now routes through repair, with no network call and no API key. Run it a hundred times in CI and it behaves identically.

9. Building a workflow in code

YAML suits authoring and sharing; the fluent WorkflowBuilder suits a graph that is generated dynamically, or an agent that belongs in a single Python file. Both produce the same graph.

Each method returns the builder, so the chain reads like the graph. data_type accepts plain Python types, and an inputs value given as a bare string is treated as a context path. Finish with .build() for a graph or .build_engine(...) for a ready engine.

from kavalai import WorkflowBuilder

engine = (
    WorkflowBuilder("Council desk (code)",
                    description="The same graph, built in Python.")
    .data_type("input", {"user_message": str})
    .data_type("classification", {"intent": str})
    .data_type("output", {"agent_response": str})
    .start("classify")
    .llm(
        "classify",
        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": "input"},          # a bare string == a context path
        output="classification",
        next="route",
    )
    .switch(
        "route",
        expr="classification.intent",
        cases={"repair": "repair_reply", "permit": "permit_reply"},
        default="general_reply",
    )
    .llm("repair_reply",
         prompt="Acknowledge the repair request and name the next step.",
         inputs={"input": "input"}, output="output", next="end")
    .llm("permit_reply", prompt="Explain briefly how to apply for this permit.",
         inputs={"input": "input"}, output="output", next="end")
    .llm("general_reply",
         prompt="Answer the villager's question helpfully and briefly.",
         inputs={"input": "input"}, output="output", next="end")
    .end()
    .build_engine(
        default_llm_model=DEFAULT_LLM_MODEL,
        agent_service=new_agent_service(),
    )
)

state = await engine.run({"user_message": "The grain tower steps are loose."})
print("path :", " → ".join(state.trace))
print("reply:", state.output_data["agent_response"][:90], "…")
path : start → classify → route → repair_reply → end
reply: Repair request acknowledged. Next step: have the grain tower steps inspected and secured b …

Recap

  • A workflow is a graph of typed nodes; callers talk only to the start / end boundary.

  • llm, agent and function nodes do the work; if / switch route on safely-evaluated expressions.

  • Every run returns a JSON-serialisable WorkflowState and is persisted through an AgentService.

  • run_stream() is the single execution path; run() drains it.

  • Inject a client_factory for deterministic tests that make no network call.

  • Author in YAML or the WorkflowBuilder — same graph either way.

Where to next

A complete runnable graph lives at examples/support_agent/support_agent.yaml.