pleach
Integrate

Pleach + OpenAI SDK

Coexist with the OpenAI SDK. Keep Chat Completions, Responses API, structured outputs; add @pleach/core for AuditableCall, family-lock, replay.

This page is the coexist pattern: @pleach/core underneath the OpenAI SDK, neither replacing the other. If you're choosing between keeping OpenAI Enterprise and moving the contract substrate, see Migrating from OpenAI Enterprise for the contract-side framing. The other coexist patterns — Anthropic SDK, Mastra, Inngest — follow the same posture.

OpenAI's SDK and Pleach aren't competing for the same slot. The OpenAI SDK is the provider transport — chat completions, the Responses API, structured outputs, tools, prompt caching, the Assistants threads. Pleach owns what sits underneath the transport — the typed AuditableCall row that lands in your Postgres, family-lock at session start, replay-deterministic streaming, subagent cost rollup to the parent turnId.

If you're already calling openai.chat.completions.create or openai.responses.create directly, don't rip it out. Wire OpenAI as the provider behind Pleach's AgentProvider interface and keep the call shape you already have.

If your team is on an OpenAI Enterprise contract (Projects, SSO, the Compliance API, ZDR), the coexist shape stays the same — Pleach sits under the SDK regardless of the billing model. The contract closes the vendor surface. The substrate closes the three downstream walls: per-axis rollup inside one Project (external customers or internal employees, teams, cost centers), a hash-chained AuditableCall row in your own Postgres, and replay- deterministic regression across model snapshots. See Migrating from OpenAI Enterprise for the contract-side walk-through.

The objection this pattern closes is vendor lock-in: you keep OpenAI's transport-side properties (prompt caching, structured outputs, the latest API surface) while gaining an audit row you own, family-locked routing, and the option to swap providers without rewriting the agent loop.

The architecture

┌─────────────────────────────────────────────────────────────┐
│  Your API route / serverless function                       │
│                                                             │
│  ┌───────────────────────────────────────────────────────┐  │
│  │  Pleach SessionRuntime                                │  │
│  │  - AuditableCall row → your Postgres                  │  │
│  │  - Family-lock at session start (tokenizer,           │  │
│  │    prompt-cache key, tool-call dialect locked)        │  │
│  │  - Replay-deterministic StreamEvent                   │  │
│  │  - Subagent rollup to parent turnId                   │  │
│  │                                                       │  │
│  │  ┌─────────────────────────────────────────────────┐  │  │
│  │  │  AiSdkProvider({ model: openai("gpt-4o") })     │  │  │
│  │  │  - OpenAI Chat Completions / Responses API      │  │  │
│  │  │  - Prompt caching, structured outputs, tools    │  │  │
│  │  │  - Native OpenAI tool-call dialect (JSON)       │  │  │
│  │  └─────────────────────────────────────────────────┘  │  │
│  └───────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────┘

The OpenAI SDK gives you the transport trail — what the model returned, what tools it called, what tokens it spent, all vendor-shaped. Pleach gives you the substrate trail — the audit row keyed (sessionId, turnId, stageId, seqWithinTurn) in your own schema, joinable to a billing or compliance review six months from now. Finance reads Pleach; the OpenAI dashboard is where you read raw usage.

The code shape — Chat Completions / Responses API

This is the cleanest pairing. OpenAI's Chat Completions and the newer Responses API are stateless from the model's point of view — the conversation lives in your DB, and each call sends the full message array. Pleach owns the session, the audit row, and the streaming primitive; OpenAI owns the transport.

import {
  createPleachRuntime,
  AiSdkProvider,
  type StreamEvent,
} from "@pleach/core";
import { SupabaseAdapter } from "@pleach/core/sessions";
import { openai } from "@ai-sdk/openai";

// One runtime per request. The AiSdkProvider wraps @ai-sdk/openai
// so the call lands at openai.chat.completions.create under the
// hood — you keep prompt caching, structured outputs, tools, and
// the native OpenAI tool-call dialect (JSON Schema, not the
// XML-shaped variant Anthropic uses).
const runtime = createPleachRuntime({
  tenantId: "acme-corp",
  userId: "user_123",
  storage: new SupabaseAdapter({ client: supabase }),
  provider: new AiSdkProvider({
    model: openai("gpt-4o"),
  }),
});

// Family-lock happens here. The session pins:
//   - tokenizer (OpenAI's cl100k variant)
//   - prompt-cache key (so prefix reuse stays stable per session)
//   - tool-call dialect (OpenAI JSON Schema)
//   - refusal pattern (OpenAI's shape, not Anthropic's)
// None of those silently mutate mid-conversation.
const session = await runtime.sessions.create({
  provider: { type: "openai" },
  model:    { id: "gpt-4o" },
});

// executeMessage writes the AuditableCall row at call time —
// before the stream completes — so finance can:
//   SELECT SUM(token_usage->>'totalTokens')::bigint
//   FROM harness_auditable_calls
//   WHERE tenant_id = $1 AND created_at > $2
// without parsing OpenAI dashboard exports.
const events: StreamEvent[] = [];
for await (const evt of runtime.executeMessage(session.id, "Hello")) {
  events.push(evt);
}

Three things this pattern gives you that calling openai.chat.completions.create directly doesn't:

  1. The AuditableCall row lands in your Postgres keyed by (sessionId, turnId, stageId, seqWithinTurn), with toolName, subagentDepth, and tokenUsage. The OpenAI SDK returns a ChatCompletion object; what it doesn't do is write a typed audit row into your DB during the call. Pleach does, before the stream finishes, so the row exists even if your function crashes between the OpenAI response and your final commit.
  2. Family-lock survives provider swaps. The day you decide to route some sessions to Anthropic for cost or capability reasons, the audit row shape doesn't change, the session contract doesn't change, and the existing OpenAI sessions keep replaying byte-identical because their family-lock froze openai at session-create time. The OpenAI SDK alone offers no such property — switching providers means rewriting the call site.
  3. Subagents spawned during the turn roll their cost back to the parent turnId via SpawnTreeState. The OpenAI SDK has no concept of subagents — if your agent loop spawns helpers, you write the parent-child attribution code yourself. Pleach ships the row shape.

The code shape — Assistants API

The Assistants API is a different shape: OpenAI persists thread state on their side, runs are stateful, and tool calls happen inside runs you poll or stream. The Pleach pairing works but is narrower — see the caveat below before committing to this path.

import OpenAI from "openai";
import {
  createPleachRuntime,
  type StreamEvent,
} from "@pleach/core";
import { SupabaseAdapter } from "@pleach/core/sessions";

const openaiClient = new OpenAI();

const runtime = createPleachRuntime({
  tenantId: "acme-corp",
  userId: "user_123",
  storage: new SupabaseAdapter({ client: supabase }),
  // No provider here — the Assistants API is driven by you
  // calling openaiClient.beta.threads.* directly. Pleach captures
  // per-call audit rows around those calls, not as a transport.
});

const session = await runtime.sessions.create({
  provider: { type: "openai" },
  model:    { id: "gpt-4o" },
});

// You drive the Assistants API yourself. Pleach's role is the
// audit wrapper around each run, not the transport.
const thread = await openaiClient.beta.threads.create();
await openaiClient.beta.threads.messages.create(thread.id, {
  role: "user",
  content: "Hello",
});

// Emit an observability row for the assistants-run call to YOUR
// destination. The thread state itself still lives at OpenAI — see caveat.
const startedAt = Date.now();
const run = await openaiClient.beta.threads.runs.createAndPoll(thread.id, {
  assistant_id: "asst_abc123",
});
runtime.observe.record({
  turnId: session.id,
  providerId: "openai",
  family: "openai",
  callClass: "converse",
  model: "openai.assistants.run",
  inputTokens: run.usage?.prompt_tokens ?? 0,
  outputTokens: run.usage?.completion_tokens ?? 0,
  costUSD: 0,
  startedAt,
  completedAt: Date.now(),
});

The pattern works — you get a per-call observability row for every Assistants run, keyed to your session and turn (the AuditableCall ledger covers the calls the runtime drives; this out-of-band Assistants call is captured via runtime.observe). What you don't get is ownership of the conversation primitive itself, because that lives in OpenAI's thread.

The Assistants API caveat

If your thread state lives at OpenAI, that's a strategic commitment Pleach can't undo. The Assistants API persists thread messages, tool outputs, and run history on OpenAI's infrastructure. Pleach can still capture per-call audit rows around each runs.create or runs.createAndPoll, but the conversation primitive itself is vendor-shaped — the durable record of "what did this conversation contain" lives at OpenAI, not in your DB.

What this means in practice:

  • Audit-at-the-call-level still works. You get an AuditableCall row per run, with tokenUsage, toolName, subagentDepth. The hash chain still holds. Finance can still roll up cost.
  • Audit-at-the-conversation-level is partial. Asking "show me the full transcript of session X" requires fetching thread state from OpenAI, not querying your Postgres. If OpenAI deprecates the Assistants API (as has happened with previous beta surfaces), your historical conversations are vendor-dependent on the migration path.
  • Replay determinism is weaker. Pleach's replay primitive reproduces the StreamEvent sequence for a turn given the same fingerprint inputs. When the conversation state lives at OpenAI, the inputs to the next call depend on what OpenAI stored — outside the fingerprint's reach.
  • Provider portability is gone. Swapping to Anthropic or a different OpenAI surface means migrating thread state out of OpenAI first, which is a one-way data move.

The recommendation: if audit matters, use Chat Completions or the Responses API with Pleach owning the session. The Assistants API is the right shape when its hosted-thread model is what you actually want — typically when the convenience of OpenAI-managed state outweighs the durability trade-off. If you reached for Assistants because it was the easiest "thread" API to wire up, the Pleach + Chat Completions pairing gives you that shape with your own DB underneath.

Where each is load-bearing

ConcernOpenAI SDK's slotPleach's slot
Sending tokens to a modelyes (that's the SDK's job)none — Pleach delegates to a provider
Prompt caching (server-side prefix reuse)yes (OpenAI's caching layer)preserved; Pleach doesn't interfere
Structured outputs (JSON Schema response format)yespreserved through AiSdkProvider
Native tool-call dialectyes (OpenAI JSON Schema)family-locked at session start so it can't silently change
Per-call audit row in YOUR Postgresnone — SDK returns a response object, not a rowAuditableCall row keyed (sessionId, turnId, stageId, seqWithinTurn)
Tamper-evident hash chainnoneprev_hash + row_hash columns at the schema level
Family + transport locknonelocked at runtime.sessions.create({ provider, model })
Replay-deterministic LLM streamnone — same prompt can return different streamfingerprint replays byte-identical StreamEvents
Subagent cost rollup to parent turnIdnone — no subagent conceptparentTurnId + rootTurnCostCap on the spawn tree
Time-travel checkpoints inside a sessionnoneruntime.checkpoints.rollback() / .list()
Multi-tenant tenant_id stamping on every rowmanualenforced by audit:tenant-scoping CI gate
Provider portability (swap to Anthropic without rewriting the agent loop)none — call sites are SDK-specificyes — swap AiSdkProvider model
OpenAI dashboard (usage, errors, rate limits)built-innone — Pleach doesn't replace it
Conversation state durabilityAssistants API: OpenAI; Chat/Responses: yoursyours (in both cases, via SupabaseAdapter)

The coverage maps barely overlap. That's why the pairing is honest — the OpenAI SDK keeps doing what it's good at (transport, prompt caching, the latest API surface), and Pleach adds the substrate properties that aren't the SDK's job.

When you don't need Pleach

  • A single-shot script that calls openai.chat.completions.create once and prints the result. No persistence, no multi-tenancy, no audit obligation. The SDK alone is the right shape; the comparison page covers when to stay on the raw SDK.
  • A chatbot where the OpenAI request id in your logs is enough audit trail. Pleach's overhead doesn't pay back until finance asks for a per-axis monthly rollup (per end customer in a SaaS, or per employee / team / cost center under one Enterprise Project) or compliance asks for the tool-invocation trail.

When you don't need the OpenAI SDK directly

  • You want provider portability from day one. Wire AiSdkProvider against @ai-sdk/openai and your call sites reach the same OpenAI surface, with the option to swap to @ai-sdk/anthropic later without touching the agent loop.
  • You're on the four-stage lattice already. The seam structure (anchor-plan, tool-loop, synthesize, post-turn) routes every call through Pleach; the OpenAI SDK shape becomes the provider's internal concern, not your code's.

Native OpenAI SDK transport (without the AI SDK)

If you want to skip @ai-sdk/openai and call the OpenAI SDK directly inside a custom provider, the AgentProvider interface is the seam. Implement execute against openai.chat.completions.create({ stream: true }) and the substrate properties above still hold — the audit row, the family-lock, the replay determinism. The Providers page walks through writing a custom provider end-to-end.

A note on the Realtime API

The OpenAI Realtime API (audio in, audio out, WebRTC transport) is out of scope for this pairing today. The Pleach session model assumes turn-based execution; bidirectional audio streams need a different substrate shape. Track the @pleach/core repo for when the Realtime story lands.

Where to go next

On this page