Skip to content

Your First Agent

What you'll learn: How kyvvu init works, what the demo agent does, and how to read the policy evaluation output.


Scaffold a project

This needs the kyvvu command (uv tool install kyvvu-cli); the scaffolded project installs the Python client from its requirements.txt. See Installation.

kyvvu init my-agent

This scaffolds project files — agent.py, requirements.txt, .env.example, .gitignore, README.md, and AGENTS.md (instructions for coding agents working in the project, kept if one exists). No authentication is required.

The output looks like:

✓ Created my-agent/

Next steps:

    cd my-agent

    # Install requirements:
    pip install -r requirements.txt

    # Run the agent -- no account needed:
    python agent.py

With no KV_API_KEY it runs offline against Kyvvu's bundled baseline
(15 policies, OWASP + EU AI Act) and prints its trace here.

To connect it to the platform, set your key and run it again:
    # (open the dashboard, go to Workspace → API Keys, and create one)
    cp .env.example .env       # add your KV_API_KEY
    # or: export KV_API_KEY=your-key

Registering applies the same baseline on the platform.
No assignment needed — see them on the Agents page, tagged Baseline.

To add more policies, go to the dashboard:
    Manifests page → select a manifest → assign to your agent
Or use the CLI:
    kyvvu list-manifests
    kyvvu assign-manifest --agent-id <id> --repo-id <id> --manifest <path>

Run it with no account

python agent.py works straight after pip install -r requirements.txt. With no KV_API_KEY set, the scaffolded agent does not register: it builds its client with an agent_id and Kyvvu's bundled baseline (load_baseline_policies(), the same 15 policies the platform applies), prints its trace in the terminal, and exits 0. The send_reply warning and the run_script block described below appear in that trace, and the run's first lines name the one change that connects it to the platform: set KV_API_KEY and run it again.

Get an API key

To connect the agent to the platform it needs a KV_API_KEY: with one, agent.py registers instead of running offline. Create one in the dashboard under Workspace → API Keys.

kyvvu auth logs the CLI in and deliberately cannot mint an agent key — a CLI login must never be able to escalate itself into an agent-scoped credential. See Installation for the full explanation.

Run the demo agent

Each step's kind field identifies its action kind; ActionKind.step_model is the model-call value used by the demo agent.

cd my-agent
pip install -r requirements.txt
export KV_API_KEY=KvKey-...
export KV_ENVIRONMENT=development   # optional, but see the note below
python agent.py

Set KV_ENVIRONMENT=development for local work. The environment an agent registers under is the environment= argument, then KV_ENVIRONMENT, then production, and the default is production everywhere. The scaffolded agent sets nothing, so without the variable your trial run registers as a production agent. kyvvu init puts KV_ENVIRONMENT=development in .env.example.

That is the whole first run. There is no assignment step before it — see Your agent is governed from its first step below — and there could not be one even if you wanted it: the agent does not exist until register_agent() runs, so there is no agent id to assign a manifest to until after this command.

The full agent.py source:

"""Minimal Kyvvu agent -- demonstrates task and step instrumentation.

This agent uses mocked LLM and resource calls so it runs offline with
no extra API keys. The point is to show Kyvvu's trace output -- every
step you see in the terminal is a behavior the policy engine evaluates.

Run:
    python agent.py

With no KV_API_KEY it needs no account: it governs itself offline against
Kyvvu's bundled baseline and prints its trace here. To connect it to the
platform, set a key and run it again -- that is the only change:
    export KV_API_KEY=KvKey-...   # dashboard -> Workspace -> API Keys
    export KV_ENVIRONMENT=development   # unset means "production"
    python agent.py
"""
from __future__ import annotations

import os
import sys

from kyvvu import Kyvvu, KyvvuBlockedError, KyvvuConfigError, RiskClassification, ActionKind, Verb
from kyvvu_baseline import load_baseline_policies


# The key decides the path: with one the agent registers on the platform,
# which applies the baseline and stores the trace; without one it runs
# offline against the same bundled baseline, with no account.
ONLINE = bool(os.environ.get("KV_API_KEY"))

if ONLINE:
    kv = Kyvvu(
        api_url=os.environ.get("KV_API_URL", "https://platform.kyvvu.com"),
        api_key=os.environ["KV_API_KEY"],
        agent_key="my-agent",
        risk_classification=RiskClassification.MINIMAL,
    )

    kv.register_agent(
        name="Hello Kyvvu",
        purpose=(
            "Demonstration agent for getting started with Kyvvu "
            "\u2014 shows Kyvvu's behavioral trace and policy "
            "evaluation with mocked LLM and resource calls."
        ),
        declared_tools=["call_llm", "fetch_user_data", "send_reply", "run_script"],
    )
else:
    kv = Kyvvu(
        agent_id="my-agent",
        risk_classification=RiskClassification.MINIMAL,
        # The same tools register_agent() declares above.
        declared_tools=["call_llm", "fetch_user_data", "send_reply", "run_script"],
        policies=load_baseline_policies(),
    )
    print(
        "No KV_API_KEY set: running offline against Kyvvu's bundled baseline\n"
        "(15 policies, OWASP + EU AI Act). No account needed; the trace prints here.\n"
        "To connect this agent to the platform, set KV_API_KEY (dashboard ->\n"
        "Workspace -> API Keys) and run it again.\n"
    )


@kv.step(ActionKind.step_model)
def call_llm(prompt: str) -> str:
    """Mocked model call -- returns a canned response."""
    return f"(mocked response to: {prompt!r})"


@kv.step(ActionKind.step_resource, Verb.GET)
def fetch_user_data(user_id: str) -> dict:
    """Mocked external resource call."""
    return {"user_id": user_id, "name": "Alice", "tier": "free"}


@kv.step(ActionKind.step_message, Verb.POST)
def send_reply(to: str, body: str) -> str:
    """Mocked outbound message -- draws a policy warning, not a block.

    The model's answer reaches a person here with no gate after the model
    call, so two EU AI Act policies warn: "AI-generated content must be
    marked before delivery" (Art. 50(2)) and "Chatbot disclosure gate
    required before outbound messages" (Art. 50(1)). That is a compliance
    finding: the step still runs, and the warning is recorded on the trace.
    """
    print(f"(would send to {to}: {body!r})")
    return "sent"


@kv.step(ActionKind.step_exec)
def run_script(code: str) -> str:
    """Mocked code execution -- intentionally blocked by OWASP policy.

    The "Code execution requires a preceding gate" policy blocks step.exec
    unless the task history already holds a step.gate whose
    output["decision"] is "pass". *Anywhere* earlier in the task
    counts -- check_step_requires_gate scans the whole history, and a bare
    step.gate with no decision does not clear the block.

    Immediate precedence belongs to a different bundled policy, "External
    content taint: high-impact actions require a fresh gate", which uses the
    step_directly_preceded_by rule. The distinction is the point of policies
    on paths: "approved at some point in this task" and "approved after the
    untrusted data arrived" are different guarantees.

    This demonstrates Kyvvu's runtime enforcement of OWASP ASI05.
    """
    return f"(would execute: {code!r})"


def handle_request(user_id: str, query: str) -> str:
    user = fetch_user_data(user_id)
    answer = call_llm(f"User {user['name']} asks: {query}")
    # This draws a warning -- model output delivered with no gate after it:
    send_reply("alice@example.com", answer)
    # This will be blocked by the OWASP policy -- no gate precedes it:
    run_script(f"save_response('{answer}')")
    # Reached only when nothing blocked the step above. On a governed agent
    # this line never runs, which is the whole point of the demo -- but the
    # function still has to keep the promise its `-> str` annotation makes.
    return answer


def explain_block(exc: KyvvuBlockedError) -> None:
    """Print what would clear this block, chosen by the rule that fired.

    A gate clears a gate rule and nothing else: it does not put a step on
    an allowlist or a host on a domain list. So the advice is picked per
    violated policy, by its rule type, rather than printed once for all.
    """
    print(
        "\nThis is Kyvvu working as intended. A block never ends the task:\n"
        "the blocked step is recorded and the task stays open, so an agent\n"
        "can resolve the block and carry on."
    )
    violated = [p for p in exc.result.policies if p.violated]
    rule_types = {p.rule_type for p in violated}
    advised = False
    if "step_requires_gate" in rule_types:
        advised = True
        print(
            "Record the approval as a gate -- it only counts when it carries\n"
            "a pass decision -- then retry in the same task:\n"
            "  kv.add_step(ActionKind.step_gate, \"human_approval\",\n"
            "              properties={\"kind\": \"human\"}, output={\"decision\": \"pass\"})"
        )
    if "step_name_in_allowlist" in rule_types:
        advised = True
        print(
            f"'{exc.step_name}' is not in the declared_tools list passed to\n"
            "kv.register_agent(). Add it there if the agent should use it."
        )
    if "domain_allowlist" in rule_types:
        advised = True
        print(
            "The step's properties.target.host is not on the policy's\n"
            "allowed_domains -- and a step that records no host at all is blocked\n"
            "too. Call an approved host, or assign your own copy of the manifest\n"
            "with your domains in it."
        )
    if any(p.name == "no_policies_available" for p in violated):
        advised = True
        print(
            "No policies could be loaded, so every step is blocked (fail closed).\n"
            "The KYVVU line printed above says why -- check KV_API_KEY first."
        )
    if not advised:
        print(
            "The policy's description says what it requires: find it in the\n"
            "manifest that defines the policy, on the dashboard's Manifests page."
        )


if __name__ == "__main__":
    try:
        kv.start_task()
        result = handle_request(user_id="user_123", query="What's the weather?")
        print(f"\nResult: {result}")
    except KyvvuBlockedError as exc:
        print(f"\n\u26d4 Policy blocked this step: {exc}")
        print(f"   Risk score: {exc.result.risk_score:.2f}")
        print(f"   Action:     {exc.result.action.value}")
        for p in exc.result.policies:
            if p.violated:
                print(f"   \u2022 {p.name} ({p.severity})")
        explain_block(exc)
        # This script gives up instead, so it closes the task itself.
        try:
            kv.error_task(error=exc)
        except KyvvuConfigError:
            pass  # Already closed, or the block was on task.start (never opened)
        # Offline, like `kyvvu try`, the block is the verdict the run exists
        # to show, so it exits 0. Online, the agent did not finish its task.
        sys.exit(1 if ONLINE else 0)
    except Exception as exc:
        try:
            kv.error_task(error=exc)
        except KyvvuConfigError:
            pass  # No task to close (start_task itself failed)
        raise
    else:
        kv.end_task()

It starts a task and runs four steps. The third one, send_reply, draws a warning and still runs; the fourth, run_script, is blocked. This transcript omits the requirements-installer output, timestamps, and warning source paths printed to stderr; identifiers are illustrative:

kyvvu.core INFO Agent registered: agent_id=agent_3f9c2a1b7d4e8f60
kyvvu_engine.io.runner INFO Policies loaded: 15 policies for agent_key='my-agent'
kyvvu_engine.io.runner WARNING Agent registration policy violation: action=warn risk=0.50
kyvvu_engine.io.runner INFO policy violation: task=d4c09846-8aa step='send_reply' action=warn risk=0.75 (2 violations)
(would send to alice@example.com: '(mocked response to: "User Alice asks: What\'s the weather?")')
kyvvu_engine.io.runner INFO policy violation: task=d4c09846-8aa step='run_script' action=block risk=1.00 (1 violations)

⛔ Policy blocked this step: Step 'run_script' blocked (risk_score=1.00): Code execution requires a preceding gate (manifest OWASP Top 10 for Agentic Applications - Default Security Template)
   Risk score: 1.00
   Action:     block
   • Code execution requires a preceding gate (critical)

This is Kyvvu working as intended. A block never ends the task:
the blocked step is recorded and the task stays open, so an agent
can resolve the block and carry on.
Record the approval as a gate -- it only counts when it carries
a pass decision -- then retry in the same task:
  kv.add_step(ActionKind.step_gate, "human_approval",
              properties={"kind": "human"}, output={"decision": "pass"})
kyvvu_engine.io.runner INFO task d4c09846-8aa ended — 6 steps (task.start×1, step.resource×1, step.model×1, step.message×1, step.exec×1, task.error×1)
kyvvu_engine.io.exporters.http INFO HttpExporter: batch accepted task=d4c09846-8aa0-4bfe-9ace-b2d541adc67f steps=6 hash_tail=97eff6b45b9150bf…

The advice under the block is chosen by the rule of the policy that fired, because a gate clears only a gate rule. A step_requires_gate block prints the gate above; a step_name_in_allowlist block tells you to add the step to declared_tools; a domain_allowlist block tells you the step's host is not approved; anything else points you to the policy's description.

HttpExporter: batch accepted is the line that matters for onboarding. It comes last because the task is sent when it closes, and the script closes it with kv.error_task() after printing the advice. It means the task's steps reached the platform, so they are on the Logs page now. You did not configure that. log_location defaults to auto, which follows the policy source — an agent whose policies come from the platform sends its trace there too. Set KV_LOG_LOCATION=stdout if you would rather keep a run local.

Your agent is governed from its first step

Registering an agent applies Kyvvu's baseline to it automatically — 15 policies from two bundled manifests, the OWASP Top 10 for Agentic Applications default security template and EU AI Act minimal/limited-risk compliance. There is no manual assignment step, no repository to connect, and nothing to configure. The dashboard shows them on the agent's row, tagged Baseline, with the version of each bundled manifest.

The baseline is on by default, not mandatory: you can unassign either manifest from the dashboard. Registering again will not put it back, because the platform honours the choice you made. To restore it, use the dashboard's Manifests page or the agent's page, or POST /api/v1/assignments with the bundled manifest path; see Removing and restoring the baseline.

When the platform cannot be reached

The first run needs the platform. register_agent() is what creates the agent, and with no cached registration there is no identity to fall back on, so registration fails closed: it retries three times (about four seconds), then raises and no step runs. Pointing the scaffold at a closed port (KV_API_URL=http://127.0.0.1:9) ends with:

kyvvu.exceptions.KyvvuAPIError: register_agent connection error: [Errno 111] Connection refused (POST http://127.0.0.1:9/api/v1/agents). There is no cached registration for agent_key 'my-agent' to fall back on, so the agent cannot start: a first run needs the platform. Check KV_API_URL (api_url) and your network. To run without the platform on purpose, pass agent_id= and policies= (or set KV_POLICY_FILE) instead of calling register_agent(), or try `kyvvu try`.

A timeout raises KyvvuTimeoutError (a subclass of KyvvuAPIError) with the same advice. Nothing is written to the registration cache, so the next run is still a first run.

Later runs survive an outage. A successful registration is cached at ~/.kyvvu/registrations/<agent_key>.json. If the registration payload, URL and key are unchanged, the next run reuses that agent_id without contacting the platform at all; if it changed, the Python client retries and then falls back to the cached agent_id with a warning. Either way the registration policies are evaluated locally against the metadata you pass, so a blocking one still stops the agent. The cache lives under HOME, so a container with an ephemeral home directory starts every run as a first run.

What is then enforced depends on the policy fetch, which fails too:

  • With KV_POLICY_CACHE_PATH set (policy_cache_path=) and a cache file written by an earlier successful fetch, the platform's last answer — every manifest assigned to the agent, not just the baseline.
  • Otherwise, the Python client's bundled copy of the 15 baseline policies, with a warning (Loaded 15 inline policies as a fallback). That is the baseline even if you have assigned more manifests or unassigned the baseline on the platform; set KV_POLICY_CACHE_PATH if that difference matters to you.

kv.policy_status() reports either as load_state=stale_cache, and the next successful fetch replaces the set with the platform's answer. A long-running agent that already holds a set keeps enforcing it through an outage. Steps and incidents from a task that ends during the outage are not delivered: each batch fails with an HttpExporter: network error warning and is not retried.

To run without the platform on purpose, do not call register_agent(). Give the client its identity and its policies directly, and leave out agent_key, so the platform is not the policy source even with KV_API_KEY exported:

from kyvvu import Kyvvu
from kyvvu_baseline import load_baseline_policies

kv = Kyvvu(
    agent_id="my-agent",
    policies=load_baseline_policies(),   # KV_POLICY_FILE=manifest.yaml replaces this list
    declared_tools=["call_llm", "fetch_user_data", "send_reply", "run_script"],
)

The trace then goes to stdout. kyvvu try does the same with a built-in demo agent and no code; see kyvvu try.

That enforces the baseline alone. To enforce a manifest of your own as well, add its policies to the list: load_manifest_policies, exported by the kyvvu client, loads a manifest file and refuses an invalid one. With this manifest saved as my-policies.yaml:

name: My support policies
version: "1.0.0"
policies:
  - name: At most one reply per task
    description: Blocks a second reply in the same task.
    rule_type: execution_max_steps
    params:
      kind: step.message
      max_steps: 1
    severity: critical               # critical blocks; low, medium and high warn
    enforcement_point: step_execution

this program enforces all 16 policies:

from kyvvu import ActionKind, Kyvvu, KyvvuBlockedError, load_manifest_policies
from kyvvu_baseline import load_baseline_policies

kv = Kyvvu(
    agent_id="my-agent",
    # The 15 bundled baseline policies, then every policy in your own manifest.
    policies=load_baseline_policies() + load_manifest_policies("my-policies.yaml"),
    declared_tools=["send_reply"],
    log_location="none",
)


@kv.step(ActionKind.step_message, verb="POST")
def send_reply(text: str) -> str:
    return f"sent: {text}"


task = kv.start_task()
try:
    print(send_reply("Your order has shipped."))
    print(send_reply("Anything else I can help with?"))
except KyvvuBlockedError as error:
    print(f"blocked: {error}")
finally:
    kv.end_task(task)

The first reply draws a warning from the baseline's EU AI Act chatbot disclosure policy and still goes out; the second is blocked by your own critical policy, with the baseline's finding reported beside it. The output, without timestamps and the warning's source line printed to stderr:

kyvvu_engine.io.runner INFO Loaded 16 policies from the local policy source (inline policies=).
kyvvu_engine.io.runner INFO policy violation: task=306d811e-efb step='send_reply' action=warn risk=0.75 (1 violations)
sent: Your order has shipped.
kyvvu_engine.io.runner INFO policy violation: task=306d811e-efb step='send_reply' action=block risk=1.00 (2 violations)
blocked: Step 'send_reply' blocked (risk_score=1.00): At most one reply per task (manifest My support policies); Chatbot disclosure gate required before outbound messages (manifest EU AI Act - Minimal / Limited Risk Compliance)

log_location="none" keeps the trace out of that output. A policy of yours may share its name with a baseline policy: both load and both are evaluated.

KV_POLICY_FILE replaces the baseline rather than adding to it: with it set and no policies=, only the manifests it names are enforced. To keep the baseline, build policies= as above. See Writing a manifest for what a manifest can hold.

Why the demo agent shows compliance warnings

At registration, the baseline's EU AI Act set asks every agent to name an accountable owner, and the scaffold passes no owner_id, so "Agent must have a designated owner" reports a warning (medium, risk 0.50 — not a block; the agent runs normally). Registration policies are evaluated against the metadata your code passes, whether or not the platform stores it, so the fix is to pass owner_id= to register_agent(); store_metadata decides only whether the platform keeps those fields.

At send_reply, the model's answer is delivered to a person with no gate recorded after the model call, so two EU AI Act Article 50 policies warn (high, risk 0.75): the content is not marked as AI-generated (Art. 50(2)), and the person was not told they are dealing with an AI (Art. 50(1)). That is a compliance finding: the step runs and the finding is recorded. A passing step.gate between call_llm and send_reply satisfies both.

The block on run_script is different in kind — a security stop (OWASP ASI05): code execution with no approval in front of it never runs.

Your first run also opens incidents

Every policy violation the engine reports is sent to the platform as an incident, so the Incidents page is not empty after one python agent.py either. This is deliberate — a governance product whose violations reach only a terminal is the same failure as one whose traces do — but it is worth knowing what you are looking at. A first run of the unmodified demo opens four, one per violated policy:

incidents why do they pile up?
"Agent must have a designated owner" the scaffold passes no owner_id, explained above no — a repeat updates the open incident for the same agent, policy and environment
"AI-generated content must be marked before delivery" and "Chatbot disclosure gate required before outbound messages" send_reply delivers model output with no gate after the model call no — step_execution violations are grouped the same way: each repeat raises the open incident's occurrence_count and moves its last_occurrence instead of opening another
"Code execution requires a preceding gate" the demo's punchline no, same reason

An incident stays open, and keeps absorbing repeats, while it is active or ignored; once you resolve it, the next violation opens a new one. See Incidents.

To stop the registration incident, pass owner_id= to register_agent(). To stop the two delivery incidents, record a passing gate between call_llm and send_reply; to stop the last, a passing gate before run_script — or resolve them, which is what the Incidents page is for.

The model call itself opens none: the EU AI Act content-marking policy asks for a gate between model output and its delivery (an outbound step.message POST, or a step.resource POST to an external target), not before the model is called.

Incidents follow the trace sink: KV_INCIDENT_LOCATION=none turns them off without touching the trace, and KV_INCIDENT_LOCATION takes the same vocabulary as KV_LOG_LOCATION (including auto).

Note

Caching: register_agent() results are cached locally at ~/.kyvvu/registrations/. If you call it on every startup with the same parameters, the Python client reuses the cached registration without a network call. You can safely call register_agent() every time your agent starts.

Note

Data minimization: By default, only your agent's opaque ID and operational fields (risk classification, environment, declared tools) are stored in Kyvvu's cloud. Descriptive metadata (name, purpose, owner) is not persisted server-side. Pass store_metadata=True to opt in to storing metadata on the platform.

What happened

The @kv.step decorator wraps each function call in a three-phase cycle:

  1. Evaluate — the engine checks the intended behaviour against all applicable policies. It reads the task's history to make path-dependent decisions.
  2. Execute — if the evaluation returns allow or warn, the function runs.
  3. Record — the completed step (with output) is appended to the task's history for future evaluations.

Steps 1 and 2 passed for fetch_user_data and call_llm, and for send_reply too: a warn still executes, and only records the finding. When run_script was evaluated:

  • The engine identified it as step.exec (code execution).
  • The OWASP policy "Code execution requires a preceding gate" searched the task history for a step.gate.
  • None was found. The policy has severity critical, so the engine returned block.
  • The decorator raised KyvvuBlockedError instead of executing the function.

Clearing the block

A bare step.gate is not enough. step_requires_gate matches on a completed gate's output.decision, which defaults to "pass" in the policy. A gate with no output, or with a denied decision, leaves the block exactly where it was:

gate recorded in task history verdict
none block (1.00)
step.gate with no properties block (1.00)
step.gate, properties={"kind": "human"}, no output block (1.00)
step.gate, properties={"kind": "human"}, output={"decision": "deny"} block (1.00)
step.gate, properties={"kind": "human"}, output={"decision": "pass"} allow (0.00)

kv.add_step() adds properties.kind="human" for a gate when you omit it. The working remedy — the one the demo prints — records the decision:

kv.add_step(
    ActionKind.step_gate,
    "human_approval",
    properties={"kind": "human"},
    output={"decision": "pass"},
)
run_script("save_response('...')")   # now allowed

See kv.add_step().

Note

A decorated gate passes unless it raises GateDenied.

@kv.step(kind="step.gate") does not read the wrapped function's return value. The decorator records the gate's decision itself, after the function finishes: pass when it returns normally, deny when it raises kyvvu.GateDenied (or any other exception). Measured against the bundled baseline:

gate recorded by the decorator verdict
no gate at all block (1.00)
@kv.step(ActionKind.step_gate) whose function raises GateDenied block (1.00)
@kv.step(ActionKind.step_gate) whose function returns normally allow (0.00)

So a human-approval function must deny by raising:

from kyvvu import GateDenied, ActionKind

@kv.step(ActionKind.step_gate, properties={"kind": "human"})
def human_approval(action: str) -> None:
    if not ask_human(action):
        raise GateDenied(f"{action} refused")

A function that only prints "denied" or returns False records a pass, because a gate passes unless it raises GateDenied. GateDenied is not a KyvvuBlockedError: a block is a policy's verdict on a step, a denial is the gate function's own decision. When you would rather state the result yourself, kv.add_step() records the decision you give it, as above.

"Preceding", not "immediately preceding"

step_requires_gate scans the whole task history. A passing gate recorded at the start of the task still satisfies it at the end, with any number of other steps in between.

Immediate precedence is a different bundled policy: "External content taint: high-impact actions require a fresh gate", built on the step_directly_preceded_by rule. Once a step.resource GET has pulled in externally-trusted content, that policy requires the gate to be the step immediately before the high-impact action — an approval given before the untrusted data arrived does not count.

The two answer different questions: "was this approved at some point?" and "was this approved after we saw the untrusted input?" Both ship in the baseline, and both are what "policies on paths" means.

Inspect the policies

kyvvu list-policies --agent-id my-agent

Without --agent-id the command lists every policy in the workspace. Because of the baseline, this is not empty on a fresh agent — 15 policies, before you assign anything:

┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┓
┃ NAME                                                  ┃ ENFORCEMENT POINT  ┃ RULE_TYPE              ┃ SEVERITY ┃ SOURCE                                                ┃ ENABLED ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━┩
│ Agent must declare a substantive purpose              │ agent_registration │ field_matches_regex    │ high     │ kyvvu-manifests-public/manifests/security/owasp-agen… │ ✓       │
│ Agent must declare a tool allowlist                   │ agent_registration │ field_not_empty        │ high     │ kyvvu-manifests-public/manifests/security/owasp-agen… │ ✓       │
│ Agent must declare a valid risk classification        │ agent_registration │ field_in_list          │ critical │ kyvvu-manifests-public/manifests/security/owasp-agen… │ ✓       │
│ Tool calls must be in the declared allowlist          │ step_execution     │ step_name_in_allowlist │ critical │ kyvvu-manifests-public/manifests/security/owasp-agen… │ ✓       │
│ Code execution requires a preceding gate              │ step_execution     │ step_requires_gate     │ critical │ kyvvu-manifests-public/manifests/security/owasp-agen… │ ✓       │
│ Destructive resource operations require a preceding   │ step_execution     │ step_requires_gate     │ critical │ kyvvu-manifests-public/manifests/security/owasp-agen… │ ✓       │
│ gate                                                  │                    │                        │          │                                                       │         │
│ External content taint: high-impact actions require a │ step_execution     │ not                    │ critical │ kyvvu-manifests-public/manifests/security/owasp-agen… │ ✓       │
│ fresh gate                                            │                    │                        │          │                                                       │         │
│ Bound resource calls per task to prevent runaway      │ step_execution     │ execution_max_steps    │ high     │ kyvvu-manifests-public/manifests/security/owasp-agen… │ ✓       │
│ loops                                                 │                    │                        │          │                                                       │         │
│ Agent must have a documented purpose                  │ agent_registration │ field_not_empty        │ high     │ kyvvu-manifests-public/manifests/compliance/eu-ai-ac… │ ✓       │
│ Agent must have an identifying name                   │ agent_registration │ field_not_empty        │ high     │ kyvvu-manifests-public/manifests/compliance/eu-ai-ac… │ ✓       │
│ Risk classification must be valid                     │ agent_registration │ field_in_list          │ critical │ kyvvu-manifests-public/manifests/compliance/eu-ai-ac… │ ✓       │
│ Agent must have a designated owner                    │ agent_registration │ field_not_empty        │ medium   │ kyvvu-manifests-public/manifests/compliance/eu-ai-ac… │ ✓       │
│ Agent purpose must be substantive                     │ agent_registration │ field_matches_regex    │ medium   │ kyvvu-manifests-public/manifests/compliance/eu-ai-ac… │ ✓       │
│ AI-generated content must be marked before delivery   │ step_execution     │ not                    │ high     │ kyvvu-manifests-public/manifests/compliance/eu-ai-ac… │ ✓       │
│ Chatbot disclosure gate required before outbound      │ step_execution     │ step_requires_gate     │ high     │ kyvvu-manifests-public/manifests/compliance/eu-ai-ac… │ ✓       │
│ messages                                              │                    │                        │          │                                                       │         │
└───────────────────────────────────────────────────────┴────────────────────┴────────────────────────┴──────────┴───────────────────────────────────────────────────────┴─────────┘

The SOURCE column shows the path of the manifest each policy came from. Table wrapping depends on terminal width.

Each policy is an instance of a rule with specific parameters. For example, "Code execution requires a preceding gate" uses the step_requires_gate rule with target_kinds: ["step.exec"]. The same rule backs "Destructive resource operations require a preceding gate" with different parameters (target_kinds: ["step.resource"], target_verb: "DELETE").

For the full details of each policy, see The Bundled Baseline and OWASP Default Manifest.

Add more policies

This step is optional, and it comes after the first run — the platform knows your agent only once it has registered, and kyvvu assign-manifest needs it to.

The baseline is a floor to build on, not the whole story. Additional policies are managed via manifests — YAML files stored in a GitHub repository:

  1. Go to the Manifests page in the dashboard
  2. Kyvvu/manifests is already connected — every new workspace starts with the public library, so the page is never empty and there is nothing to set up. Connect your own repository as well if you keep manifests in one.
  3. Click Assign on a manifest and select your agent

Or use the CLI. --agent-id takes the agent key the scaffold registers with (my-agent) or the agent_… id that kyvvu list-agents prints. Replace the example repository id 1 with the id shown by list-manifests:

kyvvu list-manifests
kyvvu assign-manifest --agent-id my-agent --repo-id 1 --manifest manifests/operational/demo.yaml

It answers with the assignment id, which kyvvu unassign-manifest --assignment-id <id> takes to remove the manifest again (kyvvu list-assignments shows it too, in the ID column):

✓ Assigned 'Kyvvu Demo' to agent agent_3f9c2a1b7d4e8f60 (2 policies created). Assignment id: 12

Kyvvu Demo layers cleanly on the scaffold: it adds "Agent purpose required" and "Max LLM calls per task" (10), the demo agent satisfies both, and the next run ends on the same run_script block as before.

Note

Not every library manifest fits the demo agent as published. manifests/security/data-exfiltration-guard.yaml blocks the scaffold on its first step:

⛔ Policy blocked this step: Step 'fetch_user_data' blocked (risk_score=1.00): Restrict outbound to approved domains (manifest Data Exfiltration Guard)
   Risk score: 1.00
   Action:     block
   • Restrict outbound to approved domains (critical)

This is Kyvvu working as intended. A block never ends the task:
the blocked step is recorded and the task stays open, so an agent
can resolve the block and carry on.
The step's properties.target.host is not on the policy's
allowed_domains -- and a step that records no host at all is blocked
too. Call an approved host, or assign your own copy of the manifest
with your domains in it.

Its allowed_domains are placeholders (internal.example.com, api.example.com), and domain_allowlist fails closed on a step.resource that records no properties.target.host — which the mocked fetch_user_data does not. The library copy cannot be edited, so to use it, copy it into your own repository, list your real domains, and assign that copy instead.

Warning

Distinct manifests can layer on one agent. Each manifest keeps and enforces its own policies, even when another manifest uses the same policy name.

The two manifests Kyvvu applies automatically, owasp-agentic-default.yaml and eu-ai-act-minimal.yaml, are the same files published in Kyvvu/manifests. Assigning a repository copy of either baseline manifest returns 409 because its manifest name is already assigned; the CLI prints the platform's reason on one line, starting Error (409 manifest_already_assigned):, and exits 1. A manifest with a different name, such as eu-ai-act-high-risk.yaml, layers on the baseline and keeps both sources' policies active.

To manage a baseline manifest from your own repository instead — to edit it, for instance — unassign the matching baseline manifest from the agent first.

See Manifests for details on manifest structure and available policy sets.


Next steps