Run & stream

September 18, 2026 · View on GitHub

The runtime exposes one agent loop through run, stream, and start. All three decode input before instructions execute and require native model services. Use runUnknown, streamUnknown, or startUnknown for external values typed as unknown. See Agent definitions.

Use InMemory.layer from effect-agent for in-memory conversations, including attached subagents. Provide it once around the application and reuse a Thread ID for follow-up Runs. It retains complete history updates and shares subagent reservation state for that Scope. IDs are generated automatically, and context preparation is optional. Use PersistentHistory.layer with a store to retain completed runs, or a durable host when execution must recover after process loss.

A valid no-tool answer needs one model call. A designated completion Tool can complete without a follow-up model call. Independent application Tools default to four concurrent handlers, behind the batch's authorization and approval barrier. Use one when execution must be serial or a fixture deliberately measures a sequential workflow. Continuity fixtures serialize changes to shared notes; Code Mode examples bound generated programs separately. Node host worker concurrency is a separate setting.

The immutable output contract retains its message identity across turns, allowing opt-in native ResponseIdTracker reuse when the rest of the prompt prefix is unchanged. Prepared context, compaction, and a resumed Run can still invalidate that prefix. Provider caching and billing depend on the selected provider and configuration.

Context preparation is optional. Provide RunContextPreparation to load extra context; without it, Runs use their normal prompt and compaction behavior. See context management for service-based recall and tagged errors.

Await one result

const program = Effect.gen(function* () {
  const result = yield* AgentRuntime.run(agent, input);
  return result;
});

run closes run-owned resources before returning decoded output. A self-contained run needs no caller Effect.scoped.

The result contains output, threadId, runId, turns, and finishReason. Budget-limited results also include exhausted, naming "turns", "tool-calls", or "tokens".

runDisposition appears only after ordinary completion when the definition declares one and its selector returns a value. It contains schema-encoded JSON. Decode durable settlement values with the same application schema.

With the default onExhaustion: "final-answer", turn, tool call, or token exhaustion allows one constrained final turn. The result reports finishReason: "budget-exhausted", and turns may exceed maxTurns by one. Duration or cost exhaustion, pending approval, interruption, and output decoding failure remain failures. Set onExhaustion: "fail" to fail before the final turn.

Observe semantic events

const events = AgentRuntime.stream(agent, input);

const program = events.pipe(
  Stream.tap((event) => Effect.log(event._tag)),
  Stream.runDrain,
);

Events cover run and turn lifecycle, text and reasoning deltas, tool activity, approval requests, and one terminal classification. Provider SDK chunks do not enter this stable union.

For structured output, treat text deltas as provisional wire data. Show activity or received-character progress until the terminal output passes its Schema; do not display partial JSON as an answer. The demo follows this pattern. Plain-text output can render provisional text directly. Primitive text and reasoning deltas are copied into owned, Schema-validated values; their transport fragmentation does not create one ownership tracing span per delta. Complex metadata retains the general bounded ownership path.

The stream uses bounded backpressure. Completion, failure, and interruption close its resources. Interrupting the only ephemeral consumer interrupts the run.

Published ToolProgress results are owned JSON snapshots. Their cumulative UTF-8 JSON size is limited to 8 MiB per run, shared by application and provider progress. This also bounds progress payloads retained for detached replay. Oversized progress fails with ModelProtocolError without truncation. Application progress must contain plain JSON data; accessors, custom serialization, and non-finite numbers fail with the same error. Terminal tool results use toolResultBounds separately.

Lower the progress allowance with bufferLimits on run, stream, or start. Larger values cannot raise the engine's ceiling:

import { type RunBufferLimits } from "effect-agent/run-options";

export const progressBufferLimits: RunBufferLimits = {
  maxToolProgressBytes: 1024 * 1024,
};

Observe typed findings

Declare updates on an Agent to give it an emit_update tool for structured intermediate findings:

<<< @/snippets/travel-planner/observe-updates.ts{ts twoslash}

The researcher definition declares the finding Schema separately from its final output. AgentUpdateEmitted contains an accepted, encoded update; AgentUpdates.observe decodes it through that Schema, including any required decoding services. Emitting a finding lets the Agent continue working and does not complete the run.

Application tools can also emit findings with AgentUpdates.emit. See update delivery guarantees for stable keys, limits, and automatic delivery from a background worker to its parent.

Connect a voice conversation

A voice adapter can delegate to the same agent and Thread as a text interface. Keep its media Scope separate from accepted durable work: closing a call or stopping playback closes media and observation, while the durable runtime retains its accepted-work obligation. Use the original idempotency key and frozen input to reconcile uncertain admission. A transcript delta is context, not an instruction to admit another Run. Corrections use ordinary queued input and steering.

The travel planner demonstrates GPT-Live client delegation. Its adapter constructs schema-validated planner requests from attributed transcripts, uses the existing planner admission path, and reconciles receipts against canonical settlement. It tracks corrections by work identity rather than treating every caption as a new task. Spoken and typed input share the demo conversation; attributed speech context is separate from the user's work request and visible message. Typed results and later research answers return to the active voice exchange. Reconnect restores relevant conversation history without restarting accepted work. The demo retains undelegated speech in the current tab; it does not add a durable partial-transcript journal.

For application-selected previews, decorate the native Effect AI LanguageModel service in the model Layer. Its streamText exposes ordered text-delta, tool-params-start, and tool-params-delta parts. The demo selects native text and only the designated deliver_response.message field; it retains a bounded provisional preview and fences writes by Submission and Attempt. Parsing and presentation belong to the adapter. Never forward reasoning, arbitrary tool arguments, or diagnostics to a voice provider. Provider protocol interception is unnecessary for this public-output path, and no additional SDK output hook is required.

Keep generation, schema validation, durable settlement, provider acknowledgment, and actual audio playback distinct. A partial completion-tool argument is provisional even when it resembles a complete sentence. A provider acknowledgment does not prove that the user heard the result. Use canonical, schema-decoded output for final answers, including after reconnect. The official Live delegation guide describes the provider-specific half of this integration.

Start and re-observe locally

const program = Effect.gen(function* () {
  const detached = yield* AgentRuntime.start(agent, input);
  const result = yield* detached.await;
  const completeTrace = yield* detached.events;
  return { result, completeTrace };
}).pipe(Effect.scoped);

start requires a caller Scope. observe replays prior events, follows new events, and ends when the run settles. events returns the complete replay after settlement. Execution resources close before await returns, while replay remains available until the owner closes.

Observers cannot backpressure execution. Closing the owner interrupts active work and observers. The handle remains process-local and never creates a daemon fiber.

Run durably on Cloudflare

Use a Cloudflare thread object to accept work that survives eviction. For a process with SQLite, use the Node host.

Assemble a custom durable runtime

Platform hosts assemble storage and runtime services for you. When building your own host, DurableAgentRuntime.layer supplies default prompt preparation and tool authorization. Use layerWithServices to supply your own service layers. It requires RunToolAuthorization and captures RunContextPreparation when provided.

Here is the default authorization policy; replace it with your application's implementation:

import { RunToolAuthorization } from "effect-agent/run-options";
import { DurableAgentRuntime } from "effect-agent/durable-agent-runtime";
import { Layer } from "effect";

export const RuntimeLive = DurableAgentRuntime.layerWithServices.pipe(
  Layer.provide(RunToolAuthorization.allowAll),
);

This layer still requires SubmissionLedger, ThreadStore, WakeScheduler, DurableRuntimeFailpoint, DurableRuntimeConfig, ToolReconciler, and Crypto.Crypto. Provide those before acquiring the runtime.

The runtime captures its services at acquisition. Supplying a different layer around a later worker call does not replace them. Acquire service dependencies in their layers and keep them alive for the runtime's Scope. Durable service hooks must have no unresolved dependencies. Preparation failures retain their AgentInputError, MemoryRecallError, or CompactionError tags; RunContextPreparationError is their type union, not a wrapper. Durable execution records failed Runs in Settlements with bounded, structured causal diagnostics and execution correlation; it does not reconstitute the original error object from storage. Programmatic Worker inspection and awaiting expose this evidence as diagnostic, while generated inspect tools and completion reports omit it. Authorization returns an allowed or denied decision, or fails with AgentToolAuthorizationCheckError when its check could not finish. Configure prompt preparation and tool authorization in their respective services.

Understand turn boundaries {#turn-boundaries}

Each turn follows this sequence:

prepare context
  → stream and reduce one model response
  → decode the complete tool batch
  → execute bounded tool handlers
  → commit results in declaration order
  → drain steering
  → evaluate stop policy
  → drain follow-up only if otherwise complete

run and stream use the same loop.

Add per-run hooks {#operational-hooks}

RunOptions accepts per-run capability hooks. This process-local example uses in-memory history. history provides an initial Prompt, and onHistory receives incremental updates.

const options: RunOptions<AppError, AppRequirements> = {
  threadId,
  history,
  input: toRunInputHook(commands),
  approval: toRunApprovalHook(approvalPolicy),
  budget: toRunBudgetHook(budget),
  context: toRunContextHook(contextTransform),
  scheduling: toRunSchedulingHook({ mode: "bounded", concurrency: 2 }),
  onHistory,
};

Hook errors join the run error channel, and their services join R. onHistory runs inline. Writes completed before a later failure or interruption remain caller-owned. Persistent history rejects competing history and input queue hooks before model or tool execution.

Pass prompt preparation as context and tool authorization as toolAuthorization when needed. Ephemeral runs read these options; providing the durable service layers alone does not install per-run hooks.

Observe recovered tool failures

A tool may fail and the model may still complete the run. Install toolFailureObserverLayer from effect-agent to report such failures.

This observer covers failures contained as results, including programmatic broker outcomes. It does not duplicate model-declared failures that propagate through the run's Effect error channel, or defects and interruptions. Native model-call argument rejections do not invoke it because no handler started; their failed results and warning telemetry remain available. Use ToolCallFailed.failureHandling and tool telemetry to distinguish returned failures from propagated ones, and handle the run's Effect exit separately.

import { toolFailureObserverLayer } from "effect-agent/run-options";
import { Effect, ErrorReporter } from "effect";

const failureReporting = toolFailureObserverLayer({
  observe: (observation) =>
    observation.cause === undefined ? Effect.void : ErrorReporter.report(observation.cause),
});

const program = AgentRuntime.run(agent, input).pipe(Effect.provide(failureReporting));

The engine does not forward observations to ErrorReporter by itself. Choose what to record and redact. The observer runs inline at most once per in-memory Attempt. Replacement Attempts may repeat an observation. Nothing here is serialized into thread history.

Observer defects cannot change the tool result, though a slow observer holds a tool permit. Avoid calling the broker, running another agent, or interrupting the observer itself. Durable hosts accept the same observer through their platform options.

Scope run resources {#interruption-is-ownership}

A run Scope owns its model stream, tool fibers, and run-local resources. Closing it interrupts children and runs finalizers. Services from an enclosing application layer remain available to other runs until the application Scope closes.

Wrap several runs with one Effect.provide(AppLive) to reuse shared services. Keep caller scoping for start, explicit resource acquisition, and any operation that requires Scope.

History retention waits for run-local cleanup, result validation, and commit before publishing RunCompleted. Interrupting a waiter for durable accepted work only detaches that waiter. Abort a durable Submission with an explicit persisted command. See Persistence & durability.

Trace agent and model calls

With an Effect tracer installed, filter gen_ai.operation.name to find agent work:

OperationSpan nameIdentity
invoke_agentinvoke_agent <agent definition ID>Agent, Thread, Run
chatchat <configured model name>Agent, Thread, Run, and Turn for ordinary model calls
execute_toolexecute_tool <tool name>Agent, Thread, Run, Turn, Tool Call

All three carry gen_ai.agent.name (the definition ID), gen_ai.agent.id (the Thread-backed instance), and gen_ai.conversation.id (the Thread ID). Existing agentId, threadId, runId, and applicable turnId attributes remain available. Agent span names replace AgentRuntime.run; update filters using that old name.

Model calls label the existing Effect AI LanguageModel.streamText span rather than creating a second model-call span. The configured model and provider are recorded as gen_ai.request.model and gen_ai.provider.name; native providers retain their response and token-usage annotations. Each retry and compaction summary has its own model span. These labels add identifiers, not prompts, instructions, or tool payloads.

An agent span covers one active execution Scope. A durable Run resumed by another Attempt can produce another span with the same Run ID; the span is not the entire wall-clock lifetime of a suspended Run. See Cloudflare tracing for the dashboard setup and alarm-root behavior.

Provider usage and cost evidence

AgentRuntime results and RunCompleted expose optional usage (own calls, including compaction) and delegatedUsage (all attached descendants). Combine them once with Usage.sumRunTotals; background workers are excluded. Child events repeat cumulative totals, so deduplicate by Run ID.

For failed or interrupted Runs, read handle.usageReport after handle.await settles or its owning Scope closes. Earlier reads are live snapshots and may omit in-flight usage. Durable settlements retain their richer per-model usageSummary for the Run's own calls.

A cost estimator receives the configured binding name in request.model and the actual provider-reported identity in request.response. Use the latter for response-sensitive pricing. request.finishMetadata carries native Effect AI finish metadata only during estimation; the engine never persists provider HTTP details or raw metadata in accounting records. A summarizer uses the same estimator with purpose: "summary".

Calls and Run totals retain usageStatus and pricingStatus. Missing legacy status is unknown, and a numeric zero without an estimate is not evidence of free execution. Run summaries distinguish complete, partial, and unknown coverage; unobservedModelCalls counts observed calls without retained accounting, which are excluded from numeric token and call totals. A canonical ModelResponseInterrupted can indicate additional unquantified provider work beyond this count.

An explicitly configured costBudgetMicrousd fails with a typed cost-policy error when the estimator reports unknown pricing, after retaining the call's usage. Uncapped Runs may continue; legacy numeric estimates and estimates without a status remain trusted host estimates.

Response records also retain each Turn's missing-call count, so approval and child suspension preserve incomplete accounting when a fresh runtime resumes the Run.

Runtime hosts use AgentRuntime.streamWithUsageAccountingUnknown with the inward ModelUsageAccounting and AgentUpdateAcceptance services from RunOptions. These dependencies remain visible in R. The native durable runtime supplies its canonical Turn accumulator and Attempt-bound update acceptance at composition, retaining updates before acknowledging them. Ordinary stream, run, and start calls provide ephemeral accounting and update acceptance.

Canonical response records own committed per-call usage. Terminal settlement uncommittedModelUsage retains only staged calls not already present in a response record; its charges are already included in usageSummary, so do not add them a second time. An isolate loss before either response or settlement commit cannot prove the lost call's usage or cost.