keelwave
SDKsPython

Decorators

Decorator API for instrumenting functions without manual run bookkeeping.

@client.agent wraps a function in a run. @client.observe records each call to a function as a step on that run. Together they trace an agent without a single with block.

import os
from keelwave import Keelwave

client = Keelwave(
    api_key=os.environ["KEELWAVE_API_KEY"],
    endpoint=os.environ.get("KEELWAVE_ENDPOINT", "http://localhost:8080"),
)


@client.observe(name="web_search", step_type="tool_call")
def web_search(q: str) -> dict:
    return {"results": [f"result for: {q}"]}


@client.agent(name="research-agent")
def run_agent(task: str) -> str:
    results = web_search(q=task)
    return f"Found: {results['results'][0]}"


answer = run_agent("what is keelwave")

That produces one run named research-agent with input "what is keelwave", a tool_call step for web_search with its arguments, result, and latency, and the returned string as the run output.

Use AsyncKeelwave and decorate async def functions for the async equivalent; the decorators on AsyncKeelwave produce async wrappers.

@agent

client.agent(fn=None, *, name: str | None = None)

Opens a run when the function is called and closes it when it returns or raises. name defaults to the function's __name__.

Both forms work:

@client.agent
def my_agent(task: str) -> str: ...


@client.agent(name="research-agent")
def my_agent(task: str) -> str: ...

Input capture. The first positional argument becomes the run's input; failing that, an input= keyword argument is used. Values are stringified and truncated to 500 characters.

Output capture. A non-None return value is stringified (again truncated to 500 characters) and set as the run's output.

Errors. The decorator does not catch your exceptions. An exception propagates, and the underlying run records status="failed" with termination_reason="error".

@observe

client.observe(fn=None, *, name: str | None = None, step_type: str = "tool_call")

Instruments a single function. name defaults to the function's __name__.

@client.observe
def web_search(q: str) -> dict: ...


@client.observe(name="search", step_type="tool_call")
def web_search(q: str) -> dict: ...

What it emits depends on where the call happens.

Inside a run

With the default step_type="tool_call", it calls run.tool_call() with the function name, its arguments, its return value, a success flag, and the measured latency — then calls run.check_fingerprint(), so duplicate calls with identical arguments trip loop detection automatically.

With any other step_type, it calls run.step() instead, using str(result) as content and {"fn": ..., "latency_ms": ...} as metadata. No fingerprint check runs in this mode.

@client.observe(step_type="retrieval")
def fetch_docs(query: str) -> list[str]: ...

Outside a run

If no run is active, @observe falls back to client.ingest_ai() with model=<function name> and provider="observe", recording status, latency, any error message, and the serialised input/output in metadata. Decorated helpers therefore still produce data when called standalone.

Argument serialisation

Positional arguments are collected under an args key, keyword arguments by their own names, and every value is stringified and truncated to 500 characters. Return values that are already dicts are sent as-is; anything else becomes {"value": ...}. When the function raises, the recorded output is {"error": ...} with the message truncated to 500 characters.

Instrumentation never breaks your program. If emitting a step fails, @observe catches the error and issues a warnings.warn instead of raising.

client.span

span() is a context manager for instrumenting a block of code rather than a whole function.

client.span(step_type: str = "span", *, name: str | None = None)
with client.span("retrieval", name="fetch_docs") as span:
    docs = fetch(query)
    span.set(content=f"fetched {len(docs)} docs")

set() records what the step should report and can be called any number of times inside the block:

span.set(
    content: str | None = None,
    *,
    tokens: int | None = None,
    metadata: dict | None = None,
)

On exit the span emits one run.step() of the given step_type, with latency_ms measured across the block added to metadata, plus span_name when name was given. If no run is active, nothing is emitted. As with @observe, emit failures become warnings, never exceptions.

Match the span to the client. Keelwave.span() emits only when a sync Run is active, and AsyncKeelwave.span() only when an AsyncRun is active.

Combining with adapters

Decorators and provider adapters compose — the adapters attach LLM traces to the same ambient run the decorators opened:

import anthropic

claude = client.wrap_anthropic(anthropic.Anthropic())


@client.observe(name="web_search", step_type="tool_call")
def web_search(q: str) -> dict: ...


@client.agent(name="research-agent")
def run_agent(question: str) -> str:
    resp = claude.messages.create(
        model="claude-opus-5",
        max_tokens=2048,
        messages=[{"role": "user", "content": question}],
    )
    ...

See adapters.

On this page