@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
For full details on
workflow, step, and evaluator, see Workflows, Steps, and Evaluators.
Worker Runtime
When you runoutput dev, the CLI starts Docker Compose which launches a worker container. The worker:
- Scans your project for workflow files (
workflow.js), step files (steps.js), evaluator files (evaluators.js), and shared components inshared/steps/andshared/evaluators/ - Creates a catalog of all discovered workflows and their activities with metadata (name, description, schemas)
- Connects to Temporal at the configured address
- Publishes the catalog so the API can resolve workflow names for this version of your code
- Starts processing workflow executions
Shutdown
SIGTERM, SIGINT, and SIGUSR2 start a graceful shutdown: the worker stops polling, lets in-flight activities run for TEMPORAL_SHUTDOWN_GRACE_TIME before asking them to cancel, gives up draining after TEMPORAL_SHUTDOWN_FORCE_TIME, closes its Temporal connection, then awaits pending hook callbacks for up to OUTPUT_HOOK_FLUSH_TIMEOUT_MS. A second signal forces an immediate exit.
Those three values are a single budget, and it has to fit inside the window your platform allows between SIGTERM and SIGKILL. The worst case is TEMPORAL_SHUTDOWN_FORCE_TIME plus OUTPUT_HOOK_FLUSH_TIMEOUT_MS, since the drain runs to its bound and then the flush runs to its own. The defaults total 25 seconds against the 30 seconds most platforms allow, leaving 5 seconds of margin. Raise the platform window before raising any of them - on Render that is maxShutdownDelaySeconds, on Kubernetes terminationGracePeriodSeconds - or the platform terminates the process mid-flush and the hook data is lost.
Uncaught exceptions and unhandled rejections take the same path, so in-flight work still drains. That drain is capped at 60 seconds: a worker still shutting down when the cap expires force quits. The cap is fixed, independent of TEMPORAL_SHUTDOWN_FORCE_TIME, and applies only to this path - a shutdown started by a signal is bounded by a second signal or by your platform instead.
File Discovery
The worker scans yoursrc/ directory for these file patterns:
Each discovered component is logged during startup:
Architecture
Output is built on Temporal.io for durable execution. Your abstractions map to Temporal primitives:
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 underoutputai.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.
Keep handlers fast. Every handler is tracked while it runs, and a shutdown awaits all the pending ones together under a single OUTPUT_HOOK_FLUSH_TIMEOUT_MS budget. There is no opt-out, so one handler awaiting a slow network call spends the allowance every other pending handler needs, and they can all be dropped together. Write to stdout or an in-process buffer and let your log pipeline or a sidecar ship the data. If a handler genuinely has to await remote I/O, batch it and raise OUTPUT_HOOK_FLUSH_TIMEOUT_MS along with your platform’s shutdown window.
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 eventIds.
Context objects on hook payloads
activityInfois Temporal’s Activity execution info object. See Temporal’sactivity.Inforeference for all fields.workflowDetailsis Output’s serializable subset of Temporal’sworkflow.WorkflowInfo. It includesworkflowId,runId,workflowType,parent,root,firstExecutionRunId,continuedFromExecutionRunId,startTime,runStartTime, andattempt.outputActivityKindis Output metadata for activity hooks and custom events emitted from activities. Possible values arestep,evaluator, andinternal_step.
on(eventName, handler) keep framework context separate from event-specific data:
emit(eventName, payload) from a step or evaluator to publish a custom event. The payload is optional and can be any JavaScript value.
If you call emit() outside a step or evaluator (and therefore outside an activity context), the event still includes eventId, eventDate, and payload, but it does not include activityInfo, workflowDetails, or outputActivityKind.
http:request stores its request fields under payload, while eventId and activity context remain at the top level.
onError payload by source
activity—eventId,eventDate,source,activityInfo,workflowDetails,outputActivityKind,errorworkflow—eventId,eventDate,source,workflowDetails,errorruntime—eventId,eventDate,source,error
Error instances. Workflow errors are still serialized to cross Temporal’s sandbox boundary, then rehydrated before hooks run — so name, message, an optional recursive cause, and other enumerable diagnostic properties survive. The value is always a bare Error, not the original subclass (instanceof FatalError is false even when error.name === 'FatalError'). Use error.name, or hasErrorType(error, FatalError) from @outputai/core, to distinguish failure kinds.
The internal $catalog workflow is excluded from lifecycle and error hooks. Activity hooks include internal activities, identified by outputActivityKind: 'internal_step'.
HTTP from Workflows
sendHttpRequest
Send HTTP requests directly from workflow code (not from steps):workflow.ts
payload:
sendHttpRequest returns only response metadata: url, status, statusText, and ok. Use responseOptions.includeHeaders to include response headers and responseOptions.includeBody to include the body. Included response headers are redacted automatically; response bodies are returned as-is.
Use $ENV_VAR_NAME placeholders for secret header values:
$ENRICHMENT_API_TOKEN from process.env.ENRICHMENT_API_TOKEN inside the activity. Workflow history and trace files store the placeholder, not the token value.
sendPostRequestAndAwaitWebhook
Send a POST request and pause the workflow until a webhook response comes back. See External Integration for the full guide./workflow/:id/feedback. Once the external system sends feedback via the API, the workflow resumes with the received payload.
Temporal Access in the Worker
The@outputai/core/temporal subpath gives worker-side code direct access to the Temporal layer, sharing the worker’s live connection — no extra configuration, TLS, or credentials handling. Temporal Activities can additionally access the workflow execution that invoked them.
getCurrentWorkflowHandle
Get a handle pinned to the exact workflow run that invoked the current step. The primary use case is signaling incremental results — e.g. LLM token batches — back to a workflow that buffers them for a client-facing update handler:steps.ts
generateTextWithStreaming reports chunks as they arrive but returns a complete result like generateText. Stream failures reject the step, allowing Temporal to record the failed activity attempt and apply its retry policy.
The handle is resolved from the activity context and pinned to the exact workflowId + runId, so a stale activity from a retried or superseded run can never signal a newer run reusing the same workflow id. If the invoking run has already completed, operations on the handle reject and the activity fails — surfacing the problem instead of signaling into the void.
createTemporalClient
For operations on other workflows,createTemporalClient() creates a full Temporal Client that shares the running worker’s connection and namespace:
client.connection; the worker owns its lifecycle.
File Structure
Each workflow lives in its own directory:Environment Variables
The worker reads these environment variables:Connection and catalog
Worker concurrency and polling
These map to Temporal worker slots, pollers, and tuners. See Worker Tuning for details and examples.Activity heartbeating
The worker sends 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 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.
Tracing
Monitoring
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.
workflow.ts
steps.ts
Log levels
Output exposes Winston’s default npm log levels:error, warn, info, http, verbose, debug, and silly.
See Winston’s 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:
debug and production defaults to info.
Use OUTPUT_TEMPORAL_LOG_LEVEL for Temporal SDK / Core runtime logs. It takes Temporal levels (TRACE, DEBUG, INFO, WARN, ERROR), not Winston levels. Default: INFO.
Metadata
The second logger argument is metadata. It should be a plain object with fields you want attached to the log record: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:
activityIdactivityTypeenvironmentlabellevelmessagemetadatarunIdservicesplatstacktimestampworkflowIdworkflowType
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:Logger.createLogger() when several logs should share the same namespace:
Worker output
The worker uses Winston for structured logging. Development (colorized, human-readable):NODE_ENV=production, JSON):