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 templates (decorator, langchain, langgraph, crewai) work out of the box when their integration packages are installed. Install kyvvu-langchain to use the LangChain or LangGraph templates. You need a custom template when you want to:
- Add metadata to specific tools — e.g., mark
fetch_customer_piiascontains_pii: trueso policies can act on PII-handling steps - Change behavior properties or kinds — e.g., tag an
archive_recordtool as destructive; callback tool verbs are controlled byread_only_toolsandwrite_toolsonKyvvu - Add domain-specific patterns — e.g., all tools starting with
db_should be tagged asresource_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 template matcher (kyvvu-templates, called by the Python client's FrameworkAdapter) 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
Kyvvuclient) is built, and a template that cannot do its job raisesKyvvuConfigErrornaming 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 kind |
KyvvuLangChainHandler, KyvvuLangGraphHandler |
task.start on on_chain_start; task.end on on_chain_end; 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 (kind, 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 kyvvu command (kyvvu-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 langchain > my-template.yaml
kyvvu template show <name> prints templates from the packages installed in the project's environment (the active or .venv interpreter, or --python <path>): decorator always, langchain and langgraph with kyvvu-langchain, and crewai with kyvvu-crewai. A name that is not installed exits 1 and lists the available ones.
Loading your custom template¶
Two ways:
Environment variable (recommended for deployment)¶
export KV_TEMPLATE_LOCATION=./my-template.yaml
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 kind. 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:
from kyvvu_templates import BehaviorTemplate
from kyvvu_langchain import KyvvuLangChainHandler
template = BehaviorTemplate.from_path("./my-template.yaml")
handler = KyvvuLangChainHandler(kv, template=template)
Kyvvu(..., template=...) sets the template for the decorator and start_task(), not for the framework handlers.
Template YAML format¶
name: my-company-langchain
version: "1.0"
description: |
Custom template for Acme Corp's LangChain agents.
rules:
- id: rule_identifier
description: Human-readable explanation
match:
event: "on_tool_start" # exact match on event name
name_pattern: "^fetch_.*" # regex match on tool/step name
behavior:
kind: "step.resource" # atomic behavior type
verb: "GET" # HTTP-style verb
step_name: "{{ name }}" # Jinja2 template — inserts the tool name
properties: # custom metadata for policy evaluation
target:
resource_type: "database"
contains_pii: true
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:
idis required and must be unique within the template.matchandbehaviorare both required mappings. A catch-all is written as an explicitmatch: {}; leavingmatchout is an error.conditionsandoutputare not keys. They arematchandbehavior, and using them is rejected with a hint.behaviormay only setkind,step_name,verb,propertiesandoutput(a completed gate decision). Any other key is an error naming the allowed ones.- A literal
kindorverbmust be in the vocabulary, and aname_patternmust 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 |
|---|---|---|
kind |
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.model, 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) |
kind; verb (when declared); name (the function's name); kwargs (when the function has arguments and capture_input is on) |
Task boundaries from Kyvvu |
kind, 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 | kind, event (on_chain_start, on_chain_end, 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_default catch-all, and declare the named reads with Kyvvu(read_only_tools=[...]):
rules:
# ... task lifecycle rules ...
# Custom: PII tools get special properties
- id: pii_tool
description: Tools that handle personally identifiable information
match:
event: "on_tool_start"
name_pattern: "^(fetch_customer|lookup_user|get_profile|search_patients).*"
behavior:
kind: "step.resource"
verb: "GET"
step_name: "{{ name }}"
properties:
target:
resource_type: "tool"
contains_pii: true
# ... rest of the default rules ...
Now you can write a policy that blocks PII tools without a preceding gate.
Mark a tool as destructive¶
- id: archive_tool
description: archive_record is a destructive operation
match:
event: "on_tool_start"
name_pattern: "^archive_record$"
behavior:
kind: "step.resource"
verb: "POST"
step_name: "{{ name }}"
properties:
target:
resource_type: "tool"
destructive: true
Tag all database tools¶
Interpolation supports a lookup, default(...) and omit_empty, and nothing else. Tool callback verbs come from the client declarations, even when a custom template supplies a verb. Use rules to assign properties, most specific first:
- id: db_write_tools
description: db_write* tools are database writes
match:
event: "on_tool_start"
name_pattern: "^db_write"
behavior:
kind: "step.resource"
verb: "POST"
step_name: "{{ name }}"
properties:
target:
resource_type: "database"
- id: db_tools
description: Every other db_ tool gets database properties
match:
event: "on_tool_start"
name_pattern: "^db_"
behavior:
kind: "step.resource"
verb: "POST"
step_name: "{{ name }}"
properties:
target:
resource_type: "database"
Declare any db_ read tools in Kyvvu(read_only_tools=[...]); a template rule cannot make them GET on its own.
Rule ordering tips¶
- Specific rules first, catch-all last. The first matching rule wins.
- 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 showalready has them; deleting one fails when the handler is built. - Keep the default
tool_defaultcatch-all as the last tool rule. Custom tool rules go beforetool_default. - Test with
KV_LOG_LEVEL=DEBUG. The Python client logs which rule matched for each event.
Debugging¶
Set KV_LOG_LEVEL=DEBUG to see template matching in action. For the PII rule above:
kyvvu: matched context {'event': 'on_tool_start', 'name': 'fetch_customer'} -> {'kind': 'step.resource', 'verb': 'GET', 'step_name': 'fetch_customer', 'properties': {'target': {'resource_type': 'tool', 'contains_pii': True}}}
This debug line shows the template match. The recorded verb is GET only if fetch_customer is in the client's read_only_tools.
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¶
- LangChain / LangGraph Integration — the built-in handler these templates work with
- Atomic Behaviours — the full vocabulary of behavior types
- Template Authoring Guide — match semantics, property extraction and a worked example
- Writing a New Integration — for non-LangChain frameworks that need a Python adapter