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
- What you will build, and what it cannot do
- Step 1: Prepare a small synthetic knowledge folder
- Step 2: Write the harness
- Step 3: Read the code as a set of boundaries
- Step 4: Test the boundaries before adding features
- Step 5: Swap in a real model
- Step 6: Extend one capability at a time
- Frequently asked questions
- Can I build this without training a model?
- Can I run it without a GPU?
- Why start with synthetic data?
- Can the assistant write or send anything?
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.mdPut 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.mjsYou 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.mjsIt 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:
- Never pass the model's tool request straight to the file system. It still goes through
tools.read_note. - Keep the limits in the runtime. Do not move
MAX_STEPSinto 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.
- Many notes. Add a
list_notestool and a simple search. Read the personal knowledge base guide for citations that actually support the answer. - Memory. Store preferences in a separate, inspectable file. See the personal AI memory guide.
- Drafts. Let the assistant prepare a reply or a calendar proposal you review. See the draft-only email tutorial.
- Actions. Only after defining exact-action approval. See the AI agent permissions guide.
- 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.