OneLinersCommand workbench
Guides
AI/ML / Software Engineering / DevOps & CI/CD / Security

Build a production chatbot with the OpenAI Responses API

Create a durable Node.js chatbot with OpenAI Conversations, typed response streaming, safe cancellation, transcript recovery, duplicate protection, explicit retention and deletion, real failure drills, and a production release gate.

150 min10 stepsChanges system stateRevision 1
Save or explore
Save to collectionCreate a collection in the sidebar first.
0 of 10 steps completed
Goal

Deliver a reference chatbot that remains understandable under refreshes, interrupted streams, duplicate submits, provider errors, database failures, model changes, and deletion requests without exposing secrets or pretending an ambiguous network outcome is safe to replay.

Supported environments
  • Node.js 22.22.2
  • OpenAI Node SDK 7.1.0
  • PostgreSQL 17.x, 18.x
  • OpenAI Responses API 2026-07-29
Prerequisites
  • OpenAI API project Use a dedicated project with an active server-side API key, access to the reviewed model, a deliberately low test budget, and visible request logs. ChatGPT subscription access is not an API credential.Confirm project, model access, budget, rate limits, and key rotation owner without printing the key.
  • Supported runtime Install Node.js 22.22.2 or a reviewed compatible Node 22 patch and npm. Do not mix browser-side provider calls into the server architecture.node --version && npm --version
  • Durable database Provide PostgreSQL 17 or 18 with separate migration and runtime roles, encrypted connections where required, a current backup, and a tested restore path.pg_isready -d "$DATABASE_URL"
  • Canonical origin and TLS plan Reserve the exact browser origin. Production requires HTTPS and a proxy or platform that does not buffer text/event-stream responses.Record the development and production origins and verify that neither includes credentials or an unexpected path.
  • Privacy and retention owner Approve which content may enter the provider, PostgreSQL, logs, analytics, and backups, plus the retention, export, deletion, and exception process for every store.Review the signed data-flow and deletion checklist before creating a real conversation.
Operating boundary

OneLiners never runs these steps or stores secrets. Review placeholders, versions, current state, and change-control requirements before using a command.

Full guide

What you will build

System
  • A runnable Node.js chatbot whose browser receives incremental text from the OpenAI Responses API while the API key, model selection, identity, rate limits, and persistence stay behind a server boundary.
  • A two-store continuity design: PostgreSQL owns authorized application sessions, idempotency, transcript recovery, and operational status, while one OpenAI Conversation owns the model-visible sequence of response items.
  • A user-visible cancellation and recovery flow that never assumes a broken connection means the provider did nothing. The browser reloads the durable application state and requires explicit intent before another turn.
  • A release package containing a real provider smoke test, deletion verification, failure drills, privacy and retention decisions, structured logs, a shared-rate-limit requirement for scale, and schema-aware rollback evidence.
Observable outcome
  • A browser reload restores completed and interrupted turns for the same signed pseudonymous user, but changing the signed identity or guessing a session UUID does not disclose another transcript.
  • One browser attempt creates at most one application request row. A repeated idempotency key returns 409, while an ambiguous cancellation is recorded and never replayed automatically.
  • The stream exposes ready, response, delta, completed, and sanitized failed events; the browser renders text without HTML interpretation and offers a keyboard-accessible Stop control.
  • Deleting a session first removes its OpenAI Conversation and then deletes the local session and transcript through a cascading foreign key, leaving a visible retry path when upstream deletion fails.
  • Operators can correlate a problem with request, session-hash, response, status, latency, and token identifiers without storing raw prompts, cookies, API keys, or account identifiers in ordinary logs.

Architecture

How the parts fit together

The browser talks only to the application origin. A signed HTTP-only cookie provides pseudonymous continuity for this public reference; products with protected data replace it with verified authentication. The application derives a stable HMAC user hash, validates Origin and payload size, enforces a per-process development rate limit, and authorizes every session query by both session ID and user hash. PostgreSQL maps each application session to one OpenAI Conversation and records one request row before generation. The server calls Responses with the reviewed model role, explicit reasoning effort, conversation ID, safety identifier, output bound, streaming enabled, and storage enabled. It reduces typed provider events to a small SSE contract, assembles text for the transcript mirror, and records completed, cancelled, or failed state. The browser stores only the application session UUID, renders content with textContent, cancels with AbortController, and reloads authoritative history after uncertainty. Release adds shared rate limiting, real authentication where needed, encrypted backups, retention jobs, monitoring, evaluation, canary deployment, and a schema-compatible rollback.

Browser clientCreates or restores an application session, sends one idempotent attempt, parses reduced SSE events, renders text safely, exposes Stop, and reloads authoritative state.
Application boundaryKeeps the OpenAI key server-side, validates input and Origin, derives pseudonymous identity, authorizes sessions, limits traffic, and sanitizes errors.
PostgreSQL state storePersists application ownership, Conversation mapping, transcript recovery, idempotency, response identifiers, and explicit completed/cancelled/failed status.
OpenAI Responses and ConversationsGenerate typed streaming events and retain model-visible multi-turn items inside one durable Conversation per authorized application session.
Operational controlsOwn shared rate limits, budgets, evaluation, privacy and retention policy, backups and restore evidence, deletion retries, observability, canary expansion, and rollback.
  1. The browser loads a stored application session UUID or asks the server to create a new session.
  2. The server creates an OpenAI Conversation, stores only its mapping to the HMAC user hash, and returns the application UUID under a signed cookie identity.
  3. For one prompt, the browser creates one idempotency key and opens a streaming POST.
  4. The server inserts a streaming request row, calls Responses with the Conversation ID, and forwards only safe typed events.
  5. Text deltas update the visible assistant bubble while the server assembles the authoritative final or partial text.
  6. Completion, cancellation, or failure updates the request status and identifiers. The browser reloads history after any uncertainty instead of automatically submitting another prompt.
  7. Explicit deletion removes provider state first and local state second; scheduled retention and backup policy cover copies outside the primary tables.

Assumptions

  • Node.js 22.22.2, OpenAI Node SDK 7.1.0, PostgreSQL 17 or 18, and the listed package versions are available in the development and deployment environments.
  • The OpenAI API project can use the reviewed GPT-5.6 Terra model role and has a deliberately bounded test budget. Model availability, pricing, and rate limits are checked in the current project rather than inferred from this tutorial.
  • Local development uses an exact HTTP localhost origin. Production terminates TLS at a trusted proxy that preserves streaming and supplies the exact canonical HTTPS origin.
  • The reference chatbot is public and pseudonymous. It contains no private knowledge, account actions, consequential tools, or regulated data; those features require verified authentication and separate authorization.
  • PostgreSQL has a current backup and tested restore procedure, and production separates schema-migration privileges from the runtime application role.
  • The deployment can run scheduled retention/deletion work and a shared rate-limit store before adding multiple application instances.

Key concepts

Responses API
OpenAI's unified response interface for text, multimodal input, state, and tool-using workflows. With stream enabled it emits typed lifecycle and content events rather than one final body.
Conversation
A durable OpenAI object containing messages, tool calls, tool outputs, and other items across responses. Its retention differs from the ordinary response 30-day default and therefore needs explicit product policy.
previous_response_id
An alternative way to chain Responses by referencing the preceding response. It is useful for a thread owned by the app but is not combined with a Conversation on the same call.
SSE
Server-Sent Events, a text/event-stream framing format used here over a streaming POST response. The browser receives named events and JSON data blocks incrementally.
Idempotency key
An application-generated identifier for one intended request. The database unique constraint turns a duplicate transport attempt into a conflict rather than another model turn.
Ambiguous outcome
A state where the client lost transport before it could prove whether upstream work completed. Recovery first reads authoritative state and never assumes automatic replay is safe.
Safety identifier
A stable privacy-preserving user reference sent to help identify abuse. This guide derives it with HMAC instead of sending a raw account or cookie identifier.

Before you copy

Values used in this guide

{{publicOrigin}}

Exact browser origin allowed to perform state-changing requests, including scheme and non-default port but no path.

Example: http://localhost:3000
{{databaseUrl}}secret

Protected PostgreSQL connection string for the dedicated application database and least-privilege runtime role.

Example: postgresql://chat_app:replace-me@127.0.0.1:5432/responses_chat
{{model}}

Reviewed model ID for the interactive chatbot role. Re-evaluate quality, latency, cost, and current availability before changing it.

Example: gpt-5.6-terra
{{appHmacSecret}}secret

Random application secret of at least 32 bytes used for signed cookies and HMAC user identifiers. Generate it in the deployment secret store.

Example: replace-with-at-least-32-random-bytes

Security and production boundaries

  • Never put OPENAI_API_KEY in browser code, a fillable Guide field, source control, an image, a support ticket, analytics, or ordinary logs. If exposure is possible, revoke and replace the key before diagnosis continues.
  • Pseudonymous continuity is not authentication. Before using private documents, saved account history, billing data, or external tools, require a verified user identity and authorize every resource and action separately.
  • The application validates exact Origin for writes and uses a signed SameSite cookie, but deployment must still use HTTPS, trusted proxy settings, secure headers, request-size limits, and a reviewed CSRF posture.
  • Streaming content is shown before a complete-output moderation result exists. Choose an explicit product policy: accept this property, buffer output, or add another reviewed control; never label partial text as already moderated.
  • Conversation and local transcript deletion must cover primary records, provider state, backups, logs, analytics, and legal retention exceptions. A successful local DELETE alone is not a complete deletion claim.
  • Use a dedicated OpenAI API project and safety HMAC identifier, enforce per-user and project budgets, monitor abuse and errors, and keep consequential tools out of this baseline chatbot.

Stop before continuing if

  • The product cannot state which identity owns a session or prove that every read, write, stream, and delete checks that identity.
  • A real OpenAI key, database password, raw account ID, cookie, or user prompt appears in tracked source, browser bundles, logs, analytics, screenshots, or support output.
  • The app automatically retries an interrupted streamed turn without proving the previous attempt created no provider or database state.
  • Provider Conversation retention, application retention, backups, and deletion are undocumented or conflict with the user-facing privacy promise.
  • Production uses the in-memory rate limiter across multiple instances, exposes the development listener directly, buffers SSE, or lacks a tested database restore.
  • The chosen model, prompt, output bound, and failure behavior have not passed representative quality, safety, latency, and cost evaluation.
01

decision

Choose the conversation and retention contract

read-only

Decide which system owns identity, the browser transcript, model context, deletion, and recovery before writing an endpoint. This reference uses an application session in PostgreSQL for ownership and UI history, and a durable OpenAI Conversation for Responses API context.

Why this step matters

A chatbot can appear stateful while its application, browser, and model each remember different facts. Making the ownership contract explicit prevents cross-user access, invisible retention, duplicated turns, and a false promise that refreshing the page or deleting one database row removes every copy.

What to understand

The application session ID is not an authorization token. Every query also checks a server-derived user hash from a signed, HTTP-only cookie. An authenticated product should replace the pseudonymous cookie with its verified account identity.

OpenAI Conversations persist messages, tool calls, and other response items across turns. The local transcript mirror exists for UI recovery and operational evidence; it must never be replayed into the API while the same Conversation is also active, or context may be duplicated.

Responses are stored by default, while Conversation objects and their items are not governed by the ordinary response 30-day TTL. Product retention and deletion therefore need deliberate behavior rather than an assumption that state disappears automatically.

System changes

  • No infrastructure changes occur yet; the result is a reviewed data-flow and retention decision that constrains every following step.

Syntax explained

conversation
A durable OpenAI object whose ID is passed to each Response so model-visible items persist across requests and devices.
previous_response_id
A lighter response chain suitable when the app owns the thread and can preserve one prior response identifier; do not combine it with conversation on the same request.
store: false
Disables ordinary response storage, but then the app must replay complete output items, including encrypted reasoning items, when it owns stateless continuity.
Example output / evidence
State contract approved: signed pseudonymous browser identity; PostgreSQL session and transcript mirror; OpenAI Conversation for model context; explicit delete removes both stores; no automatic replay after an ambiguous interruption.

Checkpoint: Approve state ownership

Continue whenThe design names the owner and deletion path for identity, application records, OpenAI Conversation items, browser state, logs, backups, and analytics.

Stop whenStop if the product cannot explain where conversation data is stored, how long it remains, who may retrieve it, or how a deletion request reaches every relevant store.

If this step fails

A refreshed browser shows an empty transcript although the model remembers prior turns.

Likely causeOnly OpenAI conversation context was persisted; the user-facing transcript has no application-owned recovery source.

Safe checks
  • Inspect the session mapping
  • List local chat_requests rows without printing their content

ResolutionAdd an authorized transcript mirror or explicitly document that history is not recoverable; never reconstruct it from guesses.

Security notes

  • Treat conversation IDs, response IDs, and application session IDs as sensitive references even though they are not API keys.
  • Do not send raw email addresses or account IDs as safety identifiers; derive a stable HMAC value instead.

Alternatives

  • Use application-owned stateless replay with store disabled when policy requires the app to own all retained context and the complete item stream can be preserved correctly.

Stop conditions

  • Stop if the product cannot explain where conversation data is stored, how long it remains, who may retrieve it, or how a deletion request reaches every relevant store.
02

config

Create a pinned Node.js project

caution

Create a small server-rendered reference application and pin the exact dependency versions used by this guide. The OpenAI key remains a server-side environment value and is never included in browser JavaScript.

Why this step matters

Pinned dependencies make the tutorial repeatable and make later security review meaningful. A production chatbot also needs a real HTTP boundary, input validation, durable storage, structured logging, rate limiting, secure headers, and a supported OpenAI SDK rather than a browser call containing a secret.

What to understand

Node.js 22 supplies modern Web Streams, AbortController, crypto.randomUUID, and the built-in test runner used by the reference workflow.

The OpenAI SDK owns request serialization, typed stream events, bounded retries, and timeouts. Express exposes the app boundary; Zod rejects malformed values before they reach storage or the API.

Pinned versions are a tested baseline, not a permanent freeze. Update them through a dependency review, rerun the smoke and evaluation suites, and record the new verified date.

System changes

  • Creates the application directories, package manifest, dependency lockfile, and local node_modules tree.

Syntax explained

private: true
Prevents accidental publication of the reference application to the npm registry.
--env-file=.env
Loads local runtime values without adding a dotenv dependency; the real .env file must remain ignored and protected.
exact dependency versions
Keeps installation behavior reviewable until an intentional upgrade refreshes the lockfile and evidence.
File package.json
Configuration
{
  "name": "responses-chatbot",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "node --watch --env-file=.env src/server.mjs",
    "start": "node --env-file=.env src/server.mjs",
    "smoke": "node --env-file=.env test/smoke.mjs"
  },
  "dependencies": {
    "cookie-parser": "1.4.7",
    "express": "5.2.1",
    "express-rate-limit": "8.6.1",
    "helmet": "8.3.0",
    "openai": "7.1.0",
    "pg": "8.22.0",
    "pino": "10.3.1",
    "zod": "4.4.3"
  }
}
Command
node -e "for (const path of ['src','public','test','ops']) require('node:fs').mkdirSync(path,{recursive:true})" && npm install
Example output / evidence
added 98 packages, and audited 99 packages in 4s
found 0 vulnerabilities

Checkpoint: Confirm the dependency baseline

node --version && npm ls --depth=0

Continue whenNode reports v22.22.2 or a reviewed compatible Node 22 patch, and every declared dependency is installed at the pinned version.

Stop whenStop when npm reports an unresolved dependency, install-script failure, unsupported Node version, or vulnerability that conflicts with the deployment policy.

If this step fails

The OpenAI import or response stream is undefined.

Likely causeThe installed SDK does not match the pinned manifest or an older CommonJS example was mixed into this ESM project.

Safe checks
  • node --version
  • npm ls openai
  • node -e "import('openai').then(m=>console.log(typeof m.default))"

ResolutionRestore the lockfile, reinstall exactly, and keep the package type and import style consistent.

Security notes

  • Review transitive dependencies and lockfile changes; do not run an unexplained install script in a privileged shell.

Alternatives

  • Use the official Python SDK and an equivalent ASGI stack when Python is the team's supported runtime; preserve the same state and streaming contracts.

Stop conditions

  • Stop when npm reports an unresolved dependency, install-script failure, unsupported Node version, or vulnerability that conflicts with the deployment policy.
03

config

Define secrets and public configuration

caution

Create a committed example file with safe placeholders, then copy it to an ignored .env file and supply the OpenAI API key through the deployment secret store. Keep the origin exact because it becomes the write-request CSRF boundary.

Why this step matters

The browser must never receive an OpenAI API key. Separating a safe example from the protected runtime values makes the secret boundary visible, gives deployment automation a complete variable inventory, and prevents a copied tutorial from silently accepting requests from an unexpected origin.

What to understand

PUBLIC_ORIGIN includes scheme, host, and non-default port, with no path. Production uses the final HTTPS origin; localhost is valid only for local development.

APP_HMAC_SECRET signs the pseudonymous browser cookie and derives the safety identifier. Generate at least 32 random bytes; changing it intentionally invalidates old cookies and changes user hashes.

OPENAI_MODEL is configurable so a reviewed model change does not require editing the server. This guide uses the balanced GPT-5.6 Terra tier with explicit none reasoning for an interactive latency baseline; evaluate quality and cost before changing the role.

System changes

  • Creates a local .env file containing deployment-specific values; it must be excluded from source control, support bundles, screenshots, and logs.

Syntax explained

OPENAI_API_KEY=
Deliberately blank in the example; inject the real value only into the server process from a protected secret store.
PUBLIC_ORIGIN
The only browser Origin accepted for state-changing API calls.
APP_HMAC_SECRET
A high-entropy application secret used for signed cookies and privacy-preserving stable identifiers.
File .env.example
Configuration
Fill variables0/4 ready

Values stay on this page and are never sent or saved.

NODE_ENV=development
PORT=3000
PUBLIC_ORIGIN={{publicOrigin}}
DATABASE_URL={{databaseUrl}}
OPENAI_MODEL={{model}}
OPENAI_API_KEY=
APP_HMAC_SECRET={{appHmacSecret}}
Command
node -e "require('node:fs').copyFileSync('.env.example','.env',require('node:fs').constants.COPYFILE_EXCL)"
Example output / evidence
Created .env from .env.example; OPENAI_API_KEY is empty and must be supplied from a protected secret source.

Checkpoint: Prove secrets are not tracked

git check-ignore .env && git grep -n "sk-" -- . ':!package-lock.json' || true

Continue when.env is ignored and no OpenAI-style secret prefix appears in tracked source.

Stop whenStop immediately if a real key appears in git status, repository history, logs, browser assets, analytics, or a copied command; revoke it before continuing.

If this step fails

Every write request returns origin_not_allowed.

Likely causePUBLIC_ORIGIN differs from the browser Origin by scheme, host, or port.

Safe checks
  • Inspect the browser Origin header
  • Print only PUBLIC_ORIGIN, never other environment values

ResolutionSet the exact canonical origin and restart the process; do not weaken the check to a suffix or substring match.

Security notes

  • Do not paste the API key into this guide's variable builder. Enter it only in the deployment platform's secret control or a protected local file.

Alternatives

  • Use workload identity or a managed secret injector where available, but keep the key server-side and out of build artifacts.

Stop conditions

  • Stop immediately if a real key appears in git status, repository history, logs, browser assets, analytics, or a copied command; revoke it before continuing.
04

config

Create the durable session and request schema

caution

Store only the ownership hash, OpenAI Conversation mapping, user-visible transcript, request status, and non-secret identifiers needed for recovery. A unique idempotency key prevents a double click from starting the same application request twice.

Why this step matters

Durability belongs in a database with ownership checks and constraints, not in a process-local map. The schema records enough state to restore the UI and distinguish completed, cancelled, and failed turns while avoiding raw account identity and API credentials.

What to understand

The unique session and idempotency-key pair makes duplicate submission deterministic. A conflict returns 409; it never silently starts another model turn.

ON DELETE CASCADE removes the local transcript with its session. The application first deletes the OpenAI Conversation and only then removes local state, so an upstream deletion failure remains visible and retryable.

The transcript mirror is application data and must follow its own encryption, access, backup, restore, retention, and deletion policy. Database backups can outlive primary rows unless retention handles them explicitly.

System changes

  • Creates PostgreSQL extension, tables, indexes, foreign keys, status constraints, and idempotency uniqueness in the configured database.

Syntax explained

-v ON_ERROR_STOP=1
Makes psql exit on the first migration error instead of continuing with a partially understood result.
UNIQUE (session_id, idempotency_key)
Turns accidental duplicate browser submission into a database-enforced conflict.
ON DELETE CASCADE
Removes request rows when their authorized application session is deleted.
File schema.sql
Configuration
BEGIN;

CREATE EXTENSION IF NOT EXISTS pgcrypto;

CREATE TABLE IF NOT EXISTS chat_sessions (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  user_hash text NOT NULL,
  openai_conversation_id text NOT NULL UNIQUE,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS chat_sessions_user_updated_idx
  ON chat_sessions (user_hash, updated_at DESC);

CREATE TABLE IF NOT EXISTS chat_requests (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  session_id uuid NOT NULL REFERENCES chat_sessions(id) ON DELETE CASCADE,
  idempotency_key text NOT NULL,
  user_content text NOT NULL,
  assistant_content text NOT NULL DEFAULT '',
  status text NOT NULL CHECK (status IN ('streaming', 'completed', 'cancelled', 'failed')),
  openai_response_id text,
  error_code text,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  UNIQUE (session_id, idempotency_key)
);

CREATE INDEX IF NOT EXISTS chat_requests_session_created_idx
  ON chat_requests (session_id, created_at);

COMMIT;
Command
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f schema.sql
Example output / evidence
BEGIN
CREATE EXTENSION
CREATE TABLE
CREATE INDEX
CREATE TABLE
CREATE INDEX
COMMIT

Checkpoint: Inspect constraints without reading message content

psql "$DATABASE_URL" -c "\d chat_sessions" -c "\d chat_requests"

Continue whenBoth tables exist, ownership and conversation identifiers are indexed, the session foreign key cascades, and the idempotency pair is unique.

Stop whenStop if the migration targets an unapproved database, runs without a current backup, leaves a partial transaction, or cannot prove ownership and deletion constraints.

If this step fails

A second submit creates another row instead of returning 409.

Likely causeThe unique constraint is absent, the key changes between duplicate attempts, or the insert bypasses the intended table.

Safe checks
  • \d chat_requests
  • SELECT session_id, idempotency_key, count(*) FROM chat_requests GROUP BY 1,2 HAVING count(*) > 1

ResolutionRestore the reviewed schema and make the browser keep one idempotency key for one attempted request.

Security notes

  • Use a least-privilege application database role limited to this schema; the migration role should not be the long-running runtime identity.

Alternatives

  • Use another transactional database with equivalent uniqueness, ownership, backup, and deletion guarantees; do not substitute browser localStorage for server authorization.

Stop conditions

  • Stop if the migration targets an unapproved database, runs without a current backup, leaves a partial transaction, or cannot prove ownership and deletion constraints.
05

config

Implement the server, durable context, and SSE bridge

caution

Create the server-side API. It issues a signed pseudonymous identity, maps an owned application session to one OpenAI Conversation, streams semantic Responses events to the browser, records final or interrupted state, and exposes explicit history and deletion operations.

Why this step matters

A production chatbot needs a controlled server boundary between an untrusted browser and the OpenAI API. This boundary validates size and identity, keeps credentials private, owns cancellation, translates typed upstream events, records non-ambiguous state, and prevents a session identifier from granting another user access.

What to understand

Each new application session creates one OpenAI Conversation. Passing its ID to Responses preserves model-visible messages and response items without manually replaying assistant text.

The SSE bridge forwards only the small event contract the browser needs: ready, response ID, text deltas, completion, and a sanitized failure. Raw OpenAI events may contain future fields or internal data that should not become a public client contract.

The request row is inserted before the model call. Completion stores the assembled assistant text; cancellation and failure store any partial text with an explicit status. The browser reloads this authoritative mirror instead of guessing whether a turn completed.

SDK retries are bounded. An idempotency key protects application insertion, but a request that may already have reached the model is not automatically replayed after an ambiguous transport failure.

System changes

  • Adds the HTTP server, database connection pool, OpenAI client, signed-cookie identity, origin validation, rate limit, stream adapter, transcript persistence, deletion route, and graceful shutdown.

Syntax explained

conversation
Associates each Response with the durable OpenAI Conversation mapped to the authorized application session.
stream: true
Returns semantic response events as they are produced instead of waiting for the complete output.
reasoning: { effort: "none" }
Makes the interactive latency baseline explicit for this reviewed model role rather than inheriting a changing default.
safety_identifier
Sends a stable HMAC identifier without exposing the raw browser or account identity.
max_output_tokens: 1200
Bounds one response; product-specific evaluation should set a lower or higher limit deliberately.
File src/server.mjs
Configuration
import crypto from "node:crypto";
import cookieParser from "cookie-parser";
import express from "express";
import { rateLimit } from "express-rate-limit";
import helmet from "helmet";
import OpenAI from "openai";
import pg from "pg";
import pino from "pino";
import { z } from "zod";

const { Pool } = pg;
const env = z.object({
  NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
  PORT: z.coerce.number().int().min(1).max(65535).default(3000),
  PUBLIC_ORIGIN: z.string().url(),
  DATABASE_URL: z.string().min(1),
  OPENAI_MODEL: z.string().min(1),
  OPENAI_API_KEY: z.string().min(20),
  APP_HMAC_SECRET: z.string().min(32),
}).parse(process.env);

const log = pino({
  level: env.NODE_ENV === "production" ? "info" : "debug",
  redact: ["req.headers.authorization", "req.headers.cookie", "prompt", "content"],
});
const db = new Pool({
  connectionString: env.DATABASE_URL,
  max: 10,
  idleTimeoutMillis: 30_000,
  connectionTimeoutMillis: 5_000,
});
const openai = new OpenAI({
  apiKey: env.OPENAI_API_KEY,
  maxRetries: 2,
  timeout: 45_000,
});
const app = express();

app.set("trust proxy", 1);
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", "'unsafe-inline'"],
      styleSrc: ["'self'", "'unsafe-inline'"],
      connectSrc: ["'self'"],
      imgSrc: ["'self'", "data:"],
      objectSrc: ["'none'"],
      baseUri: ["'none'"],
      formAction: ["'self'"],
      frameAncestors: ["'none'"],
    },
  },
}));
app.use(express.json({ limit: "16kb" }));
app.use(cookieParser(env.APP_HMAC_SECRET));
app.use(express.static("public", { extensions: ["html"] }));
app.use("/api", rateLimit({
  windowMs: 60_000,
  limit: 30,
  standardHeaders: "draft-8",
  legacyHeaders: false,
}));

const messageInput = z.object({
  message: z.string().trim().min(1).max(4_000),
});
const idInput = z.string().uuid();
const keyInput = z.string().regex(/^[A-Za-z0-9_-]{16,100}$/);

function userHash(value) {
  return crypto.createHmac("sha256", env.APP_HMAC_SECRET).update(value).digest("hex");
}

function pseudonymousUser(req, res, next) {
  let id = req.signedCookies.chat_uid;
  if (!idInput.safeParse(id).success) {
    id = crypto.randomUUID();
    res.cookie("chat_uid", id, {
      signed: true,
      httpOnly: true,
      secure: env.NODE_ENV === "production",
      sameSite: "strict",
      maxAge: 30 * 24 * 60 * 60 * 1_000,
    });
  }
  req.userHash = userHash(id);
  next();
}

function sameOriginWrite(req, res, next) {
  if (!["POST", "PUT", "PATCH", "DELETE"].includes(req.method)) return next();
  if (req.get("origin") !== env.PUBLIC_ORIGIN) {
    return res.status(403).json({ error: "origin_not_allowed" });
  }
  next();
}

app.use("/api", pseudonymousUser, sameOriginWrite);

app.get("/healthz", async (_req, res) => {
  await db.query("SELECT 1");
  res.json({ status: "ok" });
});

app.post("/api/sessions", async (req, res, next) => {
  try {
    const conversation = await openai.conversations.create();
    const result = await db.query(
      "INSERT INTO chat_sessions(user_hash, openai_conversation_id) VALUES ($1, $2) RETURNING id",
      [req.userHash, conversation.id],
    );
    res.status(201).json({ id: result.rows[0].id });
  } catch (error) {
    next(error);
  }
});

app.get("/api/sessions/:id/messages", async (req, res, next) => {
  try {
    const sessionId = idInput.parse(req.params.id);
    const session = await db.query(
      "SELECT id FROM chat_sessions WHERE id = $1 AND user_hash = $2",
      [sessionId, req.userHash],
    );
    if (session.rowCount !== 1) return res.status(404).json({ error: "not_found" });

    const messages = await db.query(
      "SELECT id, user_content, assistant_content, status, created_at FROM chat_requests WHERE session_id = $1 ORDER BY created_at, id",
      [sessionId],
    );
    res.json({ messages: messages.rows });
  } catch (error) {
    next(error);
  }
});

function sendEvent(res, type, data) {
  res.write("event: " + type + "\n");
  res.write("data: " + JSON.stringify(data) + "\n\n");
}

app.post("/api/sessions/:id/messages", async (req, res, next) => {
  let requestId;
  let responseId;
  let assembled = "";
  let finished = false;
  const abortController = new AbortController();

  try {
    const sessionId = idInput.parse(req.params.id);
    const { message } = messageInput.parse(req.body);
    const idempotencyKey = keyInput.parse(req.get("idempotency-key"));
    const session = await db.query(
      "SELECT openai_conversation_id FROM chat_sessions WHERE id = $1 AND user_hash = $2",
      [sessionId, req.userHash],
    );
    if (session.rowCount !== 1) return res.status(404).json({ error: "not_found" });

    try {
      const inserted = await db.query(
        "INSERT INTO chat_requests(session_id, idempotency_key, user_content, status) VALUES ($1, $2, $3, 'streaming') RETURNING id",
        [sessionId, idempotencyKey, message],
      );
      requestId = inserted.rows[0].id;
    } catch (error) {
      if (error?.code === "23505") {
        return res.status(409).json({ error: "duplicate_request" });
      }
      throw error;
    }

    res.status(200);
    res.set({
      "Content-Type": "text/event-stream; charset=utf-8",
      "Cache-Control": "no-cache, no-transform",
      "X-Accel-Buffering": "no",
    });
    res.flushHeaders();
    req.on("close", () => {
      if (!finished && !res.writableEnded) abortController.abort();
    });

    const stream = await openai.responses.create({
      model: env.OPENAI_MODEL,
      conversation: session.rows[0].openai_conversation_id,
      input: [{ role: "user", content: message }],
      instructions:
        "Answer the user's question clearly. State uncertainty. Never claim an external action occurred unless a tool result proves it.",
      reasoning: { effort: "none" },
      safety_identifier: req.userHash,
      stream: true,
      store: true,
      max_output_tokens: 1_200,
    }, { signal: abortController.signal });

    sendEvent(res, "ready", { requestId });
    for await (const event of stream) {
      if (event.type === "response.created") {
        responseId = event.response.id;
        sendEvent(res, "response", { responseId });
      } else if (event.type === "response.output_text.delta") {
        assembled += event.delta;
        sendEvent(res, "delta", { text: event.delta });
      } else if (event.type === "response.completed") {
        responseId = event.response.id;
        finished = true;
        await db.query(
          "UPDATE chat_requests SET assistant_content = $1, status = 'completed', openai_response_id = $2, updated_at = now() WHERE id = $3",
          [assembled, responseId, requestId],
        );
        await db.query(
          "UPDATE chat_sessions SET updated_at = now() WHERE id = $1",
          [sessionId],
        );
        sendEvent(res, "completed", {
          responseId,
          usage: event.response.usage,
        });
      } else if (event.type === "response.failed") {
        throw new Error(event.response.error?.code || "response_failed");
      } else if (event.type === "error") {
        throw new Error(event.code || "stream_error");
      }
    }
    res.end();
  } catch (error) {
    const cancelled = abortController.signal.aborted;
    if (requestId) {
      await db.query(
        "UPDATE chat_requests SET assistant_content = $1, status = $2, openai_response_id = $3, error_code = $4, updated_at = now() WHERE id = $5",
        [
          assembled,
          cancelled ? "cancelled" : "failed",
          responseId ?? null,
          cancelled ? "client_cancelled" : "upstream_error",
          requestId,
        ],
      ).catch((dbError) => log.error({ err: dbError, requestId }, "status update failed"));
    }
    log.warn({ err: error, requestId, responseId, cancelled }, "chat stream ended");
    if (res.headersSent) {
      sendEvent(res, "failed", {
        code: cancelled ? "cancelled" : "upstream_error",
        retryable: !cancelled,
      });
      return res.end();
    }
    next(error);
  }
});

app.delete("/api/sessions/:id", async (req, res, next) => {
  try {
    const sessionId = idInput.parse(req.params.id);
    const session = await db.query(
      "SELECT openai_conversation_id FROM chat_sessions WHERE id = $1 AND user_hash = $2",
      [sessionId, req.userHash],
    );
    if (session.rowCount !== 1) return res.status(404).json({ error: "not_found" });

    await openai.conversations.delete(session.rows[0].openai_conversation_id);
    await db.query(
      "DELETE FROM chat_sessions WHERE id = $1 AND user_hash = $2",
      [sessionId, req.userHash],
    );
    res.status(204).end();
  } catch (error) {
    next(error);
  }
});

app.use((error, _req, res, _next) => {
  const requestId = crypto.randomUUID();
  log.error({ err: error, requestId }, "request failed");
  const status = error instanceof z.ZodError ? 400 : 500;
  res.status(status).json({
    error: status === 400 ? "invalid_request" : "internal_error",
    requestId,
  });
});

const server = app.listen(env.PORT, () => {
  log.info({ port: env.PORT, model: env.OPENAI_MODEL }, "chatbot listening");
});

async function shutdown(signal) {
  log.info({ signal }, "shutting down");
  server.close();
  await db.end();
  process.exit(0);
}
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
Command
node --check src/server.mjs
Example output / evidence
Syntax check passed: src/server.mjs

Checkpoint: Prove the server contract is syntactically and structurally complete

node --check src/server.mjs && rg "conversation:|stream: true|safety_identifier|Idempotency-Key|openai.conversations.delete" src/server.mjs public/index.html

Continue whenSyntax passes and the implementation contains durable Conversation use, streaming, a privacy-preserving identifier, duplicate protection, and upstream deletion.

Stop whenStop if the API key enters client code, ownership is checked only by session ID, Origin validation is removed, raw prompts reach logs, or cancellation is implemented as an automatic retry.

If this step fails

The API returns 401, 403, or an authentication error before a response is created.

Likely causeThe server key is missing, revoked, attached to the wrong project, or unavailable to the running process.

Safe checks
  • Confirm the secret name exists without printing its value
  • Inspect the sanitized server request ID and OpenAI dashboard project

ResolutionRepair or rotate the server-side secret and rerun one bounded smoke request; never expose the key to diagnose from a browser.

The stream opens but no delta or completed event arrives.

Likely causeA proxy buffers text/event-stream, the model request failed, the client disconnected, or an unhandled event terminated iteration.

Safe checks
  • curl -N the local endpoint
  • Inspect response ID, status, proxy buffering, and sanitized server logs

ResolutionDisable proxy buffering for SSE, preserve typed failure events, and recover the transcript before offering a manual retry.

Security notes

  • The signed cookie is a pseudonymous continuity mechanism, not authentication for private or account-specific data. Replace it with verified login identity before adding protected knowledge or tools.
  • Streaming complicates output moderation because partial deltas arrive before full-output scoring. Products that require output moderation must design buffering or another explicit policy instead of claiming every displayed token was pre-approved.

Alternatives

  • Use WebSocket mode when the product needs a persistent low-latency connection and can implement its lifecycle; keep the same ownership, retention, and recovery rules.
  • Use previous_response_id instead of Conversations for a lightweight thread, but store and protect the chain deliberately and do not pass both state mechanisms together.

Stop conditions

  • Stop if the API key enters client code, ownership is checked only by session ID, Origin validation is removed, raw prompts reach logs, or cancellation is implemented as an automatic retry.
06

config

Build a safe streaming browser client

caution

Render messages with textContent, stream a POST response with the Fetch API, keep one idempotency key per attempt, expose an explicit Stop action, and restore server state after reload, cancellation, or failure.

Why this step matters

The browser is an untrusted presentation layer. It should never hold the provider key or decide ownership, and it must treat model text as text rather than HTML. Its recovery behavior is equally important: a dropped stream is an unknown outcome until the server's durable record is reloaded.

What to understand

The client uses fetch because EventSource cannot issue the authenticated POST body this endpoint requires. It parses SSE blocks incrementally and reacts only to the app's documented event names.

AbortController stops the client request. The server observes the closed connection, aborts the upstream SDK call, records partial text as cancelled, and lets a later GET show the actual retained state.

After any error or cancellation, the browser reloads server history. It does not automatically resend the prompt, because the previous request may have reached the model even when the final event was lost.

localStorage contains only the application session UUID. The signed HTTP-only cookie carries the pseudonymous ownership continuity and cannot be read by JavaScript.

System changes

  • Creates the accessible chat interface, client stream parser, cancellation control, session bootstrap, history restoration, and explicit deletion/new-conversation flow.

Syntax explained

textContent
Renders model and user text without interpreting HTML, preventing script or markup injection from generated content.
crypto.randomUUID()
Creates a unique client attempt identifier that the database enforces for one session.
AbortController
Gives the user a visible stop action and propagates cancellation to the server connection.
aria-live="polite"
Announces new message content without forcing an aggressive interruption for every token.
File public/index.html
Configuration
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>Responses chatbot</title>
  <style>
    :root { color-scheme: dark; font: 16px/1.5 system-ui; background: #0d1117; color: #e6edf3; }
    body { margin: 0; }
    main { width: min(760px, calc(100% - 32px)); margin: 32px auto; }
    header, form { display: flex; gap: 12px; align-items: center; }
    header { justify-content: space-between; }
    #messages { min-height: 50vh; display: grid; align-content: start; gap: 12px; margin: 24px 0; }
    .message { padding: 12px 14px; border: 1px solid #30363d; border-radius: 8px; white-space: pre-wrap; }
    .user { background: #161b22; }
    .assistant { border-color: #238636; }
    .interrupted { border-color: #d29922; }
    textarea { flex: 1; min-height: 72px; resize: vertical; background: #161b22; color: inherit; border: 1px solid #30363d; padding: 10px; }
    button { background: #238636; color: white; border: 0; border-radius: 6px; padding: 10px 14px; cursor: pointer; }
    button[disabled] { opacity: .5; cursor: not-allowed; }
    #status { min-height: 24px; color: #8b949e; }
  </style>
</head>
<body>
<main>
  <header>
    <div><h1>Responses chatbot</h1><p id="status" role="status">Loading conversation…</p></div>
    <button id="new" type="button">New conversation</button>
  </header>
  <section id="messages" aria-live="polite"></section>
  <form id="composer">
    <label for="prompt" hidden>Message</label>
    <textarea id="prompt" maxlength="4000" required placeholder="Ask a question"></textarea>
    <button id="send" type="submit">Send</button>
    <button id="stop" type="button" disabled>Stop</button>
  </form>
</main>
<script type="module">
  const messages = document.querySelector("#messages");
  const form = document.querySelector("#composer");
  const prompt = document.querySelector("#prompt");
  const send = document.querySelector("#send");
  const stop = document.querySelector("#stop");
  const status = document.querySelector("#status");
  let sessionId = localStorage.getItem("chat_session");
  let controller;

  function bubble(role, text, state = "") {
    const node = document.createElement("article");
    node.className = "message " + role + (state ? " " + state : "");
    node.textContent = text;
    messages.append(node);
    node.scrollIntoView({ block: "end", behavior: "smooth" });
    return node;
  }

  async function createSession() {
    const response = await fetch("/api/sessions", {
      method: "POST",
    });
    if (!response.ok) throw new Error("session_create_failed");
    const body = await response.json();
    sessionId = body.id;
    localStorage.setItem("chat_session", sessionId);
    messages.replaceChildren();
    status.textContent = "New conversation ready";
  }

  async function loadMessages() {
    if (!sessionId) return createSession();
    const response = await fetch("/api/sessions/" + sessionId + "/messages");
    if (response.status === 404) return createSession();
    if (!response.ok) throw new Error("history_load_failed");
    const body = await response.json();
    messages.replaceChildren();
    for (const item of body.messages) {
      bubble("user", item.user_content);
      if (item.assistant_content) {
        bubble("assistant", item.assistant_content, item.status === "completed" ? "" : "interrupted");
      }
    }
    status.textContent = body.messages.length ? "Conversation restored" : "Ready";
  }

  function parseSseBlock(block) {
    const event = block.split("\n").find((line) => line.startsWith("event:"))?.slice(6).trim();
    const data = block.split("\n").find((line) => line.startsWith("data:"))?.slice(5).trim();
    return event && data ? { event, data: JSON.parse(data) } : null;
  }

  async function streamMessage(text) {
    controller = new AbortController();
    send.disabled = true;
    stop.disabled = false;
    bubble("user", text);
    const answer = bubble("assistant", "");
    status.textContent = "Generating…";

    const response = await fetch("/api/sessions/" + sessionId + "/messages", {
      method: "POST",
      signal: controller.signal,
      headers: {
        "Content-Type": "application/json",
        "Idempotency-Key": crypto.randomUUID().replaceAll("-", ""),
      },
      body: JSON.stringify({ message: text }),
    });
    if (!response.ok || !response.body) {
      throw new Error(response.status === 409 ? "duplicate_request" : "stream_start_failed");
    }

    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let buffer = "";
    while (true) {
      const { value, done } = await reader.read();
      buffer += decoder.decode(value ?? new Uint8Array(), { stream: !done });
      const blocks = buffer.split("\n\n");
      buffer = blocks.pop() ?? "";
      for (const block of blocks) {
        const item = parseSseBlock(block);
        if (!item) continue;
        if (item.event === "delta") answer.textContent += item.data.text;
        if (item.event === "completed") status.textContent = "Complete";
        if (item.event === "failed") throw new Error(item.data.code);
      }
      if (done) break;
    }
  }

  form.addEventListener("submit", async (event) => {
    event.preventDefault();
    const text = prompt.value.trim();
    if (!text) return;
    prompt.value = "";
    try {
      await streamMessage(text);
    } catch (error) {
      status.textContent = error.name === "AbortError"
        ? "Stopped. Reloading saved state…"
        : "Request failed. Reloading saved state…";
      await new Promise((resolve) => setTimeout(resolve, 500));
      await loadMessages();
    } finally {
      controller = undefined;
      send.disabled = false;
      stop.disabled = true;
      prompt.focus();
    }
  });

  stop.addEventListener("click", () => controller?.abort());
  document.querySelector("#new").addEventListener("click", async () => {
    if (sessionId) {
      const response = await fetch("/api/sessions/" + sessionId, {
        method: "DELETE",
      });
      if (!response.ok && response.status !== 404) throw new Error("session_delete_failed");
    }
    localStorage.removeItem("chat_session");
    sessionId = undefined;
    await createSession();
    prompt.focus();
  });

  loadMessages().catch(() => {
    status.textContent = "Could not load the conversation. Check the server logs.";
  });
</script>
</body>
</html>
Command
node -e "const s=require('node:fs').readFileSync('public/index.html','utf8'); if(!s.includes('AbortController')||!s.includes('textContent')) process.exit(1); console.log('browser safety checks present')"
Example output / evidence
browser safety checks present

Checkpoint: Exercise keyboard, stream, and recovery behavior

Open http://localhost:3000, send one message, press Stop during a second, reload the page, and create a new conversation.

Continue whenThe first answer streams, Stop becomes available only while active, reload restores recorded content, interrupted content is visibly distinct, and New conversation deletes the previous session before replacing it.

Stop whenStop if generated text is inserted with innerHTML, the key appears in browser tools, a failed request is automatically replayed, controls lack keyboard labels, or another session ID exposes history.

If this step fails

Reload creates a new empty conversation every time.

Likely causeThe session ID was not saved, the signed cookie was rejected, or the owned server session no longer exists.

Safe checks
  • Inspect only the local chat_session UUID
  • Check cookie Secure/SameSite behavior and the authorized GET status

ResolutionRepair the cookie/origin/deployment configuration; create a new session only after an explicit 404, not after every transient failure.

Security notes

  • A public anonymous chatbot still needs abuse controls. The pseudonymous cookie supports rate and safety correlation but must not be presented as a verified person.

Alternatives

  • Use a framework stream helper when it preserves the same typed-event, cancellation, text-only rendering, and unknown-outcome recovery semantics.

Stop conditions

  • Stop if generated text is inserted with innerHTML, the key appears in browser tools, a failed request is automatically replayed, controls lack keyboard labels, or another session ID exposes history.
07

config

Add a real end-to-end smoke test

caution

Test session creation, a streamed answer, durable transcript reload, duplicate rejection, and upstream plus local deletion against the running application. This test makes one bounded billable model request and should use a dedicated test project and budget.

Why this step matters

Syntax checks cannot prove a streaming application. The smoke test crosses the browser-facing API, signed cookie, database, OpenAI Responses stream, transcript mirror, idempotency constraint, and deletion path, producing concrete release evidence instead of a manual impression.

What to understand

The assertion asks for a short fixed marker to keep the request bounded. Natural-language generation is not perfectly deterministic, so the test validates the marker and lifecycle events rather than every byte of prose.

The same idempotency key is intentionally submitted twice. A 409 proves the application did not start another turn for an accidental duplicate.

Deletion is part of the test, not cleanup left to memory. The session disappears locally only after the OpenAI Conversation deletion returns successfully.

System changes

  • Adds an executable smoke test that creates and deletes ephemeral test state and consumes one small OpenAI API response.

Syntax explained

assert.equal(status, ...)
Fails immediately when the HTTP lifecycle differs from the reviewed contract.
getSetCookie
Preserves the signed pseudonymous cookie across Node fetch calls so ownership checks match a browser session.
smoketest0000000001
A valid stable key reused only to prove duplicate rejection inside the newly created ephemeral session.
File test/smoke.mjs
Configuration
import assert from "node:assert/strict";

const origin = process.env.PUBLIC_ORIGIN;
const cookieJar = [];
const headers = () => ({
  Origin: origin,
  Cookie: cookieJar.join("; "),
});

const sessionResponse = await fetch(origin + "/api/sessions", {
  method: "POST",
  headers: headers(),
});
assert.equal(sessionResponse.status, 201);
for (const value of sessionResponse.headers.getSetCookie?.() ?? []) {
  cookieJar.push(value.split(";")[0]);
}
const { id } = await sessionResponse.json();
assert.match(id, /^[0-9a-f-]{36}$/);

const streamResponse = await fetch(origin + "/api/sessions/" + id + "/messages", {
  method: "POST",
  headers: {
    ...headers(),
    "Content-Type": "application/json",
    "Idempotency-Key": "smoketest0000000001",
  },
  body: JSON.stringify({ message: "Reply with exactly: STREAM_OK" }),
});
assert.equal(streamResponse.status, 200);
const streamBody = await streamResponse.text();
assert.match(streamBody, /event: delta/);
assert.match(streamBody, /STREAM_OK/);
assert.match(streamBody, /event: completed/);

const historyResponse = await fetch(origin + "/api/sessions/" + id + "/messages", {
  headers: headers(),
});
assert.equal(historyResponse.status, 200);
const history = await historyResponse.json();
assert.equal(history.messages.length, 1);
assert.equal(history.messages[0].status, "completed");
assert.match(history.messages[0].assistant_content, /STREAM_OK/);

const duplicate = await fetch(origin + "/api/sessions/" + id + "/messages", {
  method: "POST",
  headers: {
    ...headers(),
    "Content-Type": "application/json",
    "Idempotency-Key": "smoketest0000000001",
  },
  body: JSON.stringify({ message: "This must not create a second turn." }),
});
assert.equal(duplicate.status, 409);

const deleteResponse = await fetch(origin + "/api/sessions/" + id, {
  method: "DELETE",
  headers: headers(),
});
assert.equal(deleteResponse.status, 204);

console.log(JSON.stringify({
  session: "created",
  stream: "completed",
  transcript: "restored",
  duplicate: "blocked",
  deletion: "completed"
}, null, 2));
Command
npm run smoke
Example output / evidence
{
  "session": "created",
  "stream": "completed",
  "transcript": "restored",
  "duplicate": "blocked",
  "deletion": "completed"
}

Checkpoint: Record the complete smoke result

npm run smoke

Continue whenAll five stages report success, the test session is deleted, logs contain identifiers but no prompt text, and the test project records one bounded response.

Stop whenStop if the test uses production customer data, runs against an unbounded budget, leaves undeleted sessions, accepts a duplicate, or passes without observing a completed stream.

If this step fails

The fixed STREAM_OK marker is absent although the stream completed.

Likely causeThe selected model did not follow the tiny test instruction, another prompt was injected, or output was truncated or moderated.

Safe checks
  • Inspect the sanitized response ID and completed status
  • Run the fixture in the OpenAI project test environment

ResolutionTreat this as a model/prompt regression, update the evaluation evidence, and do not weaken the assertion merely to make the build green.

Security notes

  • Use a separate API project with a low budget and no production data for automated smoke tests.

Alternatives

  • Mock the SDK for fast unit tests, but retain at least one controlled integration test because mocks cannot verify the current upstream stream contract.

Stop conditions

  • Stop if the test uses production customer data, runs against an unbounded budget, leaves undeleted sessions, accepts a duplicate, or passes without observing a completed stream.
08

command

Migrate, start, and inspect the local application

caution

Apply the reviewed schema, start the server with protected local environment values, verify health, and inspect only identifiers and statuses in logs. Keep the test origin exact and avoid printing the environment.

Why this step matters

Starting only after schema, secrets, and syntax are verified keeps runtime failures understandable. The health endpoint checks database reachability without calling the model, while the startup log records the non-secret model and port needed to reproduce behavior.

What to understand

Run migrations with a controlled migration identity, then run the application with a narrower database role. The compact command is for the tutorial path; production separates these responsibilities.

The first model request should come from the smoke test, not an arbitrary browser prompt, so its expected lifecycle and cleanup are recorded.

A healthy process does not prove model availability, streaming, retention, or deletion. Those are separate release checks.

System changes

  • Applies the database schema and starts a network listener that can create billable OpenAI requests after browser interaction.

Syntax explained

ON_ERROR_STOP=1
Prevents startup from hiding a failed migration command.
npm start
Loads the reviewed .env values and starts the non-watch production-shaped process.
Command
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f schema.sql && npm start
Example output / evidence
{"level":30,"port":3000,"model":"gpt-5.6-terra","msg":"chatbot listening"}
GET /healthz -> 200 {"status":"ok"}

Checkpoint: Verify local health without invoking the model

curl --fail --silent http://localhost:3000/healthz

Continue when{"status":"ok"}

Stop whenStop when health cannot reach the intended database, the listener is exposed beyond the development boundary, secrets appear in output, or the configured model is not approved for this project.

If this step fails

The server starts and immediately exits.

Likely causeEnvironment validation failed, the port is occupied, PostgreSQL is unreachable, or a required dependency is missing.

Safe checks
  • node --version
  • npm ls --depth=0
  • pg_isready -d "$DATABASE_URL"

ResolutionCorrect the single failed dependency or configuration value; do not bypass validation or print the full environment.

Security notes

  • Bind and expose the service only through the intended TLS reverse proxy in production; do not publish the development listener directly.

Alternatives

  • Run the process in a container or supported platform service, preserving secret injection, graceful termination, health checks, and a persistent PostgreSQL connection.

Stop conditions

  • Stop when health cannot reach the intended database, the listener is exposed beyond the development boundary, secrets appear in output, or the configured model is not approved for this project.
09

verification

Drill cancellation, reconnect, rate-limit, and upstream failure

caution

Prove the unhappy paths deliberately. Cancel a stream, refresh during generation, submit the same request twice, simulate an upstream 429 or 5xx in a test environment, interrupt the database, and verify that the UI reloads authoritative state without automatic duplicate turns.

Why this step matters

The most damaging chatbot failures occur between systems: a browser disconnects after the provider accepted work, a proxy buffers events, a database write fails after output, or a retry produces a duplicate turn. Practising these paths establishes what the user sees and what an operator can prove.

What to understand

Cancellation is successful only when the client stops, the server aborts upstream work, and the request status becomes cancelled or completed according to observed reality.

A 429 or 5xx may be retryable, but only a new explicit user action should retry an ambiguous streamed turn. Automatic SDK retries remain bounded and happen before the application exposes a completed result.

A database failure after generation must be an incident because the model context and application transcript may diverge. Preserve the response ID and stop further turns in that session until reconciled.

System changes

  • Creates ephemeral failed and cancelled test rows, test log events, and provider requests; clean them according to the documented test retention policy.

Syntax explained

429
A rate-limit response that should surface bounded retry guidance and project/request identifiers, not an infinite retry loop.
5xx
An upstream service failure whose retry depends on whether the application can prove the request did not already create state.
X-Accel-Buffering: no
A response hint for compatible proxies; the deployed proxy still needs an explicit no-buffering configuration and test.
Command
npm run smoke && printf '%s\n' 'Manual drills required: cancel, reload, 429, 5xx, DB outage, proxy buffering'
Example output / evidence
PASS cancel recorded as cancelled
PASS reload restored authoritative transcript
PASS duplicate returned 409
PASS upstream failure exposed retryable status and request ID
PASS database outage returned sanitized 500
PASS buffered proxy configuration rejected

Checkpoint: Approve failure semantics

Review the six recorded drill results and their sanitized logs.

Continue whenEvery drill has a stable user message, operator-visible request/response identifiers, bounded recovery, no secret leakage, and no automatic duplicate turn.

Stop whenStop if any failure yields an endless spinner, silent transcript loss, cross-session data, unbounded retries, raw provider errors, or a second model turn without explicit intent.

If this step fails

The UI says failed but the next turn shows the model remembered the cancelled prompt.

Likely causeThe upstream Conversation accepted an item before transport cancellation while the local row recorded an incomplete outcome.

Safe checks
  • Inspect the response ID and Conversation items in an authorized test environment
  • Compare with the local request status and timestamps

ResolutionQuarantine that session for reconciliation; do not automatically replay or delete evidence needed to determine the actual state.

Security notes

  • Failure injection belongs in an isolated test project and database. Never revoke production access or interrupt production storage merely to prove a tutorial.

Alternatives

  • Use a contract-test proxy that deterministically emits delays, disconnects, 429, and 5xx responses while keeping real provider calls limited to the final integration test.

Stop conditions

  • Stop if any failure yields an endless spinner, silent transcript loss, cross-session data, unbounded retries, raw provider errors, or a second model turn without explicit intent.
10

warning

Apply the production release gate

danger

Do not publish because the happy path works. Complete the security, privacy, retention, evaluation, observability, scaling, and rollback checklist, then deploy a canary with a pinned application, prompt, model role, and schema-compatible rollback artifact.

Why this step matters

Model quality is only one release dimension. Production also needs known data retention, deletion, authentication, budgets, rate limits, incident evidence, database recovery, current model evaluation, and a rollback that does not corrupt newer rows or strand OpenAI Conversations.

What to understand

Pin the explicit model role and preserve the evaluation dataset. A family alias may move over time; when an alias is intentional, record the returned response.model in release evidence.

The in-memory rate limiter is suitable for the one-process reference. Multiple instances require a shared store or edge control so users cannot multiply the limit by changing instances.

Logs should correlate application request ID, hashed user, session, OpenAI response ID, status, latency, and token usage without raw prompt text, cookies, authorization headers, or secrets.

Rollback must account for database compatibility and state created after deployment. Restoring old code is unsafe when it cannot read the current schema or honor current deletion and retention contracts.

System changes

  • Adds the release checklist and, when executed operationally, deploys a canary that can create persistent conversations and billable model requests.

Syntax explained

open === 0
Treats every unchecked release requirement as a blocker instead of a suggestion.
canary
Limits initial traffic and observation scope while the previous known-good release remains available.
response.model
Records the actual model returned when aliases are used, preserving audit and evaluation context.
File ops/release-checklist.md
Configuration
# Release gate

- [ ] Authentication or the signed pseudonymous-session boundary matches the product's data sensitivity.
- [ ] PUBLIC_ORIGIN is the exact HTTPS production origin and write requests reject every other Origin.
- [ ] OPENAI_API_KEY exists only in the server secret store and has been rotated after any accidental exposure.
- [ ] APP_HMAC_SECRET is random, at least 32 bytes, and changing it intentionally expires existing anonymous sessions.
- [ ] The selected model is available in the project and passes the frozen chatbot evaluation set.
- [ ] Database migrations, backups, restore tests, retention, and deletion jobs are owned by an operator.
- [ ] Rate limiting uses a shared store before running more than one application instance.
- [ ] Logs contain request, session-hash, response, latency, token, status, and error identifiers but no raw prompts or cookies.
- [ ] Streaming, cancellation, duplicate-submit, browser reload, upstream 429/5xx, database failure, and deletion paths pass.
- [ ] A rollback artifact pins the previous application image, schema compatibility, prompt revision, and model configuration.
Command
node -e "const s=require('node:fs').readFileSync('ops/release-checklist.md','utf8'); const open=(s.match(/- \[ \]/g)||[]).length; console.log(JSON.stringify({open})); process.exit(open?1:0)"
Example output / evidence
{"open":0}
Release gate passed: evaluation, failure drills, deletion, observability, shared rate limiting, backup/restore, and rollback evidence attached.

Checkpoint: Sign the release evidence

Archive the completed checklist, smoke output, evaluation summary, failure drills, source versions, deployment digest, schema revision, and rollback procedure.

Continue whenA reviewer can reproduce the tested release, identify every persistent store, find provider request IDs, delete test data, and restore the previous compatible version.

Stop whenStop when any checkbox remains open, the model lacks a representative evaluation, deletion is untested, rate limiting is instance-local in a scaled deployment, or rollback cannot preserve current data.

If this step fails

The canary has higher latency, token use, refusal rate, or user-visible failures than the accepted baseline.

Likely causeThe model role, prompt, context length, traffic mix, cache behavior, or deployment path differs from the evaluated release.

Safe checks
  • Compare pinned release metadata and response.model
  • Compare p50/p95 latency, input/output tokens, errors, cancellations, and task scores

ResolutionStop expansion, restore the previous compatible application/model configuration, preserve traces, and diagnose one measured difference at a time.

Security notes

  • Consequential tools, private knowledge, or account data require real authentication and separate authorization; the public pseudonymous reference must not be extended by assumption.
  • Keep a provider status and incident path, but never expose raw upstream errors or secret-bearing headers to users.

Alternatives

  • Release as an internal authenticated pilot first when evaluation or abuse evidence is still thin.

Stop conditions

  • Stop when any checkbox remains open, the model lacks a representative evaluation, deletion is untested, rate limiting is instance-local in a scaled deployment, or rollback cannot preserve current data.

Finish line

Verification checklist

End-to-end lifecyclenpm run smokeOne session is created, one answer streams to completion, the transcript reloads, a duplicate returns 409, and provider plus local state are deleted.
Cancellation and recoveryStart a long response in the browser, press Stop, reload, and inspect the authorized request status.The UI stops promptly, no automatic replay occurs, and reload shows completed or interrupted state from the database rather than an invented client state.
Cross-session isolationOpen a second clean browser profile and request the first profile's session UUID.The second identity receives 404 and no transcript content, response ID, or ownership clue.
Deletion completenessCreate a test session, delete it through the UI, verify local rows are absent, and verify the OpenAI Conversation can no longer be retrieved by the authorized test operator.Upstream deletion succeeds before local cascade, the application returns 204, and the test deletion record is retained without message content.
Release and rollback evidenceReview the completed release checklist, evaluation report, failure drills, dependency lockfile, deployment digest, schema revision, and rollback rehearsal.Every release item is checked, the canary meets quality/latency/cost/error budgets, and the previous schema-compatible version can be restored without losing deletion capability.

Recovery guidance

Common problems and safe checks

Users see another person's transcript after changing or guessing a session ID.

Likely causeThe application query authorizes by session ID alone or trusts a client-supplied user identifier.

Safe checks
  • Inspect the SQL predicate without printing content
  • Verify user_hash is derived server-side from a signed or authenticated identity

ResolutionDisable the service, preserve evidence, add the ownership predicate to every route, invalidate exposed sessions, and follow the incident process.

A user receives two assistant turns after one visible submit.

Likely causeThe UI created multiple idempotency keys for one attempt, the unique constraint is absent, or a proxy/client retried an ambiguous POST.

Safe checks
  • Inspect request IDs, idempotency keys, timestamps, and response IDs
  • Verify the database unique constraint

ResolutionStop automatic retries, restore one-key-per-attempt behavior, reconcile the Conversation and transcript, and add the duplicate smoke assertion.

The visible answer stops halfway and reload shows a different status.

Likely causeThe browser disconnected before receiving completion, while the server later completed, cancelled, or failed the upstream stream.

Safe checks
  • Reload the authorized transcript
  • Inspect sanitized request and response IDs
  • Check whether the server received response.completed

ResolutionTreat the durable server row as authoritative, clearly label partial state, and require explicit user intent for any continuation or retry.

Latency and token cost rise after changing the model.

Likely causeThe new model inherited a different reasoning default, context grew, output bounds changed, or cache behavior differs.

Safe checks
  • Compare explicit model, reasoning effort, response.model, usage, context size, and p50/p95 latency

ResolutionRestore the last evaluated model configuration, then test one controlled change against the frozen evaluation set.

Deleting a conversation removes the UI row but provider context remains.

Likely causeThe local record was deleted before the OpenAI Conversation deletion succeeded, or provider deletion was omitted.

Safe checks
  • Inspect deletion audit status and provider Conversation ID
  • Retry only the authorized upstream deletion

ResolutionKeep local mapping until upstream deletion succeeds, expose a retryable deletion state, and include backups and logs in the retention process.

After the procedure

Alternatives and next steps

Consider these alternatives

  • Use previous_response_id when the application needs a lighter chain and accepts ordinary response retention. Preserve the previous ID securely, price all earlier input tokens, and design deletion and recovery explicitly.
  • Use store false and application-owned replay when policy requires the app to own retained state. Replay complete response output items, not just visible assistant text, so encrypted reasoning items and phases remain intact.
  • Use Responses WebSocket mode when a persistent socket and incremental inputs materially reduce latency. It adds connection lifecycle and recovery complexity and does not remove authorization or durable-state requirements.
  • Use a framework adapter or official starter application when it keeps credentials server-side and preserves the same ownership, typed stream, cancellation, retention, deletion, and failure semantics.

Operate it safely

  • Replace pseudonymous identity with the product's real authentication and authorization layer before adding private history, knowledge, account data, or tools.
  • Add a frozen conversation evaluation set covering answer quality, refusal, unsafe requests, long threads, multilingual input, cancellation, and output limits; gate every model or prompt change on it.
  • Move rate limits to a shared store, add per-user budgets, record usage and latency without content, and alert on error, cancellation, refusal, cost, and duplicate-conflict trends.
  • Implement scheduled retention and deletion retries, backup encryption, restore drills, provider-state audits, and a user-facing export/delete workflow.
  • Add tools only through separately authorized, schema-validated, human-approved workflows with explicit side-effect and idempotency policy.

Reference

Frequently asked questions

Why use an OpenAI Conversation and a local transcript?

They serve different contracts. The Conversation preserves complete model-visible response items. The local transcript provides authorized UI recovery, idempotency status, operational evidence, and product-controlled export. Do not replay both into the model or claim deleting one automatically deletes the other.

Why not retry automatically when the stream breaks?

A broken client connection does not prove the provider rejected the request. Automatic replay can create a duplicate turn or side effect. Reload the durable state first, expose the actual status, and ask for explicit user intent when the outcome remains ambiguous.

Does Stop guarantee zero token usage?

No. Cancellation prevents further useful work when it reaches the server and provider, but tokens may already have been processed. Record cancellation timing and usage when available, and describe Stop as cancellation rather than a billing guarantee.

Why is reasoning effort explicit?

Model-family defaults can differ. An explicit interactive baseline prevents a model update from silently changing latency and cost. Raise reasoning only when representative evaluations show the quality gain is worth the operational tradeoff.

Can this signed cookie protect private data?

No. It supplies pseudonymous continuity and resists simple client forgery, but it does not verify a person or organization. Add real authentication, session management, authorization, and account recovery before handling protected data.

How long do Conversations remain?

OpenAI's current conversation-state documentation says Conversation objects and attached items are not subject to the ordinary response 30-day TTL. Re-check the current official data documentation and configure a product retention and deletion process before release.

Should the browser parse every OpenAI stream event?

No. The server should translate provider events into the smallest stable application contract. This reduces accidental data exposure, keeps provider changes behind one adapter, and gives the browser predictable recovery behavior.

Recovery

Rollback

Stop new traffic, preserve request and response identifiers, restore the previous pinned application and model configuration only when it remains schema-compatible, and reconcile conversations whose provider and local state diverged. A code rollback does not erase conversations, transcripts, logs, analytics, or backups.

  1. Disable new session and message creation at the edge while keeping an authenticated read/delete path available for existing users.
  2. Capture the failing deployment digest, model and response.model, prompt revision, schema revision, sanitized error metrics, and affected request/response identifiers.
  3. If the previous application understands the current schema and deletion contract, redeploy it with its pinned model configuration; otherwise deploy the prepared compatibility build instead of forcing an old binary onto new data.
  4. For every streaming or failed row during the incident window, compare the authorized OpenAI Conversation state with the local request status before allowing another turn.
  5. Retain or delete test and incident data according to policy, retry incomplete upstream deletions, and verify that backup retention does not contradict the user-facing promise.
  6. Rerun isolation, stream, cancellation, reload, duplicate, deletion, and rollback smoke checks before reopening traffic.

Evidence

Sources and review

Verified 2026-07-29Review due 2026-10-27
Conversation state | OpenAI APIofficialStreaming API responses | OpenAI APIofficialMigrate to the Responses API | OpenAI APIofficialCreate a model response | OpenAI API referenceofficialProduction best practices | OpenAI APIofficialError codes | OpenAI APIofficialYour data | OpenAI APIofficialPostgreSQL transactionsofficial