/ LOCAL AI + AUTOMATION /
Building a Local AI Email Triage System
Using a local model, hard rules, and JavaScript to automate a real workflow.
When people talk about AI development, it can sound like you need an expensive GPU, a paid API, and serious cloud infrastructure.
What useful AI workflow could I build locally, for free, using hardware I already had?
The machine was a 2012 MacBook Pro with 8GB of RAM. Instead of starting with a chatbot, I started with a real problem: my inbox.
I wanted a system that could look at an email and answer:
- Is this actually important?
- Do I need to respond?
- Should I star it?
- Can it be archived, or ignored?
That small idea eventually became a local AI email triage system.
Start with the problem
Before any AI existed, the workflow looked like this:
Open Gmail
Read the email
Decide whether it matters
Star it, archive it, or leave it
Repeat, forever
That works. But it also means making the same small decision, over and over. The goal wasn't to hand the inbox over — it was to remove the decisions that don't actually need a mind.
Not the goal
Let AI control my inbox.
The goal
Automate the obvious decisions, and use AI only when interpretation is actually needed.
Step 1 — running an AI model locally
The first piece of the system was llama.cpp. It lets us run compatible language models directly on our own computer, instead of sending text to an external API:
Terminal
./build/bin/llama-server \
-m models/gemma3/gemma-3-4b-it-Q4_K_M.gguf \
--port 8080Now our computer has a local endpoint:
Local endpoint
http://127.0.0.1:8080Our JavaScript application can send prompts to that address just like it would talk to any other API. The important difference:
My computer
Local model
The email never has to leave the machine to get classified.
What does llama-server actually do?
The model and the server are two different pieces:
Gemma 3 4B
the model
llama-server
makes the model available to programs
JavaScript
sends requests to the server
The model itself doesn't know anything about Gmail. It just receives text and generates text — our application is what gives it a useful job.
Test the server first
Before connecting anything else, we can check whether the local server is alive. While the model is loading, the health check fails — once it's ready, it returns ok:
Simulated — try the health check
This is an important development habit:
Test one layer before adding another layer.
If the AI server isn't working, debugging Gmail at the same time just makes the problem harder to find.
Step 2 — reading Gmail
Next, the application connects to Gmail using Google's API and OAuth, and searches specifically for:
Gmail search
is:unread in:inbox category:primaryThat distinction matters — I didn't want the model wasting time on hundreds of promotional messages from the Promotions tab.
Gmail
Unread
Primary inbox
Node.js
For every email, the app extracts:
JavaScript
{
id,
from,
subject,
date,
body
}Why clean the email first?
Real emails are messy. They can contain:
The model doesn't need most of that. So before it ever sees an email, the application strips the noise out:
JavaScript
return body
.replace(/https?:\/\/\S+/g, " ")
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim()
.slice(0, 1500);We're reducing noise before asking the model to reason. That matters even more when you're running AI on limited hardware.
Step 3 — give the model a specific job
At first, the model returned something simple:
Model output
{
"priority": "high",
"action_required": true,
"summary": "Recruiter wants interview availability.",
"reason": "A response is required."
}That's already more useful than asking "what do you think about this email?" — we define a schema and force the model into a predictable structure. Later, the classifier became more detailed:
Model output
{
"category": "interview_request",
"priority": "high",
"action_required": true,
"confidence": "high",
"summary": "Company requests interview availability.",
"reason": "The sender is asking the recipient to schedule an interview."
}Now JavaScript can make decisions from the result.
/ DEBUGGING /
Then the model started making mistakes
This is where the project got a lot more interesting. An automated LinkedIn job digest, a Tennis Warehouse marketing email, and an Audible recommendation were all classified as related to a job interview or a personal request. There wasn't an interview in any of them.
See what the model said, and what was actually true:
Pick an email the classifier actually saw
From
jobs-noreply@linkedin.com
The model said
HIGH priority — "job interview"
Wrong
What's actually true
It's a routine job-alert digest. Nobody is waiting on a reply.
Should be handled by: a hard rule
Technically, the model was following pieces of the prompt. It just wasn't understanding the distinction that actually mattered.
A valid AI response is not necessarily a correct AI response.
The model is not the entire system
My first instinct could have been: get a bigger model. Instead, I changed the architecture. Some email decisions don't require AI at all — if a message comes from a known automated job-alert address, JavaScript can already decide, with no model in the loop:
Known job-alert sender
Low priority
Archive
Why spend thirty seconds asking a language model something we already know?
Rules first, AI second
rules.js
const JOB_ALERT_PATTERNS = [
/\bjobs similar to\b/i,
/\bnew jobs?\b/i,
/\bjob alert\b/i,
/\brecommended jobs?\b/i,
];Now obvious job-board emails never have to reach the model at all.
Rules vs. AI reasoning
I separated two different kinds of knowledge. One file holds things the application already knows — deterministic, same input, same result every time:
rules.js
const SENDER_RULES = {
"jobs-noreply@linkedin.com": "job_alert",
"email@alert.ally.com": "financial_alert",
};The other holds things the model needs to understand — the ambiguous cases, where language and context actually matter:
skills/email-triage.md
A job posting is NOT an interview request.
A marketing call-to-action is NOT a personal request.
A personalized greeting does not mean the email
was personally written for the recipient.I think of this file similarly to a SKILL.md or instruction file used by AI agents — it teaches the model how to reason about the cases that are genuinely uncertain.
Walk an email through it
Pick a real email below and step through exactly what happens to it — some emails never reach the model at all.
Walk an email through the pipeline
01 — Email arrives
A new message lands in the inbox.
02 — Gmail API
03 — Clean text
04 — rules.js
05 — Rule matched
06 — Archive
This is much closer to an actual AI system than simply calling a model on every email that arrives.
Step 4 — let code control the action
One more design decision mattered: the model classifies. JavaScript decides what actually happens.
JavaScript
if (
category === "billing_statement" &&
confidence === "high"
) {
return "star";
}I might personally want every billing statement starred. That's my workflow preference — the model shouldn't have to rediscover it every time it reads an email.
Step 5 — add a safe dry-run mode
Before letting the program modify Gmail, I added a flag. Dry-run mode runs the entire workflow — fetch, rules, AI, classify, decide — but instead of changing anything, it just says what it would have done:
Terminal
node classify-email.js --dry-runFlip the switch below and run it both ways on the same three emails.
Dry-run mode
Your statement is ready
alerts@chase.com
5 new jobs similar to Frontend Engineer
jobs-noreply@linkedin.com
Quick call this week?
dana@northwind-co.com
Only node classify-email.js, with no flag, runs the live version. Before giving AI-assisted software permission to change something, give yourself a way to observe its decisions first.
Comparing models
Once the workflow worked, I could swap models without rewriting the application — the JavaScript always talks to 127.0.0.1:8080, so I can stop one model server and start another. That let me compare:
One model might answer faster but make worse decisions. Another might reason better but take longer. The "best" model isn't necessarily the largest one — it's the model that works best inside the workflow you're building.
What I learned
The biggest lesson from this project wasn't how to install a language model. It was learning where AI actually belongs in an application.
Obvious email
→ code
Ambiguous email
→ AI
Personal preference
→ configuration
AI reasoning behavior
→ skill instructions
Each piece has a specific responsibility.
/ NEXT LEVEL /
What could we build next?
The next stage could turn this into a background email agent, instead of one you run by hand. Important messages could trigger a notification right away:
Interview request detected
Star
Notify immediately
While less urgent starred emails accumulate into a digest instead:
20 important messages
AI summarizes them
Slack digest
At that point, the system isn't just classifying email. It's managing a workflow.
/ CHALLENGE /
Your turn
Build your own small AI classifier. It doesn't have to use Gmail — start with three pieces:
Input
Rules
AI, only when needed
Action
You could classify:
Before writing any code, answer these three questions:
- What decisions are completely predictable? Those probably belong in code.
- Which decisions require understanding language or context? Those are good candidates for AI.
- What happens if the AI gets something wrong? That should decide how much control you give it.
The bigger idea
A local model by itself isn't an automation. The useful part comes from everything built around it:
MODEL + RULES + TOOLS + CONTEXT + PERMISSIONS + SAFETY
= WORKFLOW
The goal isn't to use AI everywhere. The goal is to find the places where AI adds judgment that ordinary application logic can't easily provide. That's what turned an old MacBook and a local language model into something that could actually do useful work.