Ad-hoc Step Recording: kv.add_step()¶
What you'll learn: How to record individual steps into the active task without wrapping a function in @kv.step.
kv.add_step() records a step into the active task imperatively. Use it for inline steps, third-party code you cannot decorate, dynamically chosen tools, or injecting a step.gate (human approval) inside a callback-based agent like LangChain. It is the missing primitive for mixing integration patterns cleanly — decorator, callback, and manual — within a single agent.
Method signature¶
kv.add_step(
kind: str | ActionKind | None = None,
step_name: str | None = None,
*,
input: dict | None = None,
output: dict | None = None,
verb: str | Verb | None = None,
properties: dict | None = None,
meta: dict | None = None,
task_id: str | None = None,
action_id: str | None = None,
**kwargs,
) -> None
kind defaults to None and **kwargs is there only so that a missing or
misspelled argument gets a clear message: in practice kind is required. A call
without it raises TypeError: Missing required step argument: kind, and an
unknown keyword raises TypeError: Unexpected step argument: <name>.
Parameters¶
| Parameter | Type | Default | Description |
|---|---|---|---|
kind |
str \| ActionKind \| None |
None |
Required in practice: None raises TypeError. Action kind enum or string (e.g. "step.gate", ActionKind.step_model). |
step_name |
str \| None |
None |
Human-readable label: 1-255 characters, no control characters. Defaults to the kind value. |
input |
dict \| None |
None |
Input data to record. |
output |
dict \| None |
None |
Output data to record. |
verb |
str \| Verb \| None |
None |
HTTP-style verb ("GET", "POST", etc.). |
properties |
dict \| None |
None |
Arbitrary key-value metadata. |
meta |
dict \| None |
None |
Engine-reserved multi-agent correlation fields. |
task_id |
str \| None |
None |
Explicit task ID: letters, digits and . _ : -, 1-100 characters. Falls back to the active task from start_task() or a framework adapter. The task must be open in this client. |
action_id |
str \| None |
None |
Identifier for this action: letters, digits and . _ : -, 1-255 characters. Generated when omitted. Unique within the task, except to pair an intended step with its own completion; a duplicate raises ValueError. |
Behaviour¶
- Requires an active task — set by
start_task(),@kv.step(kind="task.start"), or a framework adapter such asKyvvuLangChainHandler. A handler owns the task it opens and refuses to start inside one that is already active, so with a handler you do not callstart_task()yourself. - A
step.gaterecorded withadd_stephas itsproperties.kinddefaulted tohuman;kindmust behumanorguardrail, and a completed gate'soutput.decisionmust bepassordeny(ValueErrorotherwise). - A
task_id,action_idorstep_nameoutside the identifier rules raisesKyvvuValidationErrorbefore anything is recorded. - Evaluates policies before recording. Raises
KyvvuBlockedErrorif the step is blocked by an active policy. - Unlike
@kv.step, no template matching is performed — the caller provides all fields explicitly. That is also why it works on a client built withKyvvu(template=None)(no template at all), which@kv.stepaccepts too butstart_task()refuses. - Like
@kv.stepand the framework handlers, a block never ends the task: the blocked step is recorded (output.statusisblocked),KyvvuBlockedErroris raised, and the task stays open. The caller decides what happens next:error_task(),end_task(), or record an approval gate and retry in the same task. An open task nobody closes is evicted by the stale-task sweeper afterKV_TASK_MAX_AGE_SECONDS(default 3600 s).
Usage examples¶
Standalone (manual task lifecycle)¶
from kyvvu import Kyvvu
kv = Kyvvu(api_url="...", api_key="...", agent_key="my-agent")
kv.register_agent(
name="My Agent",
purpose="Looks up customer records for the support team",
# The step names of the step.resource / step.exec / step.credential
# steps this agent records. The bundled baseline blocks any other.
declared_tools=["fetch_user_data"],
)
kv.start_task()
kv.add_step(
kind="step.resource",
step_name="fetch_user_data",
verb="GET",
input={"url": "https://api.example.com/users/42"},
output={"status": 200, "user": {"name": "Alice"}},
)
kv.end_task()
Inside a @kv.step-decorated agent¶
@kv.step(kind="task.start")
def run_agent(prompt: str):
result = call_llm(prompt)
# Inject an inline gate — no separate function needed
kv.add_step(
kind="step.gate",
step_name="safety_check",
input={"content": result},
properties={"kind": "guardrail"}, output={"decision": "pass"},
)
return result
Mixed with LangChain handler¶
from langchain.tools import tool
from kyvvu import Kyvvu
from kyvvu_langchain import KyvvuLangChainHandler
kv = Kyvvu(api_url="...", api_key="...", agent_key="lc-agent")
kv.register_agent(
name="LC Agent",
purpose="Drafts and sends customer emails after human approval",
declared_tools=["send_email"], # the LangChain tool's name
)
handler = KyvvuLangChainHandler(kv)
@tool
def send_email(recipient: str, body: str) -> str:
"""Send an email after human approval."""
# The handler has already started a task via on_chain_start, and owns it:
# do not call kv.start_task() yourself, and do not put @kv.step on this
# tool (the handler already records it). add_step() inside the tool is fine:
# use it to inject a gate the handler doesn't cover:
approved = input(f"Approve sending to {recipient}? [y/n] ")
kv.add_step(
kind="step.gate",
step_name="human_approval",
input={"action": "send_email", "recipient": recipient},
properties={"kind": "human"},
output={"decision": "pass" if approved == "y" else "deny"},
)
if approved != "y":
return "Blocked by human reviewer."
return do_send(recipient, body)
# Pass the handler when invoking the chain:
agent.invoke({"input": "..."}, config={"callbacks": [handler]})
A gate recorded with add_step carries its decision explicitly in output["decision"] ("pass" or "deny"), and its kind in properties["kind"] ("human" or "guardrail"). A decorated gate passes by returning and denies by raising kyvvu.GateDenied. See Python Decorator.
get_current_task_id()¶
Convenience function that returns the task_id active in the current context (set by start_task() or a framework adapter), or None. Useful for advanced users who need to inspect the active task without a Kyvvu instance reference.
from kyvvu import get_current_task_id
task_id = get_current_task_id() # str or None
Next steps¶
- Python Decorator — function-level step instrumentation with
@kv.step - LangChain / LangGraph — callback-based integration
- Atomic Behaviours — the action kinds and verbs available
- Policies and Rules — how policy evaluation works