ollama-agent-kit
v0.2.1
Published
Minimal Node.js library for building autonomous tool-calling AI agents on local LLMs with Ollama. A unified registry merges local (Zod) tools and MCP tools: define a tool once — your agent uses it, your MCP server exposes it.
Maintainers
Readme
ollama-agent-kit
Give a local LLM hands. A minimal Node.js agent loop for Ollama that lets the model read, write and edit files, run shell commands, and call tools from any MCP server — entirely on your machine.
Table of contents
- Why an agent on a local model
- How it works
- Install
- Built-in tools
- Defining a tool
- One registry, two directions
- Configuring the agent
- Connecting to MCP servers
- Serving your tools as an MCP server
- API
- FAQ
- Tests
- License
Why an agent on a local model
An agent is only useful when it can act: modify files, run commands, touch the system it lives on. Nobody in their right mind hands that power to a cloud API. With a model running on your own hardware the equation changes: prompts, file contents and command output never leave your machine, inference costs nothing after setup, and everything keeps working offline.
ollama-agent-kit is built for exactly that scenario. Two runtime dependencies (ollama, zod), no chains, no prompt abstractions, no framework tax — small enough to run comfortably next to the model on a Raspberry Pi or a home server, readable enough to audit in an afternoon.
import { createAgent, readFileTool, writeFileTool, editFileTool, runShellCommandTool } from 'ollama-agent-kit'
const agent = createAgent({
model: 'gemma4:latest',
tools: [readFileTool, writeFileTool, editFileTool, runShellCommandTool],
})
await agent.run('Read package.json, bump the patch version, and run the tests')That's a fully autonomous loop on a local model: it reads the file, edits it, runs the command, checks the output — no API key, no network, no data leaving the LAN.
⚠️
runShellCommandToolexecutes arbitrary commands with your process's permissions. Only enable it for models and prompts you trust — see A note on trust.
How it works
The core is intentionally small: a chat loop that sends the conversation to Ollama, executes any requested tool calls in parallel, feeds the results back, and stops when the model produces a final answer (or maxTurns is reached).
createAgent(config).run(prompt)
│
├── resolves tools: your local registry + MCP servers
│
└── loop (up to maxTurns):
├── ollama.chat() with the merged tool list
├── no tool calls? → return the final answer
└── run all tool calls in parallel → feed results back → next turnInstall
npm install ollama-agent-kit
# MCP support is optional — install the SDK only if you connect to MCP servers:
npm install @modelcontextprotocol/sdkRequires Node.js 18+ and a reachable Ollama instance with a tool-calling model. webSearchTool / webFetchTool need an OLLAMA_API_KEY (Ollama Cloud).
Built-in tools
A handful of tool factories ship with the kit. Pass them bare in tools (they reuse the agent's client) or call them with options. They split into two groups: tools that act on your machine and tools that fetch information.
Act on your machine
| Tool | Purpose | Notes |
| --------------------- | ---------------------------------------------- | ----- |
| writeFileTool | Create/overwrite a file | Writes to the local filesystem |
| editFileTool | Replace an exact string occurrence in a file (all occurrences with replaceAll) | Modifies files in place |
| runShellCommandTool | Execute a shell command, returns stdout/stderr | Runs arbitrary commands with your process's permissions — only enable it for models and prompts you trust. |
Fetch information
| Tool | Purpose | Notes |
| --------------------- | ---------------------------------- | ----- |
| readFileTool | Read a file's full text content | Local filesystem |
| listDirectoryTool | List a directory's entries | Local filesystem |
| webSearchTool | Web search via the Ollama web API | Needs OLLAMA_API_KEY |
| webFetchTool | Fetch the content of a URL | Needs OLLAMA_API_KEY |
A note on trust
The "act" tools give an autonomous loop real power over your machine: it can overwrite files and run shell commands without asking. That's the point — but scope what you pass in. On untrusted input, prefer the read-only tools, run the agent in a sandbox/container, or drop runShellCommandTool. The advantage of a local model is that the blast radius is a machine you own, not an account on someone else's cloud.
Defining a tool
One object, a Zod schema, a handler. Anything on your system with an API — a light, a script, a device — becomes something the model can operate.
import { defineTool } from 'ollama-agent-kit'
import { z } from 'zod'
export const bulb = defineTool({
name: 'bulb',
description: 'Control a smart light: turn it on/off and set brightness.',
parameters: z.object({
room: z.string().describe('Room name, e.g. "studio"'),
on: z.boolean().optional(),
brightness: z.number().min(0).max(100).optional(),
}),
exposeAgent: true, // available to the agent loop
exposeMcp: true, // and publishable by your own MCP server
handler: async ({ room, on, brightness }) => setLight(room, { on, brightness }),
})parameters(Zod) is converted to JSON Schema automatically for the Ollama API.- Tools are validated up front (name present, handler is a function, no duplicates) so a malformed tool fails immediately instead of mid-conversation.
- MCP tools arrive already carrying JSON Schema (
rawParameters), so both kinds live in one registry.
See examples/home-lights.js for the full home-automation demo — an agent controlling real lights from a local model, the kind of thing that runs happily on a Raspberry Pi.
One registry, two directions
The registry is what makes the tool definition above reusable: define a tool once — your agent uses it, and your MCP server exposes it. exposeAgent / exposeMcp decide where it shows up; the same definition serves both the agent loop and your own MCP server, and external MCP tools land in the same registry next to your local ones.
Configuring the agent
createAgent injects the Ollama client, model and tools; .run() executes a prompt.
const agent = createAgent({
host: 'http://localhost:11434', // optional (this is the default) — or a pre-built `client`
apiKey: process.env.OLLAMA_API_KEY, // one key for cloud models + web tools
model: 'gemma4:latest',
tools: [bulb, webSearchTool], // bare tool factories reuse the agent's client/apiKey
onTurn: ({ turn }) => console.log(`turn ${turn}`),
onToolCall: ({ name, result }) => console.log(`→ ${name}`, result),
})
await agent.run('Turn on the studio light and tell me the weather in Naples')createAgent(config) options
| Option | Default | Description |
| -------------- | ---------------------- | ------------------------------------------------------------------ |
| host | http://localhost:11434 | Ollama host (ignored if client is given) |
| apiKey | – | Ollama API key (ignored if client is given) |
| fetch | – | Custom fetch, injected instead of patching globalThis |
| client | – | A pre-built Ollama client (overrides host/apiKey/fetch) |
| model | gemma4:latest | Any Ollama model with tool-calling support |
| think | unset | Ollama thinking effort ('low'|'medium'|'high'). Only sent when set, so non-thinking models work out of the box |
| temperature | 0.8 | Sampling temperature |
| systemPrompt | built-in | System prompt for the agent |
| maxTurns | 10 | Safety cap on loop iterations (throws MaxTurnsError if exceeded) |
| tools | [] | Local tools available to the agent. An entry can also be a factory (ctx) => Tool called with { client, apiKey, host } — so webSearchTool / webFetchTool can be passed bare and reuse the agent's client |
| mcp | null | McpClientManager | async () => tools | tools[] | falsy |
| onTurn | no-op | ({ turn, message, messages }) => void after each model turn |
| onToolCall | no-op | ({ name, arguments, result, error, turn }) => void per tool call |
| onFinal | no-op | ({ content, turns, messages }) => void on the final answer |
run(prompt, { model, tools }) accepts per-run overrides and returns the model's final answer.
Connecting to MCP servers
External MCP servers (stdio or HTTP) are connected by McpClientManager. Their tools are loaded at run time, prefixed with the server name (filesystem__read_file), and merged into the same registry — local names win on collision.
import { createAgent, McpClientManager } from 'ollama-agent-kit'
const mcp = new McpClientManager({
servers: {
filesystem: {
enabled: true,
type: 'stdio',
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-filesystem', process.cwd()],
},
},
})
const agent = createAgent({ mcp })
const answer = await agent.run('List the files in the current directory')
await mcp.close()HTTP servers behind OAuth are supported via FileOAuthProvider (tokens persisted per-server under .mcp-auth/). Log in once with examples/mcp-auth.js. You can also load a { servers: {...} } JSON file with loadMcpConfigFile(path).
Serving your tools as an MCP server
The reciprocal of createAgent: hand the same tool definitions to an MCP server and every tool flagged exposeMcp: true becomes callable by any MCP client (Claude Desktop, another agent, ...). The Zod schema is converted to JSON Schema for you.
import { createMcpServer, serveMcpStdio, serveMcpHttp } from 'ollama-agent-kit'
// Just build the configured server (register onto any transport yourself):
const server = await createMcpServer([bulb, add]) // only exposeMcp tools are registered
// Or serve it directly, one line:
await serveMcpStdio([bulb, add]) // local process, spawned over stdio
await serveMcpHttp([bulb, add], { port: 3000 }) // online at http://localhost:3000/mcpSee examples/serve-mcp.js for the full, runnable server.
Exposing it over HTTP
An MCP server speaks over one of two transports:
- stdio — the server is a local process the client spawns; it talks over stdin/stdout. No network, no auth. Ideal on a Raspberry Pi next to your hardware. Use
serveMcpStdio(tools). - Streamable HTTP — the server exposes an HTTP endpoint (
https://your-host/mcp) any client can reach. This is what "online" means. UseserveMcpHttp(tools, { port }).
serveMcpHttp starts a plain Node HTTP server (no Express dependency) in stateless mode — a fresh MCP server is created per request, which suits low-traffic home deployments. To actually expose it:
- Run it on a machine with a public address (VPS, Raspberry Pi, ...), or a tunnel like Cloudflare Tunnel / ngrok for quick tests.
- Add HTTPS with a reverse proxy (Caddy, Nginx) in front of the port.
- Protect it. An open endpoint lets anyone with the URL run your tools. The simplest guard is a shared bearer token — pass
authTokenand clients must sendAuthorization: Bearer <token>. For full OAuth, use the SDK's auth middleware.
await serveMcpHttp([bulb], {
port: 3000,
authToken: process.env.MCP_TOKEN, // require Authorization: Bearer <token>
})| Option | Default | Description |
| ------ | ------- | ----------- |
| port | 3000 | Listen port |
| host | all interfaces | Bind address |
| path | /mcp | Endpoint path |
| authToken | – | If set, require Authorization: Bearer <token> |
| enableJsonResponse | false | Return plain JSON instead of an SSE stream |
| name / version | kit defaults | Advertised MCP server identity |
| includeAll | false | Register every tool, ignoring exposeMcp |
API
import {
createAgent, createOllamaClient, defaultSystemPrompt,
createRegistry, defineTool, validateTools, toOllamaTool, toHandlerMap,
webSearchTool, webFetchTool,
readFileTool, writeFileTool, editFileTool, listDirectoryTool, runShellCommandTool,
McpClientManager, createMcpTools, loadMcpConfigFile, FileOAuthProvider, DEFAULT_CALLBACK_PORT,
createMcpServer, serveMcpStdio, serveMcpHttp,
AgentError, MaxTurnsError, ToolNotFoundError, RegistryError,
} from 'ollama-agent-kit'FAQ
Why run an agent on a local model instead of a cloud API? Because of what the agent is allowed to do. Filesystem and shell access on a cloud-connected agent means your files and command output transit someone else's servers. On a local model everything stays on your hardware: full privacy, zero inference cost after setup, and it keeps working with no internet connection.
Which Ollama models support tool calling?
Any model tagged with tool support in the Ollama library — gemma4:latest (the default), llama3.1, mistral, and others. Note that Ollama rejects a chat request that carries tools if the model doesn't support them, so pick a tool-capable model when the agent has tools configured.
Does it work fully offline?
Yes. The agent loop, local tools and stdio MCP servers need no network beyond your Ollama instance. Only webSearchTool / webFetchTool and cloud models require an OLLAMA_API_KEY.
Can the same tool be used by the agent and published over MCP?
Yes — that's the point of the registry. One defineTool definition with exposeAgent: true and exposeMcp: true serves both directions; the Zod schema is converted to JSON Schema wherever it's needed.
How does this compare to LangChain or the Vercel AI SDK?
Much smaller scope, on purpose: two runtime dependencies (ollama, zod), one loop, no prompt/chain abstractions, Ollama only. If you need multi-provider support, streaming UI helpers or RAG pipelines, use those frameworks. If you want a small, readable agent loop that gives a local model real capabilities — files, shell, MCP in both directions — this is it.
Is it written in TypeScript? No — plain ESM JavaScript, no bundled type definitions. Zod gives you runtime validation of tool arguments; editors still infer a fair amount from the JSDoc and Zod schemas.
Where does MCP fit if I'm new to it? MCP is a standard protocol for exposing tools to AI clients. This kit is both a client (your agent can call tools from any MCP server) and a server (your tools become callable by Claude Desktop or any other MCP client).
Tests
npm test # node --testLicense
MIT niceunderground
