Add another SDK
The converter, stream and engine contracts an adapter for TanStack AI, LangChain, the OpenAI SDK or Temporal implements to use the AI and workflow blocks.
The AI and workflow blocks don't import an SDK. Chats store a canonical message, streams store strings, and the run registry stores what any engine reports. The AI SDK, Chat SDK and Workflow SDK adapters are one implementation of these contracts; an adapter for another SDK implements the same ones in its own subpath or package.
The rules
These come from ADR 0010:
- An adapter owns no tables and no SQL. It calls the blocks.
- An engine that needs its own storage gets a SQL module named after the
engine, such as
workflow-sdk-worldorchat-sdk-state. - The SDK is an optional peer: load it lazily or type it structurally, so apps that don't use the adapter never install it.
- Tokens live behind a
credential_refand a credential provider, never in an adapter's options or rows.
Chat: the message converter
The AI chat block stores every message as an
AiMessage: an id, a role (system, user, assistant or tool),
parts and optional metadata. A part is text, reasoning, file,
tool-call, tool-result, tool-approval, source, data or step.
The JSON Schema is schemas/ai-message-v1.json, and aiMessageSchema is
the same check as a Standard Schema.
An adapter writes two functions, one in each direction:
| SDK | From the SDK | Back to the SDK |
|---|---|---|
| AI SDK (shipped) | fromUIMessage(message) | toUIMessage(message) |
| TanStack AI | its UI message, parts and tool calls | the same shape for useChat |
| LangChain | BaseMessage (HumanMessage, AIMessage, ToolMessage) | the BaseMessage subclasses |
| OpenAI SDK | Responses API input and output items | the input items for the next request |
Keep anything the canonical form can't hold in native. saveAssistant
stores the SDK's own message next to the canonical one, with a format
name; path(chatId, { native: true }) returns it, and the adapter uses it
when the format matches and converts the canonical message when it doesn't:
const appended = await chats.messages
.appendUser(chatId, fromSdkMessage(userMessage))
.orThrow();
const history = await chats.messages
.path(chatId, { leafId: appended.messageId, native: true })
.orThrow();
const input = history.map((stored) =>
stored.format === FORMAT ? stored.native : toSdkMessage(stored),
);
// ...run the model, then:
await chats.messages
.saveAssistant(chatId, fromSdkMessage(answer), {
parentId: appended.messageId,
format: FORMAT,
native: answer,
runId,
})
.orThrow();Wrap the model call in runs.claim and runs.release, so one answer
streams per chat, stop requests reach it and usage is recorded. Test both
converters with every message in your SDK's fixtures: each converted message
must pass the JSON Schema, and a round trip must keep the parts.
Streams: the stream store
Resumable output goes through a StreamStore from
streams: open(id), append(id, fromIdx, chunks),
read(id, fromIdx), status, isCancelled and close. Chunks are
strings, so the adapter serializes the SDK's stream parts and parses them
on the way out. Writing at an index that is already stored is a no-op, so a
retried batch is harmless. A new backend (another database, a message
broker) implements the interface and passes testStreamStore from
better-supabase/testing.
Workflows: the engine contract
The workflows block keeps a run registry that any
engine fills, so /workflows lists Workflow SDK and Temporal runs side by
side:
| Contract | An adapter provides |
|---|---|
runs.record(run) | a call on each status change: engine, externalId, definition, status, tenant, actor, error and times |
WorkflowStarter | (call) => Promise<runId> for schedules, admission and semaphores; pass idempotencyKey to the engine |
| cancellation | honor runs.requestCancel by cancelling the engine's run, then record cancelled |
Statuses are queued, running, waiting, completed, failed and
cancelled. A Temporal adapter maps WorkflowExecutionStatus onto them,
uses idempotencyKey as the workflow id, and records from an interceptor
or a completion activity.
The workflow builder adds two more:
| Contract | An adapter provides |
|---|---|
GraphCompiler | (graph) => compiled, stored through the service transport; a starter compiles again, never trusts it |
BuilderStarter | (call) => Promise<runId> that starts a published version with its input |
GraphRuntime | runKey, step(call), sleep(ms) and approval(token, node) for executeGraph inside the engine |
executeGraph(graph, input, runtime) walks the graph; the adapter's
runtime maps step to a durable step (a Temporal activity), sleep to a
durable timer and approval to a signal named by the token.
better-supabase/workflow-sdk/builder is the reference implementation.
Where to put it
An adapter inside this repository is a subpath such as
better-supabase/langchain, with its own docs page and the SDK as an
optional peer. An adapter outside it is a package that depends on
better-supabase and uses only its public exports. In both cases, run the
conformance kits from better-supabase/testing for every interface it
implements.
Last updated on