Template Authoring Guide¶
What you'll learn: How to create a custom behavioral template that maps your framework's events to Kyvvu's 12 atomic behavior types.
When you need a custom template¶
The kyvvu Python client and installed integration packages provide these templates:
decorator-- for Python agents using@kv.stepdecorators.langchain-- fromkyvvu-langchain, for LangChain agents using the callback handler.langgraph-- fromkyvvu-langchain, for LangGraph graph executions.crewai-- fromkyvvu-crewai, for CrewAI agents.
You need a custom template when you want different mappings or properties from the built-in for a framework, or when you're integrating a framework that none of the built-ins cover and isn't using @kv.step decorators. Examples: a custom HTTP-based agent, a Microsoft AutoGen agent, or any framework that emits its own event types. The template system (loading, matching, interpolation) is the kyvvu-templates package, imported as kyvvu_templates; the YAML files live with their integration.
Writing and testing a template needs only that package. The Python client and every integration already bring it with them:
pip install kyvvu-templates
A custom template replaces the built-in. It does not extend it, and nothing falls through to the built-in for an event it does not match. It governs both the steps the integration records and the task boundaries it opens, so it must contain a rule for every boundary (see Cover the task boundaries). The easiest start is a copy of the built-in: kyvvu template show langchain > my-template.yaml (the kyvvu command, from kyvvu-cli).
Template YAML anatomy¶
A template is a YAML file with three top-level keys and an ordered list of rules. Here's an annotated excerpt from the built-in decorator template:
name: decorator # Template identifier
version: "1.0" # Version string (for your own tracking)
description: | # Optional human-readable description
Maps Python decorator annotations to Kyvvu atomic behaviors.
rules: # Ordered list of matching rules
- id: model_call # Unique rule identifier
description: | # What this rule matches (for documentation)
AI model inference: LLM completion, chat, embedding, vision, etc.
match: # Conditions -- all must be true for this rule to fire
kind: "step.model"
behavior: # Output fields when the rule matches
kind: "step.model"
step_name: "{{ name }}"
properties:
model: "{{ kwargs.model | default('unknown') }}"
provider: "{{ kwargs.provider | omit_empty }}"
- id: unknown # Explicitly unclassified steps
match:
kind: "step.unknown"
behavior:
kind: "step.unknown"
step_name: "{{ name }}"
The behavior.kind field names the action kind (step.model, for example);
its verb is present only for kinds that take one. For a policy field, use a
complete path such as input.recipient to read input data or
properties.target.host to read an extracted property. Template properties
may contain both bare leaves (command, model, provider) and nested groups;
the properties. root belongs in the policy field path. Restrict a policy
reading a bare leaf to its intended kind, such as step.exec for command.
Each rule has:
| Key | Required | Purpose |
|---|---|---|
id |
Yes | Unique, non-empty string. A missing or duplicate id is an error. |
description |
No | Human-readable description. |
match |
Yes | Conditions dict. All conditions must pass (implicit AND). A catch-all is an explicit match: {}; leaving the key out is an error. |
behavior |
Yes | Output dict with kind, verb, step_name, properties, and (for a completed gate) output. |
Note: The template is validated strictly when it is loaded, and the error names the rule and the fix. A rule may carry only
id,description,matchandbehavior(conditionsandoutputare rejected with a hint to usematchandbehavior).behaviormay set onlykind,step_name,verb,propertiesandoutput(a completed gate decision), and any other key is an error. A literalkindorverboutside the vocabulary and aname_patternthat does not compile are errors too. Amatchkey that no shipped integration sets is allowed but logged as a warning when the template loads, so a typo is visible.
The context dict¶
When your integration fires, it builds a context dict and passes it to template.match(context). The template's rules match against this context.
The context keys depend on your integration. Here are the two built-in examples:
Decorator context keys:
{
"kind": "step.resource", # From @kv.step(kind=...)
"verb": "GET", # From @kv.step(verb=...)
"name": "fetch_customer", # The decorated function's __name__
"kwargs": {"url": "https://...", "customer_id": "C-123"}, # The function's arguments, by parameter name
}
kwargs is the wrapped function's arguments by parameter name for the current call, not the keyword arguments of the call site. {{ kwargs.url }} resolves only if the function has a parameter called url; a keyword swallowed by **kw appears under kw.
LangChain context keys:
{
"event": "on_llm_start", # LangChain callback name
"name": "ChatOpenAI", # Run name or model name
"serialized": {"name": "gpt-4o"}, # LangChain serialized object
"inputs": {...}, # Chain inputs (on_chain_start)
}
Designing context keys for a new framework:
- Identify what events your framework emits (e.g.
http_request,llm_call,tool_invoke). - Choose a primary discriminator key (e.g.
eventorevent_type). - Include any metadata your rules need to distinguish sub-types (e.g. HTTP method, model name, tool name).
- Include data you want to extract into properties (e.g. URL, model parameters).
Match semantics¶
Rules are evaluated top to bottom in list order. The first rule whose match block fully matches the context wins. Evaluation stops immediately -- no further rules are checked.
Exact string matching¶
Each key in match is compared to the same key in the context dict. All keys must match (implicit AND):
match:
event: "on_tool_start" # context["event"] == "on_tool_start"
verb: "GET" # AND context["verb"] == "GET"
Nested keys (dot-path)¶
Use dot notation to match nested dict values:
match:
serialized.name: "gpt-4o" # context["serialized"]["name"] == "gpt-4o"
Regex patterns¶
One condition key supports regex matching, the same on every surface:
name_pattern-- regex againstcontext["name"]: the decorated function's name, or a callback's tool, model or chain name.
The pattern is searched anywhere in the value (Python re.search); anchor with ^ and $. Template name patterns are case-insensitive. A context with no name never matches, whatever the pattern (.* included). function_name and function_name_pattern no longer exist; a template that uses function_name_pattern is rejected at load with a hint to use name_pattern. step_name is an output field (it belongs in behavior), never a match key.
match:
event: "on_tool_start"
name_pattern: "^search_catalog$" # Select this named tool's properties
Why rule order matters¶
Because of first-match-wins, specific rules must come before general ones. Put any custom tool property rules before the LangChain tool_default catch-all. Callback tool verbs are POST unless the client declares a tool in read_only_tools; a template verb cannot override this.
The fallback/default rule¶
A rule with an empty match block matches everything:
- id: fallback
match: {}
behavior:
kind: "step.unknown"
step_name: "unrecognized"
End a template with a fallback rule so that the step.unknown it produces is shaped by you. Without one, match() returns None, and a callback handler records the event as step.unknown named after the event anyway (with a warning, once per event); the decorator builds an unmatched step from its own arguments.
Cover the task boundaries¶
The template governs the task boundaries the integration opens as well as its steps, so a template passed to an integration must have a rule for each of them. This is checked when the handler or client is built, and a miss raises KyvvuConfigError naming the boundaries:
| Integration | Must match |
|---|---|
Kyvvu(template=...) |
task.start, task.end, task.error, task.idle, each by kind alone |
KyvvuLangChainHandler, KyvvuLangGraphHandler |
task.start (event: on_chain_start), task.end (on_chain_end), task.error (on_chain_error) |
KyvvuCrewAIListener |
task.start (crew_kickoff_started), task.end (crew_kickoff_completed), task.error (crew_kickoff_failed) |
A FrameworkAdapter subclass |
whatever it lists in boundary_contexts; by default all four task.* types by kind |
The worked example below covers task.start and task.end by event name, so the adapter that uses it would declare exactly those two in its boundary_contexts.
template=None means no template. Kyvvu accepts it for @kv.step and add_step (steps are built from their arguments) and refuses start_task(); the callback handlers refuse it.
Property extraction¶
String values in the behavior block support {{ }} interpolation from the context dict.
Simple lookup¶
step_name: "{{ name }}"
Resolves context["name"]. If the key is missing, produces an empty string.
Default values¶
name: "{{ kwargs.model | default('unknown') }}"
If context["kwargs"]["model"] is missing or None, produces "unknown".
Nested paths¶
host: "{{ kwargs.url | omit_empty }}"
Traverses context["kwargs"]["url"] using dot-path notation.
Leaving a key out: omit_empty¶
{{ path | omit_empty }} resolves like a plain lookup, but when it is the whole value of a key and the lookup yields nothing (missing, None or ""), the key is left out of the result instead of being emitted as "". Use it where "absent" and "empty" mean different things. The case that matters is target.host: a rule such as domain_allowlist blocks a step whose host is empty or absent, but only if the step is one the policy applies to, and an empty string would hide from you that the step never said where it was going. omit_empty keeps the honest version, no host at all. It applies only when the expression is the entire value; mixed into a longer string it renders as an ordinary lookup.
Mapping to Kyvvu's property schema¶
Properties are grouped by purpose. The standard groups (from the Behaviours reference) are:
| Group | Used with | Example keys |
|---|---|---|
target |
step.resource |
host, resource_type, system, table, trust |
bare model, provider |
step.model |
strings, not a group |
model_params |
step.model |
temperature, max_tokens |
bare kind |
step.gate |
human, guardrail |
message |
step.message |
channel, recipient, sender |
auth |
step.credential |
access_level, principal |
data |
any | classification, fields |
bare command |
step.exec |
string, not a group |
usage |
any step that declares it | total_tokens, cost_usd |
For step.credential, use GET to read, POST to issue, PATCH to rotate, and DELETE to revoke a credential. The decorator template preserves the declared verb for each operation.
A decorated step.gate is a special case: the template carries no decision, and must not default one (a default is an approval nobody gave). The decorator records output.decision after the function finishes: "pass" when it returns, "deny" when it raises kyvvu.GateDenied (or any other exception). A gate passes unless it raises GateDenied; a function that only prints or returns "denied" records a pass.
Example property extraction in a rule:
behavior:
kind: "step.resource"
verb: "GET"
step_name: "{{ name }}"
properties:
target:
host: "{{ kwargs.url | omit_empty }}"
resource_type: "{{ kwargs.resource_type | default('unknown') }}"
data:
classification: "{{ kwargs.data_class | default('') }}"
Testing your template¶
Use BehaviorTemplate.from_path() to load your template and match() to verify mappings:
from kyvvu_templates import BehaviorTemplate
t = BehaviorTemplate.from_path("my_template.yaml")
result = t.match({"event": "http_request", "method": "GET", "url": "https://api.example.com"})
print(result)
# {'kind': 'step.resource', 'verb': 'GET', ...}
Test each event type your framework emits. Test edge cases: missing fields, unknown events, events with None values. Verify the fallback rule catches unrecognized events.
Worked example: HTTP agent template¶
Suppose you have an agent that emits these events:
| Event | Data |
|---|---|
http_request |
method, url |
llm_call |
model, prompt |
approval_requested |
approver |
task_started |
-- |
task_completed |
-- |
Step 1: Task lifecycle rules¶
name: http-agent
version: "1.0"
description: |
Maps HTTP agent events to Kyvvu atomic behaviors.
rules:
- id: task_start
description: Agent begins a new task
match:
event: "task_started"
behavior:
kind: "task.start"
step_name: "task_started"
- id: task_end
description: Agent completes a task
match:
event: "task_completed"
behavior:
kind: "task.end"
step_name: "task_completed"
Step 2: LLM call rule¶
- id: llm_call
description: Agent calls an LLM
match:
event: "llm_call"
behavior:
kind: "step.model"
step_name: "llm_call"
properties:
model: "{{ model | default('unknown') }}"
Step 3: HTTP request rule¶
- id: http_request
description: Agent makes an HTTP request
match:
event: "http_request"
behavior:
kind: "step.resource"
verb: "{{ method | default('GET') }}"
step_name: "http_request"
properties:
target:
host: "{{ url | omit_empty }}"
Step 4: Approval gate¶
- id: approval
description: Agent requests human approval
match:
event: "approval_requested"
behavior:
kind: "step.gate"
step_name: "approval_requested"
properties:
kind: human
output:
decision: "{{ decision }}"
Step 5: Fallback rule¶
- id: fallback
description: Catch-all for unrecognized events
match: {}
behavior:
kind: "step.unknown"
step_name: "{{ event | default('unknown') }}"
Step 6: Test it¶
from kyvvu_templates import BehaviorTemplate
t = BehaviorTemplate.from_path("http_agent.template.yaml")
# Task lifecycle
r = t.match({"event": "task_started"})
assert r["kind"] == "task.start"
# LLM call
r = t.match({"event": "llm_call", "model": "gpt-4o", "prompt": "Hello"})
assert r["kind"] == "step.model"
assert r["properties"]["model"] == "gpt-4o"
# HTTP request
r = t.match({"event": "http_request", "method": "GET", "url": "https://api.example.com"})
assert r["kind"] == "step.resource"
assert r["verb"] == "GET"
assert r["properties"]["target"]["host"] == "https://api.example.com"
# Approval gate
r = t.match({"event": "approval_requested", "approver": "admin@co.com", "decision": "pass"})
assert r["kind"] == "step.gate"
assert r["properties"]["kind"] == "human"
assert r["output"]["decision"] == "pass"
# Unknown event falls to default
r = t.match({"event": "something_unexpected"})
assert r["kind"] == "step.unknown"
Next steps¶
- Templates overview -- deep-merge semantics and built-in template details
- Atomic Behaviours -- the 12 behavior types and property groups
- Writing a New Integration -- the
FrameworkAdapterbase class for hooking into frameworks - Pattern templates -- reference snippets for common agent architectures (RAG, ReAct, human-in-the-loop, multi-agent)