Skip to main content
This guide covers the @outputai/core workflow execution changes in v0.8.0.

What changed

  • Workflows no longer aggregate usage data or expose aggregations in run results.
  • Workflow invocation options changed.
  • Calling a workflow from inside another workflow now always starts a Temporal child workflow.

Migration steps

Replace workflow result aggregations

Workflows no longer collect usage events into workflow-level totals. As a result, workflow results now focus on the workflow output and trace metadata, and no longer expose result.aggregations. If your API or CLI consumers read usage totals from result.aggregations, move that aggregation into hooks.

Before

const result = await client.runWorkflow( 'lead_enrichment', input );

console.log( result.aggregations.cost.total );
console.log( result.aggregations.tokens.total );
console.log( result.aggregations.httpRequests.total );

After

Record cost events as they happen, then aggregate them in your own store. Hook payloads include workflowDetails, so you can group totals by workflow id and run id.
// hooks.ts
import { on } from '@outputai/core/hooks';
import type { HttpRequestCostEvent } from '@outputai/http';
import type { LLMUsageEvent } from '@outputai/llm';

type Totals = {
  cost: number,
  tokens: number,
  httpRequests: number
};

const totalsByRun = new Map<string, Totals>();

function getTotalsKey( payload: { workflowDetails: { workflowId: string, runId: string } } ) {
  return `${payload.workflowDetails.workflowId}:${payload.workflowDetails.runId}`;
}

function getTotals( key: string ) {
  const current = totalsByRun.get( key ) ?? { cost: 0, tokens: 0, httpRequests: 0 };
  totalsByRun.set( key, current );
  return current;
}

on<HttpRequestCostEvent>( 'cost:http:request', payload => {
  const totals = getTotals( getTotalsKey( payload ) );

  totals.cost += payload.total;
  totals.httpRequests += 1;
} );

on<LLMUsageEvent>( 'cost:llm:request', payload => {
  const totals = getTotals( getTotalsKey( payload ) );

  totals.cost += payload.total;
  totals.tokens += payload.tokensUsed;
} );
For production systems, write the hook payloads to durable storage instead of an in-memory map. Use eventId as the idempotency key if your sink can receive retries or duplicate deliveries.
// hooks.ts
import { on } from '@outputai/core/hooks';
import type { HttpRequestCostEvent } from '@outputai/http';

on<HttpRequestCostEvent>( 'cost:http:request', async payload => {
  await usageEvents.insert( {
    id: payload.eventId,
    workflowId: payload.workflowDetails.workflowId,
    runId: payload.workflowDetails.runId,
    source: 'http',
    requestId: payload.requestId,
    cost: payload.total,
    occurredAt: new Date( payload.eventDate )
  } );
} );
If you previously displayed workflow-level totals in a run detail page, query your aggregation store by workflowId and runId alongside the workflow result.

Update workflow invocation options

The second argument to a workflow function is now named WorkflowInvocationOptions. If you imported the old invocation configuration type directly, update that import:
// Before
import type { WorkflowInvocationConfiguration } from '@outputai/core';

// After
import type { WorkflowInvocationOptions } from '@outputai/core';
The shape also changed: invocation-level activity options are now passed as activityOptions. The old options property is no longer accepted for workflow calls.

Before

await enrichCompanyWorkflow(
  { domain: input.domain },
  {
    options: {
      retry: {
        maximumAttempts: 1
      }
    }
  }
);

After

await enrichCompanyWorkflow(
  { domain: input.domain },
  {
    activityOptions: {
      retry: {
        maximumAttempts: 1
      }
    }
  }
);
The invocation options shape is:
type WorkflowInvocationOptions = {
  activityOptions?: TemporalActivityOptions,
  detached?: boolean,
  context?: DeepPartial<WorkflowContext>
};
detached and context remain invocation options. The field to update for this migration is activityOptions.

Review workflow-to-workflow calls

Calling a workflow function from inside another workflow now always starts a Temporal child workflow. In older versions, some direct workflow calls were rewritten only in specific code paths. When that rewrite happened, the called workflow could effectively run as part of the parent workflow’s execution path: its activities were scheduled from the parent workflow, and the child workflow did not necessarily appear as a distinct Temporal child execution. In v0.8.0, this is different by design. A direct call like this:
import enrichCompanyWorkflow from '../enrich_company/workflow.js';

export default workflow( {
  name: 'lead_enrichment',
  async fn( input ) {
    return enrichCompanyWorkflow( { domain: input.domain } );
  }
} );
now starts enrich_company as a Temporal child workflow. That means:
  • The called workflow has its own Temporal workflow execution, run id, history, and retry/cancellation behavior.
  • Activities inside the called workflow are scheduled by the child workflow, not by the parent.
  • The parent receives the child workflow result when you await or return the call.
  • Errors from the called workflow cross a child workflow boundary.
  • The child appears as a child workflow in Temporal and in traces.
Most awaited calls keep the same value-level behavior:
const childOutput = await enrichCompanyWorkflow( { domain: input.domain } );
But operational behavior changes. Check code that depended on parent workflow activity history, parent-close behavior, or catching specific raw Temporal failure shapes.

Checklist

  • Remove usage of workflow result .attributes and .aggregations.
  • Add hook handlers for cost:http:request and cost:llm:request if you still need usage totals.
  • Persist hook events or aggregated totals outside the workflow result.
  • Replace workflow invocation options with top-level activityOptions.
  • Review every workflow-to-workflow call and treat it as a Temporal child workflow boundary.
  • Update tests that assume workflow-to-workflow calls run inside the parent execution.