UseArazzo Runner

In development

Executes Arazzo workflows step by step against live APIs, and hands back a full run trace, not just a pass or fail.

@usearazzo/runner executes Arazzo Specification workflows against live APIs described by OpenAPI Specification source descriptions.

Under heavy development. This package is developed inside the arazzo-toolkit monorepo and is not yet published to npm. It will become publicly installable once its API stabilizes. Until then, APIs may change without notice and there is nothing to install.

Supported versions:

  • ✓ Arazzo 1.0.0, 1.0.1 (workflow documents)
  • ✓ OpenAPI 2.0, 3.0.x, 3.1.x (source descriptions)

Architecture

Running an Arazzo workflow is a pipeline of small, single-responsibility building blocks. Each layer reads run state but never mutates it. WorkflowExecutor is the single writer that records outputs and interprets the returned control-flow action. Every layer takes its collaborator rather than building one, so a deterministic stub drops in for tests at any level.

Runner architecture WorkflowExecutor calls StepExecutor, which calls OpenAPIOperationExecutor, which sends the built request through HTTPClient. DocumentRegistry supplies loaded documents to all three executors. WorkflowExecutor iterates steps · owns state · control flow StepExecutor runs one Arazzo step OpenAPIOperationExecutor builds request · normalizes response HTTPClient sends it · global fetch by default execute step execute OpenAPI operation send the built request DocumentRegistry loads & caches supplies documents to all three

HTTPClient is drawn dashed because it is the seam you are meant to replace.

DocumentRegistry

Loads and caches Arazzo and OpenAPI documents, so a source description referenced by many steps is fetched and parsed once.

import { DocumentRegistry } from '@usearazzo/runner';

const registry = new DocumentRegistry();

// the entry Arazzo document
const arazzoDoc = await registry.acquireEntryDocument(
  'https://example.com/petstore-order-workflow.arazzo.yaml',
);

// a source description, resolved by name to an absolute URI, then acquired
const uri = arazzoDoc.resolveSourceDescriptionURI('petstoreAPI');
const openapiDoc = await registry.acquire(uri);

registry.clear(); // drop cached documents to reclaim memory

WorkflowExecutor

The stateful orchestrator that runs a whole workflow. It iterates a workflow's steps in list order, calling StepExecutor per step, and owns the run state that accumulates each step's outputs so later steps can read $steps.*.outputs.

import {
  DocumentRegistry,
  OpenAPIOperationExecutor,
  StepExecutor,
  WorkflowExecutor,
} from '@usearazzo/runner';

const registry = new DocumentRegistry();
const arazzoDoc = await registry.acquireEntryDocument(
  'https://example.com/petstore-order-workflow.arazzo.yaml',
);

// compose bottom-up: operation executor -> step executor -> workflow executor
const operationExecutor = new OpenAPIOperationExecutor();
const stepExecutor = new StepExecutor({ document: arazzoDoc, registry, operationExecutor });
const executor = new WorkflowExecutor({ document: arazzoDoc, registry, stepExecutor });

const result = await executor.execute('authenticateAndOrderPet', {
  inputs: { username: 'user1', password: 'secret', preferredPetStatus: 'available' },
});

console.log(result.status);      // 'completed' | 'ended' | 'failed'
console.log(result.outputs);     // workflow $outputs, resolved against final state
console.log(result.steps);       // trace: each step's id, success, action, attempts, durationMs
console.log(result.durationMs);  // elapsed time for the whole run

The options bag that second argument carries:

OptionMeaning
inputsthe workflow's inputs, read through $inputs
executeOptionsopaque bag forwarded to every step's operation, for example server or requestInterceptor
dependencyInputsinputs for the workflows run to satisfy dependsOn, keyed by workflowId
runDependenciesrun those dependsOn workflows first (default true)
signalan AbortSignal that cancels the run

Run state is created fresh per execute call and owned internally, and the result you get back is read-only.

StepExecutor

Executes a single Arazzo step that invokes an OpenAPI operation: locates the operation, resolves parameters and request body against the pre-request context, delegates the call, then evaluates successCriteria, resolves outputs, and selects the next action. It reads run state and mutates nothing. The caller records the outcome.

const outcome = await stepExecutor.execute(step, state);

console.log(outcome.successful); // true when every successCriterion passed
console.log(outcome.outputs);    // resolved step outputs, keyed by name
console.log(outcome.action);     // the selected onSuccess / onFailure action, or undefined

OpenAPIOperationExecutor

Executes a single OpenAPI operation and returns its normalized response. It is Arazzo-agnostic and can be used standalone, with just an OpenAPI document and an operationId, no workflow involved. The default transport is global fetch; pass httpClient to swap it for undici, a proxy-aware client, or a canned response in tests.

import { DocumentRegistry, OpenAPIOperationExecutor } from '@usearazzo/runner';

const registry = new DocumentRegistry();
const openapiDoc = await registry.acquire('https://petstore3.swagger.io/api/v3/openapi.json');

const locator = {
  document: openapiDoc,
  jsonPointer: openapiDoc.operationIndex.get('findPetsByStatus'),
};

const executor = new OpenAPIOperationExecutor();
const response = await executor.execute(locator, { parameters: { status: 'available' } });

console.log(response.status, response.body);
console.log(response.request.url); // the URL as actually sent

Swapping the transport

The transport is the extension point, and its contract is deliberately small: a function from the built request to a WHATWG Response. The default is global fetch in one line, exported as httpClientFetch so a custom transport can delegate to it rather than reimplement it.

import { fetch, Agent } from 'undici';
import { OpenAPIOperationExecutor } from '@usearazzo/runner';

// any HTTP stack drops in: here undici with a dispatcher global fetch cannot take
// (connection-pool tuning, a proxy, client certificates)
const dispatcher = new Agent({ connections: 128, keepAliveTimeout: 60_000 });

const pooledExecutor = new OpenAPIOperationExecutor({
  httpClient: (request) => fetch(request.url, { ...request, dispatcher }),
});

// or serve canned responses in a test: no network, no interception hooks
const offlineExecutor = new OpenAPIOperationExecutor({
  httpClient: async () =>
    new Response('[{"id":1,"status":"available"}]', {
      status: 200,
      headers: { 'content-type': 'application/json' },
    }),
});

Whatever you plug in has to hold up three ends of the bargain:

  • Resolve with a Response for every HTTP status. A non-2xx is a valid Arazzo outcome for a step's successCriteria to judge, never an error.
  • Throw only on genuine transport failure, which the executor wraps as a ClientError carrying the original as cause.
  • Honor request.signal when it is present.

Response normalization happens on the executor's side of the seam, so $response.body means the same thing no matter which transport is underneath.

Choosing the server

By default the request goes to the first server the operation declares, with a relative server resolved against the URL the document itself was loaded from. That covers the common case with no options at all. The server option does double duty:

// selection: the value matches a declared server, so its variable defaults come
// along and only the ones you name are overridden
await executor.execute(locator, {
  server: 'https://{region}.example.com/{basePath}', // the raw template, as declared
  serverVariables: { region: 'us' },                   // basePath keeps its declared default
});

// override: the value matches nothing declared, so it simply is the base URL
await executor.execute(locator, { server: 'https://staging.internal.test/api' });

There is deliberately no silent fallback. A server matching nothing declared is never quietly swapped for the first declared one, so a typo fails loudly at the network rather than succeeding against the wrong host. An override replaces the declared base path along with the host, so include it if the API expects one. Overriding works on Swagger 2.0 as well; selection and serverVariables are 3.x only, since 2.0 has no server list and no URL templates.

Credentials are applied via a requestInterceptor that runs after the request is built and before it is sent. Arazzo itself is auth-agnostic, so the runner does not model authentication.

Control Flow

After each step, the selected onSuccess / onFailure action determines what happens next:

Action Behavior
no matching action success falls through to the next step; failure breaks and returns status: 'failed'
end stops the run early with status: 'ended', returning outputs accumulated so far
goto jumps to a stepId within the current workflow
retry re-runs the step up to retryLimit (default 1), waiting retryAfter seconds between attempts; each step's attempts count is surfaced in the trace

A runaway goto loop, retry, or sub-workflow tree is bounded by maxSteps (default 1000, shared across the whole call tree). Workflows can call other workflows: a step targeting a workflowId records the sub-run under $workflows.<id>, and dependsOn workflows run to completion first, with results readable the same way. Nesting past maxWorkflowDepth (default 32) or re-entering a workflow already in progress throws.

A run accepts an AbortSignal: the signal is observed at every boundary (before each step, before each retry attempt, before entering a sub-workflow) and is forwarded to the transport, so a request in flight is cancelled rather than merely awaited.

What Steps Inherit

A workflow can hand its steps two things, and they behave in opposite ways.

Default actions replace

A workflow's successActions and failureActions apply to every step as a default. A step declaring its own onSuccess or onFailure overrides the corresponding list wholesale. There is no per-action merge, and the two fall back independently, so a step can override only its failure actions and still inherit the workflow's success actions.

Parameters merge

A workflow's parameters also reach every step, but these merge rather than replace. The specification lets a step override an inherited parameter but says it can never remove one, so each step ends up with the union of both lists, its own declaration winning.

A parameter's identity is the (name, in) pair, not the name alone. A step declaring a trace query parameter overrides an inherited trace query parameter, and leaves an inherited trace header untouched, because those are two parameters bound for two places. Names are case-sensitive, per the specification.

Inheritance copies the declarations, not resolved values, so a workflow-level value holding a runtime expression is evaluated once per step, in that step's own context. A workflow parameter reading $steps.login.outputs.token therefore means what it looks like it means: each step sees the state as it entered, not a value frozen when the workflow began.

This is inheritance rather than dispatch, so it happens during normalization. A normalized workflow's steps already carry what they inherit, exactly as a normalized OpenAPI operation already carries the parameters it inherits from its Path Item, and neither executor knows about it.

Not Yet Supported

These land in later work. Each throws a named ExecutionError rather than behaving incorrectly:

  • Step-level goto to a workflowId
  • A retry carrying a stepId / workflowId reference to run before retrying
  • Cross-document workflow references (a workflowId naming a workflow in another document). Same-document only for now

Rest of the Toolkit

Validator

Validate a document before you run it, using the same Arazzo semantics the Runner interprets.

Explore Validator

CLI

The planned run command will be a thin wrapper around this library.

Explore CLI

Under the hood, the Runner reuses the loading, resolution, and normalization primitives shared with @usearazzo/parser and @usearazzo/resolver.

Frequently Asked Questions

Can I install the Runner today?

Not from npm. @usearazzo/runner is under heavy development in the open, inside the arazzo-toolkit monorepo. It will publish once its API stabilizes.

Does the Runner handle authentication?

Not directly, and that is deliberate. Arazzo says nothing about credentials, so the Runner does not model them either. It gives you two seams instead. A requestInterceptor decorates the built request after it is assembled and before it is sent, which covers the everyday cases: bearer tokens, API keys, basic auth, anything that ends up as a header, query parameter, or cookie. Transport-level concerns such as mTLS client certificates or a proxy belong in a replacement httpClient. Interceptor edits land in the request record that $request.* and the trace read, whereas transport-level changes are deliberately invisible to it.

Can I swap the HTTP transport?

Yes. OpenAPIOperationExecutor accepts an httpClient, a function from the built request to a WHATWG Response. The default is global fetch in one line; undici, a pooled client, or a canned response for tests drop in the same way.

What happens when a step's criteria aren't met?

That's a normal outcome, not an error: the step's successful flag is false and its onFailure action runs. Malformed input, such as a step with no operation target or an unknown action type, throws an ExecutionError instead.