---
title: "@outputai/core"
description: "Workflow orchestration, worker runtime, and the building blocks for every Output app"
---

The `@outputai/core` package is the foundation of every Output app. It gives you `workflow`, `step`, and `evaluator` — the three building blocks for defining what your app does. It also provides the worker runtime that connects to Temporal and runs your workflows in production.

## What's in the Package

```typescript
import {
  // Building blocks
  workflow,
  step,
  evaluator,

  // Evaluation result types
  EvaluationBooleanResult,
  EvaluationNumberResult,
  EvaluationStringResult,
  EvaluationFeedback,

  // HTTP from workflows
  sendHttpRequest,
  sendPostRequestAndAwaitWebhook,

  // Logging from workflows and steps
  Logger,

  // Error types
  FatalError,
  ValidationError,

  // Zod (re-exported for convenience)
  z
} from '@outputai/core';
```

| Export | Description |
|--------|-------------|
| `workflow` | Define orchestration logic that coordinates steps |
| `step` | Define units of work that handle I/O (API calls, database queries, etc.) |
| `evaluator` | Define steps that score content and return evaluation results |
| `EvaluationBooleanResult` | Pass/fail evaluation result |
| `EvaluationNumberResult` | Numeric score evaluation result |
| `EvaluationStringResult` | Category/label evaluation result |
| `EvaluationFeedback` | Structured feedback (issue + suggestion + priority) |
| `sendHttpRequest` | Send HTTP requests from within workflows |
| `sendPostRequestAndAwaitWebhook` | Send a POST and pause until a webhook responds |
| `Logger` | Structured logger that works from both workflows and steps |
| `FatalError` | Non-retryable error — stops immediately |
| `ValidationError` | Schema validation failure — stops immediately |
| `z` | Zod schema library for input/output validation |

For full details on `workflow`, `step`, and `evaluator`, see [Workflows](/workflows), [Steps](/steps), and [Evaluators](/evaluators).

## Worker Runtime

When you run `output dev`, the CLI starts Docker Compose which launches a worker container. The worker:

1. **Scans your project** for workflow files (`workflow.js`), step files (`steps.js`), evaluator files (`evaluators.js`), and shared components in `shared/steps/` and `shared/evaluators/`
2. **Creates a catalog** of all discovered workflows and their activities with metadata (name, description, schemas)
3. **Connects to Temporal** at the configured address
4. **Starts processing** workflow executions

### File Discovery

The worker scans your `src/` directory for these file patterns:

| Location | Purpose |
|----------|---------|
| `workflows/*/workflow.js` | Workflow definition |
| `workflows/*/steps.js` | Step implementations |
| `workflows/*/evaluators.js` | Evaluator implementations |
| `shared/steps/*.js` | Shared steps (importable by any workflow) |
| `shared/evaluators/*.js` | Shared evaluators (importable by any workflow) |

Each discovered component is logged during startup:

```
[Scanner] [info] Workflow loaded {"name":"lead_enrichment","path":"src/workflows/lead_enrichment/workflow.js"}
[Scanner] [info] Component loaded {"type":"step","name":"lookupCompany","path":"src/workflows/lead_enrichment/steps.js"}
```

### Architecture

Output is built on [Temporal.io](https://temporal.io) for durable execution. Your abstractions map to Temporal primitives:

| Output.ai | Temporal |
|-----------|---------|
| `workflow()` | Workflow |
| `step()` | Activity |
| `evaluator()` | Activity |

When you call a step from a workflow, Output executes it as a durable activity with automatic retries, schema validation, and tracing. If the worker crashes mid-execution, Temporal replays the workflow and skips already-completed steps.

## Hooks

Register handlers in hook files that the worker loads at startup (list paths under `outputai.hookFiles` in `package.json`). Import from `@outputai/core/hooks`. The framework wraps each handler in a try/catch: failures are logged and do not stop the worker or workflow runs.

Every hook payload includes an **`eventId`** — a UUID v4 stamped per emit — and an **`eventDate`**, the millisecond epoch timestamp for when the event was emitted. Use `eventId` as a stable per-emit idempotency key for downstream dedup (webhook retry handling, ClickHouse `ReplacingMergeTree`, audit logs, etc.). Distinct emits — including `http:request` and `cost:http:request` for the same fetch — receive distinct `eventId`s.

| Function | When it runs | Payload |
|----------|--------------|---------|
| `onError` | Activity, workflow, or runtime error | Object always includes `eventId`, `eventDate`, `source`, and `error`. Other fields depend on `source` — see below and [Error Hooks](/operations/error-hooks) |
| `onBeforeWorkerStart` | Once after hooks, workflows, and activities are loaded; before the Temporal worker is created | None |
| `onWorkflowStart` | A user workflow execution starts | `eventId`, `eventDate`, `workflowDetails` |
| `onWorkflowEnd` | A user workflow execution completes successfully | `eventId`, `eventDate`, `workflowDetails` |
| `onWorkflowError` | A user workflow execution fails | `eventId`, `eventDate`, `workflowDetails`, `error` |
| `onActivityStart` | A step or evaluator activity starts | `eventId`, `eventDate`, `activityInfo`, `workflowDetails`, `outputActivityKind` |
| `onActivityEnd` | A step or evaluator activity completes successfully | `eventId`, `eventDate`, `activityInfo`, `workflowDetails`, `outputActivityKind`, `aggregations` |
| `onActivityError` | A step or evaluator activity fails | `eventId`, `eventDate`, `activityInfo`, `workflowDetails`, `outputActivityKind`, `aggregations`, `error` |
| `on(eventName, handler)` | Custom events emitted elsewhere in the stack | Your event payload plus `eventId` and `eventDate`. When emitted from an Output activity, the framework also attaches `activityInfo`, `workflowDetails`, and `outputActivityKind`. |

**Context objects on hook payloads**

- `activityInfo` is Temporal's Activity execution info object. See Temporal's [`activity.Info`](https://typescript.temporal.io/api/interfaces/activity.Info) reference for all fields.
- `workflowDetails` is Output's serializable subset of Temporal's [`workflow.WorkflowInfo`](https://typescript.temporal.io/api/interfaces/workflow.WorkflowInfo). It includes `workflowId`, `runId`, `workflowType`, `parent`, `root`, `firstExecutionRunId`, `continuedFromExecutionRunId`, `startTime`, `runStartTime`, and `attempt`.
- `outputActivityKind` is Output metadata for activity hooks and custom events emitted from activities. Possible values are `step`, `evaluator`, and `internal_step`.
- `aggregations` contains activity-scoped attribute totals collected while the step or evaluator ran. It is `null` when no attributes were collected.

```typescript
type WorkflowDetails = {
  workflowId: string;
  runId: string;
  workflowType: string;
  parent?: { workflowId: string; runId: string; namespace: string };
  root?: { workflowId: string; runId: string };
  firstExecutionRunId: string;
  continuedFromExecutionRunId?: string;
  startTime: number;
  runStartTime: number;
  attempt: number;
};
```

```typescript
type Aggregations = {
  cost: { total: number };
  tokens: {
    total: number;
    [tokenType: string]: number | undefined;
  };
  httpRequests: { total: number };
};
```

**`onError` payload by `source`**

- **`activity`** — `eventId`, `eventDate`, `source`, `activityInfo`, `workflowDetails`, `outputActivityKind`, `aggregations`, `error`
- **`workflow`** — `eventId`, `eventDate`, `source`, `workflowDetails`, `error`
- **`runtime`** — `eventId`, `eventDate`, `source`, `error`

`onWorkflowStart`, `onWorkflowEnd`, and `onWorkflowError` do **not** run for the internal `$catalog` workflow. `onActivityStart`, `onActivityEnd`, and `onActivityError` do **not** run for internal activities. `onError` can still report failures from any workflow or activity, including internal ones, if you need them.

## HTTP from Workflows

### sendHttpRequest

Send HTTP requests directly from workflow code (not from steps):

```typescript workflow.ts
import { workflow, sendHttpRequest } from '@outputai/core';
import { EnrichmentInput, EnrichmentOutput } from './types.js';

export default workflow({
  name: 'lead_enrichment',
  inputSchema: EnrichmentInput,
  outputSchema: EnrichmentOutput,
  fn: async (input) => {
    const response = await sendHttpRequest({
      url: 'https://api.example.com/companies',
      method: 'GET',
      headers: { 'Authorization': 'Bearer token' }
    });

    return { company: response.name, summary: response.description };
  }
});
```

For POST or PUT requests, include a `payload`:

```typescript
const response = await sendHttpRequest({
  url: 'https://api.example.com/companies',
  method: 'POST',
  payload: { domain: 'acme.com' }
});
```

<Warning>
`sendHttpRequest` is only callable from within workflows. Steps and evaluators can make HTTP requests directly using fetch or any HTTP client.
</Warning>

### sendPostRequestAndAwaitWebhook

Send a POST request and pause the workflow until a webhook response comes back. See [External Integration](/workflows/external-integration#pattern-1-workflow-initiates-the-request) for the full guide.

```typescript
import { sendPostRequestAndAwaitWebhook } from '@outputai/core';

const response = await sendPostRequestAndAwaitWebhook({
  url: 'https://your-app.com/callback',
  payload: { data: 'value' }
});
```

The workflow pauses after sending the request and waits for a response at `/workflow/:id/feedback`. Once the external system sends feedback via the API, the workflow resumes with the received payload.

## File Structure

Each workflow lives in its own directory:

```text
src/workflows/
└── lead_enrichment/
    ├── workflow.ts           # Workflow definition
    ├── steps.ts              # Step implementations
    ├── evaluators.ts         # Evaluators (optional)
    ├── types.ts              # Zod schemas
    ├── prompts/              # LLM prompt templates
    │   └── generate_summary@v1.prompt
    └── scenarios/            # Test scenarios
        └── test_input.json
```

## Environment Variables

The worker reads these environment variables:

### Connection and catalog

| Variable | Default | Description |
|----------|---------|-------------|
| `OUTPUT_CATALOG_ID` | — | **Required.** Name of the local catalog (use your email) |
| `TEMPORAL_ADDRESS` | `localhost:7233` | Temporal backend address |
| `TEMPORAL_NAMESPACE` | `default` | Temporal namespace |
| `TEMPORAL_API_KEY` | — | API key for remote Temporal (blank for local) |

### Worker concurrency and polling

These map to Temporal’s [Worker task slots and pollers](https://docs.temporal.io/develop/worker-performance): they control how many tasks the worker runs at once (executor slots) and how many long-polling connections fetch tasks from the task queue. Tune them for your workload and host resources. Temporal recommends keeping poller count lower than executor slot count.

| Variable | Default | Description |
|----------|---------|-------------|
| `TEMPORAL_MAX_CONCURRENT_ACTIVITY_TASK_EXECUTIONS` | `40` | Max Activity Task executions at once (task slots). Each step (API, LLM, etc.) is one activity; lower to reduce memory under load. |
| `TEMPORAL_MAX_CONCURRENT_WORKFLOW_TASK_EXECUTIONS` | `200` | Max Workflow Task executions at once. Workflows are lightweight; this can be high. |
| `TEMPORAL_MAX_CACHED_WORKFLOWS` | `1000` | Max number of Workflow Executions in the [sticky workflow cache](https://docs.temporal.io/sticky-execution). Lower values free memory sooner after traffic spikes. |
| `TEMPORAL_MAX_CONCURRENT_ACTIVITY_TASK_POLLS` | `5` | Max concurrent pollers for Activity Task Queues. Increase to ingest work faster when slots are often free. |
| `TEMPORAL_MAX_CONCURRENT_WORKFLOW_TASK_POLLS` | `5` | Max concurrent pollers for Workflow Task Queues. |

### Activity heartbeating

The worker sends [Activity Heartbeats](https://docs.temporal.io/develop/typescript/failure-detection#activity-heartbeats) to the Temporal Service so it knows the activity is still making progress. If no heartbeat is received within the activity’s **Heartbeat Timeout** (set per activity in workflow options, e.g. `heartbeatTimeout` in `proxyActivities`), the server considers the activity timed out and may schedule another [Activity Task Execution](https://docs.temporal.io/tasks#activity-task-execution) per the retry policy. That makes heartbeats important during deploys: when a worker restarts, the server detects missing heartbeats and retries on another worker instead of waiting for the full Start-To-Close Timeout. Set each activity’s Heartbeat Timeout longer than `OUTPUT_ACTIVITY_HEARTBEAT_INTERVAL_MS` so the server does not time out before the next heartbeat.

| Variable | Default | Description |
|----------|---------|-------------|
| `OUTPUT_ACTIVITY_HEARTBEAT_INTERVAL_MS` | `120000` (2 min) | How often the worker sends a heartbeat while an activity is running. Must be less than the activity’s Heartbeat Timeout. |
| `OUTPUT_ACTIVITY_HEARTBEAT_ENABLED` | `true` | Whether to send heartbeats. Set to `false` only if you do not use long-running activities or Heartbeat Timeouts. |

### Tracing

| Variable | Default | Description |
|----------|---------|-------------|
| `OUTPUT_TRACE_LOCAL_ON` | — | Enable local trace files — see [Tracing](/operations/tracing) |
| `OUTPUT_TRACE_REMOTE_ON` | — | Enable S3 trace upload (requires Redis + AWS) |
| `OUTPUT_TRACE_HOST_PATH` | — | Host path for Docker trace file mounting |
| `OUTPUT_TRACE_REMOTE_S3_BUCKET` | — | S3 bucket for remote traces |
| `OUTPUT_REDIS_URL` | — | Redis address (required for remote tracing) |
| `OUTPUT_REDIS_TRACE_TTL` | `604800` (7 days) | TTL in seconds for Redis keys holding workflow trace data before S3 upload |
| `OUTPUT_AWS_REGION` | — | AWS region for S3 bucket |
| `OUTPUT_AWS_ACCESS_KEY_ID` | — | AWS access key |
| `OUTPUT_AWS_SECRET_ACCESS_KEY` | — | AWS secret key |

### Monitoring

| Variable | Default | Description |
|----------|---------|-------------|
| `OUTPUT_WORKER_TELEMETRY_INTERVAL_MS` | `0` | Interval between worker telemetry log messages. `0` means off. |
| `TEMPORAL_SHUTDOWN_GRACE_TIME` | Temporal SDK default | Time to let in-flight work drain after worker shutdown starts. Accepts milliseconds or duration strings like `15s`. |
| `TEMPORAL_SHUTDOWN_FORCE_TIME` | Temporal SDK default | Extra time before forceful worker shutdown. Accepts milliseconds or duration strings like `15s`. |

## Logging

`@outputai/core` exports a `Logger` object that you can call from both workflows and steps. Use the same shape as the internal worker logger: a string message plus an optional metadata object.

Under the hood, Output routes all logs into the worker's Winston logger. Workflow logs cross the Temporal sandbox through workflow sinks, while step logs use an activity-scoped bridge. Both paths end up in the same worker log hooks, so the output format, namespaces, and log level filtering stay consistent with the rest of the runtime.

```typescript workflow.ts
import { workflow, Logger, z } from '@outputai/core';

export default workflow({
  name: 'lead_enrichment',
  outputSchema: z.object({
    summary: z.string()
  }),
  fn: async () => {
    Logger.info('Starting enrichment', { source: 'crm' });

    // ...

    Logger.debug('Generated summary', { tokenCount: 842 });
    return { summary: 'Acme Corp is a B2B SaaS company...' };
  }
});
```

```typescript steps.ts
import { step, Logger, z } from '@outputai/core';

export const lookupCompany = step({
  name: 'lookup_company',
  inputSchema: z.object({
    domain: z.string()
  }),
  outputSchema: z.object({
    name: z.string()
  }),
  fn: async ({ domain }) => {
    Logger.info('Looking up company', { domain });

    const response = await fetch(`https://api.example.com/companies/${domain}`);
    Logger.http('Company lookup response', { status: response.status });

    return response.json();
  }
});
```

### Log levels

Output exposes Winston's default npm log levels:

`error`, `warn`, `info`, `http`, `verbose`, `debug`, and `silly`.

See Winston's [logging levels](https://github.com/winstonjs/winston#logging-levels) documentation for the priority order. Lower-priority logs are filtered according to the configured worker log level.

Use `OUTPUT_LOG_LEVEL` to control what the worker emits:

```bash
OUTPUT_LOG_LEVEL=debug
```

When unset, development defaults to `debug` and production defaults to `info`.

### Metadata

The second logger argument is metadata. It should be a plain object with fields you want attached to the log record:

```typescript
Logger.warn('Retrying provider request', {
  provider: 'anthropic',
  attempt: 2,
  requestId: 'lead-enrichment-123'
});
```

Output automatically adds execution fields such as `workflowId`, `workflowType`, `runId`, `activityId`, and `activityType` when it writes the log.

Some metadata field names are reserved because Winston, Output log hooks, or log formatting use them internally. Output drops these fields from logger metadata before building the final log message:
- `activityId`
- `activityType`
- `environment`
- `label`
- `level`
- `message`
- `metadata`
- `runId`
- `service`
- `splat`
- `stack`
- `timestamp`
- `workflowId`
- `workflowType`

If you need to include a value with one of those meanings, use a domain-specific name instead, such as `providerMessage`, `errorStack`, or `sourceTimestamp`.

### Namespace

Logs from within workflows have the "Workflow" namespace, while those from activity context have "Activity".

Namespace is a discrete field in the JSON output in production, and a prefix for the string message in development.

The namespace can be customized by setting it in the metadata:

```js
Logger.warn('My awesome message', { namespace: 'Custom' });
```

Use `Logger.createLogger()` when several logs should share the same namespace:

```js
const log = Logger.createLogger('LLM');

log.info('Generating completion', { model: 'claude-sonnet-4' });
log.warn('Provider request retrying', { attempt: 2 });
```

Metadata on an individual log call can still override the logger's default namespace:

```js
const log = Logger.createLogger('LLM');

log.info('Using fallback provider', { namespace: 'LLM Fallback' });
```

### Worker output

The worker uses Winston for structured logging.

**Development** (colorized, human-readable):
```
[info] Worker: Loading workflows... { callerDir: "/app/src" }
[info] Scanner: Workflow loaded { name: "lead_enrichment", path: "..." }
```

**Production** (`NODE_ENV=production`, JSON):
```json
{"level":"info","message":"Loading workflows...","namespace":"Worker","service":"output-worker","environment":"production","timestamp":"..."}
```

The same logger calls are formatted as human-readable logs in development and JSON logs in production.

## API Reference

For complete TypeScript API documentation, see the [Core Module API Reference](https://output-ai-reference-code-docs.onrender.com/modules/core_src.html).
