Tutorial

How to Build Your Own AI Assistant: A Read-Only First Project

Build a first personal AI assistant with one read-only tool, a hard step limit and a run receipt. Tested 50-line Node.js starter, boundary tests and next steps.

Contents

An open notebook with a pen lying across it on a wooden desk

Image: Gabriel Cox on Unsplash.

The best first AI assistant is small: one model, one read-only tool, one folder it may read, a hard step limit and a receipt for every run. It cannot send, delete or buy anything, so you can understand all of it before you trust it with more.

This tutorial builds that assistant in about 50 lines of JavaScript. It answers questions from your notes and shows its work. It is the hands-on companion to the AI harness engineering guide.

You do not need to train a model.

You do not need a GPU.

You need Node.js and a folder of notes.

What you will build, and what it cannot do

You ask a question. The assistant decides to read a note, reads it through a tool that only works inside one folder, answers with the source, and returns a receipt:

{
  "question": "What is the next milestone for Project Alpha?",
  "steps": [
    { "step": 1, "tool": "read_note", "args": { "name": "project-alpha.md" }, "ok": true }
  ],
  "stop": "answered",
  "answer": "Next milestone: read-only beta to five testers on 14 November.",
  "sources": ["project-alpha.md"]
}

That is the real output of the code below, run on Node.js 22 before publication.

Just as important is what it cannot do:

it cannot read outside the knowledge folder,
it cannot write, send or delete anything,
it cannot loop forever,
it cannot finish without saying why.

These limits are enforced by the code, not requested in a prompt. That distinction is the whole idea of a harness.

Step 1: Prepare a small synthetic knowledge folder

Start with made-up notes, not your real inbox. Synthetic data lets you know the right answer in advance and means a mistake costs nothing.

Create a project folder with this layout:

my-assistant/
  harness.mjs
  knowledge/
    project-alpha.md

Put this in knowledge/project-alpha.md:

# Project Alpha

Owner: Sam
Next milestone: read-only beta to five testers on 14 November.
Open risk: the calendar export drops recurring events.

Later, add a note that does not answer the question and one that contradicts another. A useful assistant has to handle both.

Step 2: Write the harness

Save this as harness.mjs:

// harness.mjs: a bounded, read-only personal harness. Tested on Node.js 22.
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { pathToFileURL } from 'node:url';

const KNOWLEDGE_DIR = path.resolve('knowledge'); // the only folder the tool may read
const MAX_STEPS = 4;                              // a hard stop, whatever the model wants
const TIMEOUT_MS = 20_000;

// One read-only tool. It validates its own input, so the model cannot widen its reach.
export const tools = {
  async read_note({ name }) {
    if (typeof name !== 'string' || !/^[a-z0-9-]+\.md$/.test(name)) throw new Error('invalid note name');
    return readFile(path.join(KNOWLEDGE_DIR, name), 'utf8');
  },
};

// A scripted stand-in for a model, so the example runs without an API key.
// Swap in your provider's client. It must return { tool, args } or { answer, sources }.
async function callModel(messages) {
  const last = messages.at(-1);
  if (last.role === 'user') return { tool: 'read_note', args: { name: 'project-alpha.md' } };
  if (last.role === 'tool' && last.ok) {
    const line = last.content.split('\n').find((l) => l.startsWith('Next milestone:'));
    if (line) return { answer: line, sources: ['project-alpha.md'] };
  }
  return { answer: 'I could not find that in your notes.', sources: [] };
}

export async function run(question) {
  const receipt = { question, steps: [], stop: null };
  const messages = [{ role: 'user', content: question }];
  const deadline = Date.now() + TIMEOUT_MS;

  for (let step = 1; step <= MAX_STEPS; step++) {
    if (Date.now() > deadline) return { ...receipt, stop: 'timeout' };
    const decision = await callModel(messages);
    if (decision.answer) return { ...receipt, stop: 'answered', answer: decision.answer, sources: decision.sources };

    const tool = tools[decision.tool];
    try {
      if (!tool) throw new Error('tool not allowed');
      const content = await tool(decision.args ?? {});
      receipt.steps.push({ step, tool: decision.tool, args: decision.args, ok: true });
      messages.push({ role: 'tool', ok: true, content });
    } catch (error) {
      receipt.steps.push({ step, tool: decision.tool, args: decision.args, ok: false, error: error.message });
      messages.push({ role: 'tool', ok: false, content: error.message });
    }
  }
  return { ...receipt, stop: 'step_limit' };
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  console.log(JSON.stringify(await run('What is the next milestone for Project Alpha?'), null, 2));
}

Run it from the project folder:

node harness.mjs

You should see the receipt shown at the top of this article.

Step 3: Read the code as a set of boundaries

Each block exists to prevent a specific failure.

Code Prevents
KNOWLEDGE_DIR and the name pattern in read_note Reading files outside the folder, including ../ tricks and absolute paths
tools containing only read_note Any action the task does not need. There is no write tool to misuse
MAX_STEPS and TIMEOUT_MS Loops and runaway cost, regardless of what the model requests
try / catch around tool calls A tool failure crashing the run or being hidden from the model
stop on every return Runs that end without a reason you can debug
sources in the answer Answers you cannot check

The model never touches the file system directly. It can only ask for read_note, and the tool decides whether the request is valid. That is the pattern every later capability follows.

Step 4: Test the boundaries before adding features

Save this as boundary.test.mjs next to the harness:

import assert from 'node:assert/strict';
import { run, tools } from './harness.mjs';

const ok = await run('What is the next milestone for Project Alpha?');
assert.equal(ok.stop, 'answered');
assert.equal(ok.answer, 'Next milestone: read-only beta to five testers on 14 November.');
assert.deepEqual(ok.sources, ['project-alpha.md']);
assert.equal(ok.steps.length, 1);
assert.equal(ok.steps[0].ok, true);

for (const name of ['../secret.md', '..\\secret.md', 'C:/Windows/win.ini', '/etc/passwd', 'notes/../x.md', '', null, undefined, 42]) {
  await assert.rejects(() => tools.read_note({ name }), /invalid note name/, `accepted ${String(name)}`);
}
await assert.rejects(() => tools.read_note({ name: 'missing-note.md' }), /ENOENT/);
console.log('PASS', JSON.stringify(ok));

Run it:

node boundary.test.mjs

It passed on Node.js 22 before publication. It checks the useful path, nine attempts to escape the folder or pass bad input, and a missing file reported as an error rather than an invented answer.

Keep this file. Every time you change the harness, run it again. A boundary that worked last week is not proof it works today.

Step 5: Swap in a real model

The scripted callModel exists so the example runs anywhere. To make the assistant useful, replace it with a call to a model through your provider's SDK or a local runtime.

Keep the same contract. Your new callModel receives the message list and must return one of two shapes:

  • { tool: 'read_note', args: { name: '...' } } to request a tool, or
  • { answer: '...', sources: ['...'] } to finish.

Most model APIs support structured tool calling, so map their tool-call format onto this shape and tell the model which notes exist. Two rules keep the boundaries intact:

  1. Never pass the model's tool request straight to the file system. It still goes through tools.read_note.
  2. Keep the limits in the runtime. Do not move MAX_STEPS into the prompt.

Before connecting a cloud model, decide which notes may leave your machine. The local vs cloud vs hybrid guide helps you map that.

Step 6: Extend one capability at a time

Resist adding five things at once. Add one, rerun the boundary tests, then add the next.

  1. Many notes. Add a list_notes tool and a simple search. Read the personal knowledge base guide for citations that actually support the answer.
  2. Memory. Store preferences in a separate, inspectable file. See the personal AI memory guide.
  3. Drafts. Let the assistant prepare a reply or a calendar proposal you review. See the draft-only email tutorial.
  4. Actions. Only after defining exact-action approval. See the AI agent permissions guide.
  5. Tests. Grow the boundary file into a real suite. See the evaluation guide.

If you want a broader tour of agent building with frameworks, see how to build an AI agent. This tutorial deliberately stays smaller.

Frequently asked questions

Can I build this without training a model?

Yes. The harness uses an existing model. This example even runs without one, using a scripted stand-in.

Can I run it without a GPU?

Yes. The harness itself is ordinary JavaScript. A GPU only matters if you later run a model locally.

Why start with synthetic data?

You know the right answers, so you can tell whether the assistant is correct, and a bug cannot expose anything real.

Can the assistant write or send anything?

No. It has one read-only tool. Adding write capability is a separate, deliberate step with its own approval design.

Build Your Own AI AssistantPersonal AI AssistantAI Harness EngineeringTutorialNode.js