Skip to content

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_pii as contains_pii: true so policies can act on PII-handling steps
  • Change behavior properties or kinds — e.g., tag an archive_record tool as destructive; callback tool verbs are controlled by read_only_tools and write_tools on Kyvvu
  • 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 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 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 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:

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:

  • 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 kind, step_name, verb, properties and output (a completed gate decision). Any other key is an error naming the allowed ones.
  • A literal kind 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
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

  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 before tool_default.
  4. 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