AI & ML

Building Your First Local Agent with CoPaw

· 5 min read
SitePoint Premium
Stay Relevant and Grow Your Career in Tech
  • Premium Results
  • Publish articles on SitePoint
  • Daily curated jobs
  • Learning Paths
  • Discounts to dev tools
Start Free Trial

7 Day Free Trial. Cancel Anytime.

How to Build a Local AI Agent with CoPaw

  1. Install Node.js 18+, native build tools, and a local LLM runtime such as Ollama with a pulled model.
  2. Scaffold a new CoPaw project using npx create-copaw-agent my-first-agent and install dependencies.
  3. Configure copaw.config.js with your model provider, memory backend, tool directory, and server port.
  4. Verify the agent server starts and responds to a test prompt via curl.
  5. Create custom tools by adding .js files to the /tools directory following the tool contract.
  6. Launch the React dashboard with npm run dashboard and connect to the agent via WebSocket.
  7. Harden the agent with a tool allowlist, input validation, and path-traversal guards.
  8. 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?

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 the build-essential package, 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 with ollama 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 &amp;. 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

  1. ✅ Node.js 18+ and npm installed (Node 21+ recommended for stable fetch support).
  2. ✅ Native build tools installed (Xcode CLT / build-essential / VS Build Tools).
  3. ✅ Local LLM running (Ollama + model pulled with correct tag verified via ollama list) OR API key configured.
  4. ✅ CoPaw package verified on npm (npm show create-copaw-agent).
  5. ✅ CoPaw project scaffolded and dependencies installed.
  6. copaw.config.js configured with model, memory, tool settings, and tool allowlist.
  7. ✅ Agent server starts without errors; test prompt returns expected response.
  8. ✅ At least one custom tool created and auto-registered.
  9. ✅ Tool invocation confirmed via a natural-language prompt.
  10. ✅ React dashboard running and connected to agent via WebSocket.
  11. ✅ Dashboard customized with at least one modified component.
  12. ✅ Memory persistence verified (restart agent, confirm history retained).
  13. ✅ Tool allowlist configured and input validation (path traversal guard, URL validation) in place.
  14. ✅ Performance tuned for your hardware (quantization, context window).

You add tools by dropping a .js file in the /tools directory.

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.