Run tools across every provider with one call
What you will achieve
Section titled “What you will achieve”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.
When and why you need this
Section titled “When and why you need this”Every official SDK ships a different built-in runner:
- Anthropic —
client.beta.messages.toolRunner(...)(beta, Zod wrapper viabetaZodTool) - Google — Automatic Function Calling (AFC) via a
CallableToolshape ({ tool, callTool }) - OpenAI — no built-in runner in the core SDK; you need the separate
@openai/agentspackage, anAgentclass, and arun()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.
Step by step
Section titled “Step by step”Step 1 — One-shot with complete()
Section titled “Step 1 — One-shot with complete()”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.
Step 2 — Stateful agent with AgentLoop
Section titled “Step 2 — Stateful agent with AgentLoop”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);Step 3 — Hook into loop events
Section titled “Step 3 — Hook into loop events”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.
Step 4 — Inspect the run report
Section titled “Step 4 — Inspect the run report”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}`); }}Step 5 — Stop a running loop
Section titled “Step 5 — Stop a running loop”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 secondssetTimeout(() => loop.stop(), 5_000);
const res = await loop.complete('Do a long research task...');// res.finishReason will be 'stop' (from the stop() call)Your options
Section titled “Your options”AgentLoopConfig — full option set:
| Option | Type | Default | Purpose |
|---|---|---|---|
client | LLMClient | required | The LLM to drive the loop |
system | string | () => string | Promise<string> | '' | Agent persona / role. Pass a function for live-reload prompts |
context | string | '' | Task-specific background (lower priority than system in registry) |
tools | AgentTool[] | [] | Executable tools; keyed by function name |
history | ConversationHistory | HistorySnapshot | fresh | Reuse existing history or restore from snapshot |
maxTokens | number | undefined | Per-step max_tokens (applies to every LLM call in the loop) |
temperature | number | undefined | Per-step temperature |
thinking | ThinkingConfig | undefined | Reasoning mode ({ mode: 'on' | 'off' | 'auto', effort? }) |
cache | CacheConfig | undefined | Prompt cache strategy (see Prompt Caching) |
parallelToolCalls | boolean | true | Run tool calls in a step with Promise.all |
toolTimeout | number | 30_000 | Per-tool-call timeout in ms; fires the ctx.signal AbortSignal |
guardrails | Guardrail[] | [] | Input/output checks; first tripwire halts the run |
policy | PermissionPolicy | null | Gate tool calls through an allow/deny/ask policy |
approve | (req) => Promise<ApprovalDecision> | null | Human-in-the-loop callback for policy ‘ask’ decisions |
checkpoint | Persistence | null | Persist loop snapshot at each approval suspension point |
hooks | HookBus | fresh | Shared 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:
continueOnError | Behaviour |
|---|---|
true (default) | Error message sent as tool result; model may handle gracefully |
false | Loop 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:
| Value | Execution | When to use |
|---|---|---|
true | Promise.all across all calls in one step | Stateless I/O (API calls, reads) — lowest latency |
false | Sequential in model-returned order | Stateful: call N side-effects must happen before call N+1 |
Compare the SDKs
Section titled “Compare the SDKs”The tabs below show the real production code from the official-samples corpus. The LOC badge on each tab counts non-blank lines.
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.
Gotchas and next steps
Section titled “Gotchas and next steps”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:
- Tools guide — full
defineToolAPI, parameter schemas, error handling - Agent Loop guide — loop internals, hooks reference,
ConversationHistory - Agent Patterns — guardrails, HITL approval, multi-agent delegation
- Multi-step loop (hand-rolled) — the same scenario showing what ORXA replaces