npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@silverprotocol/openai-agents

v0.12.11

Published

AgJSON normalizer for the OpenAI Agents SDK (RunItemStreamEvent / Responses streaming → AgEvent[]) — fixture-tested.

Readme

@silverprotocol/openai-agents

AgJSON normalizer for the OpenAI Agents SDK (RunItemStreamEvent / Responses streaming → AgEvent[]) — fixture-tested. It sits on top of your OpenAI Agents run and translates its native stream into framework-neutral AgJSON; it doesn't replace the SDK you already use.

Install

npm install @silverprotocol/openai-agents @openai/agents

@silverprotocol/core is a required peer (pulled in automatically). @openai/agents is the OpenAI Agents SDK itself — you need it to produce the stream. (The normalizer handles the native events structurally, so if you only feed it already-captured events it's just a type-level peer.)

Upstream compatibility

Supported peer range: @openai/agents >=0.2.0 <0.22 (npm-enforced, optional — this package never imports the SDK).

Wire shapes outside the verified set never crash the normalizer, and the fixture-drift gate (scripts/check-fixture-drift.mjs) turns every newly-appeared upstream field into a tracked triage disposition rather than an unnoticed drop — carried losslessly where a carry channel exists (see sdk-surface.json for this facet's per-field dispositions, including its disclosed silently-dropped gaps).

| @openai/agents | verified | with | evidence | | --- | --- | --- | --- | | 0.21.0 | 2026-10-25 | 0.12.8 | @openai/agents 0.19.0 -> 0.21.0 with agents-core, agents-openai and agents-realtime 0.21.0 (the transitive openai stays 7.22.0); peer range widened from >=0.2.0 <0.20 to >=0.2.0 <0.22, so 0.20 and 0.21 are both in range. Two carries land with the widen. 0.20's optional toolSearchMcpToolNames on a tool-search output item (the hosted MCP tool names a server-executed search established) rides that search's tool.done providerMetadata beside toolSearchAgentName; without it the value would be dropped. Detail keys of input_tokens_details / output_tokens_details beyond the three mapped ones (cached_tokens, cache_write_tokens, reasoning_tokens) ride one ext.openai.unparsed {name: "usage-details", raw} per response before the round's terminal, and a mapped key whose value is not a number sets no AgUsage counter (it used to land null inside usage); openai 7.43.0's types define no other detail keys, so this serves OpenAI-compatible providers, whose nested breakdowns 0.21 keeps through a streamed run. Host-side, a model call after an approval resume counts toward maxTurns from 0.20, and RunState moves to schema 1.22 (0.20) and 1.23 (0.21): a state saved by 0.19 or 0.20 resumes on 0.21, but a state saved by 0.21 does not resume on 0.20. The SDK's own streams for the 17 engine-built openai-hosted scenarios are byte-identical on 0.19.0, 0.20.0 and 0.21.0. The facet's tests and the corpus replay pass at 0.21.0. Live: echo-gpt61sol (gpt-6.1-sol) captured green at 0.21.0 on this facet with no drops and no new fields; the model did not reason this run, so the committed cassette, which carries a reasoning item, was kept. | | 0.19.0 | 2026-10-07 | 0.12.4 | @openai/agents 0.18.0 -> 0.19.0 (agents-core, agents-openai and agents-realtime with it); the peer range widens to <0.20. No facet change. The consumed openai-node client stays 7.22.0 (7.30.0 was not adopted). agents-core 0.19.0 declares @modelcontextprotocol/client as an optional ^2.0.0; an override floors it at 2.2.0 for GHSA-6qxp-vccf-f47h (resolves 2.3.1). The 17 engine-built openai-hosted natives regenerate byte-identical on 0.19.0. Live: echo-gpt61sol (gpt-6.1-sol) captured green with the same event shape as echo-gpt6sol, reasoning item included. | | 0.18.0 | 2026-09-23 | 0.6.3 | Re-attestation at the SAME peer (@openai/agents 0.18.0 is still npm latest) because the consumed transitive openai-node client floated 7.15.0 -> 7.22.0 (agents-openai declares ^7.2.0; 7.22.0 was pinned with a release-age exclusion and a fresh resolution - without the exclusion the range silently resolves an older client). The SSE ResponseStreamEvent union goes 58 -> 59; the only new member, response.compaction.compacting, fires only with compaction, which the capture path never requests. Every type the facet consumes is byte-identical. One runtime change on the HTTP path: from 7.17.0 the SSE parser flushes the event still in progress at EOF, so a complete trailing record is delivered where 7.15.0 dropped it, and a record truncated mid-JSON now surfaces a SyntaxError where agents-core used to report ModelBehaviorError('Model did not produce a final response!'); it never duplicates an event. That changes host-error TEXT only, not the mapping. LIVE at 0.18.0 / 7.22.0: echo-gpt56 and echo-gpt6astra refreshed; app-spec-structured-result refreshed and MIGRATED gpt-5.5 at agents 0.12.0 -> gpt-5.6-sol, re-proving the MCP structuredContent -> item.customData channel six minors later; NEW echo-gpt6sol and echo-gpt6luna (GA 2026-09-22). The Agents SDK has no model-specific settings for either, so both run at the API-default effort medium. echo-gpt6sol is the FIRST openai seed that actually reasons (30 reasoning tokens), and it exposes a pre-existing facet gap: the reasoning item arrives as response.output_item.done BEFORE response.completed while the SDK's reasoning_item_created arrives AFTER it, so the facet surfaces the item only as ext.openai.late-reasoning {itemId, encryptedContent} and emits no first-class reasoning block. The carried blob is the RIGHT one: OpenAI re-encrypts at each stage (three distinct ciphertexts on this seed), and the facet carries the final blob from response.completed / response_done, the one stateless replay sends back. access_programs now arrives as null on every model (allowlisted null-only beside the existing .cyber entry). echo-gpt55 is kept deliberately as the historical 0.12.0 record. Census ZERO drops / ZERO new fields after triage. | | 0.18.0 | 2026-09-15 | 0.6.2 | peer 0.17.0->0.18.0 across 0.17.1 and 0.17.2 (0.17.1 alone is a ~40-fix batch), adopted with the declared peer range widened >=0.2.0 <0.18 -> <0.19 (closes typescript-sdk#32; ceiling widened, floor kept, per precedent). TYPED SURFACE is a no-op for this facet: agents-core events.d.ts and result.d.ts are byte-identical to 0.17.0, RunItemStreamEventName (10) and protocolItem (17) inventories unchanged, and types/protocol.d.ts's only delta across the whole span is one optional toolSearchAgentName on ToolSearchOutputItem (absent from ToolSearchCallItem) -> CARRIED on the tool lifecycle following the existing executionStatus precedent; reachable only through tool_search, which no corpus scenario configures, so it is fixture-tested. The 0.18.0 headline changes (Docker file APIs moving inside the container, UnixLocalSandboxClient fileIOProtection, imageGenerationTool action) are all off this facet's Responses streaming path. CORRECTNESS FIX, the real finding: openai 7.15.0 types Response.status as the full ResponseStatus union ('completed'|'failed'|'in_progress'|'cancelled'|'queued'|'incomplete', responses.d.ts:6186) and ResponseCompletedEvent carries a full Response (:1652-1656), but this facet's local type declared only 'completed'|'incomplete', so a response.completed whose response.status was 'failed' or 'cancelled' fell through the decision tree to a SUCCESSFUL turn close - a WRONG MAPPING, not a drop. agents-openai 0.17.1+ independently agrees the status is authoritative (it now rejects unsuccessful terminal states) but it yields the raw model carrier BEFORE throwing, so the facet has already decided the close by then and must decide it correctly itself. The local type is widened to the wire union and an unsuccessful terminal status now closes as turn.error carrying the status as the code, placed LAST so refusal, content_filter and response.incomplete keep their exact prior precedence and output; one deliberate sharpening - a self-contradictory completed/status:'incomplete' that also carries incomplete_details.reason prefers the reason, so the code matches byte-for-byte what the response.incomplete branch would have emitted. Absent or 'completed' status is byte-identical to 0.17.0, proven by negative controls over absent / completed / in_progress / queued / an unrecognized future value. Also corrected: the doc block invalidated by 0.17.1 is the bare-error arm (a bare error is now terminal upstream, so the facet's non-terminal advisory degrades to ext.openai.error plus an engine-synthesized turn.abort{stream-truncated}, with turn.error materializing only once the host feeds host_error), NOT the response.failed block, which was already terminal at 0.17.0. TRANSITIVE openai-node floated 7.10.0->7.15.0 by fresh resolution: the SSE ResponseStreamEvent union is IDENTICAL at 58 members and every consumed type is byte-identical. LIVE at 0.18.0 / openai 7.15.0: echo-gpt56 and echo-gpt6astra refreshed - census ZERO drops / ZERO new fields on both. MODEL-GATED REQUEST-SHAPE CHANGE observed on Astra: the SDK's reasoning.effort and text.verbosity echoes moved medium -> low (both were PRESENT at 0.17.0, not absent); normalized usage is byte-identical (reasoningTokens stays 0 on the echo), so this is cassette-visible only. Facet tests 127 -> 129. | | 0.17.0 | 2026-09-05 | 0.6.1 | Re-attestation at the SAME peer (0.17.0 still latest) with the transitive openai-node client deliberately floated 7.8.0->7.10.0 (fresh resolution): the SSE ResponseStreamEvent union is IDENTICAL (58 members) across 7.8/7.9/7.10 over the HTTP transport the facet and capture agent use; 7.10.0 adds WebSocket-only steering events (response.steer.), an input-only configuration_update item, FunctionToolCall.async, incomplete_details.reason 'max_messages' (7.9.0) / 'steered' (7.10.0) - the response.incomplete arm goes straight to turn.error{code: reason, message: reason}, so the raw reason survives with zero code change (regression pin added) - and ResponseError.misalignment {detailed_explanation, error_type, steer{message}} with code misalignment_policy_violation (GPT-6 Astra misalignment monitoring). CARRY added: the response.failed arm now emits a turn-scoped ext.openai.misalignment {responseId, misalignment} verbatim immediately before turn.error (turn.error has no providerMetadata slot per spec section 4); absent => byte-identical; synthetic-tested only - documented auto-stop applies to persisted-reasoning / WebSocket / compaction requests, none used by the e2e HTTP path. compute_units watch item CLOSED: added in 7.8.0 types, removed in 7.9.0, never on the wire. 7.10.0 also adds a resources/safety SafetyAlert surface (webhook plane, out of scope). MODEL RELEASE: gpt-6-astra (GA 2026-09-03; sole id, no dated snapshot; effort low..max, 'none' -> 400; temperature/top_p rejected; $10/$1 cached/$50) captured live as echo-gpt6astra through @openai/agents 0.17.0 (no temperature/top_p/effort sent by the runner for this model) - census: ONE new response-level attribute response.access_programs.cyber = 'standard' at three norm-paths (allowlisted as router-plane program-tier metadata beside service_tier); zero drops otherwise; 0 reasoning tokens on the echo (Astra chose not to reason). DEFAULT_MODEL.openai/vercel stay gpt-5.6-sol (a decision still open). | | 0.17.0 | 2026-09-02 | 0.5.4 | Re-attestation at the SAME peer (0.17.0 is still latest; published 2026-08-19) after the cohort's fresh lockfile resolution floated the transitive openai client 7.4.0->7.8.0 (agents-openai depends on openai ^7.2.0): openai-node 7.5.0+ adds five response.shell_call_command./shell_call_output_content.* stream events (hosted shell tool; ignored by the facet's default arm, cannot appear in MCP-only scenarios) and 7.8.0 adds ResponseUsage.compute_units?: number|null ('Currently null when available'; already gone again from 7.9.0's types) - census watch item. LIVE: echo-gpt56/openai RE-CAPTURED (gpt-5.6-sol) - census ZERO drops / ZERO new fields; the gpt-5.6 alias still routes to gpt-5.6-sol (OpenAI docs re-checked 2026-09-02; no newer public model family - new ids are the Daybreak-gated gpt-5.6-cyber / gpt-daybreak--latest). | | 0.17.0 | 2026-08-28 | 0.5.3 | 0.17.0 MINOR study (typescript-sdk#20/#24, downstream guuey#338): all seven consumed agents-core dist files BYTE-IDENTICAL to 0.16.0 (events.d.ts, types/protocol.d.ts, items.d.ts, result.d.ts, usage.d.ts, runner/streaming.mjs, runner/modelOutputs.mjs; RunItemStreamEventName 10, protocolItem 17) and agents-openai's only change is websocket replay-safety bookkeeping in openaiResponsesModel.mjs (no event emission touched) -- wire-no-op on every consumed surface; drift gate zero member drift. LIVE capture: echo-gpt56 standing seed REFRESHED at 0.17.0 (gpt-5.6-sol), 46 native events, event-type census identical to the 0.14.2 capture. The census caught ONE wrapper-level field the d.ts-pair inventory cannot see: RunToolCallOutputItem.executionStatus ('executed', introduced in agents-core 0.15.0 -- present since the 0.15/0.16 studies, first observed live here). Disposition handled: carried verbatim on tool.done providerMetadata alongside caller (set only when the runner actually invoked the function tool; absent on guardrail/cancellation-synthesized results); 3 facet tests, transforms + registry entries, snapshot regenerated. Peer range widened >=0.2.0 <0.17 -> <0.18; e2e pin 0.17.0; full gate suite green. | | 0.16.0 | 2026-08-15 | 0.4.4 | 0.16.0 MINOR study: both consumed agents-core dist files BYTE-IDENTICAL to 0.15.0 (events.d.ts and types/protocol.d.ts; RunItemStreamEventName 10 members, protocolItem 17, full zod-literal inventory zero-delta) - the minor is agent/handoff/runner internals the facet does not ingest. Peer range widened >=0.2.0 <0.16 -> <0.17 (ships as 0.4.4 so consumers installing 0.16.x stop getting npm peer warnings); e2e pin 0.16.0, forced gate suite green. | | 0.15.0 | 2026-08-13 | 0.4.2 | 0.15.0 MINOR study (typescript-sdk#15, filed as [peer-drift] — the nightly's forced-0.15.0 gate suite was already green): RunItemStreamEventName unchanged (10 members incl. compaction_item_created), protocolItem unchanged (17) — both drift-gate-verified; wire-no-op on every consumed surface. Peer range widened >=0.2.0 <0.15 -> <0.16 (ships as 0.4.3 so consumers installing latest stop getting npm peer warnings); e2e pin 0.15.0. | | 0.14.3 | 2026-08-09 | 0.4.1 | peer sweep: 0.14.2->0.14.3 d.ts diff — ONE new RunItemStreamEventName member (compaction_item_created, drift-gate-caught), protocolItem union unchanged (17). Disposition handled: mapped to the first-class compaction content.block converging with the claude facet (synthetic test pins the mapping + no-double-carry + clean fold). Full gate suite green at the new pin (110 facet tests, 949 workspace). | | 0.14.2 | 2026-08-04 | 0.3.9 | 0.14.0→0.14.2 verified a run-stream wire no-op: agents-core events.d.ts / types/protocol.d.ts / items.d.ts / result.d.ts byte-identical across 0.14.0/0.14.1/0.14.2 (both hops diffed); agents-openai 0.14.2 metadata-only. Non-wire: 0.14.1 = type re-exports + sandbox runtime; 0.14.2 = RunState CURRENT_SCHEMA_VERSION 1.15 (serialized state, not streamed), MCP credential redaction, realtime transport_event now preserving raw fields (realtime-only — this facet does not normalize realtime transports; noted for any future realtime facet). Pin bumped; live re-capture in this cohort's capture step. | | 0.14.0 | 2026-07-29 | 0.3.7 | Live capture landed (echo-gpt56 @ gpt-5.6-sol, agents-core 0.14.0): gpt-5.6-sol populates AssistantMessageItem.phase='final_answer' on the real wire, and the census caught that the synthetic-frame text.end carry never fires live — message_output_created for the final round arrives after response_done closes the message. Fixed with ext.openai.late-phase {itemId, phase} on the id'd post-close path (late-citations degrade convention); 2 unit tests added (carry + 0.13.5-shaped negative control); census transforms/registry updated ([].data.response.output[].phase, [].item.rawItem.phase); replay + census gates green. program/program_output and caller remain synthetic-only (programmatic tool calling needs the hosted tool enabled in an e2e scenario — future capture). | | 0.14.0 | 2026-07-29 | 0.3.7 | fixture suite + drift gate + full 0.13.5->0.14.0 agents-core d.ts diff audit: envelopes unchanged (RunItemStreamEventName still the same 9 names; raw StreamEvent kinds identical) — all five wire additions carried at the facet layer: program/program_output protocol items (openai >=6.46 programmatic tool calling, SUPERSEDES the 0.13.2 KNOWN-DEFERRED bullet — the SDK's converters now push them as RunToolCallItem/RunToolCallOutputItem, dist/runner/modelOutputs.mjs; tool.start name:'builtin:program' with {code,fingerprint} verbatim args, callId-correlated tool.done, status incomplete->error), optional ToolCaller caller provenance on function/hosted/shell/apply-patch call+result items (tool.start/tool.done providerMetadata, camelCase verbatim; the authoritative raw output_item.added path normalizes openai-node's snake_case caller_id), AssistantMessageItem.phase (matching text.end providerMetadata, per-part + the id-less late-message ext payload), ShellCallResultItem.status (NEW optional enum — 'incomplete' joins the error mapping even on all-clean exits; carried verbatim on tool.done providerMetadata); synthetic-frame unit tests incl. 0.13.5-shaped negative controls (output unchanged); live capture pending (echo-gpt56 refresh) | | 0.13.5 | 2026-07-22 | 0.3.5 | LIVE capture: echo-gpt56 standing seed REFRESHED at 0.13.5 (gpt-5.6) — the new itemId OBSERVED live on the flattened output_text_delta arm and triaged (census allowlist + field registry, first-observed provenance recorded); replay + census gates green (zero untriaged drops) | | 0.13.5 | 2026-07-22 | 0.3.5 | fixture suite + drift gate + full 0.13.2->0.13.5 agents-core d.ts/dist diff audit: events/items/usage/protocol item shapes and the stream emitters (runner/streaming.mjs, runner/modelOutputs.mjs) byte-identical; sole wire-relevant delta is an optional camelCase itemId on the SDK-flattened output_text_delta arm (protocol.d.ts StreamEventTextStream) — deliberately no-op'd as the documented duplicate of the authoritative model:response.output_text.delta path, whose snake_case item_id is unchanged; the rest is request-side tool-definition plumbing (FunctionTool/SerializedFunctionTool providerData, explicitly stripped from tool_search_output payloads by serializeFunctionToolForToolSearchOutput) and sandbox/tracing infra | | 0.13.2 | 2026-07-13 | 0.3.3 | LIVE capture: echo-gpt56 corpus (gpt-5.6) — landed as a standing replay/census seed; cache_write_tokens mapping confirmed on real wire (turn.done usage.cacheWriteTokens); zero unparsed events | | 0.13.2 | 2026-07-13 | 0.3.3 | fixture suite + drift gate + adversarially-verified 0.12.0->0.13.2 wire-surface audit (GPT-5.6 era: cache_write_tokens mapped, invalidFinalOutput late-message rescue; hosted multi-agent + programmatic tool calling documented KNOWN-DEFERRED); live capture pending (echo-gpt56 scenario ready) | | 0.12.0 | 2026-07-03 | 0.1.0 | live captures: echo-gpt55 + app-spec-structured-result corpus (gpt-5.5) |

Usage

Usage semantics (spec draft.5). usage.inputTokens is OpenAI's input_tokens, which includes cached input: cacheReadTokens (input_tokens_details.cached_tokens) and cacheWriteTokens (input_tokens_details.cache_write_tokens) are breakdowns of it, so never add them to inputTokens. This follows OpenAI's documentation of those fields as a detailed breakdown of the input tokens; every capture in this package's corpus reports zero cached tokens. Likewise outputTokens includes reasoning, and reasoningTokens is its breakdown. Usage is reported per response, on the round's turn.done (cumulative: false), so a provider-reported cost such as OpenRouter's usage.cost, carried as costUsd, is per response and sums across turns; costScope is not set. A round closed at flush() with turn.abort carries that usage, cost included, on its message.end. OpenAI's own wire has no other detail keys (openai 7.43.0 types); any other key an OpenAI-compatible provider sends in input_tokens_details or output_tokens_details (a flat counter or a nested breakdown) maps to no AgUsage field, so it rides one ext.openai.unparsed event, {name: "usage-details", raw}, emitted before the round's terminal, with raw holding only those entries as received. A mapped detail key whose value is not a number sets no counter and rides there too. Nothing from that event is added to usage.

import { Agent, run } from "@openai/agents";
import { createOpenaiNormalizer } from "@silverprotocol/openai-agents";

const agent = new Agent({ name: "Assistant", instructions: "Use the echo tool." });
const n = createOpenaiNormalizer();
const agEvents = [];

// `run(..., { stream: true })` is the OpenAI Agents SDK's own streamed run —
// you keep using your framework as-is; this package normalizes what it emits.
const stream = await run(agent, "call the echo tool", { stream: true });
for await (const native of stream) agEvents.push(...n.push(native));
await stream.completed;              // let the run finish
agEvents.push(...n.flush());         // seal anything still open

push() returns the AgEvent[] synthesized from each native stream event (RunItemStreamEvents or raw Responses-API events — both handled); flush() drains buffered end-of-stream state. Any async/sync iterable of those native events works — a live streamed run, or events you captured earlier.

Then fold the resulting AgEvents into messages and turns with @silverprotocol/core's reduce() — the same client code regardless of which framework produced the stream.

Spec: silverprotocol.io/AgJSON — canonical in silverprotocol/AgJSON; wire version 1.0.0-draft.8.

MCP tool results: structuredContent, _meta, uiData

The OpenAI Agents SDK does not put an MCP result's structuredContent or _meta in the tool output (by default the model sees only the content text). To keep them, opt in on each MCPServer with customDataExtractor (@openai/agents ≥ 0.12). The SDK delivers its return value on the tool output item as customData, and the normalizer reads it there:

new MCPServerStreamableHttp({
  url,
  customDataExtractor: (ctx) =>
    ctx.structuredContent !== undefined || ctx.resultMeta !== undefined
      ? {
          ...(ctx.structuredContent !== undefined ? { structuredContent: ctx.structuredContent } : {}),
          ...(ctx.resultMeta !== undefined ? { _meta: ctx.resultMeta } : {}),
        }
      : undefined,
});
  • customData.structuredContent becomes tool.done.structuredContent.
  • customData._meta becomes the tool-result block's _meta, verbatim.
  • When _meta.ui is present (MCP Apps), the same payload also lands on uiData, the model-hidden surface channel. structuredContent is left as it is.

Without _meta the output is unchanged.

Ids across invokes

Build one normalizer per invoke. The facet uses ids from the wire where it has them (turn_<response id>). Where it has to mint its own, it uses a per-invoke stem, so ids stay unique when a host folds every invoke of a conversation into one reducer:

  • a response with no response.created, or a host error with no turn open, gets a fallback turn turn_<stem>_<n>;
  • a handoff gets a subagent turn turn_<stem>_handoff_<n>.

A resumed invoke can stream a handoff before any turn of the invoke has opened. The facet then opens the resumed invoke's own turn, turn_resume_<callId> (an id from the wire), at that handoff, and the handoff's subagent turn is parented to it, so the subagent turn carries the host's thread.

By default the stem is a random openai_<16 hex> drawn once per normalizer. Pass createOpenaiNormalizer({ invokeId }) to make the output deterministic (replay, tests). An invokeId you pass must be unique per invoke within a fold.

Thread id

Pass the host's own thread id as createOpenaiNormalizer({ threadId }). The normalizer stamps it as the threadId of every turn and message it opens, handoff turns included, and never places it in a turn id: it is the partition root a key-value store writes each unit under (spec §1.2). The OpenAI stream carries no host thread id (conversationId and previousResponseId are run options the host holds), so with no threadId option the facet stamps the fixed label "openai" as a facet-local placeholder, not a partition root; a host that persists by threadId supplies its own.

Handoffs

An Agents SDK handoff is a transfer: the source agent does not resume, and the target agent's model rounds are ordinary top-level turns. The facet brackets the transfer itself, from handoff_requested to handoff_occurred, as a nested turn under the source round. At handoff_occurred it emits:

  • the nested turn's turn.done (success, finishReason: "unknown", no usage), then its subagent.done;
  • handoff (kind: "transfer", toAgentName);
  • tool.done for the transfer call (transfer_to_<agent>), whose result the SDK delivers only on handoff_occurred. This closes the source round with its own turn.done.

A transfer whose call gets an ordinary tool result instead of handoff_occurred never ran (the SDK ignored it, up to 0.8.0; see below). Its nested turn closes at that result with turn.abort (no reason), then subagent.done, and the result lands as the call's tool.done. Brackets are matched to their transfer by call id.

When the model requests several handoffs in one response, the SDK runs only the first. What happens to the others depends on the @openai/agents version:

  • From 0.8.1, nothing about them reaches the stream. The SDK drops them from the run's history; only when the conversation is server-managed (conversationId or previousResponseId) does it send the model a synthetic result for each. At handoff_occurred, each call of the source round that still has no result, is a function call, and has had no run-item naming it stops being pending: it gets no tool.done and is carried as ext.openai.dropped-call (reason: "no-run-item-at-handoff"), and the source round closes with its own turn.done in the same batch. A call the SDK acknowledged (a tool_called, an approval request, or any run-item naming it) stays pending, and the round closes when its result lands. A result that still arrives for a released call is carried the same way (reason: "result-after-release"), never as a tool.done.
  • Up to 0.8.0, the SDK streams a result for each ignored call ("Multiple handoffs detected, ignoring this one."), which lands as that call's tool.done, and the source round closes normally. The ignored handoff's nested turn, opened at its handoff_requested, closes at that result with turn.abort, as above.

The source round is still closed at flush() when:

  • a handoff input filter removes handoff_occurred or the round's tool results from the stream (for example removeAllTools): turn.abort (stream-truncated);
  • an approval is pending beside the transfer (reachable on 0.8.x): turn.done (paused), naming that approval;
  • a program, hosted-shell or tool-search call of the round is still pending.

A call replayed on a resumed invoke gets no tool.start from the facet, so it is never released this way.