Private AI Assistant That Reads Your Email: 100% Local (Node.js + Ollama)

0 views

Most “AI email assistant” demos quietly ship your inbox to a hosted model. This build goes the other way: a small agent written in plain Node.js 22, with zero npm packages, talking to a local model served by Ollama (qwen2.5:3b). It summarizes an inbox, creates calendar invites, writes a design document with a Mermaid diagram, and drafts replies. It never sends anything. Below is the architecture from the video, the design decisions that matter, and the failure modes you should expect when you run something like this on a 3B model.

⚡ TL;DR – The agent is a loop: send the task plus tool schemas to a local Ollama model, run the one tool it asks for, feed the result back, stop after 8 steps. – Five tools: list_emails, read_email, draft_reply, create_meeting (.ics), write_document (Markdown + Mermaid). – There is no send function. You approve drafts outside the model. – Expect a 3B model to guess, pick the wrong tool, or invent ids. Feed errors back and validate in code.

Before you start

  • Node.js 22 or newer (built-in fetch, AbortSignal.timeout).
  • Ollama installed, then ollama pull qwen2.5:3b. Check it is serving with curl http://localhost:11434/api/tags.
  • A folder called mailbox/ with a few sample .eml files. Export some from your mail client or write them by hand. Don’t point this at a real inbox until you’ve read the last section.
  • Optional: pandoc if you want Word output.
text
assistant/
  llm.js        # Ollama helper
  tools.js      # five tools
  agent.js      # loop + CLI
  mailbox/      # *.eml input
  drafts/       # replies waiting for approval
  out/          # .ics and .md files

Step 1: Understand the architecture

You give the agent a task. The agent sends the task plus a list of tool definitions to the local model. The model never executes anything itself: it replies with a request such as list_emails. Your code runs that function, appends the result to the conversation, and calls the model again. That repeats until the model returns a final answer, with a hard limit of eight steps. It is the same loop an MCP client runs, just with the tools written as plain functions so every line is visible.

Step 2: Define the five tools

  • list_emails: reads a folder of .eml files and returns id, from, subject and date.
  • read_email: returns headers and body for one id.
  • draft_reply: writes a reply into a drafts folder. There is no send function anywhere in the codebase.
  • create_meeting: writes a real .ics file, which Outlook, Google Calendar and Apple Calendar all import.
  • write_document: saves Markdown, which can include a Mermaid flowchart and be converted to Word with pandoc.

The mailbox in the video is a local test folder with five sample emails, not a real account. Each file uses the standard RFC 822 shape your mail client already understands: headers (From, To, Subject, Date), a blank line, then the body.

js
// Minimal .eml parser: headers, blank line, body
import { readFile, readdir } from 'node:fs/promises';

export async function readEml(path) {
  const raw = await readFile(path, 'utf8');
  const [head, ...rest] = raw.split(/\r?\n\r?\n/);
  const headers = {};
  for (const line of head.split(/\r?\n/)) {
    const i = line.indexOf(':');
    if (i > 0) headers[line.slice(0, i).toLowerCase()] = line.slice(i + 1).trim();
  }
  return { headers, body: rest.join('\n\n') };
}

Step 3: Write the Ollama helper

Ollama’s /api/chat endpoint accepts a tools array in the same JSON-schema style as other function-calling APIs. Because Node 22 ships fetch, the helper is a dozen lines:

js
export async function chat(messages, tools) {
  const res = await fetch('http://localhost:11434/api/chat', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ model: 'qwen2.5:3b', messages, tools, stream: false }),
    signal: AbortSignal.timeout(120_000),
  });
  if (!res.ok) throw new Error(`Ollama HTTP ${res.status}`);
  return (await res.json()).message;
}

The timeout is not decoration. On a CPU, a 3B model can take a long time on a large prompt, and one of the real failures in the video is exactly that: a timeout on a long inbox summary.

Step 4: Build the agent loop, one tool per step

js
const MAX_STEPS = 8;
for (let step = 0; step < MAX_STEPS; step++) {
  const msg = await chat(messages, toolDefs);
  messages.push(msg);
  const call = msg.tool_calls?.[0];
  if (!call) return msg.content;            // final answer
  const fn = tools[call.function.name];
  let result;
  try {
    result = fn ? await fn(call.function.arguments) : { error: 'unknown tool' };
  } catch (e) {
    result = { error: e.message };          // feed errors back, don't crash
  }
  messages.push({ role: 'tool', content: JSON.stringify(result) });
}
throw new Error('step limit reached');

Two choices here are deliberate. Executing only the first tool call per step keeps small models from firing off a chain of guesses. Returning errors as tool results, instead of throwing, lets the model notice “bad id” and retry with a correct one.

Step 5: Describe the tools to the model

The model only knows what your schemas say. Short, specific descriptions beat long system prompts:

js
export const toolDefs = [
  { type: 'function', function: {
      name: 'list_emails',
      description: 'List emails in the inbox. Returns id, from, subject, date. Call this first.',
      parameters: { type: 'object', properties: {} } } },
  { type: 'function', function: {
      name: 'read_email',
      description: 'Read one email by id. Always read an email before summarizing or replying to it.',
      parameters: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } } },
  { type: 'function', function: {
      name: 'draft_reply',
      description: 'Save a reply draft for the user to approve. This does NOT send.',
      parameters: { type: 'object',
        properties: { id: { type: 'string' }, body: { type: 'string' } }, required: ['id', 'body'] } } },
];

Notice “Call this first” and “Always read an email before summarizing”. Those phrases came directly from fixing failures, covered below.

Step 6: Make it draft-only (a design decision, not a prompt)

It is tempting to write “never send without asking” in the system prompt. Prompts are suggestions; code is a guarantee. The agent in this project simply has no send capability. draft_reply writes a file, and a separate human-in-the-loop step lists drafts and asks you to approve or reject each one. Only after approval would a separate, non-LLM script hand the draft to a mail API. If you take one idea from this build, take this one: remove dangerous capabilities instead of asking the model to be careful with them.

Step 7: Create calendar invites and design docs

js
import { writeFile } from 'node:fs/promises';
const stamp = d => d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');

export async function createMeeting({ title, start, minutes = 30, attendees = [] }) {
  const s = new Date(start);
  if (Number.isNaN(s.getTime())) return { error: `Bad start date: ${start}` };
  const e = new Date(s.getTime() + minutes * 60_000);
  const ics = [
    'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//local-assistant//EN',
    'BEGIN:VEVENT', `UID:${crypto.randomUUID()}`, `DTSTAMP:${stamp(new Date())}`,
    `DTSTART:${stamp(s)}`, `DTEND:${stamp(e)}`, `SUMMARY:${title}`,
    ...attendees.map(a => `ATTENDEE:mailto:${a}`),
    'END:VEVENT', 'END:VCALENDAR',
  ].join('\r\n');
  const file = `out/${Date.now()}.ics`;
  await writeFile(file, ics);
  return { file };
}

Note the line endings: the iCalendar spec uses CRLF, and some clients are strict about it.

An .ics file is plain text. A minimal VEVENT with DTSTART, DTEND, SUMMARY and ATTENDEE lines is enough for every major calendar app. In the demo, one sample email asks for a 30-minute call on Tuesday 3 November at 3:00 PM and to invite a second person, which is a good test: the model has to pull the date, the duration and the extra attendee out of free text. Validate those fields in code (is the date parseable, is the end after the start) before writing the file.

For documents, asking the model for Markdown with a fenced mermaid block gives you a diagram that renders on GitHub and in many editors, and pandoc doc.md -o doc.docx turns it into Word for people who want that.

Step 8: Run it and fix the real failures

bash
node agent.js "Summarize my inbox and draft replies to anything that needs one"
node approve.js   # review drafts: y to approve, n to reject

The video shows the agent’s mistakes on screen, which is the most useful part:

  • Guessing before reading. The model summarized an email it had not opened. Fix: the system prompt says to call read_email before describing content, and the tool result is the only source of facts.
  • Wrong tool. It called write_document when a reply was asked for. Tighter tool descriptions help more than longer prompts.
  • A bad id. It invented an email id. Because errors come back as tool results, it recovered on the next step.
  • A copied example. It echoed sample values from the prompt. Keep examples generic or remove them.
  • Missing an email that needed a reply. A 3B model will miss things. Treat its triage as a first pass, not a verdict.

Step 9: Connect real Gmail, Outlook or Teams (optional)

The video explains but does not demo this. Gmail needs the Gmail API with your own OAuth app registration; Outlook and Teams go through Microsoft Graph. Keep the tool interface identical and swap the implementation: list_emails calls the API instead of reading a folder. Be honest with yourself about privacy here: the model stays local, but reading real mail still connects to Google or Microsoft. Request read and draft scopes only, not send.

Troubleshooting

  • fetch failed / ECONNREFUSED: Ollama is not running. Start it with ollama serve and confirm port 11434.
  • The model never calls a tool: check that the model supports tool calling (qwen2.5 does) and that you’re passing tools, not putting schemas in the prompt text.
  • AbortError after two minutes: the prompt is too large for CPU inference. Truncate email bodies in read_email (for example to 4,000 characters) and summarize one email per step.
  • Arguments arrive as a string: some model/runtime combinations return arguments as JSON text. Normalize with typeof a === 'string' ? JSON.parse(a) : a.
  • Calendar app rejects the .ics: check CRLF line endings, DTSTAMP, and a unique UID.

Production checklist

  • No send scope in any credential the agent can reach.
  • Step limit, request timeout, and body truncation in place.
  • Every tool validates its inputs and returns { error } instead of throwing.
  • Drafts and generated files logged with the task that produced them.
  • A human reviews every outgoing message.

FAQ

Can I use a bigger model?

Yes. The loop doesn’t care. Change the model name to something like qwen2.5:7b or a cloud model. Fewer mistakes, more compute or cost.

Why not use MCP?

You can. These five functions map one-to-one onto MCP tools. Writing them inline first makes the loop easy to understand; moving them to an MCP server later lets other clients reuse them.

Is this really private?

The model and your prompts stay local. If you connect a real mailbox, the mail itself still comes from Google or Microsoft over their API.

Official documentation

Key takeaways

  • An agent is a loop: model asks for a tool, code runs it, result goes back, with a hard step limit.
  • Remove the send capability entirely; approval happens outside the model.
  • Feed tool errors back to the model instead of crashing.
  • Validate extracted dates and attendees in code before writing .ics files.
  • A local 3B model keeps data on your machine but makes visible mistakes; design for review.

Watch: 100 Days of AI on YouTube

Short videos from CheatCoders, one AI engineering topic per day.

Open the playlist on YouTube · CheatCoders on YouTube

Deep-dive PDF

Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.

PDF for this post is being prepared. Subscribe now and we’ll unlock downloads sitewide as packs go live.

Subscribe free →


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a comment

No account needed. Name and email are optional.