Building Custom Adapters
Use Crux's shared orchestration layer to build adapters for any AI SDK with minimal boilerplate.
This guide is for developers building adapters for SDKs not covered by the built-in @crux/ai, @crux/openai,
@crux/google, and @crux/anthropic packages. Most users don't need this.
Overview
Crux adapters translate between the SDK-agnostic prompt system and a specific AI provider's API. Every adapter does the same orchestration work:
- Resolve the prompt into system/messages/schema/settings
- Convert to SDK-specific format (messages, settings, tools)
- Wrap with middleware, timing, and hooks
- Call the SDK
- Normalize metadata for devtools, evals, and quality experiments
Step 3 is identical across all adapters. Provider runtimes compile into a private execution facade, which wires prompt resolution, tool lifecycle, safety policy, retries, stream safety, metadata stamping, and memory capture around the SDK-specific call. If your SDK has ordinary chat text/structured/stream calls, start with defineProviderRuntime({ turn }); use the lower-level shared orchestration functions only when you are writing a bespoke runtime compiler.
Fast path: native chat providers
Use defineProviderRuntime() from @crux/core/adapter when your SDK boundary looks like OpenAI Chat Completions, Anthropic Messages, or Google GenAI content generation: Crux should own the loop, and your package should own provider wire facts.
import { defineProviderRuntime } from '@crux/core/adapter'
const bindAcme = (client: AcmeClient) => ({
call: (request, mode) => (mode === 'structured' ? client.parse(request) : client.create(request)),
stream: (request) => client.stream(request),
})
export const acmeProviderRuntime = defineProviderRuntime({
id: 'acme',
turn: {
bind: bindAcme,
request: acmeRequest,
response: acmeResponse,
stream: {
request: (request) => ({ ...request, stream: true }),
textDelta: acmeTextDelta,
},
settings: acmeSettings,
outputSchema: acmeOutputSchema,
transcript: acmeTranscript,
},
})
export const createAcme = acmeProviderRuntime.createThis keeps tests small: bind acmeProviderRuntime.create(scriptedClient), run prompts through the public adapter methods, and assert on the scripted client's captured request bodies. Add provider-specific transcript tests for unusual message conversion behavior.
Native transcript codec
Convert between Crux's canonical Message format and your SDK's format with a NativeTranscriptCodec:
import type { Message } from '@crux/core'
import type { NativeTranscriptCodec } from '@crux/core/adapter'
export function toMessages(sdkMessages: readonly unknown[]): Message[] {
return sdkMessages.map((msg) => ({
role: normalizeRole(msg),
content: extractText(msg),
}))
}
export function fromMessages(messages: readonly Message[]): YourSDKMessage[] {
return messages.map((msg) => ({
role: msg.role,
content: msg.content,
}))
}
export const acmeTranscript = {
fromMessages,
toMessages,
readAssistant: (raw) => ({
text: raw.message.content,
toolCalls: raw.message.toolCalls,
}),
} satisfies NativeTranscriptCodec<YourSDKMessage, AcmeResponse>Keep tool-call ids, provider-specific roles, rich tool results, and assistant extraction in this codec. Request builders should consume args.providerMessages rather than converting args.messages again.
Request, response, and settings hooks
Runtime hooks should be narrow and typed. Request hooks build SDK-native input; response hooks normalize metadata only; transcript hooks own assistant text/tool calls.
import type { GenerationSettings } from '@crux/core'
import type { NativeChatRequestArgs, NativeResponseMetadata } from '@crux/core/adapter'
interface AcmeExtra extends Record<string, unknown> {
readonly toolChoice?: 'auto' | 'none'
}
interface AcmeRequest {
readonly model: string
readonly messages: readonly YourSDKMessage[]
readonly temperature?: number
readonly maxTokens?: number
readonly toolChoice?: 'auto' | 'none'
}
function acmeRequest(args: NativeChatRequestArgs<AcmeExtra, YourSDKMessage>): AcmeRequest {
return {
model: args.model,
messages: args.providerMessages,
...acmeSettings(args.settings),
toolChoice: args.extra.toolChoice,
}
}
function acmeSettings(settings: GenerationSettings): Record<string, unknown> {
const result: Record<string, unknown> = {}
if (settings.temperature !== undefined) result.temperature = settings.temperature
if (settings.maxTokens !== undefined) result.maxTokens = settings.maxTokens
return result
}
function acmeResponseMeta(result: AcmeResponse): NativeResponseMetadata {
return {
usage: {
inputTokens: result.usage?.input ?? 0,
outputTokens: result.usage?.output ?? 0,
totalTokens: (result.usage?.input ?? 0) + (result.usage?.output ?? 0),
},
finishReason: result.finish_reason,
responseId: result.id,
actualModelId: result.model,
}
}SDK-loop runtimes
Use the loop branch when the SDK owns the tool loop. Implement typed hooks for the SDK boundary and let Crux steer the policy layer through the executor request:
import { defineProviderRuntime } from '@crux/core/adapter'
export const acmeSdkRuntime = defineProviderRuntime({
id: 'acme-sdk',
loop: {
describeModel: (model: AcmeModel) => ({ provider: model.provider, modelId: model.id }),
settings: acmeSdkSettings,
bind: (client: AcmeGateway) => ({
run: (request) => runAcmeLoop(client, request),
attemptStructured: (request) => runAcmeStructuredAttempt(client, request),
stream: (request) => runAcmeStream(client, request),
}),
},
})run() should return completed text/object/messages metadata or a suspended approval outcome. attemptStructured() makes one provider attempt and returns invalid schema results in-band; Crux owns the retry loop.
What your adapter doesn't need
Thanks to the shared orchestration, your adapter does not need to implement:
- Middleware integration (
getRuntime().middleware/ wrapping) - Hook dispatch (
onGenerate,onError) - Fallback loop logic
- Timeout handling
- Stream progress interception boilerplate
- Tool approval suspend/resume,
LoadSkillre-resolution, safety guardrails/constraints, validation retry, or memory capture
These are handled once in @crux/core and shared across all adapters.
Reference
See the adapter reference for full type signatures and JSDoc.