hashbrown

OpenAI

Follow one tool-free request as streamed props become a trusted two-step plan preview.

Recorded in the real React workbench with OpenAI and the actual Hashbrown SDK. The standalone Quickstart and workbench share PlanCard and its registration. No browser tools, acceptance action or application writes. Unchanged provider events are paced at 20 ms for filming; the normal host adds no delay. Footage slowed to half speed for readability. Replay makes no requests. No narration. Read the text walkthrough.

Connect your application's chat endpoint to OpenAI's Responses API. The browser sends conversation history and feature contracts as AG-UI input. Our server chooses the model, holds the key, and maps the provider's stream back to AG-UI events. This lets the same React screen use text, tools, and structured output through one endpoint.

The teaser follows the real OpenAI-backed client flow. The server integration below is the reusable part; the inspector and timeline belong to the workbench.

Keep provider access on your server

Scroll horizontally to see the full diagram, or expand it.

BROWSERAPPLICATION SERVERPROVIDERmessagesrequestresponseeventsYour clientMessages and UIYour endpointAccess and credentialsModel providerModel inference
Your client owns rendering. Your endpoint checks access and calls the provider with server credentials.
Read the diagram

The browser sends messages to an application endpoint. The endpoint checks access and calls the provider. The provider response returns through the endpoint as events for the client to render. Credentials stay on the server. Solid arrows show requests; dashed arrows show returned data.

Keep provider access on your server

Your client owns rendering. Your endpoint checks access and calls the provider with server credentials.

Read the diagram

The browser sends messages to an application endpoint. The endpoint checks access and calls the provider. The provider response returns through the endpoint as events for the client to render. Credentials stay on the server. Solid arrows show requests; dashed arrows show returned data.

Install the server adapter

Install the OpenAI adapter, the official OpenAI SDK, and the AG-UI SSE encoder:

npm install @hashbrownai/openai openai @ag-ui/core @ag-ui/encoder

Streaming Text Responses

HashbrownOpenAI.stream.text(options) accepts an AG-UI RunAgentInput and returns an AsyncIterable<AGUIEvent>. The adapter uses OpenAI's streaming Responses API. Encode its events as AG-UI SSE at your HTTP boundary.

The model is server configuration and is not read from the client run input.

API Reference

NameTypeDescription
apiKeystringOpenAI API key.
baseURLstring(Optional) Base URL of a server that implements the Responses API.
modelstringServer-selected OpenAI model.
inputOpenAIHashbrownRunAgentInputAG-UI run input, including messages and tools.
signalAbortSignal(Optional) Cancels the OpenAI request when the HTTP client disconnects.
activitiesActivityCallback(Optional) Sends activities alongside the model's output. See Sending Activities.
reasoningfalse | { effort?, summary? }(Optional) Controls the reasoning request parameter. See Reasoning.
builtInToolsOpenAIBuiltInTools(Optional) Turns on provider-run tools. See Built-in Tools.
transformRequestOptions(params) => params | Promise<params>(Optional) Transforms the final streaming Responses request (ResponseCreateParamsStreaming).

The adapter maps system and developer instructions, message history, tool definitions, and tool results. Text and tool calls become canonical AG-UI events. Provider and mapping failures are emitted as RUN_ERROR events.

Responses API

The adapter streams from OpenAI's Responses API. Every request sends store: false, so OpenAI does not keep conversation state for you. Hashbrown replays the earlier turns, including earlier reasoning, from the history your client sends, which also works with zero-data-retention organizations.

baseURL must point at a server that implements the Responses API. Servers that only implement Chat Completions (for example vLLM, OpenRouter, or LM Studio, depending on the server and mode) are no longer supported by @hashbrownai/openai.

Reasoning

Reasoning models, which are models whose names start with o and a digit (o3), gpt-5, or gpt- followed by a major version of 5 or later, stream reasoning summaries by default. -chat variants such as gpt-5-chat-latest are not reasoning models. Hashbrown sends reasoning: { summary: 'auto' } and include: ['reasoning.encrypted_content'] for them, and leaves effort to the model. Other models get no reasoning field.

Use the reasoning option to change this:

const stream = HashbrownOpenAI.stream.text({
  apiKey: process.env.OPENAI_API_KEY!,
  model: 'gpt-5',
  input,
  reasoning: { effort: 'low', summary: 'detailed' },
});
  • reasoning: { effort, summary } is sent as given. A missing summary defaults to 'auto'. effort is sent only when you set it.
  • reasoning: false stops Hashbrown from requesting reasoning summaries. It does not turn reasoning off: a reasoning model may still return encrypted reasoning, which Hashbrown still replays on the next turn.

The client receives reasoning as AG-UI REASONING_MESSAGE_START, REASONING_MESSAGE_CONTENT, and REASONING_MESSAGE_END events for the summary text, and REASONING_ENCRYPTED_VALUE events for the encrypted content. On the Hashbrown side, assistant messages expose them as reasoning and reasoningDetails.

Structured Output

Set input.forwardedProps.hashbrown.responseSchema to use OpenAI native JSON schema output:

const input = {
  ...runInput,
  forwardedProps: {
    hashbrown: {
      responseSchema: {
        type: 'object',
        properties: {
          answer: { type: 'string' },
        },
        required: ['answer'],
        additionalProperties: false,
      },
    },
  },
};

Node.js Server Integration

One browser contract, a provider-specific server

Scroll horizontally to see the full diagram, or expand it.

HashbrownbrowserApplicationrouteProvideradapterNativemodel API1 POST AG-UIRunAgentInput2 Input + servermodel, credentials,signal3 Native SDKrequest4 Provider stream5 Canonical AG-UIevents6 AG-UI SSE
The route owns access policy, credentials and model selection. The adapter translates the selected provider’s native stream.
Read the diagram

The browser posts AG-UI run input to an application route. The route passes input and server configuration to a provider adapter. The adapter calls the native model API, maps its stream to AG-UI events, and the route encodes those events as SSE for the browser.

One browser contract, a provider-specific server

The route owns access policy, credentials and model selection. The adapter translates the selected provider’s native stream.

Read the diagram

The browser posts AG-UI run input to an application route. The route passes input and server configuration to a provider adapter. The adapter calls the native model API, maps its stream to AG-UI events, and the route encodes those events as SSE for the browser.

The examples below host /run in an ordinary application. Configure your React client to call that route, using the Quickstart for the framework setup. They show provider mapping, SSE encoding, and disconnect cancellation. Add your application's validation, identity, authorization, and request limits before exposing the route; see Securing a public endpoint. A TypeScript assertion on the request body does not validate it.

import type { RunAgentInput } from '@ag-ui/core';
import { EventEncoder } from '@ag-ui/encoder';
import { HashbrownOpenAI } from '@hashbrownai/openai';
import express from 'express';

const app = express();
app.use(express.json());

app.post('/run', async (req, res) => {
  const abortController = new AbortController();
  req.once('aborted', () => abortController.abort());
  res.once('close', () => abortController.abort());
  const stream = HashbrownOpenAI.stream.text({
    apiKey: process.env.OPENAI_API_KEY!,
    model: process.env.OPENAI_MODEL ?? 'gpt-5-nano',
    input: req.body as RunAgentInput,
    signal: abortController.signal,
  });
  const encoder = new EventEncoder();

  res.header('Cache-Control', 'no-cache, no-store, must-revalidate');
  res.header('Content-Type', encoder.getContentType());
  res.header('Connection', 'keep-alive');
  res.flushHeaders();

  for await (const event of stream) {
    res.write(encoder.encodeSSE(event));
  }

  if (!res.writableEnded) {
    res.end();
  }
});

app.listen(3000);
import type { RunAgentInput } from '@ag-ui/core';
import { EventEncoder } from '@ag-ui/encoder';
import { HashbrownOpenAI } from '@hashbrownai/openai';
import Fastify from 'fastify';

const fastify = Fastify();

fastify.post('/run', async (request, reply) => {
  const abortController = new AbortController();
  request.raw.once('aborted', () => abortController.abort());
  reply.raw.once('close', () => abortController.abort());
  const stream = HashbrownOpenAI.stream.text({
    apiKey: process.env.OPENAI_API_KEY!,
    model: process.env.OPENAI_MODEL ?? 'gpt-5-nano',
    input: request.body as RunAgentInput,
    signal: abortController.signal,
  });
  const encoder = new EventEncoder();

  reply.header('Cache-Control', 'no-cache, no-store, must-revalidate');
  reply.header('Content-Type', encoder.getContentType());
  reply.header('Connection', 'keep-alive');

  for await (const event of stream) {
    reply.raw.write(encoder.encodeSSE(event));
  }

  if (!reply.raw.writableEnded) {
    reply.raw.end();
  }
});

fastify.listen({ port: 3000 });
import type { RunAgentInput } from '@ag-ui/core';
import { EventEncoder } from '@ag-ui/encoder';
import { Body, Controller, Post, Req, Res } from '@nestjs/common';
import { HashbrownOpenAI } from '@hashbrownai/openai';
import type { Request, Response } from 'express';

@Controller()
export class RunController {
  @Post('run')
  async run(
    @Body() input: RunAgentInput,
    @Req() req: Request,
    @Res() res: Response,
  ) {
    const abortController = new AbortController();
    req.once('aborted', () => abortController.abort());
    res.once('close', () => abortController.abort());
    const stream = HashbrownOpenAI.stream.text({
      apiKey: process.env.OPENAI_API_KEY!,
      model: process.env.OPENAI_MODEL ?? 'gpt-5-nano',
      input,
      signal: abortController.signal,
    });
    const encoder = new EventEncoder();

    res.header('Cache-Control', 'no-cache, no-store, must-revalidate');
    res.header('Content-Type', encoder.getContentType());
    res.header('Connection', 'keep-alive');
    res.flushHeaders();

    for await (const event of stream) {
      res.write(encoder.encodeSSE(event));
    }

    if (!res.writableEnded) {
      res.end();
    }
  }
}
import type { RunAgentInput } from '@ag-ui/core';
import { EventEncoder } from '@ag-ui/encoder';
import { HashbrownOpenAI } from '@hashbrownai/openai';
import { Hono } from 'hono';

const app = new Hono();

app.post('/run', async (c) => {
  const input = (await c.req.json()) as RunAgentInput;
  const stream = HashbrownOpenAI.stream.text({
    apiKey: process.env.OPENAI_API_KEY!,
    model: process.env.OPENAI_MODEL ?? 'gpt-5-nano',
    input,
    signal: c.req.raw.signal,
  });
  const encoder = new EventEncoder();
  const textEncoder = new TextEncoder();

  return new Response(
    new ReadableStream({
      async start(controller) {
        for await (const event of stream) {
          controller.enqueue(textEncoder.encode(encoder.encodeSSE(event)));
        }
        controller.close();
      },
    }),
    {
      headers: {
        'Cache-Control': 'no-cache, no-store, must-revalidate',
        'Content-Type': encoder.getContentType(),
      },
    },
  );
});

export default app;

Built-in Tools

Built-in tools run on the model provider instead of in your app or on your server. Turn them on with builtInTools. The keys are the camelCase form of the builtin value a call reports, so a key means the same capability on every Hashbrown provider adapter. The available keys are webSearch and codeExecution.

const stream = HashbrownOpenAI.stream.text({
  apiKey: process.env.OPENAI_API_KEY!,
  model: 'gpt-4.1-mini',
  input,
  signal,
  builtInTools: { webSearch: true },
});

Pass true for the provider defaults, or the tool's own configuration (for example search_context_size, user_location or filters), which is sent unchanged: builtInTools: { webSearch: { search_context_size: 'low' } }. Web search needs a model that supports the Responses API web_search tool.

On the client, each search arrives as an entry in the assistant message's serverToolCalls, with kind: 'provider' and builtin: 'web_search'. Hashbrown never runs it, and a client tool with the same name is never called in its place. The entry carries a ready-to-show label (for example Searched 'hashbrown ai' · 3 sources), the queries it ran, the sources it found, and its outcome. A failed search settles as complete with outcome: 'failure' and an error. See Server-Executed Tool Calls for every field.

The answer's url_citation annotations become citations on the assistant message. Each citation's sourceId is the id of a source, and quote holds the cited text. A citation's source can be on the call or on the message: in the sources of one of the message's serverToolCalls, or, when no search returned it, in the message's own sources. Look in both.

export function Answer({ message }: { message: UiAssistantMessage<never> }) {
  const sources = [
    ...(message.serverToolCalls ?? []).flatMap((call) => call.sources ?? []),
    ...(message.sources ?? []),
  ];

  return (
    <>
      {message.serverToolCalls?.map((call) => (
        <p key={call.toolCallId}>{call.label}</p>
      ))}
      {message.ui}
      <ul>
        {message.citations?.map((citation, index) => {
          const source = sources.find((s) => s.id === citation.sourceId);

          return source?.href ? (
            <li key={`${citation.sourceId}-${citation.start ?? index}`}>
              <a href={source.href}>{source.title}</a>
            </li>
          ) : null;
        })}
      </ul>
    </>
  );
}

OpenAI requires inline citations to be clearly visible and clickable wherever your UI shows the answer. Source links may carry ?utm_source=openai; keep it.

Web search source lists need the builtInTools.webSearch option, because it also requests web_search_call.action.sources. A web_search tool you add through transformRequestOptions instead still streams as a provider tool call, with a label and queries, but without sources.

Code Execution

Enable the provider's Code Interpreter with builtInTools.codeExecution. Pass true for { type: 'code_interpreter', container: { type: 'auto' } }. Hashbrown also requests code_interpreter_call.outputs, alongside any reasoning or web search includes.

const stream = HashbrownOpenAI.stream.text({
  apiKey: process.env.OPENAI_API_KEY!,
  model: 'gpt-5',
  input,
  signal,
  builtInTools: { codeExecution: true },
});

For native configuration, pass the tool's options without type. The adapter clones the configuration before building the request. For example, an automatic container can receive provider file IDs and a memory limit:

import type { OpenAIBuiltInTools } from '@hashbrownai/openai';

const builtInTools: OpenAIBuiltInTools = {
  codeExecution: {
    container: {
      type: 'auto',
      file_ids: ['file-input'],
      memory_limit: '4g',
    },
  },
};

To use an existing provider container, pass its ID instead:

const builtInTools: OpenAIBuiltInTools = {
  codeExecution: { container: 'cntr-existing' },
};

The model must support Code Interpreter; unsupported requests fail through the normal provider error path. You can combine code execution with webSearch, application tools, reasoning and structured output.

Each execution appears in the assistant message's serverToolCalls with name: 'code_interpreter', kind: 'provider', builtin: 'code_execution', an icon of run and a ready-to-show label. A registered app tool with the same name remains separate. While code arguments stream, the call has status: 'inProgress'; once arguments finish, it has status: 'executing' until a native execution result arrives. Arguments ending does not mean execution succeeded.

A completed native execution settles as status: 'complete' with outcome: 'success'. Native failed or incomplete executions settle with outcome: 'failure', a readable error and a rejected public result. Successful result.value contains the serialized native result, including code, container_id, status and outputs (logs or image values). Native code: null means the provider did not supply code. Image outputs with a real URL can also appear in the call's sources.

Generated-file annotations belong to the assistant answer. Find a citation's source by matching citation.sourceId to source.id across the message's own sources and its calls' sources. A file source has kind: 'file', fileId, a title and, when supplied, containerId. Hashbrown does not invent a download URL or attach an answer file to a guessed execution. Character positions and quotes are optional: invalid spans, ambiguous Unicode offsets and noncontiguous text parts retain their source references without fabricated positions.

Your server owns file retrieval and storage. Use the native file/container IDs with the provider's file APIs, applying your app's access checks, and provide your own download route if needed. Containers and files remain provider-owned and ephemeral.

Every request uses store: false. Hashbrown omits provider execution display calls and their synthetic results from later requests, while preserving ordinary application calls and results. The earlier native execution context is therefore absent: automatic container reuse across turns is not promised. When your application needs retained container state, keep the returned container ID and supply it explicitly through codeExecution.container on the next run.

Sending Activities

Pass activities to send AG-UI activities alongside the model's output. Activities are display-only progress, such as a plan or a progress report, and the model never sees them. See Activities for how the client renders them.

import { HashbrownOpenAI } from '@hashbrownai/openai';

const stream = HashbrownOpenAI.stream.text({
  apiKey: process.env.OPENAI_API_KEY!,
  model: 'gpt-5-nano',
  input,
  signal,
  activities: async ({ activity, model }) => {
    const plan = activity.snapshot(`plan-${input.runId}`, 'plan', {
      steps: ['Read the question', 'Answer'],
      done: 0,
    });

    await model;

    plan.patch([{ op: 'replace', path: '/done', value: 2 }]);
  },
});

The callback receives an activity writer, a model promise, and the run's signal:

  • activity.snapshot(id, activityType, content) sends an ACTIVITY_SNAPSHOT and returns a handle. The handle's patch(operations) sends an ACTIVITY_DELTA with RFC 6902 JSON Patch operations, and replace(content) sends a new snapshot.
  • Activity ids must be unique within the thread. Reusing an id from an earlier turn updates that turn's activity in place, so the example builds the id from input.runId to give each run its own plan.
  • model resolves when the model stream ends, before the run finishes, and rejects if the model call fails.
  • Activity events and model events are sent in the order they happen. The callback starts after RUN_STARTED, and the terminal event waits until the callback settles. Model text streams as it arrives and is never held back.
  • If the callback throws while the model is streaming, Hashbrown aborts the model and ends the run with one RUN_ERROR. If it throws after the model has finished, RUN_ERROR replaces RUN_FINISHED. If the model fails too, the model's error wins.
  • When the client disconnects, signal aborts. No terminal event is sent, and writes become no-ops.
  • A write after the run has closed throws, as does a second snapshot that reuses an id with a different activityType.

Transform Request Options

transformRequestOptions receives the final Responses API request (ResponseCreateParamsStreaming) after AG-UI input has been mapped. Use it for server-owned provider settings.

const stream = HashbrownOpenAI.stream.text({
  apiKey: process.env.OPENAI_API_KEY!,
  model: process.env.OPENAI_MODEL ?? 'gpt-5-nano',
  input,
  transformRequestOptions: (options) => ({
    ...options,
    max_output_tokens: 2048,
  }),
});

Learn more about transformRequestOptions

Explore it in the workbench

Open the workbench below and ask for a two-step plan. In Requests, compare the AG-UI input with the streamed response; in UI, follow the resolved props into the trusted card. State stays unchanged because this lesson previews a plan without accepting it.

The workbench's server route adds bounded lesson policy around an OpenAI adapter. Compare that route with the OpenAI route above: the client contract stays AG-UI, while authentication, native request options, and capability support belong to the selected provider. This is the same provider path shown in the teaser.

OpenAIInstall the server adapterStreaming Text ResponsesAPI ReferenceResponses APIReasoningStructured OutputNode.js Server IntegrationBuilt-in ToolsCode ExecutionSending ActivitiesTransform Request OptionsExplore it in the workbench