Skip to content

Run a model in-process (ONNX, no server, no socket)

A local OpenAI-compatible server — llama-server, Ollama, vLLM, LM Studio — is just a baseUrl, so it needs no recipe. This page is about the case that does: the model runs inside your process and there is no server at all.

const client = createInProcessClient({
model: "my-local",
generate: (request) => ({ content: "" }),
})

That is the whole integration. No baseUrl — there is no URL. No apiKey — there is no auth. No style — there is no wire to be neutral about. And no Response, no RoundTripper, no HttpMessageHandler: the HTTP envelope is the library’s job, not yours.

Your generate receives the assembled request and returns exactly one assistant message.

You receive:

field
messages the conversation so far, including the system prompt and tool results
tools the tool schemas offered for this call
model the model name you passed
body every other key the client assembled, if you want it

You return either an answer, or a request for tools — never both:

{ content: "The answer is 5." } // finish the run
{ toolCalls: [{ name: "add", arguments: { a: 2, b: 3 } }] } // call a tool

Optionally add usage. Omit it and the run reports zero tokens rather than failing.

Three things the library does so you do not have to: it derives finish_reason, it builds the choices envelope, and it encodes tool arguments — pass a structured value and it is serialized for you, or pass an already-encoded string and it goes through untouched.

Each of these runs a full tool-calling turn — the model asks for add, the tool executes, the model answers — with zero sockets opened and no wire configuration.

import { createInProcessClient, createToolkit } from "toolnexus"
let turn = 0
const client = createInProcessClient({
model: "my-local",
generate: (req) => {
if (++turn === 1) return { toolCalls: [{ name: "add", arguments: { a: 2, b: 3 } }] }
return { content: `The answer is ${req.messages.at(-1).content}.` }
},
})
const r = await client.run("What is 2 + 3?", { toolkit: await createToolkit({ builtins: false, extraTools: [add] }) })

The constructor is model-agnostic. Two runnable examples in the repo wire it to an actual ONNX model — Qwen/Qwen2.5-1.5B-Instruct, int8, 1.7 GB — including the ChatML template and <tool_call> parsing a real model needs:

Python python/examples/local_onnx_model.py
Go golang/examples/onnx/
tool call : get_weather({'city': 'Chennai'}) -> {"city": "Chennai", "tempC": 31, "sky": "clear"}
answer : The current weather in Chennai is 31°C with clear skies.
turns : 2 | sockets opened: 0

One warning worth inheriting: pick a model actually trained for tool calling. A 135M instruct model will hallucinate an answer rather than call, and no amount of transport code fixes that.

It has not gone anywhere. fetch / httpClient / transport remains the answer when you need a proxy, mTLS, credential injection or record-replay — see Bring your own HTTP client. The in-process constructor is built on it, not beside it.

Appendix: the raw transport’s body shape, per port

Section titled “Appendix: the raw transport’s body shape, per port”

You do not need this to run an in-process model — createInProcessClient handles it. It matters only if you drop to the transport directly (proxy, mTLS, record-replay), because each port’s is idiomatic and the body shape differs:

Port Seam Request body you receive Response body you return
JavaScript fetch init.body — a JSON string a Response (JSON string)
Python http_transport (post/open) a dict a dict
Go HTTPClient (http.RoundTripper) req.Bodybytes *http.Response (bytes)
Java httpClient (subclass HttpClient) HttpRequestbytes HttpResponse<String>
C# HttpHandler (HttpMessageHandler) HttpRequestMessagebytes HttpResponseMessage
Elixir transport (1-arg fn) req.body — an un-marshalled map a map
Clojure :http-client ((url headers body)) a JSON string {:status :headers :body}, body a JSON string

Python and Elixir hand you the body already parsed, so an in-process model there pays no serialization at all. The other five hand you bytes, because that is what their HTTP client hands them.

This is the part worth being explicit about: you swap one option, and nothing else in your setup moves.

The seam is scoped to the LLM path on purpose. Your MCP servers, agent skills, native and HTTP tools, built-ins and outbound A2A agents keep using their own clients and keep working exactly as they did against a hosted model — you do not re-register them, re-wire them, or run them in a special mode:

// The ONLY line that changes when you move from GPT-4o to a model in your process.
const client = createClient({
baseUrl: "http://local.invalid", style: "openai", model: "local", apiKey: "unused",
fetch: localFetch(new LocalModel()), // ← this line, and nothing else
})
// Untouched. Still real MCP servers over stdio and HTTP, still real skills.
const toolkit = await createToolkit({
mcpConfig: "./mcp.json",
skillsDir: "./skills",
})

That separation is deliberate, and it is what makes a local model useful rather than merely isolated. A model running in your process still needs to read files, query GitHub, call your REST API and hand work to another agent — so the MCP servers and HTTP tools should open their real connections. A seam that silenced them along with the model would have turned a working agent into a sandbox.

Run against the shared examples/mcp.json fixture, that is exactly what you get — a socket-free model calling a real stdio MCP server it knows nothing about:

mcp status : {"example-remote":"disabled","everything":"connected"}
tools : 24 | has everything_echo: true | has skill: true
text : MCP replied: Echo: hello from a local model
toolCalls : [ [ 'everything_echo', 'Echo: hello from a local model' ] ]

The MCP server was spawned, connected and called normally. The model never opened a connection. Neither half knows about the other, which is the whole design.

Related: Bring your own HTTP client for the proxy / mTLS / credentials version of the same seam.