This is a submission for the Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle
The problem
A one-person trade loses work for a dull reason: enquiries arrive while their hands are full, and nobody answers them. The plumber is under a sink, the barber has both hands busy, the tutor is mid-lesson. A text, a DM, a form message, a missed call — each one needs a reply, and each one waits.
The going fix is a closed, metered AI assistant: someone else's LLM API bolted onto the workflow. For a one-person business that's a bad deal on three counts.
- It's metered. Hundreds of messages a month, every one billed, so the margin quietly leaks.
- It ships the customer's data. Job details are names, addresses, and what's wrong with someone's house. That shouldn't have to leave the owner's machine.
- You don't control the model. The vendor can reprice it or retire it under you at any time.
So I built the opposite: a front desk whose triage runs on an open-weight model you host yourself, and which lives in the terminal.
What I Built
second-ring is a terminal front desk. You type the enquiry in; a local model reads it, works out what the job is, and asks for the few details the owner actually needs — one question at a time. When the picture is complete the job sits in a queue, and you approve or dismiss it from the same prompt. Nothing is confirmed to anyone until a human says so.
One thing I'll be straight about: a plumber is not going to open a terminal. What makes this worth paying for is that the triage is the expensive, privacy-sensitive part, and it runs on the owner's own machine for nothing. The delivery layer — a phone number, a WhatsApp bot — is the obvious next step, and it is not built. I'd rather ship the part that decides, and be honest about the part that's missing.
The rule the whole thing is built around:
The model analyses. Deterministic code acts.
The model is never allowed to book, to promise, or to quote. It classifies and extracts. Everything that reaches a customer is decided in ordinary code I can read and test.
flowchart LR
C[You, as the caller] -->|types a message| A[Terminal front desk]
A -->|prompt + schema| M[(Ollama / Gemma 3 local)]
M -->|JSON| V{Deterministic validate}
V -->|missing field| C
V -->|complete + confident| DB[(SQLite jobs)]
DB --> O[You, as the owner]
O -->|/approve| C
V -->|needs_human| O
There is no server, no port, no account and no API key. One process, one SQLite file, one local model.
Demo
A real run against Gemma 3 4B in Ollama, on a CPU box with no GPU. Piping the lines in makes it reproducible in one command:
printf '%s\n' \
"hi, my kitchen sink is leaking badly under the cabinet" \
"it's Dave, 12 Main St, Riverbend, free tomorrow morning" \
"/jobs" "/approve 1" "/jobs" | node src/cli.js
Riverbend Plumbing — terminal front desk
model: gemma3:4b @ http://localhost:11434 (up)
type a message as the caller, or /help
<- Hi there! Could you tell me which suburb the kitchen is in?
[owner] New job for Riverbend Plumbing
[owner] caller - leaking taps and toilets
[owner] Dave, 12 Main St, Riverbend
[owner] Wants: tomorrow morning
[owner] Job #1 — /approve 1 to confirm.
<- Thanks, we've got your details and passed them to Sam. You'll hear back shortly to lock in a time.
id status caller service name / address wants
-- ------- ------ ------------------------ --------------------------- ----------------
1 pending caller leaking taps and toilets Dave, 12 Main St, Riverbend tomorrow morning
job #1 booked. The caller would get:
"Confirmed! Sam will be in touch to lock in a time. Thanks for choosing Riverbend Plumbing."
id status caller service name / address wants
-- ------ ------ ------------------------ --------------------------- ----------------
1 booked caller leaking taps and toilets Dave, 12 Main St, Riverbend tomorrow morning
The model asks for exactly one thing at a time, and the confirmation is our text, not the model's — it never confirms a time or names a price.
And the case the product exists for — something that must not be swallowed by a form:
[owner] Needs you now: caller (job #1) — /jobs to review.
<- Thanks for the message. Sam will get back to you shortly.
id status caller service name / address wants
-- ----------- ------ ----------- -------------- -----
1 needs_human caller burst pipes
An emergency never becomes a booking. It becomes a job marked needs_human and a straight hand-off — the same reply a caller would get from a human who had stepped away.
Code
MIT-licensed, and small: github.com/MAXxDEVIL/Side-Hustle
How I Built It
The open pieces are the whole point, so the stack is deliberately thin. Node's built-ins do the work — node:readline, node:sqlite, global fetch — and Gemma 3 runs locally through Ollama. No framework, no dependencies, nothing to install beyond the model.
The interesting part isn't the model call, it's the part that decides. This is the whole decision layer:
export function decide(extraction = {}, ctx) {
const { business, maxTurns = 8, turns = 0 } = ctx;
const intent = text(extraction.intent).toLowerCase();
const modelReply = text(extraction.reply_text);
// Anything unusual, or a caller who will not stop, goes to a human.
const needsHuman =
extraction.needs_human === true || HANDOFF_INTENTS.has(intent) || turns >= maxTurns;
if (needsHuman) {
return { action: "handoff", field: null, reply: ownerHandoff(business) };
}
// Ask for exactly one missing detail at a time.
const missing = REQUIRED_FIELDS.filter((f) => !text(extraction[f]));
if (missing.length > 0) {
const field = missing[0];
return { action: "ask", field, reply: isSafeForCaller(modelReply) ? modelReply : ASK[field] };
}
// Complete: our own confirmation text, never the model's.
return { action: "complete", field: null, reply: jobConfirmed(business) };
}
Two guardrails sit in front of every reply. They are dumb regexes on purpose — cheap, obvious, and testable:
const PRICE = /(?:[$€£]\s?\d|\b\d+\s?(?:dollars|usd|eur|gbp|quid|bucks)\b|\bper\s?hour\b|\bfree\b)/i;
const PROMISE = /\b(you'?re booked|booking confirmed|confirmed for|we'?ll be there|see you (?:on|at)|scheduled for)\b/i;
If a model reply contains a price or a booking promise, it's thrown away and replaced with a safe template. A small model will sometimes try to be helpful and quote a callout fee. It never gets the chance.
The unglamorous parts matter as much:
- The decision layer is pure, so 21 tests cover it, the store and the guardrails with no network and no model.
- Every job is recorded before anything is shown, so a crash mid-reply cannot lose the lead.
- One open job per caller. A follow-up ("thanks!") does not file a second lead; approving clears the thread so a later message can start a new one.
-
If the model is unreachable, the message is still filed as
needs_humanand the caller gets a human hand-off. It degrades, it doesn't drop. - A turn cap ends a thread that will not resolve.
- Every model input/output is logged locally, which is how the next section came to exist.
What the real model taught me
The first time I pointed this at Gemma 3 4B instead of a test stub, every happy path already passed — and the run still found two bugs a stub could never produce.
The follow-up bug. I cleared a caller's conversation the moment their job completed. So when they replied "yes that works, thanks", the model had no context left, read the message as a wrong number, and the deterministic layer — doing exactly what it was told — filed an empty job and paged the owner about a job that did not exist. The fix was to keep the thread (capped, with the turn counter reset) and allow at most one open job per caller; approving that job clears the thread so a later message can start a new one.
The empty-object bug. Asked to respond to a finished conversation, Gemma would sometimes return a bare {} instead of the schema. Setting Ollama's format to "json" only demands valid JSON, not the right JSON. Passing the actual JSON Schema as format makes every key required, and the empty objects stopped — six runs out of six after the change.
Neither bug was really about model quality. Both sat at the seam between a probabilistic component and code that has to be certain, which is the whole design argument of this project.
Why Does Open Innovation Matter?
This is the section the theme asks for, so let me be concrete.
Cost. A hosted assistant bills per message, forever. Here the marginal cost of a triage is zero — the model is on the machine. For a business where one lost job is worth a few hundred dollars, "free at the margin" changes whether the product works at all.
Privacy. The triage input is the most sensitive thing the business touches: a stranger's name, address, and the state of their home. Running inference locally means that text never crosses a network boundary I don't own. That is not a nice-to-have for a trade where trust is the product.
Control. Open weights mean the owner can swap a 4B model for a 12B one, or move to Qwen or Llama, without anyone's permission and without touching the product. A closed API gives you one lever and someone else holds it.
The counter-argument is worth stating honestly: a local model is worse than a frontier API at open-ended conversation. That's precisely why the design keeps the model on a narrow, structured job — classify and extract — and lets deterministic code do everything that a customer actually sees. I'm not trying to win a chat benchmark. I'm trying to not lose a job.
The approach follows a pattern I found in an earlier entry in this challenge — "Building a Privacy-First AI CRM Assistant for My Friend's Business" by bangkah, which makes the same separation: "the AI analyzes the data, but deterministic code executes the actions." Two independent builds landing on that seems like the right shape for AI that touches real businesses.
My Agent Session
That session covers an earlier version of this build — an HTTP server with webhooks and a browser review page — and the first two bugs found while testing it: a reply sent before the job was saved, and a review page that was open to anyone. I later cut the whole HTTP layer to make the tool terminal-only; the triage core it produced is what ships.
My First User
Not yet — I'm the only person who has used it so far. The next step is putting it in front of one real tradesperson and replacing this paragraph with what they actually said. I would rather leave this honest than invent a user.
Prize Categories
- Best Use of Gemma — Gemma 3 running locally through Ollama is the triage model: it classifies the message and extracts the job. Swap it out and the product stops working, which is what "meaningful use" should mean.
Top comments (0)