Skip to content

Run tools across every provider with one call

Call two tools in sequence — get_user_city then get_weather — and get a final text answer, using the same TypeScript regardless of which provider (OpenAI, Anthropic, Google) handles the request. Then explore AgentLoop directly for stateful agents with hooks, reports, and step control.

Every official SDK ships a different built-in runner:

  • Anthropicclient.beta.messages.toolRunner(...) (beta, Zod wrapper via betaZodTool)
  • Google — Automatic Function Calling (AFC) via a CallableTool shape ({ tool, callTool })
  • OpenAI — no built-in runner in the core SDK; you need the separate @openai/agents package, an Agent class, and a run() call

Three incompatible APIs. If you want to support all three providers, you write three separate tool-runner integrations and maintain them as the SDKs evolve.

@combycode/llm-sdk ships its own AgentLoop at the core. complete() delegates to it automatically when tools are passed — so the one-liner already uses the runner. AgentLoop directly gives you lifecycle hooks, run reports, stop() control, and persistence.

import { complete, defineTool } from '@combycode/llm-sdk';
const getUserCity = defineTool({
name: 'get_user_city',
description: "Return the user's current city.",
params: {},
execute: () => 'Paris',
});
const getWeather = defineTool({
name: 'get_weather',
description: 'Get the current weather for a city.',
params: { city: 'string' },
execute: ({ city }) => `sunny in ${city}`,
});
const { text } = await complete({
model: process.env.LLM_MODEL!,
apiKey: process.env.LLM_API_KEY,
prompt: 'What is the weather where I am?',
tools: [getUserCity, getWeather],
maxTokens: 512,
});
console.log(text); // "The weather in Paris is sunny."

Switch LLM_MODEL between anthropic/claude-haiku-4-5, openai/gpt-4o, and google/gemini-2.0-flash — the rest is identical.

For multi-turn agents that remember history between calls, use AgentLoop directly:

import { createLLM, AgentLoop, defineTool } from '@combycode/llm-sdk';
const llm = createLLM({
model: process.env.LLM_MODEL!,
apiKey: process.env.LLM_API_KEY,
});
const loop = new AgentLoop({
client: llm,
system: 'You are a helpful travel assistant.',
tools: [getUserCity, getWeather],
maxTokens: 512,
toolTimeout: 10_000,
});
const r1 = await loop.complete('What is the weather where I am?');
console.log(r1.text);
// History is preserved -- follow-up remembers prior context:
const r2 = await loop.complete('Is that warm enough for a picnic?');
console.log(r2.text);

AgentLoop emits lifecycle events on its hook bus. Attach handlers to observe steps, tool calls, and errors:

loop.hooks.on('onStepStart', (ctx) => {
console.log(`Step ${ctx.step} starting (${ctx.messageCount} messages)`);
});
loop.hooks.on('onToolCallStart', (ctx) => {
console.log(`Calling ${ctx.toolName} with`, ctx.arguments);
});
loop.hooks.on('onToolCallComplete', (ctx) => {
console.log(`${ctx.toolName} done in ${ctx.latencyMs} ms`);
});
loop.hooks.on('onRunComplete', (ctx) => {
console.log(`Run done: ${ctx.reason}, reply: ${ctx.text}`);
});

Hook names follow the pattern onRunStart, onRunComplete, onRunError, onStepStart, onStepComplete, onToolCallStart, onToolCallComplete, onToolCallError, onGuardrailTriggered, onApprovalRequested, onApprovalResolved, onWarning.

After each complete() call, loop.lastReport contains the full execution breakdown:

const res = await loop.complete('What is the weather where I am?');
const r = loop.lastReport!;
console.log(`${r.stepCount} steps, ${r.toolCallCount} tool calls`);
console.log(`LLM time: ${r.totalLlmTimeMs} ms, tool time: ${r.totalToolTimeMs} ms`);
console.log(`Total tokens: ${r.totalUsage.totalTokens}`);
for (const step of r.steps) {
console.log(` Step ${step.index}: ${step.finishReason}, ${step.usage.totalTokens} tokens`);
for (const tc of step.toolCalls) {
console.log(` ${tc.toolName}: ${tc.latencyMs} ms, error=${tc.error}`);
}
}

loop.stop() signals the loop to halt at the next step boundary. Safe to call from any hook or external timer:

const controller = new AbortController();
// Stop after 5 seconds
setTimeout(() => loop.stop(), 5_000);
const res = await loop.complete('Do a long research task...');
// res.finishReason will be 'stop' (from the stop() call)

AgentLoopConfig — full option set:

OptionTypeDefaultPurpose
clientLLMClientrequiredThe LLM to drive the loop
systemstring | () => string | Promise<string>''Agent persona / role. Pass a function for live-reload prompts
contextstring''Task-specific background (lower priority than system in registry)
toolsAgentTool[][]Executable tools; keyed by function name
historyConversationHistory | HistorySnapshotfreshReuse existing history or restore from snapshot
maxTokensnumberundefinedPer-step max_tokens (applies to every LLM call in the loop)
temperaturenumberundefinedPer-step temperature
thinkingThinkingConfigundefinedReasoning mode ({ mode: 'on' | 'off' | 'auto', effort? })
cacheCacheConfigundefinedPrompt cache strategy (see Prompt Caching)
parallelToolCallsbooleantrueRun tool calls in a step with Promise.all
toolTimeoutnumber30_000Per-tool-call timeout in ms; fires the ctx.signal AbortSignal
guardrailsGuardrail[][]Input/output checks; first tripwire halts the run
policyPermissionPolicynullGate tool calls through an allow/deny/ask policy
approve(req) => Promise<ApprovalDecision>nullHuman-in-the-loop callback for policy ‘ask’ decisions
checkpointPersistencenullPersist loop snapshot at each approval suspension point
hooksHookBusfreshShared hook bus (for wiring engine-level observers)

continueOnError — tool failure handling:

By default when execute throws, the loop sends the error message to the model and continues (continueOnError: true). Override from the onToolCallError hook:

continueOnErrorBehaviour
true (default)Error message sent as tool result; model may handle gracefully
falseLoop halts and re-throws the error

toolTimeout — per-call deadline:

The ctx.signal AbortSignal passed to execute fires after toolTimeout ms. The tool should call .throwIfAborted() or pass the signal to any fetch/async ops. If execute does not honour the signal the loop still proceeds after the timeout fires, but the tool call may continue running in the background.

parallelToolCalls:

ValueExecutionWhen to use
truePromise.all across all calls in one stepStateless I/O (API calls, reads) — lowest latency
falseSequential in model-returned orderStateful: call N side-effects must happen before call N+1

The tabs below show the real production code from the official-samples corpus. The LOC badge on each tab counts non-blank lines.

import {complete, defineTool} from '@combycode/llm-sdk';

// The differentiator: our AgentLoop IS the runner — one built-in loop over ALL
// providers. (Official: Anthropic toolRunner, Google AFC, and OpenAI needs the
// separate @openai/agents package — three incompatible APIs.) Same code as the
// hand-rolled 08 because for us there's nothing to hand-roll.
const getUserCity = defineTool({
    name: 'get_user_city',
    description: "Get the user's city.",
    params: {},
    execute: () => 'Paris'
});
const getWeather = defineTool({
    name: 'get_weather',
    description: 'Get the weather for a city.',
    params: {city: 'string'},
    execute: () => 'sunny'
});

const t0 = performance.now();
const {text} = await complete({
    model: process.env.LLM_MODEL!,
    apiKey: process.env.LLM_API_KEY,
    prompt: 'What is the weather where I am?',
    tools: [getUserCity, getWeather],
    maxTokens: 512,
});

console.log(JSON.stringify({result: text.trim(), ms: Math.round(performance.now() - t0)}));

LOC delta: ORXA = 26 lines vs OpenAI Agents SDK = 25 lines vs Anthropic = 25 lines vs Google = 21 lines. The raw line count is similar — the structural difference is that the official SDKs each require a different tool shape, a different runner API, and (for OpenAI) an extra package, while ORXA uses one defineTool + complete() for all three.

The deeper difference: each official runner is a black box specific to one provider. ORXA’s AgentLoop is the same loop for all providers, exposes the full hook bus, emits structured run reports, supports dump()/restore() for persistence, and composes with guardrails and permission policies.

system as a function enables live-reload. If you pass system: () => loadPromptFromFile(), the function is called at the start of every complete() / stream() call. The loop picks up prompt changes without reconstruction.

complete() destroys its internal client. The one-shot complete() helper creates and destroys an LLMClient per call. For high-throughput use, create a shared LLMClient via createLLM() and reuse AgentLoop across calls.

dump() / restore() enable persistence. Call loop.dump() to get a serializable AgentLoopSnapshot (history, tool names, reports, pending approvals). Pass it to AgentLoop.restore(snapshot, { client, tools }) to resume in a new process.

stop() is cooperative, not immediate. The loop checks _stopRequested at step boundaries. A tool call already in flight will still complete before the loop stops.

Next steps: