Skip to main content
Decorators are how your Python module becomes a deployed agent worker. The platform dispatches incoming task events to your @on_task handler over SSE, calls @on_boot once at startup, runs @on_tool_* hooks around every tool invocation, and routes auth events through @on_auth_event. For the tool-registration decorator, see @register_tool: it lives next to the tools API, since it constructs Tool objects rather than wiring runtime hooks.

Minimal worker

Run this module: python worker.py: and the worker subscribes to the platform’s SSE stream, picks up tasks dispatched to its agent, runs them, and saves results. Tasks are auto-marked Completed (or Error on raise) and metrics are reported automatically when task.tokens is set.

Required environment

@on_task validates required env vars eagerly at import. Missing any of these raises ModuleException: For local development without an org-managed agent, set these to your dev values.

@on_task

@on_task registers a function as the agent’s task executor. The decorated function runs every time the platform dispatches a task to this worker: over SSE in production, and via the embedded HTTP server (POST /invoke on port 59321) for local invocations and Cloud Run-style integrations. The runtime auto-detects whether your function is a regular handler (returns Task) or a streaming handler (async generator that yields TaskUpdateEvent).

Decorator forms

Required signature

The handler must accept exactly one parameter named task. Either positional or keyword form works.

Handler types

The decorator detects which kind you wrote by inspecting whether your function is an async generator.

Regular handler

Sync or async, returns the (possibly mutated) Task.
The runtime persists the returned task automatically. Don’t call task.asave() yourself unless you need to checkpoint mid-handler.

Streaming handler

Async generator that yields TaskUpdateEvents. Used for token-by-token streaming responses. A streaming handler emits two kinds of events. Each token (or text chunk) from the LLM is yielded as a TaskUpdateEventType.Chunk event, which the platform forwards to subscribers in real time. After the loop ends, the handler yields one final TaskUpdateEventType.TaskFinished event carrying the completed Task object. The platform uses that final event to mark the task complete and persist its result.
Streaming handlers are exposed via POST /invoke only: the SSE listener wraps them in an adapter that consumes the generator and tracks the final Task from the TaskFinished event.

What the runtime does

When you decorate a function with @on_task:
  1. Validates the signature (must accept task).
  2. Detects handler type (regular vs streaming).
  3. Starts an embedded HTTP server on port 59321 for POST /invoke (always, in a daemon thread). Override the port with XPANDER_STREAMING_PORT.
  4. Registers an SSE listener with the Events module. The listener:
    • Reads XPANDER_API_KEY, XPANDER_ORGANIZATION_ID, XPANDER_AGENT_ID from env (raises ModuleException if any are missing).
    • Subscribes to the platform’s task-dispatch stream.
    • Acquires a semaphore (max_sync_workers=6 by default) before dispatching.
    • Sets task status to Executing before calling your handler.
    • Calls your handler.
    • Persists the returned task; if your handler raised, marks the task Error with the exception string as the result.
    • Reports metrics if task.tokens is set.
    • Re-runs the handler with a continuation prompt if deep planning is enabled (multi-step task plans tracked in task.deep_planning) and items remain incomplete, up to MAX_PLAN_RETRIES = 5. Pre-retry the runtime triggers session compaction.

Test mode

Pass test_task (or use the CLI override) to invoke the handler once locally instead of subscribing to the platform.

With a LocalTaskTest

The runtime registers a worker, creates the test task, dispatches it to the handler, and exits after completion (printing the final result). Useful for local iteration without invoking from the dashboard.

From the CLI

The decorator also responds to --invoke / --prompt arguments on sys.argv, so you can run any @on_task-decorated module with:
Supported flags:

Runtime caveats

  • One handler per process. The decorator subscribes to the SSE stream on import. Decorating multiple functions in the same module isn’t useful: the most recently registered handler wins. Use multiple Python processes (or container instances) if you need to host different agents.
  • HTTP server starts immediately. The POST /invoke endpoint is up before the SSE listener finishes connecting. If port 59321 is in use the runtime logs a warning and continues. Override the port with the XPANDER_STREAMING_PORT env var.
  • Synchronous handlers are dispatched on a thread pool. A thread pool of max_sync_workers=6 handles sync handlers concurrently. Async handlers run on the event loop.

Errors raised by the decorator

  • TypeError: handler doesn’t accept task, or returns/yields the wrong type.
  • ModuleException: required env vars are missing when the SSE listener starts.

@on_boot / @on_shutdown

@on_boot registers a function to run before the SSE listener subscribes to the platform: useful for warming caches, opening database pools, validating environment, or pre-fetching data. @on_shutdown registers a function to run during graceful shutdown.
Both decorators accept either sync or async functions, and you can register multiple handlers: the runtime invokes them in registration order.

Decorator forms

Lifecycle ordering

If a @on_boot handler raises, the worker fails to start: the exception propagates and the process exits. If a @on_shutdown handler raises, the error is logged but the runtime continues with the next shutdown handler.

Examples

Open a DB pool at boot, close on shutdown

Validate env at boot

Raising in @on_boot aborts startup before the worker takes any tasks: useful for fail-fast environment validation.

Multiple handlers

Both run, in declaration order: 1 then 2. The runtime awaits each one before moving on.

Sync and async mix

Sync handlers are called directly; async ones are awaited. Either works.

Notes

  • Boot/shutdown handlers are class-level on Events. They register globally per process: there’s no scoping to a specific Events instance. This is fine for a one-process-one-agent model.
  • Shutdown handlers run after the SSE listener is stopped but before final cleanup. By then the process is no longer accepting new tasks; in-flight tasks are cancelled.

@on_tool_before / @on_tool_after / @on_tool_error

@on_tool_before, @on_tool_after, and @on_tool_error register hooks that fire around tool invocations: useful for logging, validation, caching, alerting, or analytics. The hooks run during every Tool.ainvoke (which means around every framework-driven tool call too).
You can register multiple hooks of each type: they fire in registration order. Sync and async functions are both supported.

Required signatures

Decorator forms

The same form applies to @on_tool_after and @on_tool_error. The optional configuration parameter is reserved for future use; hooks don’t currently read it.

Examples

Log every tool call

Block invocations matching a payload pattern

Hooks can’t cancel invocations directly (the runtime catches their exceptions and continues), but you can mutate state or short-circuit by raising a guard before the call:
Hooks that raise are caught by the runtime: the exception is logged but doesn’t propagate to the caller. To genuinely block a tool, validate at the framework layer or in @register_tool function bodies.

Cache successful results

Alert on failures

Notes

  • Hooks fire once per tool invocation, regardless of whether the tool is local or remote.
  • Exceptions in hooks are caught and logged via loguru: they never crash the invocation pipeline.
  • Hooks are class-level on ToolHooksRegistry (process-wide singleton). There’s no scoping to a specific repository.
  • The auto-emitted ToolCallRequest / ToolCallResult events on task.aevents() are independent of these hooks. The hooks fire even when report_activity=False in Tool.ainvoke.

@on_auth_event

@on_auth_event registers a handler that fires whenever the agent runtime emits an authentication event: for example, when an MCP server requires the end-user to log in via OAuth. Use it to surface the OAuth URL to your UI, kick off external auth flows, or log auth attempts.
The handler is auto-registered globally: you don’t pass it anywhere. Every Backend.aget_args(...) call routes auth events through registered handlers automatically.

Required signature

The function must accept exactly three parameters: agent, task, event. Names are flexible; arity matters. Sync or async are both accepted.
event.type is always "auth_event" for handlers registered with this decorator.

Event payload shapes

event.data is one of the MCP OAuth response variants. Switch on event.data.type:

Examples

Show the OAuth URL to the user

Multiple handlers stack

Both handlers fire on every auth event. Order is registration order.

Combine with a per-call callback

You can pass an additional one-off callback to Backend.aget_args(auth_events_callback=...): it runs in addition to every globally-registered handler.
Both global_handler and per_call fire when an auth event happens during the resulting agent’s run.

Helper functions

The module also exposes two helpers for testing and management:
These aren’t re-exported from the top-level xpander_sdk package: import from the decorator module directly.