For the complete documentation index, see llms.txt. This page is also available as Markdown.

Writing a Custom Template

What you'll learn: How to customize the way framework events map to Kyvvu atomic behaviors — without writing any Python code.


When do you need a custom template?

The built-in templates (decorator, langchain, langgraph, crewai) work out of the box for most agents. You need a custom template when you want to:

  • Add metadata to specific tools — e.g., mark fetch_customer_pii as contains_pii: true so policies can act on PII-handling steps

  • Change verb classification — e.g., your archive_record tool is a destructive DELETE, not the default GET

  • Add domain-specific patterns — e.g., all tools starting with db_ should be tagged as resource_type: "database"

  • Add new event types — e.g., map a custom callback event to step.gate

You do not need a custom template to add policies — policies are separate from templates. Templates control what behaviors are emitted; policies control what happens when those behaviors are evaluated.

How templates work

A template is a YAML file with an ordered list of rules. Each rule has:

  • match — conditions that match against the event context (event name, tool name, etc.)

  • behavior — the Kyvvu atomic behavior to emit when the rule matches

Rules are first-match-wins — the engine tries each rule in order and uses the first one that matches. Order matters.

A custom template replaces the built-in

An integration uses one template, and it governs both the steps the integration records and the task boundaries it opens (task.start, task.end, task.error). A template you pass replaces the built-in for that integration. It does not extend it, and nothing falls through to the built-in for an event your template does not match.

That has two consequences:

  • Your template must cover every task boundary the integration opens. It is checked when the handler (or the Kyvvu client) is built, and a template that cannot do its job raises KyvvuConfigError naming the missing boundaries, instead of failing on the first task. The boundaries are:

    Integration
    Boundaries the template must match

    Kyvvu(...) (decorator, start_task, end_task, error_task, idle_task)

    all four: task.start, task.end, task.error, task.idle, each matched by step_type

    KyvvuLangChainHandler, KyvvuLangGraphHandler

    task.start on on_chain_start; task.end on on_chain_end and on on_agent_finish; task.error on on_chain_error

    KyvvuCrewAIListener

    task.start on crew_kickoff_started; task.end on crew_kickoff_completed; task.error on crew_kickoff_failed

  • Start from a copy of the built-in, so the boundary rules are already there, and change only the rules you care about (next section).

template=None and the default

Every template argument defaults to a sentinel, kyvvu.default_template, which means "the built-in template for this integration, or the file named by KV_TEMPLATE_LOCATION if that is set".

template=None is different: it means no template at all.

Surface

template=None

Kyvvu(template=None): @kv.step and kv.add_step

Accepted. Steps are built from their own arguments (step_type, verb, step_name, properties); no rule runs.

Kyvvu(template=None): start_task() and the other task methods

Refused with KyvvuConfigError: there is no rule to build a task boundary from. Use a framework handler, which brings its own template, or give the client a template.

KyvvuLangChainHandler, KyvvuLangGraphHandler, KyvvuCrewAIListener

Refused with KyvvuConfigError: a callback integration cannot build behaviors without a template.

An event your template does not match

A callback event that the handler treats as a step, and your template has no rule for, is recorded as a step.unknown named after the event's name (or the event itself), and a warning is logged once per event. It is never dropped, because a dropped step is a step no policy ever saw. This is the same outcome as the match: {} catch-all that every built-in template ends with, so you only need your own catch-all if you want to shape that step. The decorator differs: an unmatched decorated call is built from the decorator's own arguments.

Starting from the built-in template

Print any built-in template with the CLI and save it. This works from any install, including a wheel, because it reads the package data rather than a repository path:

kyvvu template show <name> accepts decorator, langchain, langgraph and crewai. An unknown name exits 1 and lists the available ones.

Loading your custom template

Two ways:

Every integration you build with the default picks this file up, with no code changes. Because it then replaces the built-in of each of them, one file has to cover the boundaries of every integration in the process. A langchain template set this way makes Kyvvu(...) itself refuse to build, because the client checks the four task.* types by step_type. When a process mixes the decorator with a handler, pass the template to the handler instead.

Constructor parameter (explicit)

The template goes to the handler, which is the thing that uses it:

Kyvvu(..., template=...) sets the template for the decorator and start_task(), not for the framework handlers.

Template YAML format

A rule has exactly four keys: id, description, match and behavior. The loader is strict and names the rule and the fix when a file breaks one of these:

  • id is required and must be unique within the template.

  • match and behavior are both required mappings. A catch-all is written as an explicit match: {}; leaving match out is an error.

  • conditions and output are not keys. They are match and behavior, and using them is rejected with a hint.

  • behavior may only set step_type, step_name, verb and properties. Any other key is an error naming the allowed four.

  • A literal step_type or verb must be in the vocabulary, and a name_pattern must be a valid regex.

Match fields

Each key in match is compared with the same key in the event context, and every key must match (implicit AND). There are two kinds of key:

Kind
Key
How it is compared

Equality

any context key, including a dot-path such as serialized.name

The context value must equal the value you wrote.

Regex

name_pattern

Tested against the context's name key. Searched anywhere in the value (Python re.search); anchor with ^ and $. Template name patterns are case-insensitive.

name_pattern is the only regex key, and it is the same key on every surface. function_name_pattern was removed; a template that uses it is rejected with a hint to use name_pattern. step_name is an output field (it goes in behavior), never a match key.

A context with no name never matches a name_pattern, whatever the pattern, .* included. A match key that none of the shipped integrations set is allowed, because custom integrations define their own context, but it is logged as a warning when the template loads, so a typo is visible.

A match of {} matches everything (catch-all).

Behavior fields

Field
Type
Description

step_type

string

Atomic behavior type: step.model, step.resource, step.exec, step.gate, step.message, task.start, task.end, etc.

verb

string

GET, POST, PATCH, DELETE, or omit for types that don't carry a verb (step.exec, step.gate, step.unknown, and the four task.* boundary types)

step_name

string

Name for the step. Supports Jinja2-style expressions: {{ name }}, {{ serialized.name }}

properties

dict

Arbitrary metadata attached to the behavior. Policies can inspect these via rule parameters.

String values accept {{ path }}, {{ path | default('x') }} and {{ path | omit_empty }}, which drops the key from the result when the value is missing or empty (see the Template Authoring Guide).

Available context keys

These are available in match conditions and {{ }} expressions. What is in the context depends on the surface that fires the event:

Surface
Context keys

Decorator step (@kv.step)

step_type; verb (when declared); name (the function's name); kwargs (when the function has arguments and capture_input is on)

Task boundaries from Kyvvu

step_type, plus whatever the caller passed as context=; a decorated task.start, task.end, task.error or task.idle function adds name and kwargs

LangChain / LangGraph, steps

event (on_llm_start, on_chat_model_start, on_retriever_start, on_tool_start, on_custom_event, on_message_in, on_message_out, and for KyvvuLangGraphHandler on_interrupt); name; task_id; serialized, metadata and tags for model, retriever and tool events; data for on_custom_event

LangChain / LangGraph, boundaries

step_type, event (on_chain_start, on_chain_end, on_agent_finish, on_chain_error), name; serialized and inputs on on_chain_start

CrewAI

event (crew_kickoff_started, crew_kickoff_completed, crew_kickoff_failed, llm_call_started, tool_usage_started, on_message_in, on_message_out); name; task_id for steps; event details under crewai_metadata

For the decorator, {{ kwargs.* }} means the wrapped function's arguments by parameter name, for the current call. It is not the keyword arguments of the call site: {{ kwargs.url }} resolves only if the function has a parameter called url, and a keyword swallowed by **kw appears under kw.

Common customizations

Mark specific tools as PII-handling

Add a rule before the default tool_read catch-all:

Now you can write a policy that blocks PII tools without a preceding gate.

Classify a tool as destructive (DELETE)

Tag all database tools

Interpolation supports a lookup, default(...) and omit_empty, and nothing else, so a verb cannot be computed from the name. Write one rule per verb, most specific first:

Rule ordering tips

  1. Specific rules first, catch-all last. The first matching rule wins.

  2. Keep the task boundary rules, and do not reorder them behind a broad rule. Your template replaces the built-in, so a copy made with kyvvu template show already has them; deleting one fails when the handler is built.

  3. Keep the default tool_default catch-all as the last tool rule. Custom tool rules go between tool_write and tool_default.

  4. Test with KV_LOG_LEVEL=DEBUG. The engine logs which rule matched for each event.

Debugging

Set KV_LOG_LEVEL=DEBUG to see template matching in action:

If you see kyvvu: template 'my-template' has no rule for event 'on_tool_start'; recording it as step.unknown, your template is missing a rule for that event type, and the steps are being recorded as step.unknown.


Next steps

Last updated