This is a submission for the Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine
What I Built
Every town has a ghost story. Most of them survive in a few local blogs, a line on Wikipedia, and the memory of whoever runs the walking tour. Candlelit Legends goes looking for them.
You type a place, like "Toronto", "Kingston, ON" or just "Ontario". Gemma searches the web, reads what people wrote, picks a legend you haven't heard yet, and retells it as a short tale on a parchment scroll. The story types itself out by candlelight. Sound cues the model placed in the text, such as a tolling bell, a creaking floorboard or footsteps on the stairs, play as the typewriter reaches them. At the end, a card shows the exact site, a label saying whether the tale is documented history, local legend or internet lore, and the sources so you can check them yourself.
It all runs on my laptop. The model, the agent loop, the story history and the page are all local, and no step needs an account or an API key.
You can shape each tale before you light the candle:
| Option | Choices |
|---|---|
| Story type | Any, Ghost, Cryptid, Cursed place, Water and shipwreck, Folklore, Historical mystery |
| Spookiness | Cozy, Campfire, Unsettling |
| Narrator | Auto, Old innkeeper, Lighthouse keeper, Skeptical historian, The ghost itself |
| Length | Short (about 250 words) or Long (about 600 words) |
| Truth level | Close to sources, or Dramatic embellishment |
Or press Roll the dice and let it choose everything except the place.
How I read the theme
The Halloween half is easy: these are ghost stories, told in the dark, with sound.
The DÃa de los Muertos half is what shaped the rules. Many local legends are about real people who really died. When I asked for Toronto, Gemma found J.P. Radelmüller, keeper of the Gibraltar Point Lighthouse, who was killed there in 1815. Two soldiers were tried for his death and acquitted. The legend says he still walks the lighthouse, searching for his missing remains.
That's a real man. So the storyteller prompt says the eeriness belongs to the legend and the dead are remembered with respect, never mocked. The story is built from facts the agent pulled from sources, so Radelmüller is remembered rather than made up. A local history check makes sure each place's dead aren't retold the same way twice.
Here's how that Toronto tale opened, in the lighthouse keeper's voice that the style rotation picked:
The old light still stands, a sentinel of stone and glass against the vast, brooding expanse of the lake. Gibraltar Point, they call it now, a stoic finger pointing out toward the turning grey waters.
I've kept this watch for many a season now, and I've seen the tides turn a hundred times. Many a foggy dawn has smothered the lamp's beam, and the sea has whispered its secrets against the rock. (waves against the dock)
And how it ended:
If you ever find yourself near these waters, under the sweep of this old light, remember the keeper who could not find his peace. And tell me, friend, when the night is deepest, do you ever feel the chill of old sorrows passing by?
Demo
Code
Candlelit Legends
Name a place and get a real, sourced local legend, retold as a short spooky tale on a parchment scroll, with sound cues timed to the text. Everything runs on your laptop through Ollama. The only request that leaves the machine is the web search (DuckDuckGo, with Wikipedia as a fallback).
See Candlelit Legends Design Doc.md for the full design.
Run it
# 1. Python deps (3.10+)
python -m venv .venv
.venv\Scripts\activate # macOS/Linux: source .venv/bin/activate
pip install -r requirements.txt
# 2. Models
ollama serve # if it isn't already running
ollama pull gemma4:e4b # story model (or set CL_MODEL to another)
ollama pull nomic-embed-text # optional: turns on the similarity check
# 3. Start the app, then open http://localhost:8000
uvicorn candlelit.server:app --port 8000
Terminal only (milestone 1 in the doc):
python -m candlelit.cli "Kingston, ON" --type ghost --length long -v
Tests…
The repo is small enough to read in one sitting:
| File | What it does |
|---|---|
candlelit/pipeline.py |
The 10-step agent loop, in plain Python |
candlelit/prompts.py |
The three prompts and their JSON schemas |
candlelit/llm.py |
A thin client for Ollama: schema-constrained chat and embeddings |
candlelit/search.py |
DuckDuckGo search, Wikipedia fallback, page extraction, caching |
candlelit/history.py |
SQLite history, duplicate-legend checks, similarity |
candlelit/sounds.py |
The sound whitelist and the code that enforces cue rules |
web/ |
One HTML page, plain CSS and JavaScript, no build step |
How I Built It
The stack, all open:
-
Gemma 4 E4B (
gemma4:e4b) as the researcher and the storyteller - Ollama (llama.cpp underneath) for local inference and grammar-enforced JSON output
- nomic-embed-text through Ollama for the repetition check
- ddgs for DuckDuckGo search and trafilatura to pull clean text out of web pages
- FastAPI with Server-Sent Events, SQLite, and a single vanilla HTML page
I tested it on a GTX 1050 with 4 GB of VRAM, a graphics card from 2016.
Gemma does three jobs
Each story goes through 10 steps. Three of them are Gemma calls, each with its own temperature and its own JSON schema:
- Plan searches (Gemma, temperature 0.2). Turn "Ontario" into 3 or 4 queries aimed at specific towns, lighthouses and roads, not the whole province.
- Search DuckDuckGo (code)
- Skip legends already told (code)
- Read the pages with trafilatura (code)
-
Choose a legend and extract facts (Gemma, temperature 0.2). Every fact must carry the number of the source it came from and be marked
documentedorlegend. - Pick a narrator, tone, opening and ending (code)
- Write the tale (Gemma, temperature 0.9). It gets the facts list only, never the raw web pages.
- Validate the sound cues (code)
- Check similarity against past tales with local embeddings (code)
- Save to history (SQLite)
Two loops wrap these steps. If fewer than 2 usable pages turn up, the agent widens to nearby towns and then the region, and says so in character ("The records are thin here. Asking in the nearby towns…"). If a finished story sits too close to an old one (cosine similarity above 0.85), Gemma rewrites it with a note naming the tale it echoed.
Every Gemma call goes through the same small function. Ollama turns the JSON schema into a grammar, so Gemma can only produce output that fits it:
body = {
"model": self.model, # gemma4:e4b
"messages": [{"role": "system", "content": system},
{"role": "user", "content": user}],
"format": schema, # enforced as a grammar by llama.cpp
"stream": False,
"think": False, # skip the thinking pass to save time
"options": {"temperature": temperature, "num_ctx": config.NUM_CTX},
}
Here's what the selection step returned for Toronto (trimmed):
{
"location": "Gibraltar Point Lighthouse, Toronto Islands",
"legend": "The ghost of keeper J.P. Radelmüller",
"label": "local legend",
"facts": [
{"text": "The lighthouse was built in 1808 and is one of the city's oldest buildings.", "kind": "documented", "source": 1},
{"text": "Radelmüller died on January 2, 1815.", "kind": "documented", "source": 2},
{"text": "Two soldiers, John Henry and John Blueman, were tried and acquitted in relation to the death.", "kind": "documented", "source": 2},
{"text": "His ghost is said to be searching for his missing remains.", "kind": "legend", "source": 1}
]
}
Gemma decides, code checks
A 4-billion-parameter model is a good storyteller and a careless clerk, so anything that has to be right is checked in code, not left to the prompt:
- Already-told legends are removed before Gemma sees the results, matched by URL and by normalized legend name. If Gemma picks a told legend anyway, code catches it and asks again.
- Style rotation is picked in code. Gemma gets "Narrator: a lighthouse keeper writing it down during a long night watch" as an instruction, not a suggestion. That's why five stories in a row don't all open with "The fog rolled in…"
- Sound cues are parsed against a whitelist. Unknown IDs, cues in the first sentence (so the silence lands first), and cues less than two sentences apart are all dropped. Each kept cue also shows a faded caption like (a bell tolls), so the story works with sound off.
- Labels are double-checked. If every source is Reddit, Blogspot, Fandom or similar, the label becomes "internet lore" whatever Gemma said.
There are 18 tests covering these rules.
What running a small model taught me
- Small models cite lazily. Gemma often credits one page for every fact. So code scans the other pages, and any page that names the same legend gets listed as a source too.
- Widening can drift. An early version widened the search whenever Gemma cited fewer than 2 pages. It asked for Salem, Massachusetts and ended up in Salem, New Jersey. Now it widens only when fewer than 2 usable pages exist, and the prompt tells Gemma to ignore towns that share a name in another state.
-
It's slow on old hardware, and the design leans into that. A short tale takes about 3.5 to 4 minutes on the GTX 1050, mostly in the select and write calls. Instead of a spinner, the page shows in-character status lines while the candle gutters: "Searching the old records…", "Reading what the townsfolk wrote…", "Dipping the quill…". Waiting in the dark turns out to suit a ghost story. If you want it faster,
CL_MODEL=gemma4:e2bswaps in the smaller model.
What's still rough
The facts list said Radelmüller died in 1815, and the story said "the second day of eighteen-twenty-five". That was in "Close to sources" mode, which forbids new dates. Grounding the writer in a facts list cuts down on made-up details, but it doesn't remove them. The next fix is a code check that every year written in the story appears in the facts, with a rewrite when one doesn't.
Sound with no downloads
The sound engine plays real recordings from sounds/ if they're there. If they aren't, it synthesizes every sound in the browser with the Web Audio API: wind, waves, hearth, bell, knocks, heartbeat. So the app has sound on the first run, with no files to download and no licenses to track. One quiet ambient loop plays under the whole tale and dips whenever a one-shot plays.
Why Does Open Innovation Matter?
For this project, open isn't a nice extra. Without it the app wouldn't exist in this form.
It runs on the computer I own. Gemma 4 E4B fits on a 4 GB GPU from 2016. I didn't need new hardware or a cloud account to build an AI agent that plans, researches and writes.
Each story costs nothing. One tale is at least three model calls, more when the search widens or a draft gets rewritten. Every new story is also embedded and compared against the whole history. Tuning this meant running the same places over and over to adjust prompts and the similarity threshold. With local inference that's free, so I could experiment as much as I liked, and so can anyone who runs it.
I control every step. Planning and selection run cold at 0.2 for reliable JSON. Writing runs hot at 0.9 for prose that surprises. Gemma's thinking pass is off, which saves time on old hardware. The grammar that forces valid JSON runs on my machine, where I can see it. The narrator's voice is mine to shape, and since the weights are open, a LoRA trained on public-domain ghost stories is a real next step rather than a feature request.
The model can be swapped. CL_MODEL points at any Ollama model. The same pipeline runs Gemma 4 E2B on a weaker laptop or a larger model on a better machine.
Your history stays home. Every place you've asked about and every tale you've read sits in one SQLite file on your laptop. Even the fonts are self-hosted, so opening the page doesn't contact Google.
It won't disappear. This one fits the theme best. The point of the app is keeping old stories alive. A hosted model can be deprecated, repriced or switched off. The Gemma weights on my disk will still tell Radelmüller's story in ten years.
Where I gave something up, honestly: a big hosted model would write faster, and probably better but then you would be giving a bit of your own data, be it your location and preferences to the bigger models.
My Agent Session
My agent session has sensitive information in it.
The Spirit of It
If the place the user gave has a published story it can bring up the old memories in a fun and creative manner.
Prize Categories
Best Use of Gemma. Gemma 4 E4B, run locally through Ollama, is the core of the app. It plans the searches, reads the sources and chooses the legend, pulls out cited facts, writes the tale in a rotating narrator's voice, and places the sound cues. Each job has its own temperature and JSON schema. There's no other language model in the pipeline, and the whole thing was built to fit a small Gemma on a 4 GB GPU.
Best Use of ElevenLabs ElevanLabs generated the audio for the demo with its powerful and natural voice generation capable models.

Top comments (0)