A task reminder that works perfectly, and behaves completely unhinged about it.
Talk normally. It captures the task. Then it will not let it go.
Pitch Deck Β· Demo Script Β· PRD Β· TRD Β· Runbook
Expand
SahurHub is a palm-sized desk companion: an animated 3D character living on a Raspberry Pi panel, driven end to end by Qwen. You talk to it from your phone. It talks back.
There is no "add task" button. You mention in passing that you should stretch in three minutes β it quietly captures that, schedules it, and then follows up. And follows up. And escalates.
The design principle is a sincerity inversion, and it is the whole product:
| Behavior | |
|---|---|
| The engine | Never drops a task, never mistimes a follow-up. Task facts stay accurate. |
| The character | Completely deranged about all of it. Escalating percussion menace. |
The engine is the straight man. The character is the bit. The bit never breaks the tool.
Built for the Qwen Brainrot Hackathon 2026 β a competition whose brief is to build something deliberately useless that nonetheless genuinely works. SahurHub won Most Creative Qwen Integration at the Physical Final.
Three.js Sahur at the panel's native 480Γ320, with the connection badge and the phone URL to join from. On the device this runs rotated to upright portrait; tapping the badge shows a scannable QR.
| Conversation | Tasks |
|---|---|
![]() |
![]() |
| Two tasks captured from ordinary conversation β no command, no form. | Live countdowns and escalation state: the deterministic half, doing its job. |
Hold-to-talk from the phone, or just type. Audio goes to qwen3-asr-flash.
qwen3.6-flash streams the reply and its control tags in the same pass β no second classifier call, no JSON mode. Task capture is a side effect of talking.
A captured task is scoped to its conversation and scheduled from its duration. The scheduler is plain deterministic code; the model never touches timing.
Reminders fire on schedule. Each ignored one advances an escalation level and re-arms. The wording gets worse; the facts do not change.
The kiosk consumes the same event stream: expression poses, amplitude-driven lip-sync, and a knock animation whose intensity tracks the escalation tier.
| Feature | What It Does |
|---|---|
| ποΈ Voice and text, both | Hold-to-talk push-to-talk or plain typing, with barge-in that cuts an in-flight reply mid-sentence |
| πͺ€ Task capture without a command | Tasks are extracted from ordinary conversational context β no add-task syntax to learn, and no second model call to pay for |
| β° Deterministic escalation | pending β reminding β escalated Γ2 β missed, driven by a timer kernel that rebuilds itself from SQLite after a restart |
| π Two characters, one seam | Sahur (wooden log, percussion menace) and Tralala (blue sneakers, cardio menace) β each a manifest plus a bible that drives prompt, voice, colors, and animations |
| π§± Procedural 3D, zero asset pipeline | Characters are composed from Three.js primitives in code β nothing to download, nothing to rig, and it re-builds from scratch inside the 2-hour finals window |
| π Poke to retaliate | Tap the character on the panel or from the phone and it swipes back, interrupting whatever it was saying |
| πΌοΈ Uploads with a vision lane | Text and images up to 5 MB β text enters context, images go through Qwen vision, and failures degrade to an in-character line instead of crashing |
| π Audio follows you | Exactly one playback target at a time, Phone or Device, while the kiosk keeps receiving amplitude data for lip-sync either way |
| π¬ Real conversations | Multiple threads with async-generated titles, editable and deletable, persisted in bun:sqlite |
| π Polished remote | Light/dark tokens with no flash on load, self-hosted Fredoka, and a hand-rolled markdown renderer that never touches dangerouslySetInnerHTML |
| π₯ Soundboard stings | Task-completion and entrance beats, because the alternative was silence |
One Bun process. It runs a plain HTTP listener for the kiosk, an mkcert-backed HTTPS listener for the phone (browser mic capture requires it), and a shared /ws endpoint on both.
flowchart TD
subgraph Devices["Clients"]
KIOSK["Kiosk panel<br/>Three.js Β· 480Γ320"]
PHONE["Phone remote<br/>React + Vite"]
end
subgraph Server["Bun process (single)"]
WS["/ws Β· shared on both listeners"]
ORCH["Orchestrator<br/>ordered turn queue"]
PERSONA["Persona<br/>5-layer prompt compiler"]
KERNEL["Kernel<br/>tags Β· scheduler Β· lifecycle"]
DB[("bun:sqlite<br/>conversations Β· messages Β· tasks")]
end
subgraph Qwen["Qwen β Alibaba Model Studio"]
ASR["qwen3-asr-flash"]
CHAT["qwen3.6-flash"]
TTS["qwen3-tts-flash"]
end
PHONE <-->|"text Β· PCM audio Β· events"| WS
KIOSK <-->|"events Β· TTS chunks"| WS
WS --> ORCH
ORCH --> PERSONA --> CHAT
ORCH -->|"audio in"| ASR
ORCH -->|"clean text out"| TTS
CHAT -->|"reply + inline tags"| KERNEL
KERNEL --> DB
KERNEL -->|"reminders on schedule"| ORCH
TTS -->|"PCM16LE"| WS
The separation that makes the joke safe to ship:
| Owned By | What It Controls |
|---|---|
| The kernel (code) | Task existence, durations, wake times, escalation level, lifecycle transitions |
| The model (Qwen) | Every word spoken, the expression, the animation trigger |
The model can request a task via a tag, but the kernel owns the clock. A hallucinating model produces worse jokes β never a missed reminder.
Control flows as streaming text, not JSON. The reply and its side effects arrive in one generation:
Tok. The chair is a trap. <|emotion:smug|> Stretch now. <|task:stretch|3|>
| Tag | Args | Effect |
|---|---|---|
emotion |
name | Sets the expression pose on the panel |
task |
name|minutes |
Captures a task and schedules it |
task_done |
reference | Completes an active task by id, id-prefix, or name |
remind |
minutes | Fires the character's remind animation (knock / stomp) |
escalate |
β | Fires the escalate animation (posture shift / strut) |
schedule |
minutes | Emits a schedule event, played immediately |
Each character's manifest maps remind and escalate onto its own animation, so the same tag reads as a knock from Sahur and a stomp from Tralala.
The parser strips tag-shaped garbage from visible text, holds a trailing partial tag across streamed chunks, bounds every argument, and deduplicates events per turn. Malformed output degrades to plain speech rather than an error.
| State | Kernel Behavior |
|---|---|
pending |
First duration interval elapses |
reminding |
Reminder emitted; next wake is one duration later |
escalated 1, 2 |
Each ignored follow-up advances a level and re-arms for one duration |
missed |
A third ignored state ends the task and clears its wake time |
done / dismissed |
Terminal manual actions that cancel scheduling |
| Layer | Technologies |
|---|---|
| Runtime | Bun Β· TypeScript Β· Bun workspaces Β· Biome + Prettier |
| Server | Dual HTTP/HTTPS listeners Β· shared WebSocket Β· bun:sqlite Β· zero runtime deps |
| AI | qwen3-asr-flash β qwen3.6-flash β qwen3-tts-flash |
| Kiosk | Three.js Β· procedural primitive model Β· amplitude-driven lip-sync |
| Remote | React 19 Β· Vite Β· hand-rolled CSS tokens Β· self-hosted Fredoka |
| Device | Raspberry Pi 5 Β· Waveshare 3.5" SPI panel Β· labwc kiosk Β· systemd |
| Local HTTPS | mkcert Β· Avahi mDNS (sahurhub.local) |
| Component | Purpose |
|---|---|
| Raspberry Pi 5 | Device host |
| Official Pi 5 PSU | Required for stable demo power |
| Waveshare 3.5" RPi LCD (A) Rev4.0, SPI | ILI9486 + XPT2046 panel |
| USB speaker | Device-side audio output |
| Phone hotspot | Demo network and remote access |
No microphone or camera on the device. The phone webapp supplies text, push-to-talk audio, and uploads β which is also why the phone listener has to be HTTPS.
Want to build one? The runbook walks the whole thing top to bottom: assembly, SD-card flashing, provisioning, and operation.
The app runs fully offline without an API key β a deterministic mock brain drives the whole UI, kiosk, and scheduler. Add a key only when you want real Qwen.
- Bun β package manager and runtime
- mkcert β for the local HTTPS certificate
- A POSIX shell (the setup scripts are bash)
1. Install mkcert (Debian/Ubuntu/WSL2)
sudo apt update && sudo apt install -y libnss3-tools
curl -JLO https://dl.filippo.io/mkcert/latest?for=linux/amd64
chmod +x mkcert-v*-linux-amd64 && sudo mv mkcert-v*-linux-amd64 /usr/local/bin/mkcert2. Bootstrap the repository
git clone https://github.com/TolongLabs/SahurHub
cd SahurHub
./scripts/setup.shThat runs bun install, builds both browser apps, installs the mkcert CA, mints cert/ with SANs for localhost + your LAN IP + sahurhub.local, and scaffolds .env.local without overwriting an existing one.
bun run dev| Surface | URL | Notes |
|---|---|---|
| Kiosk | http://localhost:8080/ |
Preview at 480Γ320 in the device toolbar |
| Remote | https://localhost:8443/phone |
The /phone path is required β / is the kiosk |
| Command | Purpose |
|---|---|
bun run dev |
Run the server |
bun run dev:remote |
Vite dev server for the remote app, with HMR |
bun run build:remote |
Build the app served at /phone |
bun run build:kiosk |
Build the kiosk bundle |
bun test |
Run tests |
bun run typecheck |
Type-check without emitting |
bun run lint |
Biome checks |
WSL2: WebGL is commonly blocklisted under WSLg, so the kiosk shows
RENDERER ERROR. Use the Windows browser against the forwardedlocalhostinstead. Full notes in the runbook.
All settings live in .env.local, which is never committed. See .env.example for the annotated list.
| Variable | What It Does |
|---|---|
DASHSCOPE_API_KEY |
Alibaba Model Studio key. Blank = mock brain, no network calls |
QWEN_CHAT_MODEL |
Reply + tag generation β qwen3.6-flash |
QWEN_ASR_MODEL |
Speech to text β qwen3-asr-flash |
QWEN_TTS_MODEL |
Text to speech β qwen3-tts-flash |
QWEN_VISION_MODEL |
Image-upload lane |
SAHURHUB_WIFI_SSID / _PASS |
Hotspot credentials consumed by setup-pi.sh |
SAHURHUB_STATIC_IP |
Pin a static LAN address for a stable demo URL |
With a key present, each spoken reply costs roughly $0.002 after the free TTS quota.
PORT and HTTPS_PORT are shell overrides rather than .env entries β use them to run parallel instances:
PORT=8081 HTTPS_PORT=8444 bun run devHardware assembly, SD-card flashing, provisioning, and day-to-day operation all live in one place:
π docs/runbook.md β build one yourself, top to bottom
| Section | Covers |
|---|---|
| Bill Of Materials | Exactly what to buy, and the power supply that trips everyone |
| Assembling The Hardware | Seating the 26-pin LCD HAT without bricking the boot |
| Flashing The SD Card | Raspberry Pi OS Bookworm 64-bit, and the settings to preset |
| Provisioning SahurHub | One idempotent script, and what each stage actually does |
| Troubleshooting | Hardware, service, model, and network failure modes |
The short version, on a freshly flashed Pi:
git clone https://github.com/TolongLabs/SahurHub
cd SahurHub
sudo ./scripts/setup-pi.sh --ssid "<hotspot>" --pass "<password>"SahurHub/
βββ src/
β βββ server/
β β βββ index.ts # dual listeners, routes, WS session wiring
β β βββ orchestrator.ts # ordered turn queue, barge-in, event dispatch
β β βββ kernel/ # tags Β· scheduler Β· task lifecycle
β β βββ persona/ # character registry + 5-layer prompt compiler
β β βββ llm/ # Qwen backends and the offline mock
β β βββ db/ # bun:sqlite schema and DAO
β βββ kiosk/ # Three.js panel app + procedural model/
β βββ shared/ # protocol.ts β the sole wire contract
βββ apps/remote/ # React + Vite phone webapp (Bun workspace)
βββ characters/ # <id>/character.json + bible.md
βββ scripts/ # setup.sh Β· setup-pi.sh Β· dev-kiosk.sh
βββ spikes/ # qwen-probe Β· https-mic
βββ assets/screenshots/ # the images in this README
βββ docs/ # this README, PRD, TRD, runbook, demo deck
Distributed under the MIT License. See LICENSE for details.
Built by TolongLabs for the Qwen Brainrot Hackathon 2026.
![]() Tuna @AlaskanTuna |
![]() Chaos @chaosiris |
![]() Doraemon @Doraemon-00 |
| Software & integration β server, kernel, persona, kiosk, remote. | Hardware & assembly β Pi build, panel, enclosure, demo rig. | Documentation & testing. |
- Qwen & Alibaba Cloud Model Studio β the ASR, chat, and TTS models doing the central work
- Three.js β the kiosk renderer
- Bun β runtime, bundler, test runner, and SQLite driver in one binary
- mkcert β painless local HTTPS, without which the phone mic would not work
- Shields.io β the badges above




