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:
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
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:
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:
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):
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
return {
type: 'text',
content: 'The generated text',
thinking: { content: 'Reasoning...' }, // Optional
shouldStop: true, // true for natural completion
stopReason: 'end_turn',
};Tool Calls
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:
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
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:
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→ strategyforced_tool: forced calls carrytoolChoice: { type: 'tool', toolName }(andreasoningMode: 'disabled'unlesshistoryBoundThinking). Forward the named tool choice to the provider; never silently fall back toauto.forcedToolChoice: false, nativeStructuredOutput: true→ strategynative_output: forced calls carryoutputFormat({ type: 'json_schema', name, schema, description? },schemaa Zod schema to convert) andminOutputTokens. Send it as the provider's structured-output format, use at leastminOutputTokens, and return the answer as an ordinarytextstep result whosecontentis 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_unavailableforoutputSchemaagents. status: 'unresolved'(e.g. a model id you do not recognise) →outputSchemaagents are refused before their run starts; agents withoutoutputSchemarun 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:
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 successfullytool_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:
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)withoutawaitleaks an unhandled rejection. - A bare
await callbacks.onError(error)lets the rejection escapegenerateStep. On Temporal a throwngenerateStepis 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:
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:
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
// 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.
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 (orrawText, an unparseable answer) is returned asJSON.stringify(output)only when the request carriesoutputFormat. 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 JSONtext: a JSONtextresponse would also pass against a runtime that droppedoutputFormat. - Reasoning blocks. A scripted
reasoningentry is a string (''is an empty signed block) or{ redacted: true }(a redacted block: no text, opaqueproviderOptions.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,
strictand non-cacheproviderOptions. 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 droppedpartsarray 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
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
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:
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.
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:
// 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:
// 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
- LLM Overview - Understanding the adapter interface
- Vercel AI SDK Adapter - Reference implementation
- Testing Guide - Using mocks in tests