Witching Hour is a ghost-hunting app with one strict rule. You give it a recording of a place, the app measures what really happened in it (a light flickering, a knock, something moving), and the "spirit" on the Ouija board can only talk about those measured clues. No evidence, no ghost.

On the Friday before Halloween, I was doing one last test before calling it done. I opened one recording, then switched to a second one. The board started to answer, and something was off: the spirit was talking about the first recording. The app was showing the analysis of the old file, with old data, on top of the new one.

That sounds small, but it broke the one promise the app makes. A spirit that only speaks from evidence is worthless if the evidence comes from the wrong recording. And the deadline was a day and a half away.

I laughed a little, because I had literally built a ghost that wouldn't leave, and then I opened the debugger.

TL;DR: Witching Hour is a ghost-hunting web app that reimagines the Ouija board with real measurements and open models. TabPFN, a foundation model for tables, is the "spirit meter": it learns what a house usually does and flags the nights that don't fit. Gemma 4 tells the story, but under one rule: cite or vanish. Every sentence must point to a measured event, and every number in it must match, or it is deleted. In the main case, a staged hallway clip, the browser measures a light flicker at 7 seconds and three knocks, and a fictional spirit gets to use only those clues. The evidence is real. The spirits are fiction. Built in Halloween week for the Hacktoberfest 2026 Open-Source AI Challenge, Week 4: Ghost in the Machine.

The Warning: a staged hallway scene, a woman in her coat near the front door

What I Built

Imagine this: coat on, keys in hand, you are about to go out. But you get a strange feeling. Something feels wrong. You put the keys down and decide to stay home.

Later, you watch the hallway recording. The light flickers. Three knocks. Something crosses the edge of the frame.

Bad bulb? A draft? Or the reason you stayed?

Plenty of people would answer the third option. In a 2009 Pew Research Center survey, 18% of U.S. adults said they had seen a ghost or been in the presence of one, and 29% said they had felt in touch with someone who had already died (Pew Research Center). And if you ask an AI to explain the hallway, it will happily invent an answer: NewsGuard found that leading chatbots repeated false claims in 35% of news answers in August 2025, up from 18% a year earlier (NewsGuard, via VKTR). A believer and a confident storyteller is a dangerous mix. That is why the spirit in Witching Hour is not allowed to invent anything: every sentence has to cite a real measurement from the recording, or it vanishes.

That is The Warning, the heart of the demo. Kenji, a fictional client in Japan, brings a twenty-second staged recording to a very small, very opinionated ghost-hunting crew:

  • Luna, a calaverita medium, wants to ask the spirit.
  • Dr. Bigotes, a black cat with a clipboard, wants to check the light bulb.
  • Xochi, a xoloitzcuintli night watch, writes a calm note for the family in the morning.

Luna the Medium, Dr. Bigotes the Skeptic and Xochi the Night Watch

The app, called PARANORMAL.exe, opens three cases:

  • The House: four months of sensor readings from a real research home. Find the strange night, then hear a ghost story, a skeptic's explanation and a morning note, all about the same numbers.
  • The Unheard: a recording. The browser measures light, motion and sound, marks the moments that stand out, and takes those clues to a fictional séance.
  • The Room: your turn. Camera and microphone, a short baseline, and the board answers what changes around you.

Demo

🎬 The film and the tour:

🔮 Live demo: witching-hour.onrender.com (the free server sleeps, so give it a minute to wake up, like any good ghost).

GitHub logo jenniferevangelista-de / witching-hour

What happened in the house at 3 AM? TabPFN learns what was normal, Gemma tells the story, every sentence cites a sensor.

Witching Hour

What if someone tried to warn you, and you never heard them?

Witching Hour is a fictional ghost-hunting experience built for Halloween and Día de los Muertos, using real sensor anomaly measurements. A crew of three tiny ghost hunters carries an open-source kit, and their terminal, PARANORMAL.exe, opens three kinds of cases:

Case What you bring What the kit measures
🏚️ The House a house's sensor log (the demo uses a real house in Belgium, four months, every 10 minutes) TabPFN, the spirit meter, learns what every room should feel like at every minute of the night and flags the readings that break the pattern
🎞️ The Unheard a recording of the night you almost went out and something made you stay light, motion and sound, frame by frame, inside the browser (OpenCV does the same in Python); the moments that stand out become numbered evidence
…

The glass moving across the recorded séance board

How It Works

Witching Hour architecture: evidence measured by TabPFN and the browser, told by Gemma, checked by cite or vanish

The spirit meter knows what this house usually does

For each sensor, TabPFN v2 reads the previous fourteen days as context and returns a range for every ten-minute slot from midnight to 6 a.m. Outdoor weather and sensors in other rooms are features. A reading far outside its range becomes a numbered event: E1, E2, E3.

Then I planted ghosts on purpose: smooth artificial bumps in 240 night-sensor pairs, the same for every detector, with thresholds aimed at a 5% false-alarm rate.

Detector Planted cases found Strongest cases found
TabPFN v2 25.0% 37.8%
LightGBM quantile regression 9.6% 14.4%
Same-hour seasonal rule 13.3% 21.1%

TabPFN found the most, and three quarters of the faint ghosts still got away. I left that in the README on purpose. A ghost app that admits what it misses felt more honest than one that sees ghosts everywhere.

Chart: TabPFN catches 2x more planted ghosts. Ghosts caught: TabPFN 25%, seasonal rule 13%, LightGBM 10%. Found the right time: 87%, 66%, 61%. Honest 90% band: 95%, 84%, 59%. AUC 0.644, 0.634, 0.573

The House: the haunting map

Cite or vanish

Gemma doesn't get the raw log. It gets small evidence cards, and every sentence comes back with the IDs it is based on:

{ "text": "At 05:40, the machines woke up.", "cites": ["E1"] } 

Then the validator decides if the sentence lives:

def check_sentence(text, cites, events_by_id, night=None, window=(0, 6)): """None if the sentence is grounded, otherwise the reason it is not.""" valid = [c for c in cites if c in events_by_id] if not valid: return "cites no known event or check" times, values = allowed_numbers([events_by_id[c] for c in valid], night, extra) for m in _NUM.finditer(text): if m.group(1) is not None: # a time like 05:40  if f"{int(m.group(1)):02d}:{m.group(2)}" not in times: return f"time {m.group(0)} is not in the cited events" else: # a number like 26  v = float(m.group(3).replace(",", ".")) if not any(abs(v - a) <= max(0.11, 0.02 * abs(a)) for a in values): return f"number {m.group(3)} is not in the cited events" return None 

No known citation: gone. A time that isn't in the cited events: gone. A number that doesn't match: gone. That is the whole trick, and it is surprisingly hard to argue with.

It doesn't prove the explanation. Dr. Bigotes may blame the kettle with total confidence, and the kettle is still a hypothesis. But it does stop the story from inventing the facts.

The House, one night and three voices: Luna tells the story, every sentence cites the measured spike, and the spirit meter reads a full apparition

The spirit has a contract too

On the board, the spirit gets at most three words, known evidence IDs and an explicit "fictional" flag. When the Guardian spells I AM HERE, it belongs to the fiction. The flicker at seven seconds still belongs to the clip.

The recorded séance: Luna reads the glass, Bigotes checks the evidence, Xochi keeps watch

Partner Technologies

  • TabPFN v2: the spirit meter, on CPU, no login, benchmarked against LightGBM and a seasonal rule.
  • Gemma 4 E4B via Ollama: every story, every spirit, on my own machine. In a bake-off of four remote open-weight models through Backboard, the local Gemma reference passed 45/45 sentences.
  • Tinker: I fine-tuned Qwen3-8B with LoRA on stories the validator accepted. On unseen nights, sentences passing went from 84/88 to 124/125.
  • ElevenLabs: the whole cast has a voice: 114 recorded files.
  • Temporal, Sentry, Mastra, Browserbase, SerpApi, Render, GitHub Actions: a night investigation that survives a killed worker, traced spans for every model call, a TypeScript agent with memory, a skeptic who checks the official weather page himself, and the live demo.

Chart: which spirit makes things up the least? Share of story sentences whose numbers and times match the cited sensor: Gemma 4 E4B local 100% (45/45, 12/12 stories), Mistral Small 3.1 100% but only 4 of 12 valid stories, Qwen 2.5 72B 97%, Llama 3.3 70B 96%, Gemma 3 27B 96%

Hackathon Experience

The challenge theme was Ghost in the Machine, and I had one rule for myself: no fake magic. If my app says a light flickered, it measured a light flickering.

The week, honestly:

October 28 was 60 commits. The spirit meter, the narrator, the validator, the crew. I was having way too much fun naming a cat.

That same afternoon, the story changed. I had a second idea brewing, a terminal called PARANORMAL.exe where a good spirit tries to warn someone: "don't go out today". The person stays. Only later does the board reveal the message. Two projects, one deadline. I decided to merge them, and that is how The Warning, The Unheard and The Room were born, all tied by one question: what if someone tried to warn you, and you never heard them?

My home page was 10,035 pixels tall. Every feature I added landed on the same scroll. A navigation bar and hash routes later, it was 1,360. Kill your darlings, or at least give them tabs.

The ghost that wouldn't leave. The opening scene, on October 30. When you picked a new file, the old transcript could linger, and a slow replay finishing late could repaint the old story over the new case. Correct citations are useless if they belong to the wrong recording. The fix was a version number on every recording:

function beginRecording(kind) { const version = ++recordingVersion; // a new case starts here recordingRequest?.abort(); // the old one is cancelled recordingRequest = new AbortController(); resetBoard("uh"); // ...every async answer checks recordingCurrent(version) before it paints } 

An old answer can still arrive. It just isn't allowed to paint anymore. Five Playwright tests now guard it, and the suite on main passes 81 tests.

Halloween itself was 39 commits. The last ones were the videos, with a creepy score made in code, at 11 p.m. The deadline was 03:59 my time, and I finished with a few hours to spare.

What I learned: open models make it possible to argue with your pipeline. I could swap the detector, plant the same ghosts, fine-tune a smaller narrator and check if it learned the same discipline. With a closed API, I would have had a story. With open models, I had a story and the receipts.

The evidence receipts panel

What It Can't Do Yet

  • It misses most faint ghosts. 25% of planted cases found at a 5% false-alarm target. Thresholds and evaluation share the same sample, so this is exploratory, not a guarantee.
  • Citations locate, they don't prove. "Cite or vanish" catches invented numbers and missing references. It doesn't verify every explanation.
  • The public demo uses recorded or template answers for the model, so it stays fast and free. The live model path needs a configured provider.
  • One house, from 2016. The "tonight" forecast applies that house's model to today's weather without matching validation.
  • Privacy depends on the install. Video is analysed in the browser, but summoning sends event descriptions to the app. A fully local install can switch the external services off.

Thank You

To DEV and MLH for Hacktoberfest 2026 and a theme that let me build something spooky and serious. To the teams behind TabPFN, Gemma and every partner tool that had a free tier generous enough for a Halloween-week builder. To the researchers who published the house dataset. And to Luna, Dr. Bigotes and Xochi, who argued with each other so I didn't have to.

The evidence is real. The spirits are fiction. The feeling is yours.

All characters, clients and spirits are fictional; the clips are staged.