Building Your First Local Agent with CoPaw


- Premium Results
- Publish articles on SitePoint
- Daily curated jobs
- Learning Paths
- Discounts to dev tools
7 Day Free Trial. Cancel Anytime.
How to Build a Local AI Agent with CoPaw
- Install Node.js 18+, native build tools, and a local LLM runtime such as Ollama with a pulled model.
- Scaffold a new CoPaw project using
npx create-copaw-agent my-first-agentand install dependencies. - Configure
copaw.config.jswith your model provider, memory backend, tool directory, and server port. - Verify the agent server starts and responds to a test prompt via
curl. - Create custom tools by adding
.jsfiles to the/toolsdirectory following the tool contract. - Launch the React dashboard with
npm run dashboardand connect to the agent via WebSocket. - Harden the agent with a tool allowlist, input validation, and path-traversal guards.
- Optimize local LLM performance by tuning quantization, context window, and GPU offloading.
A local agent is an AI assistant that runs entirely on a developer's machine, with no mandatory cloud calls required. By the end of this tutorial, readers will have a functioning local agent with custom tools and a React-based control interface.
Table of Contents
- Why Build a Local AI Agent?
- Understanding CoPaw's Core Concepts
- Setting Up Your Development Environment
- Configuring Your First Agent
- Building Custom Tools for Your Agent
- Adding a React Dashboard
- Extending and Hardening Your Agent
- Implementation Checklist
- What Comes Next
Why Build a Local AI Agent?
A local agent is an AI assistant that runs entirely on a developer's machine, with no mandatory cloud calls required. Every inference step, every tool invocation, and every piece of conversation history stays on local hardware. Building your first local agent with CoPaw sidesteps per-token costs, network latency, and cloud dependency. Local agents also work offline, making them practical for air-gapped environments, sensitive codebases, and rapid prototyping without metered API bills.
CoPaw is a local agent framework built on JavaScript and Node.js with an optional React-based dashboard. It ships roughly a dozen direct dependencies, installs under 5 MB on disk before models, and idles at around 60 MB of resident memory. You add tools by dropping a .js file in the /tools directory. CoPaw targets web developers who want to build personal AI assistants without leaving the ecosystem they already know. Rather than requiring Python environments, complex orchestration layers, or mandatory cloud endpoints, CoPaw keeps the entire agent loop within a Node.js runtime and exposes familiar conventions for configuration, tooling, and UI.
By the end of this tutorial, readers will have a functioning local agent with custom tools and a React-based control interface. The prerequisites are straightforward: Node.js 18 or later, npm or yarn, basic JavaScript and React knowledge, and either a local LLM runtime such as Ollama or an API key for an optional remote model.
Understanding CoPaw's Core Concepts
Architecture Overview
CoPaw's agent loop follows a four-phase cycle: perception, reasoning, action, and observation. CoPaw ingests the user's prompt and any relevant context from memory. It passes that context to the configured language model, which decides whether to respond directly or invoke a tool. If the model selects a tool call, CoPaw executes it. CoPaw then collects tool outputs and feeds them back into reasoning for synthesis into a final response. This entire loop runs locally in a Node.js runtime, with no external orchestration service required.
Key Components
The Agent Core processes prompts and decides on tool usage. It manages the conversation turn, marshals inputs to the model, and parses structured tool-call outputs.
CoPaw discovers tools automatically from a configured directory through the Tool Registry. Each tool is a JavaScript function following a standard contract: export a name, description, parameters schema, and an async execute function. Drop a conforming file into /tools and the agent picks it up on the next server start.
For conversation history and context, the Memory Store provides local persistence. CoPaw supports both file-based storage and SQLite, selectable through a single configuration field.
The Dashboard is an optional React UI for interacting with and monitoring the agent. It bundles a chat interface, tool activity log, and memory inspector into a Vite-based application.
How CoPaw Differs from Other Frameworks
LangChain, AutoGPT, and CrewAI all offer agent capabilities, but they lean heavily on Python ecosystems, cloud-first architectures, or multi-file chain definitions and required provider wrappers. Note that LangChain also offers a JavaScript SDK (langchain.js), but its primary ecosystem and documentation remain Python-centric. CoPaw takes a local-first, JavaScript-native approach: no mandatory Python dependency, no required cloud endpoint, no chain abstraction to learn. For web developers already working in JavaScript and React, CoPaw fits directly into existing workflows.
CoPaw takes a local-first, JavaScript-native approach: no mandatory Python dependency, no required cloud endpoint, no chain abstraction to learn.
Setting Up Your Development Environment
Prerequisites Checklist
Before starting, confirm the following are installed and available:
- Node.js 18 or later and npm (or yarn).
- Native build tools required for compiling native Node.js addons used later in this tutorial: on macOS run
xcode-select --install, on Linux install thebuild-essentialpackage, and on Windows install Visual Studio Build Tools. - A local LLM runtime such as Ollama with a pulled model, or an OpenAI-compatible API key for a remote model. Install Ollama from ollama.com and run
ollama pull llama3:8b(approximately 4 GB download) to pull the recommended model before continuing. Verify the model is available withollama list. - Git installed.
- A code editor. VS Code is recommended for its integrated terminal and JavaScript tooling.
Installing CoPaw
Verify the CoPaw scaffolder package exists on npm, then scaffold a new project, install dependencies, and verify the installation:
npm show create-copaw-agent
npx create-copaw-agent my-first-agent
cd my-first-agent
npm install
npx copaw --version
If npm show create-copaw-agent returns a 404 error, the package is not yet published to the npm registry. Check the CoPaw GitHub repository for alternative installation instructions.
The create-copaw-agent scaffolder generates a project structure with the following directories and files:
/agents— agent definition files./tools— custom tool implementations./config— configuration files./dashboard— a pre-configured React (Vite) application.server.js— the main entry point for the agent server.
Each directory serves a distinct role in the agent loop, and the separation keeps tool logic, agent behavior, and UI concerns cleanly isolated.
Configuring Your First Agent
The Configuration File
The copaw.config.js file is the central point of control. It defines the agent's identity, model connection, memory backend, tool discovery path, tool allowlist, and server port. The agent server uses CommonJS (module.exports); the dashboard under /dashboard is an ES module Vite project — do not use module.exports inside the dashboard directory.
// copaw.config.js
'use strict';
const provider = process.env.COPAW_MODEL_PROVIDER || 'ollama';
if (provider === 'openai' && !process.env.OPENAI_API_KEY) {
throw new Error(
'[copaw] OPENAI_API_KEY environment variable is required when provider is "openai". ' +
'Set it before starting the server.'
);
}
module.exports = {
agent: {
name: 'my-first-agent',
systemPrompt: 'You are a helpful local assistant. Use available tools when the user asks for file operations or web lookups.',
},
model: {
provider,
model: process.env.COPAW_MODEL || 'llama3:8b',
baseUrl: process.env.COPAW_BASE_URL || 'http://localhost:11434',
timeout: 30000,
retries: 2,
...(provider === 'openai' && { apiKey: process.env.OPENAI_API_KEY }),
},
memory: {
type: 'file', // 'file' or 'sqlite'
path: './data/memory.json', // use ./data/memory.db when type is 'sqlite'
},
tools: {
directory: './tools',
allowlist: ['readFile', 'fetchUrl'],
},
server: {
port: Number(process.env.PORT) || 3100,
},
};
Set agent.name to whatever display label you want in logs and the dashboard; it does not need to match the directory name. systemPrompt shapes the model's behavior across all conversations. Under model, you configure which LLM provider and endpoint CoPaw connects to. Set memory.type to 'file' or 'sqlite' depending on your persistence needs, and point memory.path to the storage location (use a .json extension for file-based storage, .db for SQLite, so the file is easily identifiable). CoPaw reads tools.directory to auto-discover tool files at startup. The tools.allowlist array restricts which registered tools the agent may call; omit the key entirely to permit all registered tools. server.port sets the HTTP port for the agent's API. If you set the provider to 'openai' without the OPENAI_API_KEY environment variable, the configuration throws a clear error at startup rather than failing silently at runtime.
Connecting a Local LLM
The model provider configuration block for a local Ollama instance:
model: {
provider: 'ollama',
model: 'llama3:8b',
baseUrl: 'http://localhost:11434',
timeout: 30000,
retries: 2,
},
The provider field accepts 'ollama' for local inference. The model field should match the exact tag of a model already pulled into Ollama. Verify the exact tag with ollama list after pulling and update this field to match. The baseUrl points to the local Ollama HTTP API. The timeout value in milliseconds prevents the agent from hanging on slow inference, and retries controls how many times CoPaw will retry a failed model call.
For developers who prefer a remote model, switching to an OpenAI-compatible endpoint requires changing provider to 'openai', setting model to the desired model name, updating baseUrl to the provider's API URL, and adding an apiKey field. For example:
model: {
provider: 'openai',
model: 'gpt-4o',
baseUrl: 'https://api.openai.com/v1',
apiKey: process.env.OPENAI_API_KEY,
},
Never hardcode API keys in configuration files. Use environment variables as shown above.
Verifying the Setup
Start the agent server and send a test prompt to confirm everything is wired correctly:
npm run dev
In a separate terminal:
curl -X POST http://localhost:3100/chat \
-H "Content-Type: application/json" \
-d '{"message": "Hello, CoPaw!"}'
The expected response is a JSON object containing the agent's reply:
{
"response": "Hello! I'm your local assistant. How can I help you today?",
"toolCalls": [],
"timestamp": "2025-01-15T10:30:00.000Z"
}
(Note: timestamp reflects the actual request time and will differ from this example.)
If the response returns successfully with an empty toolCalls array, the agent core, model connection, and server are all functioning.
Building Custom Tools for Your Agent
What Are Agent Tools?
The agent invokes JavaScript functions, called tools, to perform actions beyond text generation. These include file I/O, API calls, calculations, and system commands. CoPaw's tool contract requires each tool to export a name, a natural-language description (which the model reads to decide when to use the tool), a JSON-schema parameters block, and an async execute function.
Creating a File Reader Tool
Create tools/readFile.js with the following implementation. The path-traversal guard restricts file access to the ./data directory, and a size cap prevents loading excessively large files into memory:
// tools/readFile.js
const fs = require('fs/promises');
const path = require('path');
const ALLOWED_BASE = path.resolve('./data');
const MAX_FILE_BYTES = 1 * 1024 * 1024; // 1 MB hard cap
module.exports = {
name: 'readFile',
description: `Reads the contents of a file at the given file path and returns the text content up to 1 MB. Only files within the ${ALLOWED_BASE} directory are accessible.`,
parameters: {
type: 'object',
properties: {
filePath: {
type: 'string',
description: 'The relative or absolute path to the file to read.',
},
},
required: ['filePath'],
},
execute: async ({ filePath }) => {
try {
const resolvedPath = path.resolve(filePath);
// Ensure resolvedPath is strictly inside ALLOWED_BASE (not equal to it)
const relative = path.relative(ALLOWED_BASE, resolvedPath);
if (
!relative ||
relative.startsWith('..') ||
path.isAbsolute(relative)
) {
return { success: false, error: 'Access denied: path outside allowed directory.' };
}
// Stat before reading to enforce size cap
const stat = await fs.stat(resolvedPath);
if (stat.size > MAX_FILE_BYTES) {
return { success: false, error: `File exceeds maximum allowed size of ${MAX_FILE_BYTES} bytes.` };
}
const content = await fs.readFile(resolvedPath, 'utf-8');
return { success: true, content };
} catch (error) {
// Avoid leaking internal paths in error messages
const safe = error.code === 'ENOENT' ? 'File not found.' : 'Could not read file.';
return { success: false, error: safe };
}
},
};
Security note: The ALLOWED_BASE guard uses path.relative() to prevent the agent from reading arbitrary files such as /etc/passwd or SSH keys, and it cannot be bypassed with symlinks that resolve outside the allowed directory. The file size cap prevents out-of-memory crashes and context-window overflow when the agent reads large files. Always restrict file-system tools to a specific sandbox directory. Adjust './data' to match your project's data directory.
CoPaw auto-discovers tools from the directory specified in copaw.config.js. Any .js file in the /tools directory that exports an object matching the tool contract is automatically registered when the server starts. No manual registration step is needed.
Creating a Web Fetcher Tool
Create tools/fetchUrl.js. This tool includes SSRF protections that restrict requests to public HTTP/HTTPS URLs, a timeout to prevent the agent from hanging on slow responses, and a body size cap to prevent memory exhaustion:
// tools/fetchUrl.js
const ALLOWED_SCHEMES = ['https:', 'http:'];
// Extend this list for your environment; reject everything else
const BLOCKED_HOSTNAMES = /^(localhost|127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|169\.254\.)/;
const FETCH_TIMEOUT_MS = 10_000;
const MAX_BODY_BYTES = 2 * 1024 * 1024; // 2 MB
module.exports = {
name: 'fetchUrl',
description:
'Fetches a public web page and returns plain text (HTML tags stripped), truncated to 5 000 characters. Only http/https URLs to public hosts are permitted. JavaScript, CSS, and HTML entity content may remain in the output.',
parameters: {
type: 'object',
properties: {
url: {
type: 'string',
description: 'The full https:// or http:// URL of the web page to fetch.',
},
},
required: ['url'],
},
execute: async ({ url }) => {
let parsed;
try {
parsed = new URL(url);
} catch {
return { success: false, error: 'Invalid URL.' };
}
if (!ALLOWED_SCHEMES.includes(parsed.protocol)) {
return { success: false, error: 'Only http and https URLs are permitted.' };
}
if (BLOCKED_HOSTNAMES.test(parsed.hostname)) {
return { success: false, error: 'Access to private or loopback addresses is not permitted.' };
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) {
return { success: false, error: `HTTP ${response.status}: ${response.statusText}` };
}
// Stream body up to MAX_BODY_BYTES to avoid OOM on large responses
const reader = response.body.getReader();
const chunks = [];
let totalBytes = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.byteLength;
chunks.push(value);
if (totalBytes > MAX_BODY_BYTES) {
await reader.cancel();
break;
}
}
const html = new TextDecoder().decode(
chunks.reduce((acc, c) => {
const merged = new Uint8Array(acc.byteLength + c.byteLength);
merged.set(acc);
merged.set(c, acc.byteLength);
return merged;
}, new Uint8Array(0))
);
const plainText = html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
const truncated = plainText.substring(0, 5000);
return { success: true, content: truncated };
} catch (error) {
if (error.name === 'AbortError') {
return { success: false, error: `Request timed out after ${FETCH_TIMEOUT_MS}ms.` };
}
return { success: false, error: 'Fetch failed.' };
} finally {
clearTimeout(timer);
}
},
};
The tool uses the global fetch API, which is experimental in Node 18–20 (start the server with node --experimental-fetch server.js) and stable in Node 21+. Alternatively, install node-fetch as a polyfill. The regex-based HTML tag removal produces a rough text extraction; output may include residual script content, CSS text, and HTML entities such as &. For production use, replace the regex with an HTML-parsing library such as node-html-parser. The output is truncated to 5,000 characters to stay within reasonable token limits for the agent's context window. Error handling returns structured error objects rather than throwing, so the agent can reason about failures and communicate them to the user.
Security note: This tool validates URL schemes and blocks requests to private/loopback IP ranges to prevent SSRF attacks. The BLOCKED_HOSTNAMES regex covers common private ranges; extend it for your environment. In production, consider adding a domain allowlist to further restrict which hosts the agent can access.
Registering and Testing Tools
Send a prompt that triggers tool use:
curl -X POST http://localhost:3100/chat \
-H "Content-Type: application/json" \
-d '{"message": "Read the contents of ./data/README.md and summarize it"}'
The expected response shows the agent's tool-call decision and the synthesized answer:
{
"response": "The README.md file describes the my-first-agent project. It includes setup instructions, a list of available tools, and configuration details for connecting to a local LLM via Ollama.",
"toolCalls": [
{
"tool": "readFile",
"input": { "filePath": "./data/README.md" },
"output": { "success": true, "content": "# my-first-agent
..." }
}
],
"timestamp": "2025-01-15T10:35:00.000Z"
}
The toolCalls array confirms which tool the agent invoked, what parameters it passed, and what the tool returned. The agent then used that output to produce its summarized response.
Adding a React Dashboard
Scaffolding the Dashboard
CoPaw ships with a /dashboard directory containing a pre-configured React application built with Vite. The scaffold bundles socket.io-client and Tailwind CSS. Confirm with cat dashboard/package.json before starting. Start it alongside the agent server:
npm run dashboard
The terminal output confirms the Vite dev server is running. Navigate to http://localhost:5173 to access the dashboard. If port 5173 is in use, Vite selects the next available port — check the terminal output for the actual URL.
Key Dashboard Components
The Chat Interface connects to the agent over WebSocket. The dashboard displays each message after the model finishes responding; this implementation does not stream tokens. Tool calls appear inline in the response text.
The Tool Activity Log shows a visual feed of which tools the agent called during each turn, including the input parameters and output data. Use it to verify that the agent selected the right tool and passed correct arguments.
The Memory Inspector lets you browse and clear conversation history. When the agent gives unexpected responses, check here first: stale or bloated context is the most common cause of degraded output.
When the agent gives unexpected responses, check here first: stale or bloated context is the most common cause of degraded output.
Customizing the Dashboard
Modify the ChatPanel component to add a custom system prompt selector dropdown. The socket connection is created inside a useEffect hook and stored in a ref so it is properly cleaned up on unmount and does not leak across hot-reloads:
// dashboard/src/components/ChatPanel.jsx
import { useState, useEffect, useRef } from 'react';
import { io } from 'socket.io-client';
const systemPrompts = [
{ label: 'Default Assistant', value: 'You are a helpful local assistant.' },
{ label: 'Code Reviewer', value: 'You are a code reviewer. Analyze code for bugs, style issues, and improvements.' },
{ label: 'Summarizer', value: 'You are a summarizer. Provide concise summaries of any content given to you.' },
];
export default function ChatPanel() {
const [selectedPrompt, setSelectedPrompt] = useState(systemPrompts[0].value);
const [message, setMessage] = useState('');
const [messages, setMessages] = useState([]);
// Socket created once per component instance; cleaned up on unmount
const socketRef = useRef(null);
useEffect(() => {
const socket = io('http://localhost:3100');
socketRef.current = socket;
// Named handler reference so .off() removes only this listener
const handleResponse = (data) => {
setMessages((prev) => [
...prev,
{ id: crypto.randomUUID(), role: 'agent', content: data.response },
]);
};
socket.on('response', handleResponse);
return () => {
socket.off('response', handleResponse);
socket.disconnect();
};
}, []); // empty deps: run once per mount
const sendMessage = () => {
if (!message.trim() || !socketRef.current) return;
const id = crypto.randomUUID();
setMessages((prev) => [...prev, { id, role: 'user', content: message }]);
socketRef.current.emit('chat', { message, systemPrompt: selectedPrompt });
setMessage('');
};
return (
<div className="flex flex-col h-full p-4">
<select
className="mb-4 p-2 border rounded"
value={selectedPrompt}
onChange={(e) => setSelectedPrompt(e.target.value)}
>
{systemPrompts.map((p) => (
<option key={p.label} value={p.value}>{p.label}</option>
))}
</select>
<div className="flex-1 overflow-y-auto space-y-2">
{messages.map((msg) => (
// Stable UUID key — not array index
<div key={msg.id} className={msg.role === 'user' ? 'text-right' : 'text-left'}>
<span className="inline-block p-2 rounded bg-gray-100">{msg.content}</span>
</div>
))}
</div>
<div className="flex mt-4">
<input
className="flex-1 p-2 border rounded-l"
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && sendMessage()}
placeholder="Type a message..."
/>
<button className="p-2 bg-blue-500 text-white rounded-r" onClick={sendMessage}>
Send
</button>
</div>
</div>
);
}
This component uses useState to manage the selected system prompt and message state, and useEffect to create the socket connection and listen for agent responses. The socket is stored in a useRef so it persists across renders, and the cleanup function disconnects it on unmount to prevent connection leaks during hot-reloads. Each message is assigned a stable crypto.randomUUID() key to avoid React reconciliation issues. The selected prompt is sent as part of the chat payload, allowing users to switch the agent's behavior without restarting the server. Tailwind CSS is included by default in the dashboard scaffold, and additional panels can be added following the same component pattern.
Extending and Hardening Your Agent
Adding Memory Persistence
Switching from file-based to SQLite memory improves performance and queryability for agents with long conversation histories. Update memory.type to 'sqlite' and memory.path to './data/memory.db' in copaw.config.js, then install the required package: npm install better-sqlite3. Note: better-sqlite3 is a native Node.js addon that requires build tools to compile. On macOS, ensure Xcode Command Line Tools are installed (xcode-select --install). On Linux, install build-essential. On Windows, install Visual Studio Build Tools. If npm install fails with gyp ERR! find Python or similar errors, the build toolchain is missing. After you restart the server, CoPaw stores conversation history in a SQLite database at the configured memory.path.
Guarding Against Unsafe Actions
Tools that interact with the file system or network introduce security surface area. CoPaw supports a tool allowlist in the configuration file, restricting which tools the agent is permitted to call. Add the allowlist key to the tools section of copaw.config.js:
tools: {
directory: './tools',
allowlist: ['readFile', 'fetchUrl'],
},
Omitting the allowlist key permits all registered tools. Beyond the allowlist, individual tools should validate their inputs before executing. For file system tools, resolve paths and confirm they fall within an expected directory (as demonstrated in the readFile tool above). For network tools, validate URLs against a domain allowlist and block private/loopback addresses (as demonstrated in the fetchUrl tool above). For additional isolation of high-risk operations, consider sandboxing tool execution using Node.js worker_threads or a library such as isolated-vm.
Performance Tips for Local LLMs
Running LLMs locally demands attention to quantization and resource allocation. Q4_K_M is a middle-ground quantization level that balances model quality and memory consumption; see the llama.cpp quantization discussion for trade-offs across quantization types, and benchmark on your own hardware to confirm it fits your needs. Adjusting the context window and maximum token limits in copaw.config.js directly affects both memory usage and response quality. As a hardware baseline, budget 8 GB of RAM minimum for Q4-quantized 7B parameter models, though requirements grow with larger context windows and heavier quantization. Larger models require proportionally more memory. GPU offloading through Ollama improves inference speed (benchmark on your hardware to measure the gain, as results vary widely by GPU).
Implementation Checklist
- ✅ Node.js 18+ and npm installed (Node 21+ recommended for stable
fetchsupport). - ✅ Native build tools installed (Xcode CLT /
build-essential/ VS Build Tools). - ✅ Local LLM running (Ollama + model pulled with correct tag verified via
ollama list) OR API key configured. - ✅ CoPaw package verified on npm (
npm show create-copaw-agent). - ✅ CoPaw project scaffolded and dependencies installed.
- ✅
copaw.config.jsconfigured with model, memory, tool settings, and tool allowlist. - ✅ Agent server starts without errors; test prompt returns expected response.
- ✅ At least one custom tool created and auto-registered.
- ✅ Tool invocation confirmed via a natural-language prompt.
- ✅ React dashboard running and connected to agent via WebSocket.
- ✅ Dashboard customized with at least one modified component.
- ✅ Memory persistence verified (restart agent, confirm history retained).
- ✅ Tool allowlist configured and input validation (path traversal guard, URL validation) in place.
- ✅ Performance tuned for your hardware (quantization, context window).
You add tools by dropping a
.jsfile in the/toolsdirectory.
What Comes Next
This tutorial produced a fully local AI agent with custom tools and a React dashboard, powered by CoPaw. The most useful next step is building a multi-tool chain: create a tool that calls readFile, passes the content to a summarizer tool, and writes the result back to disk. That pattern exercises CoPaw's tool composition model and exposes the edges you will hit in production. See the CoPaw documentation and GitHub repository for advanced patterns and contributed tool libraries.