Skip to main content

Workflows, components and tools

Model spans tell you what was sent to a model. Declared spans tell you where in your application it happened:

  • hajer.workflow(id): a user-facing capability, such as answering a support question. This is the unit the platform shows a trace as, and the unit that eval obligations are written against.
  • hajer.component(id): a step inside a workflow, such as a retriever, a planner or an agent.
  • hajer.tool(id, name=..., arguments=...): one tool execution.

A model call outside any workflow is still recorded, as a trace of one generation. Inside a workflow, it is a generation under the workflow span.

Three ways to declare a span​

Each of the three works as a decorator on a sync or async function, as a with block, and as an async with block.

import hajer


@hajer.workflow("wf_support")
def handle(message: str) -> str: ...


@hajer.workflow("wf_support")
async def handle_async(message: str) -> str: ... # awaited inside the span


def handle_inline(message: str) -> str:
with hajer.workflow("wf_support"):
...


async def handle_inline_async(message: str) -> str:
async with hajer.workflow("wf_support"):
...

The decorator keeps the function's signature. An async def is awaited inside the span, so the span covers the whole coroutine.

Choosing ids​

Use ids that are:

  • Stable. The same workflow keeps the same id across deploys. The platform and your evals correlate on it.
  • Non-secret. Ids are exported as span attributes. Never put a customer id, an email address or a token in one.
  • Static. An id names a kind of work, not one execution. Calling a workflow repeatedly with the same id produces a separate span for each run.

A prefix per kind (wf_, cmp_, tool_) keeps ids readable, but is not required.

tip

Use the same workflow ids in your eval suites' metadata.hajer.workflowId, so the platform can connect a test to the production workflow it covers. See Test metadata.

Tools​

hajer.tool(tool_id, *, name=None, arguments=None)
  • tool_id: the stable id, recorded as hajer.tool.id.
  • name: the tool's name as the model knows it, recorded as gen_ai.tool.name. Defaults to tool_id.
  • arguments: a JSON-compatible dict of the arguments, recorded as gen_ai.tool.call.arguments.

The span is named execute_tool {name} and carries gen_ai.operation.name = "execute_tool". The SDK does not run the tool; it records that your code did.

arguments is fixed when you create the span. For arguments that change per call, use a with block inside the function:

def lookup_order(order_id: str) -> dict:
with hajer.tool("tool_order_lookup", name="lookup_order", arguments={"order_id": order_id}):
return orders_api.get(order_id)

Tool arguments are content. They are recorded only when content capture is on, are redacted first, and are clipped to HAJER_WRAPPED_CALL_MAX_BYTES. See Content capture and redaction.

Nesting​

Ids are inherited inward. A component inside a workflow carries the workflow's id. A tool inside both carries both ids. This works across await and in threads that copy the context, such as asyncio.to_thread.

This example has a workflow, an agent component and the tool it uses:

import hajer

REFUNDS = {"cust_123": "pending", "cust_456": "completed"}


@hajer.tool("tool_refund_status", name="get_refund_status")
def get_refund_status(customer_id: str) -> str:
return REFUNDS.get(customer_id, "unknown")


@hajer.component("cmp_refund_agent")
def refund_agent(customer_id: str) -> str:
status = get_refund_status(customer_id)
if status == "pending":
return "Your refund is still pending."
if status == "completed":
return "Your refund has completed."
return "I cannot find a refund for this account."


@hajer.workflow("wf_support")
def handle(message: str, customer_id: str) -> str:
if "refund" in message.lower():
return refund_agent(customer_id)
return "I can help with refund questions."


handle("Where is my refund?", "cust_123")

One call to handle produces this trace:

workflow wf_support
└── component cmp_refund_agent
└── execute_tool get_refund_status

Any model call made inside refund_agent appears as a generation under component cmp_refund_agent.

Span attributes​

SpanNameAttributes
hajer.workflow("wf_support")workflow wf_supporthajer.workflow.id
hajer.component("cmp_refund_agent")component cmp_refund_agenthajer.component.id, and the enclosing hajer.workflow.id
hajer.tool("tool_refund_status", name="get_refund_status", arguments=...)execute_tool get_refund_statushajer.tool.id, gen_ai.tool.name, gen_ai.operation.name (execute_tool), gen_ai.tool.call.arguments (with content capture), and the enclosing workflow and component ids

Every declared span also carries the session, user, tags, metadata and environment in force. See Sessions and context.

A workflow names only itself: a workflow nested inside a component does not carry that component's id. A component outside any workflow is allowed and carries only its own id.

Errors​

An exception raised inside a declared span propagates unchanged. The span records the exception's qualified class name as error.type and sets an error status. It never records the exception message or stack trace.

Local records and scope​

hajer.workflow also opens a hajer.scope(workflow=id), which collects the model calls made inside it, including calls made in child tasks. You can read them in-process, for example in tests:

with hajer.scope(workflow="wf_support") as operation:
handle("Where is my refund?", "cust_123")

for call in operation.calls:
print(call.provider, call.model, call.usage)

See scope in the API reference.

Without OpenTelemetry installed​

Without the otel extra, declared spans still run your code, keep the id stack and collect calls in their scope. They emit nothing, and the hajer logger says so once.