Write a step handler
A handler is a function from your project that an auto step runs. Use one when a step needs logic that no built-in adapter covers: a calculation, a check, a call to your own code. At the end of this guide you have a handler that reads its input and its settings, and you know the three ways it can end: return a result, fail, or stop the run.
Before you start
Section titled “Before you start”You need a workflow you can run. Write your first workflow makes one.
1. Write the handler
Section titled “1. Write the handler”Create registry.mjs beside workflow.yaml. The file’s default export has a handlers map. Each handler has an id and an execute function:
export default { handlers: { order_total: { id: 'order_total', async execute(inputs, context) { const quantity = inputs.params.quantity; const unitPrice = context.config.unit_price; if (typeof quantity !== 'number' || quantity <= 0) { throw new Error(`quantity must be a positive number, got ${JSON.stringify(quantity)}`); } if (quantity > 1000) { return { abort: { message: 'Orders over 1000 units are quoted by hand.' } }; } return { data: { total: quantity * unitPrice } }; }, }, },};execute receives two things:
| Argument | Holds |
|---|---|
inputs.params |
The step’s input, built by the step’s input_map. |
context.config |
The step’s config block from the workflow file. |
context.run_params |
The parameters the run was started with. |
context.resources |
The outputs of earlier steps, by step name. |
context.run_id |
The ID of the run. |
2. Point the workflow at it
Section titled “2. Point the workflow at it”Name the file with extensions at the top of the workflow, and name the handler on the step:
id: pricename: Price an orderversion: 1extensions: ./registry.mjs
params_schema: type: object additionalProperties: false required: [quantity] properties: quantity: type: number
steps: total: description: Work out the order total. execution: auto handler: order_total config: unit_price: 12.5 input_map: quantity: run.params.quantity
confirm: description: Say whether the total looks right. execution: agent depends_on: [total] prompt: 'The total is {{ context.resources.total.total }}. Does that look right?' input_schema: type: object additionalProperties: false required: [ok] properties: ok: type: booleanThe handler value must match a key in the handlers map.
3. Check and register
Section titled “3. Check and register”Check the file:
realm workflow validate ./It prints:
Valid: price v1 (2 steps)Extensions: ./registry.mjs (adapters: 0, handlers: 1, processors: 0)ℹ 1 step ready for structured_output: strict — run 'realm workflow validate --explain' for detail (REALM_NO_NUDGE=1 to silence).Notice the Extensions line. It confirms that Realm loaded your file and found one handler. Then register:
realm workflow register ./4. Run it
Section titled “4. Run it”Start a run with a quantity of 4 from an assistant connected over MCP. The engine runs total in the first call, start_run, whose reply says so, and the next step’s prompt shows the result:
Step 'total' completed. Ready for the agent: 'confirm'.
The total is 50. Does that look right?Version 0.46.0 and earlier say 1 step(s) now available. instead of naming the step.
The record keeps the handler’s input and output:
1. total success 1ms hash: 4e944dad Taken by: ops@server-1 (from the OS user, via mcp-stdio) Input: {"quantity":4} Resolved: {"quantity":4} Output: {"total":50}The three ways a handler ends
Section titled “The three ways a handler ends”Return { data: … } when the work is done. The object becomes the step’s output. Later steps read it as context.resources.<step>. Return data for every outcome the workflow should deal with, including “nothing found”.
Throw an error when the step cannot do its work. The step fails. With a quantity of -1:
Step 'total' failed. Run is terminated.ENGINE_HANDLER_FAILED: Handler 'order_total' threw: quantity must be a positive number, got -1The run ends as failed, unless the workflow has a step that handles the failure. See Handle failure.
Return { abort: { message } } when the run should stop on purpose. This is for a condition you expected, where carrying on makes no sense. With a quantity of 5000:
Phase: abortedSealed by: handler_abort (total)Cause: Handler 'total' aborted the run: Orders over 1000 units are quoted by hand.The run ends as aborted, not failed, and every later step is skipped.
If you see something else
Section titled “If you see something else”Step 'total' is blocked: its handler 'order_totl' is not registered in this runner.(on the MCP reply of the call that attempted the step: itscontext_hintforadvance_runandexecute_step, itswarningsforstart_run;realm agentprints⚠ Step 'total' is blocked: handler 'order_totl' is not registered in this runner. The run is NOT failed — add handler 'order_totl' and re-attach (…)). Version 0.46.0 has noadvance_runand returns this block fromstart_runas astatus: errorreply, not inwarnings. Thehandlername on the step does not match a key in yourhandlersmap.realm workflow validatedoes not catch this; it shows up when the step runs. The run is not ended: correct the name, register again, and the step can run.Handler step 'total' aborted the run: undefinedYour handler returnedabortwithout amessage. Use{ abort: { message: '…' } }.
See also
Section titled “See also”- Handlers reference covers the full interface and the built-in handlers.
- Project extensions covers the
extensionsfile, including adapters and TypeScript. - Test a workflow