Crux
GuidesAdvanced

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 @use-crux/ai, @use-crux/openai, @use-crux/google, and @use-crux/anthropic packages. Most users don't need this.

Overview

Your SDK is not covered by the built-in adapters, and you want the full Crux pipeline around it anyway: defineSingleTurnProviderBundle() gets you a complete adapter from a handful of typed hooks, with Crux owning the orchestration and your package owning only the provider wire facts. Use defineProviderRuntime() only when you need the lower-level runtime compiler directly; see the adapter reference for how runtimes compile into the shared execution facade.

Fast path: native chat providers

Use defineSingleTurnProviderBundle() from @use-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 { defineSingleTurnProviderBundle } from "@use-crux/core/adapter";

const bindAcme = (client: AcmeClient) => ({
  call: (request, mode) =>
    mode === "structured" ? client.parse(request) : client.create(request),
  stream: (request) => client.stream(request),
});

const acme = defineSingleTurnProviderBundle({
  id: "acme",
  bind: bindAcme,
  profile: {
    request: acmeRequest,
    response: acmeResponse,
    stream: {
      request: (request) => ({ ...request, stream: true }),
      textDelta: acmeTextDelta,
    },
    settings: acmeSettings,
    outputSchema: acmeOutputSchema,
    transcript: acmeTranscript,
  },
});

export const acmeProviderRuntime = acme.runtime;
export const createAcme = acme.create;
export const acmeHelpers = acme.helpers();

The bundle keeps the public runtime, adapter factory, and lightweight helper factory in one place. If the public factory should accept options instead of a raw dependency object, add deps.create and deps.helpers mappers; Google uses this to keep CachedContent policy in @use-crux/google while core receives only a narrow cache resolver.

This keeps tests small: bind acmeProviderRuntime.create(scriptedClient), run prompts through the public adapter methods, and assert on the scripted client's captured request bodies. For reusable adapter coverage, add the provider-runtime conformance suite; it exercises the same public runtime boundary used by the built-in adapters:

import { describeCruxAdapterConformance } from "@use-crux/core/adapter/testing/vitest";

describeCruxAdapterConformance({
  name: "acme",
  runtime: acmeProviderRuntime,
  harness: acmeConformanceHarness(),
  capabilities: {
    ownership: "single-turn",
    structuredOutput: true,
    streaming: true,
  },
});

Keep provider-specific transcript tests for unusual message conversion behavior.

Media and completed operations

Treat canonical image, audio, video, and file parts as ordinary messages. Normalize and validate them in the package that owns the provider SDK, before the network boundary. Known unsupported combinations should throw UnsupportedCapabilityError before I/O; unknown custom model ids should reach native provider validation. Preserve provider continuation state only in typed provider options, never visible text or observability.

Use defineCompletedOperation() plus bindCompletedOperation() for non-streaming image generation, transcription, or speech. The immutable definition owns normalization, private support preflight, invocation, result validation, and descriptor-only reporting. It must not accept storage, expose a capability registry, or enter the language/tool loop. If the provider cannot implement an operation, omit it from the runtime extension instead of returning a throwing stub.

Test the public bound operation with deterministic SDK fakes and contribute its support row to the private conformance fixtures. Reports may contain provider/model/execution/call counts and bounded media descriptors, but never bytes, URLs, file ids, filenames, refs, or native responses.

Native transcript codec

Convert between Crux's canonical Message format and your SDK's format with a NativeTranscriptCodec:

import type { Message } from "@use-crux/core";
import type { NativeTranscriptCodec } from "@use-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.

Compile a codec from a dialect

Writing fromMessages/toMessages/appendToolRound by hand means re-reading Message.metadata (tool calls, tool names, model output, error flags) in every provider. Instead, implement a ProviderTranscriptDialect and let core own the canonical transcript semantics. Core extracts neutral ProviderTranscriptUnits, groups adjacent tool results, renders fallback text through shared ToolResultEncodingHelpers, and appends each tool round exactly once with appendCanonicalToolRound():

import { defineProviderTranscriptCodec } from "@use-crux/core/adapter";
import type { ProviderTranscriptDialect } from "@use-crux/core/adapter";

const acmeDialect: ProviderTranscriptDialect<YourSDKMessage, AcmeResponse> = {
  // `system` units can be dropped; return undefined to emit nothing.
  encodeText: ({ role, text }) =>
    role === "system" ? undefined : { role, content: text },
  encodeAssistant: ({ text, toolCalls = [] }) =>
    encodeAssistant(text, toolCalls),
  // `helpers.plainText` / `contentParts` / `errorFlag` give one canonical reading
  // of each result; fan out to one wire message per result via a returned array.
  encodeToolResults: ({ results }, helpers) =>
    results.map((r) => encodeToolResult(r, helpers)),
  decodeMessage: (msg) => decodeToUnits(msg),
  readAssistant: (raw) => ({
    text: raw.message.content,
    toolCalls: raw.message.toolCalls,
  }),
};

export const acmeTranscript = defineProviderTranscriptCodec(acmeDialect);

A dialect only ever sees neutral units (ProviderToolCall, ProviderToolResult) and SDK types (never raw Message.metadata), so the public fromMessages() and runtime tool-round appends cannot drift apart. Prefer this over a hand-written NativeTranscriptCodec for any tool-calling provider; reach for a bespoke appendToolRound only if the canonical transcript units genuinely cannot represent your wire format.

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.

Response hooks, payload validators, and SDK-loop ports return provider facts, not Crux correlation IDs. Preserve provider identity as responseId; the Core managed-operation boundary adds the exact traceId and spanId after the payload is complete. See the payload-versus-observed-result contract.

import type { GenerationSettings } from "@use-crux/core";
import type {
  NativeChatRequestArgs,
  NativeResponseMetadata,
} from "@use-crux/core/adapter";

interface AcmeExtra extends Record<string, unknown> {
  /** Provider-native override for options Crux does not model portably. */
  readonly nativeToolChoice?: "auto" | "none";
}

interface AcmeRequest {
  readonly model: string;
  readonly messages: readonly YourSDKMessage[];
  readonly temperature?: number;
  readonly maxTokens?: number;
  readonly toolChoice?: "auto" | "none" | "required" | { name: string };
}

function acmeRequest(
  args: NativeChatRequestArgs<AcmeExtra, YourSDKMessage>,
): AcmeRequest {
  return {
    model: args.model,
    messages: args.providerMessages,
    ...acmeSettings(args.settings),
    ...(args.extra.nativeToolChoice
      ? { toolChoice: args.extra.nativeToolChoice }
      : {}),
  };
}

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;
  if (settings.toolChoice !== undefined) {
    result.toolChoice =
      typeof settings.toolChoice === "string"
        ? settings.toolChoice
        : { name: settings.toolChoice.tool };
  }
  return result;
}

function acmeResponseMeta(result: AcmeResponse): NativeResponseMetadata {
  const inputTokens = result.usage?.input;
  const outputTokens = result.usage?.output;

  return {
    usage:
      inputTokens !== undefined && outputTokens !== undefined
        ? {
            inputTokens,
            outputTokens,
            totalTokens: inputTokens + outputTokens,
            inputTokenDetails: {},
            outputTokenDetails: {},
          }
        : undefined,
    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 "@use-crux/core/adapter";

export const acmeSdkRuntime = defineProviderRuntime({
  id: "acme-sdk",
  ownership: "loop-owned",
  loop: {
    describeModel: (model: AcmeModel) => ({
      provider: model.provider,
      modelId: model.id,
    }),
    settings: acmeSdkSettings,
    bind: (client: AcmeGateway) => ({
      runTextLoop: (request) => runAcmeLoop(client, request),
      runStructuredAttempt: (request) =>
        runAcmeStructuredAttempt(client, request),
      runStream: (request) => runAcmeStream(client, request),
    }),
  },
});

bind(client) returns the client-dependent operations; Crux assembles them with describeModel/settings/id into a gateway-closed LoopRuntimePort. runTextLoop() should return completed text/object/messages metadata or a suspended approval outcome. runStructuredAttempt() 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 (getHooks().middleware / wrapping)
  • Hook dispatch (onGenerate, onError)
  • Fallback loop logic
  • Timeout handling
  • Stream progress interception boilerplate
  • Tool approval suspend/resume, LoadSkill re-resolution, safety guardrails/constraints, validation retry, or memory capture

These are handled once in @use-crux/core and shared across all adapters.

Reference

See the adapter reference for full type signatures and JSDoc.

On this page