createClient
JavaScript · package toolnexus · SPEC §8 · js/src/client.ts
function createClient(opts: ClientOptions): ClientThe host loop. Give it a provider endpoint and a Toolkit, and it runs the whole tool-calling
cycle: build the system prompt, inject the skill catalog, call the model, execute the tool calls it
asks for — in parallel where the model requested parallel — feed results back, and repeat until the
model stops asking.
When to use it
Section titled “When to use it”Use createClient when you want an agent, not just tool schema. It is the difference between
“I have a list of tools” and “something is running”.
It is vendor-neutral by construction: style selects the wire format (OpenAI-shaped or
Anthropic-shaped) and baseUrl points anywhere — OpenAI, Anthropic, OpenRouter, a local Ollama, or
your own gateway. The toolkit does not change.
Why this and not your own loop
Section titled “Why this and not your own loop”Prefer createClient when you would otherwise have to hand-write:
- Parallel and chained tool calls. The model can request several calls in one turn and then more based on the results. Getting the transcript shape right across both providers is fiddly.
- Suspension (§10). A tool returning
Pendingparks the run. WithwaitForset, the client resolves and retries. Without it,run()returns{ status: "pending" }so a durable host can resume later — same contract either way. - Retries and deadlines. Transient 429/5xx handling, exponential backoff with jitter, and a whole-run timeout that aborts the in-flight request.
- Memory.
ask(prompt, { id })keeps a transcript across turns. - Hooks and metrics. Lifecycle interception and one metric event per model and tool call.
Examples
Section titled “Examples”1. One turn against a toolkit
Section titled “1. One turn against a toolkit”import { createToolkit, createClient } from "toolnexus"
const tk = await createToolkit({ skillsDir: "./examples/skills" })
const client = createClient({ baseUrl: "https://api.openai.com/v1", style: "openai", model: "gpt-4o-mini", apiKey: process.env.OPENAI_API_KEY,})
const res = await client.run("Say hello using the hello-world skill.", { toolkit: tk })console.log(res.output)
await tk.close()2. Multi-turn memory and streaming
Section titled “2. Multi-turn memory and streaming”ask remembers by conversation id; stream yields text deltas and tool-call events as they
happen. This mirrors js/examples/streaming.ts and js/examples/memory.ts.
const client = createClient({ baseUrl: "https://openrouter.ai/api/v1", style: "openai", model: "anthropic/claude-sonnet-4", apiKey: process.env.OPENROUTER_API_KEY, systemPrompt: "You are a terse assistant.", maxTurns: 10,})
// Remembers across calls — same id, same transcript.await client.ask("My name is Muthu.", { toolkit: tk, id: "conv-1" })const res = await client.ask("What is my name?", { toolkit: tk, id: "conv-1" })console.log(res.output) // knows the answer
// Streaming: text deltas plus tool-call events.for await (const ev of client.stream("Summarise the repo.", { toolkit: tk, id: "conv-1" })) { if (ev.type === "text") process.stdout.write(ev.delta) if (ev.type === "tool_call") console.log("\n[calling]", ev.name)}3. The full surface — hooks, metrics, resilience, suspension
Section titled “3. The full surface — hooks, metrics, resilience, suspension”const client = createClient({ baseUrl: "https://api.anthropic.com/v1", style: "anthropic", model: "claude-sonnet-4", apiKey: process.env.ANTHROPIC_API_KEY,
// Resilience. retries: 3, retryBaseMs: 500, // exponential + jitter timeoutMs: 120_000, // whole-run deadline, aborts the in-flight request
// Durable memory instead of process-lifetime in-memory. store: myPostgresConversationStore,
// Lifecycle middleware — audit, redact, veto, rewrite. hooks: { beforeTool: async ({ name, args }) => { if (name === "bash") return { veto: "shell disabled in prod" } console.log("[tool]", name, args) }, afterTool: async ({ name, result }) => console.log("[done]", name, result.output?.length), },
// One event per model call, per tool call, per run. onMetric: (ev) => statsd.timing(`toolnexus.${ev.event}`, ev.durationMs),
// §10 — a tool returned Pending; resolve it and the loop retries the tool. waitFor: async (request) => ({ kind: "input", value: await askOnSlack(request) }),
// Provider-specific escape hatches (§8 Gap 1/2). requestParams: { max_tokens: 8192 }, // wins on collision bodyTransform: (body) => ({ ...body, metadata: { tenant: "acme" } }), fetch: myProxiedFetch, // LLM path only, retries included})Options
Section titled “Options”| Option | Type | What it does |
|---|---|---|
baseUrl |
string |
Provider endpoint. Required. |
style |
ClientStyle |
Wire format — OpenAI-shaped or Anthropic-shaped. Required. |
model |
string |
Model id. Required. |
apiKey |
string |
Read from the environment; never hard-code it. |
headers |
Record<string, string> |
Extra request headers. |
systemPrompt |
string |
Prepended; the skill catalog is injected alongside it. |
maxTurns |
number |
Cap on loop iterations. |
hooks |
Hooks |
Lifecycle middleware around model and tool calls. |
retries |
number |
Transient-error retries. Default 2. |
retryBaseMs |
number |
Base backoff, exponential + jitter. Default 500. |
timeoutMs |
number |
Whole-run deadline; aborts the in-flight request. |
store |
ConversationStore |
Conversation provider for ask(prompt, { id }). Default in-memory. |
onMetric |
(ev: MetricEvent) => void |
Metric sink. No cost when unset. |
waitFor |
(req: Request) => Promise<Answer> |
§10 suspension resolver. Omit ⇒ run() returns status:"pending". |
requestParams |
Record<string, unknown> |
Merged into every body; wins on collision. messages/tools/stream forbidden. |
bodyTransform |
(body) => body |
Rewrite the assembled body just before marshal. |
fetch |
typeof fetch |
HTTP transport for the LLM path only. |
What you get back
Section titled “What you get back”| Method | Returns | |
|---|---|---|
run(prompt, ctx) |
Promise<RunResult> |
One turn; loops until the model stops. |
ask(prompt, ctx) |
Promise<RunResult> |
Like run, but remembers by ctx.id. |
stream(prompt, ctx) |
AsyncGenerator<StreamEvent> |
Text deltas and tool-call events. |
conversation(ctx) |
Conversation |
An explicit multi-turn handle. |
translate(req) |
Promise<TranslateResult> |
§11 single turn — declares tools, executes nothing. |
conversationStore() |
ConversationStore |
The store in use. |
metrics() |
string |
Rendered metrics snapshot. |
See also
Section titled “See also”createToolkit— what the client runs againstHooks·Conversation·onMetricpending— suspend a run and ask a human