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:
- The
AuditableCallrow lands in your Postgres keyed by(sessionId, turnId, stageId, seqWithinTurn), withtoolName,subagentDepth, andtokenUsage. The OpenAI SDK returns aChatCompletionobject; 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. - 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
openaiat session-create time. The OpenAI SDK alone offers no such property — switching providers means rewriting the call site. - Subagents spawned during the turn roll their cost back to
the parent
turnIdviaSpawnTreeState. 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
AuditableCallrow per run, withtokenUsage,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
StreamEventsequence 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
| Concern | OpenAI SDK's slot | Pleach's slot |
|---|---|---|
| Sending tokens to a model | yes (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) | yes | preserved through AiSdkProvider |
| Native tool-call dialect | yes (OpenAI JSON Schema) | family-locked at session start so it can't silently change |
| Per-call audit row in YOUR Postgres | none — SDK returns a response object, not a row | AuditableCall row keyed (sessionId, turnId, stageId, seqWithinTurn) |
| Tamper-evident hash chain | none | prev_hash + row_hash columns at the schema level |
| Family + transport lock | none | locked at runtime.sessions.create({ provider, model }) |
| Replay-deterministic LLM stream | none — same prompt can return different stream | fingerprint replays byte-identical StreamEvents |
Subagent cost rollup to parent turnId | none — no subagent concept | parentTurnId + rootTurnCostCap on the spawn tree |
| Time-travel checkpoints inside a session | none | runtime.checkpoints.rollback() / .list() |
Multi-tenant tenant_id stamping on every row | manual | enforced by audit:tenant-scoping CI gate |
| Provider portability (swap to Anthropic without rewriting the agent loop) | none — call sites are SDK-specific | yes — swap AiSdkProvider model |
| OpenAI dashboard (usage, errors, rate limits) | built-in | none — Pleach doesn't replace it |
| Conversation state durability | Assistants API: OpenAI; Chat/Responses: yours | yours (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.createonce 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
AiSdkProvideragainst@ai-sdk/openaiand your call sites reach the same OpenAI surface, with the option to swap to@ai-sdk/anthropiclater 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
Migrating from OpenAI Enterprise
The matching contract-side page — keep the Enterprise contract, add per-end-customer rollup inside a Project and replay-deterministic eval.
Pleach + Anthropic SDK
The same coexist posture for Anthropic's transport — prompt-caching breakpoints, the latest tools API, batches, files.
Pleach + Inngest
Coexist with Inngest (or Trigger.dev / DBOS / Temporal) for durability, with the audited LLM turn inside the step.
The AuditableCall row
What lands in your Postgres on every LLM call, and what you can join it against.
Pleach + Anthropic SDK
Coexist with the Anthropic SDK. Keep prompt caching, tools API, batches, files; add @pleach/core for AuditableCall, family-lock, replay.
Pleach + Mastra
Coexist with Mastra — keep the workflow primitive, the hosted dashboard, vector DB, and evals; add `@pleach/core` for the audited LLM turn inside the step. Not a migration.