# Pleach + OpenAI SDK (/docs/with-openai-sdk)



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](/docs/migrating-from-openai-enterprise)
for the contract-side framing. The other coexist patterns —
[Anthropic SDK](/docs/with-anthropic-sdk),
[Mastra](/docs/with-mastra),
[Inngest](/docs/with-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](/docs/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 [#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 [#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.

```typescript
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-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.

```typescript
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 [#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 [#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 `StreamEvent`s                       |
| 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 [#when-you-dont-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](/docs/comparison) 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 [#when-you-dont-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) [#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](/docs/providers) walks through writing a custom
provider end-to-end.

## A note on the Realtime API [#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](https://github.com/pleachhq/core) for when
the Realtime story lands.

## Where to go next [#where-to-go-next]

<Cards>
  <Card title="Migrating from OpenAI Enterprise" href="/docs/migrating-from-openai-enterprise" description="The matching contract-side page — keep the Enterprise contract, add per-end-customer rollup inside a Project and replay-deterministic eval." />

  <Card title="Pleach + Anthropic SDK" href="/docs/with-anthropic-sdk" description="The same coexist posture for Anthropic's transport — prompt-caching breakpoints, the latest tools API, batches, files." />

  <Card title="Pleach + Inngest" href="/docs/with-inngest" description="Coexist with Inngest (or Trigger.dev / DBOS / Temporal) for durability, with the audited LLM turn inside the step." />

  <Card title="The AuditableCall row" href="/docs/auditable-call-row" description="What lands in your Postgres on every LLM call, and what you can join it against." />
</Cards>
