Skip to content

Custom LLM Adapters ​

Build your own LLM adapter to integrate with custom providers, private APIs, or specialized models. This guide covers the adapter interface and implementation patterns.

The LLMAdapter Interface ​

Every adapter must implement:

typescript
interface LLMAdapter {
  generateStep(input: LLMGenerateInput): Promise<StepResult>;
  resolveCapabilities?(config: LLMConfig): CapabilityResolution; // optional
  checkOutputFormat?(
    format: LLMOutputFormat,
    config: LLMConfig
  ): { ok: true } | { ok: false; reason: string }; // optional
}

generateStep receives all context and returns a typed result. The two optional methods tell the framework how the model can be forced to finish (see Capabilities and native structured output); an adapter without them behaves exactly as before (the legacy profile).

Input Structure ​

typescript
interface LLMGenerateInput {
  // Conversation history (includes system prompt as first message)
  messages: Message[];

  // Available tools (framework format)
  tools: Tool[];

  // LLM configuration
  config: LLMConfig;

  // Cancellation signal
  abortSignal?: AbortSignal;

  // Streaming callbacks
  callbacks?: LLMStreamCallbacks;

  // Context
  agentId: string;
  agentType: string;

  // Forced completion only (omitted on ordinary calls)
  executionPhase?: 'work' | 'forced_completion';
  toolChoice?: LLMToolChoice; // forced_tool strategy: a named { type: 'tool', toolName }
  reasoningMode?: 'default' | 'disabled'; // forced_tool strategy on non-history-bound models
  outputFormat?: LLMOutputFormat; // native_output strategy: constrain the response to this JSON schema
  minOutputTokens?: number; // native_output strategy: max(config.maxOutputTokens ?? default, this)
}

Messages ​

Messages use a simple format:

typescript
type Message =
  | { role: 'system'; content: string }
  | { role: 'user'; content: string }
  | { role: 'assistant'; content: string; toolCalls?: ToolCallInfo[] }
  | { role: 'tool'; toolCallId: string; toolName: string; content: string };

Convert these to your provider's format in the adapter.

Tools ​

Framework tools have Zod schemas:

typescript
interface Tool {
  name: string;
  description: string;
  inputSchema: z.ZodType; // Zod schema
  outputSchema?: z.ZodType;
  execute: (input, context) => Promise<unknown>;
}

Convert to your provider's format (usually JSON Schema):

typescript
import { zodToJsonSchema } from 'zod-to-json-schema';

function convertTool(tool: Tool): ProviderTool {
  return {
    name: tool.name,
    description: tool.description,
    parameters: zodToJsonSchema(tool.inputSchema),
  };
}

Output Structure ​

Return one of four result types:

Text Response ​

typescript
return {
  type: 'text',
  content: 'The generated text',
  thinking: { content: 'Reasoning...' }, // Optional
  shouldStop: true, // true for natural completion
  stopReason: 'end_turn',
};

Tool Calls ​

typescript
return {
  type: 'tool_calls',
  toolCalls: [{ id: 'tc1', name: 'search', arguments: { query: 'AI' } }],
  subAgentCalls: [], // Framework handles sub-agents
  content: 'Let me search for that.', // Optional accompanying text
  thinking: undefined,
  shouldStop: false, // Never stop on tool calls
  stopReason: 'tool_use',
};

Structured Output ​

When the LLM calls the __finish__ tool, return it as an ordinary tool call, with every other call and the text of the same response. Do not filter or special-case it:

typescript
return {
  type: 'tool_calls',
  toolCalls: [
    { id: 'call_1', name: 'lookup', arguments: { q: 'x' } },
    { id: 'call_2', name: '__finish__', arguments: { summary: 'The result', score: 0.95 } },
  ],
  subAgentCalls: [],
  content: 'Here is the result.',
  shouldStop: false,
  stopReason: 'tool_use',
};

Core decides the step. The first __finish__ call completes the run, keeps its assistant message and gets an acknowledged result. Every other call in the step is answered with a typed not_executed result, and a forced completion call that carries extra calls is a recoverable protocol miss. There is no structured_output step result: a step result that ended the run without an assistant message lost the turn's trace (RM-35).

Error ​

typescript
return {
  type: 'error',
  error: new Error('Rate limit exceeded'),
  shouldStop: true,
  stopReason: 'error',
};

Capabilities and native structured output ​

When an outputSchema agent has to be forced to finish, the framework picks a completion strategy from what your adapter reports for the model:

typescript
import type { CapabilityResolution, LLMConfig, LLMOutputFormat } from '@helix-agents/core';
import { LEGACY_CAPABILITIES } from '@helix-agents/core';

class MyAdapter implements LLMAdapter {
  resolveCapabilities(config: LLMConfig): CapabilityResolution {
    // Synchronous, pure, no I/O: called on every LLM call (every runtime except DBOS).
    if (isMyStructuredOnlyModel(config.model)) {
      return {
        status: 'resolved',
        capabilities: {
          forcedToolChoice: false, // rejects a named tool choice
          nativeStructuredOutput: true, // supports a JSON-schema response format
          historyBoundThinking: false,
        },
        profile: 'my-structured-only',
        source: 'table',
      };
    }
    return {
      status: 'resolved',
      capabilities: LEGACY_CAPABILITIES,
      profile: 'legacy',
      source: 'default',
    };
  }

  checkOutputFormat(format: LLMOutputFormat, config: LLMConfig) {
    // Called at run entry for `outputSchema` agents that resolve to `native_output`.
    return canSendNatively(format.schema)
      ? { ok: true as const }
      : { ok: false as const, reason: 'z.record is not supported' };
  }
  // ...generateStep
}
  • forcedToolChoice: true → strategy forced_tool: forced calls carry toolChoice: { type: 'tool', toolName } (and reasoningMode: 'disabled' unless historyBoundThinking). Forward the named tool choice to the provider; never silently fall back to auto.
  • forcedToolChoice: false, nativeStructuredOutput: true → strategy native_output: forced calls carry outputFormat ({ type: 'json_schema', name, schema, description? }, schema a Zod schema to convert) and minOutputTokens. Send it as the provider's structured-output format, use at least minOutputTokens, and return the answer as an ordinary text step result whose content is the JSON text. Do not parse it: the framework turns it into the completion-tool call, validates it and treats unparseable text as a recoverable miss.
  • Neither → framework_completion_strategy_unavailable for outputSchema agents.
  • status: 'unresolved' (e.g. a model id you do not recognise) → outputSchema agents are refused before their run starts; agents without outputSchema run with legacy behaviour.
  • historyBoundThinking: true → the model binds thinking to the request prefix: forced calls keep the full system prompt and tool set, and the framework refuses a call whose prompt or tools changed (framework_prompt_prefix_changed).

If your provider returns several reasoning blocks per response, or text before reasoning, return them as ordered parts on the step result ({ type: 'reasoning', text, providerOptions }, { type: 'text', text }, { type: 'tool-call', toolCallId }) and replay an assistant message's parts verbatim when present. See Preserved thinking.

Stop Reason Mapping ​

Map your provider's finish reasons to framework stop reasons:

typescript
import type { StopReason } from '@helix-agents/core';

function mapStopReason(providerReason: string): StopReason {
  switch (providerReason) {
    case 'stop':
    case 'end':
      return 'end_turn';

    case 'tool_calls':
    case 'function_call':
      return 'tool_use';

    case 'length':
    case 'max_tokens':
      return 'max_tokens';

    case 'content_filter':
    case 'safety':
      return 'content_filter';

    default:
      return 'unknown';
  }
}

The framework handles stop reasons:

  • end_turn, stop_sequence: Agent completes successfully
  • tool_use: Continue execution (not a real stop)
  • max_tokens, content_filter, refusal, error, unknown: Agent fails

Error stop reasons complete the run on Temporal and DBOS (DI-26)

The documented failure for these reasons holds only on JS, the Cloudflare DO and Cloudflare Workflows. For an agent without outputSchema, Temporal and DBOS end a run whose final text step stops with content_filter, refusal, max_tokens, error or unknown as completed with no error. Observed live for content_filter on both and for max_tokens on Temporal; the other reasons follow from source. Agents with an outputSchema are not affected, except on DBOS persistent-mode follow-up turns, which run without the schema (MA-21). See DI-26 in the conformance findings.

Streaming Callbacks ​

Invoke callbacks for real-time updates:

typescript
async generateStep(input: LLMGenerateInput): Promise<StepResult> {
  const { callbacks } = input;

  // Stream text tokens
  for await (const token of provider.streamTokens()) {
    await callbacks?.onTextDelta?.(token);
  }

  // Stream thinking content
  if (thinkingChunk) {
    await callbacks?.onThinking?.(thinkingChunk, isComplete);
  }

  // Notify of tool calls
  for (const toolCall of toolCalls) {
    await callbacks?.onToolCall?.(toolCall);
  }

  // Report errors: see "Reporting errors" below — use reportLLMError
  if (error) {
    const onErrorFailure = await reportLLMError(callbacks, error);
    return { type: 'error', error, shouldStop: true, stopReason: 'error',
      ...(onErrorFailure ? { onErrorFailure } : {}) };
  }

  return result;
}

The callbacks can return a rejecting promise (DI-23)

Every callback is typed void | Promise<void>, and the runtimes (JS, Temporal, Cloudflare Workflows) implement them as async writes to the run's stream. When that stream has already ended or failed, the write rejects. Always await them, as the examples on this page do: a callback called without await leaves an unhandled promise rejection, which exits a Node process under the default --unhandled-rejections=throw. See DI-23 in the conformance findings.

Reporting errors ​

Report an LLM error with reportLLMError(callbacks, error) from @helix-agents/core rather than calling callbacks.onError yourself. It awaits onError and, if onError rejects, returns that rejection (normalized to an Error) instead of throwing. Put it on the error result as onErrorFailure, and keep the original LLM error in error:

  • A bare callbacks.onError(error) without await leaks an unhandled rejection.
  • A bare await callbacks.onError(error) lets the rejection escape generateStep. On Temporal a thrown generateStep is an activity failure: the activity is retried, the model is called again, and the run's error becomes the stream-write failure instead of the provider error.

The runtimes log a surfaced onErrorFailure at warn (event llm.on_error_callback_failed, with sessionId, runId and the original error code), so the adapter does not need to log it. The built-in VercelAIAdapter and MockLLMAdapter both follow this contract.

Complete Example ​

Here's a minimal adapter for a hypothetical API:

typescript
import type {
  LLMAdapter,
  LLMGenerateInput,
  StepResult,
  ParsedToolCall,
  StopReason,
} from '@helix-agents/core';
import { FINISH_TOOL_NAME, coerceToolCallArguments, reportLLMError } from '@helix-agents/core';
import { zodToJsonSchema } from 'zod-to-json-schema';

export class MyProviderAdapter implements LLMAdapter {
  private apiKey: string;

  constructor(apiKey: string) {
    this.apiKey = apiKey;
  }

  async generateStep(input: LLMGenerateInput): Promise<StepResult> {
    const { messages, tools, config, abortSignal, callbacks, agentId, agentType } = input;

    try {
      // Convert messages to provider format
      const providerMessages = this.convertMessages(messages);

      // Convert tools to provider format
      const providerTools = tools.map((t) => ({
        name: t.name,
        description: t.description,
        parameters: zodToJsonSchema(t.inputSchema),
      }));

      // Call provider API (streaming)
      const response = await fetch('https://api.myprovider.com/chat', {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${this.apiKey}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          messages: providerMessages,
          tools: providerTools,
          max_tokens: config.maxOutputTokens,
          temperature: config.temperature,
          stream: true,
        }),
        signal: abortSignal,
      });

      // Process streaming response
      let text = '';
      const toolCalls: ParsedToolCall[] = [];
      let finishReason: string | undefined;

      const reader = response.body?.getReader();
      if (!reader) throw new Error('No response body');

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        const chunk = JSON.parse(new TextDecoder().decode(value));

        // Handle different chunk types
        if (chunk.type === 'text_delta') {
          text += chunk.text;
          await callbacks?.onTextDelta?.(chunk.text);
        } else if (chunk.type === 'tool_call') {
          const toolCall = {
            id: chunk.id,
            name: chunk.name,
            // Some models/SDKs surface tool inputs as a JSON *string* (notably
            // for large/nested inputs). Coerce at the boundary so the value is
            // an object before it is executed, persisted, or replayed — a raw
            // string later replays as a non-object tool_use.input and the
            // provider rejects it (400). coerceToolCallArguments is exported
            // from @helix-agents/core for exactly this. `__finish__` is left
            // as-is: its output schema may be a non-object.
            arguments:
              chunk.name === FINISH_TOOL_NAME
                ? chunk.arguments
                : coerceToolCallArguments(chunk.arguments),
          };
          toolCalls.push(toolCall);
          await callbacks?.onToolCall?.(toolCall);
        } else if (chunk.type === 'finish') {
          finishReason = chunk.reason;
        }
      }

      // Map stop reason
      const stopReason = this.mapStopReason(finishReason);

      // Handle tool calls. A `__finish__` call (structured output) is returned like
      // any other call, with its siblings and the text: core decides the step.
      if (toolCalls.length > 0) {
        return {
          type: 'tool_calls',
          toolCalls,
          subAgentCalls: [],
          content: text || undefined,
          shouldStop: false,
          stopReason: 'tool_use',
        };
      }

      // Text response
      return {
        type: 'text',
        content: text,
        shouldStop: stopReason !== 'tool_use',
        stopReason,
      };
    } catch (caught) {
      const error = caught instanceof Error ? caught : new Error(String(caught));
      // Awaits callbacks.onError and never throws (see "Reporting errors"):
      // a rejecting onError (e.g. the run's stream already ended) comes back
      // as onErrorFailure, and the provider error stays the step's error.
      // Rethrowing instead would, on Temporal, retry the activity and mask
      // the provider error behind the stream-write failure.
      const onErrorFailure = await reportLLMError(callbacks, error);

      return {
        type: 'error',
        error,
        shouldStop: true,
        stopReason: 'error',
        ...(onErrorFailure ? { onErrorFailure } : {}),
      };
    }
  }

  private convertMessages(messages: LLMGenerateInput['messages']) {
    return messages.map((msg) => {
      switch (msg.role) {
        case 'system':
          return { role: 'system', content: msg.content };
        case 'user':
          return { role: 'user', content: msg.content };
        case 'assistant':
          if (msg.toolCalls) {
            return {
              role: 'assistant',
              content: msg.content,
              tool_calls: msg.toolCalls.map((tc) => ({
                id: tc.id,
                function: { name: tc.name, arguments: JSON.stringify(tc.arguments) },
              })),
            };
          }
          return { role: 'assistant', content: msg.content ?? '' };
        case 'tool':
          return {
            role: 'tool',
            tool_call_id: msg.toolCallId,
            content: msg.content,
          };
      }
    });
  }

  private mapStopReason(reason: string | undefined): StopReason {
    switch (reason) {
      case 'stop':
        return 'end_turn';
      case 'tool_calls':
        return 'tool_use';
      case 'length':
        return 'max_tokens';
      case 'content_filter':
        return 'content_filter';
      default:
        return 'unknown';
    }
  }
}

MockLLMAdapter for Testing ​

The framework includes MockLLMAdapter for testing, exported from @helix-agents/core/testing/mocks (no vitest import, so it also works in scripts and Worker bundles) and from @helix-agents/core/testing:

typescript
import { MockLLMAdapter } from '@helix-agents/core/testing/mocks';

// Create with pre-configured responses
const mock = new MockLLMAdapter([
  { type: 'text', content: 'Searching...', shouldStop: false },
  {
    type: 'tool_calls',
    toolCalls: [{ id: 'tc1', name: 'search', arguments: { query: 'AI' } }],
  },
  { type: 'structured_output', output: { result: 'Found it!' } },
]);

// Or add responses incrementally
mock.addResponse({ type: 'text', content: 'Hello', shouldStop: true });

// Use in tests
const executor = new JSAgentExecutor(stateStore, streamManager, mock);
const handle = await executor.execute(agent, 'Test');
const result = await handle.result();

// Verify call count
expect(mock.getCallCount()).toBe(3);

Mock Response Types ​

typescript
// Text response
{
  type: 'text',
  content: 'Generated text',
  shouldStop: true,
  stopReason: 'end_turn',  // Optional
}

// Tool calls
{
  type: 'tool_calls',
  toolCalls: [{ id: 'tc1', name: 'search', arguments: { q: 'test' } }],
  subAgentCalls: [],  // Optional
  content: 'Accompanying text',  // Optional
}

// Structured output: shorthand for a tool_calls step with one `__finish__` call
// whose arguments are `output` (the persisted assistant message carries it)
{
  type: 'structured_output',
  output: { summary: 'Done' },
}

// Native structured output (the native_output strategy's forced call): the JSON answer
// only when the request carries `outputFormat`, else MOCK_NATIVE_OUTPUT_WITHOUT_FORMAT prose
{
  type: 'native_output',
  output: { summary: 'Done' }, // or rawText: 'not json' (a miss)
}

// Error
{
  type: 'error',
  message: 'Rate limit exceeded',
  recoverable: true,  // Optional
}

Provider-faithful mode ​

Pass capabilities to make the mock behave like a model with those capabilities: it reports them through resolveCapabilities (profile capabilityProfile, default 'mock'), returns the real API's provider_invalid_request 400 for a named or required tool choice when forcedToolChoice is false, and, when historyBoundThinking is true, signs scripted reasoning blocks against the request prefix and rejects a later request whose system prompt, tools or earlier messages changed. RoutingMockLLMAdapter takes the same options.

typescript
const mock = new MockLLMAdapter(
  [
    {
      type: 'tool_calls',
      toolCalls: [{ id: 't1', name: 'search', arguments: {} }],
      reasoning: ['', 'plan', { redacted: true }],
    },
    { type: 'text', content: 'Done.', shouldStop: true, stopReason: 'end_turn' }, // triggers forced completion
    // The forced call's answer: JSON only if the request carries `outputFormat`.
    { type: 'native_output', output: { summary: 'done' } },
  ],
  {
    capabilities: {
      forcedToolChoice: false,
      nativeStructuredOutput: true,
      historyBoundThinking: true,
    },
    capabilityProfile: 'anthropic-5.5',
  }
);

What the faithful mode checks, as the 5.5-generation API does:

  • Native answers. A { type: 'native_output', output } response (or rawText, an unparseable answer) is returned as JSON.stringify(output) only when the request carries outputFormat. Without it the mock answers in prose (MOCK_NATIVE_OUTPUT_WITHOUT_FORMAT), so a runtime that drops the response format never completes. Script a native answer this way, not as JSON text: a JSON text response would also pass against a runtime that dropped outputFormat.
  • Reasoning blocks. A scripted reasoning entry is a string ('' is an empty signed block) or { redacted: true } (a redacted block: no text, opaque providerOptions.mock.redactedData). Each block is chained to the one before it, so an edited, reordered or removed block mid-history is rejected; a removed leading run is allowed.
  • Tools. The signatures bind each tool's name, description, schema, strict and non-cache providerOptions. Cache markers are not bound, so moving one is allowed.
  • Another model's blocks are skipped, as the API drops them, so a model switch is not rejected for them.
  • The turn in progress. A request that ends with tool results answering the last assistant message is rejected (provider_invalid_request) when that message lost thinking blocks the mock issued: a dropped parts array or a dropped trailing block. An earlier turn may drop its blocks.

A scripted reasoning array requires capabilities. The simulator itself is exported as FaithfulProviderSimulator from @helix-agents/core/testing.

Testing Patterns ​

typescript
import { describe, it, expect, beforeEach } from 'vitest';
import { MockLLMAdapter } from '@helix-agents/core/testing/mocks';

describe('MyAgent', () => {
  let mock: MockLLMAdapter;

  beforeEach(() => {
    mock = new MockLLMAdapter();
  });

  it('should use tools correctly', async () => {
    // First step: LLM requests tool
    mock.addResponse({
      type: 'tool_calls',
      toolCalls: [{ id: 'tc1', name: 'lookup', arguments: { id: '123' } }],
    });

    // Second step: LLM completes with output
    mock.addResponse({
      type: 'structured_output',
      output: { status: 'success', data: 'Found' },
    });

    const result = await executor.execute(agent, 'Look up item 123');
    expect(result.output).toEqual({ status: 'success', data: 'Found' });
  });

  it('should handle errors', async () => {
    mock.addResponse({
      type: 'error',
      message: 'API unavailable',
      recoverable: false,
    });

    const result = await executor.execute(agent, 'Test');
    expect(result.status).toBe('failed');
  });
});

Best Practices ​

1. Handle Abort Signals ​

typescript
async generateStep(input: LLMGenerateInput): Promise<StepResult> {
  const { abortSignal } = input;

  // Pass to fetch
  const response = await fetch(url, { signal: abortSignal });

  // Or check manually
  if (abortSignal?.aborted) {
    return {
      type: 'error',
      error: new Error('Request aborted'),
      shouldStop: true,
      stopReason: 'error',
    };
  }
}

2. Always Return StepResult ​

Never throw from generateStep(). Return error results instead:

typescript
try {
  // LLM call
} catch (error) {
  return {
    type: 'error',
    error: error instanceof Error ? error : new Error(String(error)),
    shouldStop: true,
    stopReason: 'error',
  };
}

3. Pass the finish Tool Through ​

The __finish__ tool (FINISH_TOOL_NAME) signals structured output. Return it in toolCalls like any other call, alongside every call co-emitted with it and the response's text. Do not coerce its arguments: the output schema may be a non-object, and core repairs it against the schema.

typescript
import { FINISH_TOOL_NAME, coerceToolCallArguments } from '@helix-agents/core';

return {
  type: 'tool_calls',
  toolCalls: rawCalls.map((tc) => ({
    id: tc.id,
    name: tc.name,
    arguments: tc.name === FINISH_TOOL_NAME ? tc.arguments : coerceToolCallArguments(tc.arguments),
  })),
  subAgentCalls: [],
  content: text || undefined,
  shouldStop: false,
  stopReason: 'tool_use',
};

4. Preserve Accompanying Text ​

When LLM returns text with tool calls, preserve it:

typescript
// LLM: "Let me search for that." + [search tool call]
return {
  type: 'tool_calls',
  toolCalls: [...],
  content: 'Let me search for that.',  // Don't discard!
  ...
};

5. Invoke Callbacks Correctly ​

Stream callbacks as data arrives, not in bulk at the end:

typescript
// Good: Stream as received
for await (const chunk of stream) {
  await callbacks?.onTextDelta?.(chunk);
}

// Bad: Buffer then emit at end
const text = await stream.collect();
await callbacks?.onTextDelta?.(text); // Not streaming!

Next Steps ​

Released under the MIT License.