Bet on yourself. Talk to the hand.
Quick Start · API · Architecture · Structure · Development
OffHand is a habit-accountability app with exactly one interaction: you talk to The Challenger, a cheeky hand persona, and it does the rest. You say "bet 100 fingers on the run tomorrow"; the LLM calls create_bet and the wager is on record. Later you say you did it; it calls update_bet and your balance moves.
There are no forms, no stake pickers, no confirm buttons — the tool calls are the UI. The backend is API-only; the frontend is static HTML/CSS/JS with no build step, installable as a PWA.
Important
Fingers are only credited, never debited on placement. create_bet (src/service/bet_service.py:10) writes the bet but does not touch User.balance. Balance changes only inside update_bet when a bet is marked complete (balance += amount + counter_bet) or reopened. The system prompt tells the model that stakes are deducted at creation time — the code does not do that yet.
Warning
Passwords are stored and compared in plaintext (src/service/user_service.py:24), and GET /user/{user_id} returns the whole User document, password field included. CORS is allow_origins=["*"]. Hobby stage — do not point this at real users.
| 💬 Chat-driven betting | Four LangChain tools (create_bet, get_all_bets, get_bet, update_bet) bound to Gemini; the model places and settles bets itself. |
| 🧠 Per-session memory | MongoDBChatMessageHistory in the chat_history collection, keyed by session_id. The last 3 stored messages are replayed into each prompt. |
| 🔒 user_id never reaches the LLM | The route stashes it in a ContextVar; tools read it from there, so the model cannot act on another user's bets. |
| 🎙️ Voice in, voice out | webkitSpeechRecognition for input, speechSynthesis for replies, with a system voice picker in the menu. |
| 📱 Installable PWA | Manifest + service worker (network-first for HTML, cache-first for assets); the landing page redirects to /app.html when launched standalone. |
| 📝 Markdown replies | marked + DOMPurify render the bubble; TTS reads the stripped innerText. |
sequenceDiagram
participant UI as ui/app.html
participant R as chatbot_router
participant CV as current_user_id ContextVar
participant L as call_llm
participant G as Gemini
participant T as bet_service tools
participant M as MongoDB
UI->>R: POST /chatbot/ (text body + user_id, session_id headers)
R->>CV: set(user_id)
R->>L: call_llm(text, session_id)
L->>M: load chat_history for session_id
L->>G: SYSTEM_PROMPT + last 3 msgs + input
G-->>L: tool_calls
L->>T: ainvoke(args)
T->>CV: get() to scope the query
T->>M: read or write bets and users
T-->>L: tool result
L->>G: results appended, invoke again
G-->>L: final text
L->>M: append user and AI message
L-->>UI: reply string
UI->>R: GET /user/{id} to refresh balance
| Piece | Concrete component | Store |
|---|---|---|
| Chat entry | src/api/chatbot_router.py | — |
| Tool loop | src/ai/llm.py → ChatGoogleGenerativeAI |
— |
| Bet tools | src/service/bet_service.py | bets |
| Auth / profile | src/api/user_router.py → user_service.py | users |
| Memory | MongoDBChatMessageHistory |
chat_history |
All three collections live in the offhand database (src/config/database.py).
Prerequisites: Python 3.13 (see .python-version), uv, a MongoDB you can reach, and a Google AI Studio API key.
git clone https://github.com/Avi-Rana-1718/Offhand.git
cd Offhand
uv sync1. Environment — all three are required and have no defaults; the app fails at import if any is missing (src/config/setting.py). Create .env in the repo root:
MODEL=gemini-2.5-flash
API_KEY=<google-ai-studio-key>
MONGODB_URL=mongodb://localhost:27017Note
There is no docker-compose.yaml in this repo. MongoDB is your responsibility — run one locally or point MONGODB_URL at Atlas. Collections and indexes are created by Beanie on first boot.
2. Backend — the makefile is named MakeFile, which GNU make does not auto-discover on case-sensitive filesystems, so pass it explicitly (on Windows plain make run works):
make -f MakeFile run # uv run uvicorn main:app --reload --app-dir srcListens on http://localhost:8000 (uvicorn's default — no port is set anywhere in the code).
3. Frontend — separate process, static files only:
make -f MakeFile ui # python -m http.server 5173 --directory uiOpen http://localhost:5173, sign up in the dialog, and start talking. ui/app.html hardcodes const API = "http://localhost:8000" at line 432 — change it there to target a deployed backend.
4. Verify without the UI:
curl -s -X POST localhost:8000/user/signup \
-H 'Content-Type: application/json' \
-d '{"email":"a@b.c","password":"pw","username":"avi"}'
# {"user_id":"1f0c...uuid4...","username":"avi"}
curl -s -X POST localhost:8000/chatbot/ \
-H 'Content-Type: text/plain' \
-H 'user_id: <user_id from above>' \
-H 'session_id: any-string' \
--data 'bet 100 fingers on running tomorrow'
# "Locked in. **run tomorrow** — 100 fingers. Don't make me regret this."Chat
| Method | Path | Body | Headers |
|---|---|---|---|
POST |
/chatbot/ |
raw text/plain — the user's message |
user_id, session_id (both required, 400 otherwise) |
Returns the reply as a JSON-encoded string. There is no request model — the route reads await request.body() directly.
Users
| Method | Path | Body | Returns |
|---|---|---|---|
POST |
/user/signup |
{email, password, username} |
{user_id, username} — 400 if the email exists |
POST |
/user/signin |
{email, password} |
{user_id, username} — 401 on mismatch |
GET |
/user/{user_id} |
— | the full User document — 404 if unknown |
update_user_about exists in the service layer but is not routed.
LLM tools (not HTTP)
| Tool | Args | Effect |
|---|---|---|
create_bet |
name, amount, counter_bet=0 |
Inserts a bet for the ContextVar user. Balance untouched. |
get_all_bets |
completed |
Bets for the user with that status; 404 if none. |
get_bet |
bet_id |
One bet, scoped to the user. |
update_bet |
bet_id, name?, amount?, completed? |
Edits fields; on completed=True credits amount + counter_bet and debits counter_bet from hands_balance. |
Bet.expires_at exists on the model but nothing ever sets it — no expiry or auto-forfeit yet.
| Layer | Choice |
|---|---|
| API | FastAPI ≥0.137.1, Uvicorn ≥0.49.0 |
| ODM | Beanie 1.26.0 + Motor ≥3.7.1 — IDs are uuid4 strings, not ObjectIds (base_entity.py) |
| LLM | langchain-google-genai ≥4.2.6, model from $MODEL |
| Memory | langchain-mongodb ≥0.11.0 |
| Config | pydantic-settings ≥2.14.2 |
| Frontend | vanilla JS, Space Grotesk + Inter, Ionicons 7.1.0, marked 12.0.2, DOMPurify 3.1.6 (all CDN) |
| Tooling | uv, ruff ≥0.15.17 |
bcrypt and google-genai are declared in pyproject.toml but imported nowhere in src/.
src/
main.py FastAPI app, CORS, lifespan-managed Mongo client
api/chatbot_router.py POST /chatbot/
api/user_router.py POST /user/signup, /user/signin, GET /user/{id}
ai/llm.py tool loop over Gemini
ai/prompts.py SYSTEM_PROMPT() — takes no args
ai/context.py current_user_id ContextVar
service/bet_service.py the four @tool functions
service/user_service.py plain async helpers (not LLM tools)
model/ BaseEntity, User, Bet
config/ Settings, init_db
ui/
index.html landing page, redirects to app.html when standalone
app.html chat UI, auth dialog, mic, TTS
manifest.webmanifest start_url = /app.html
sw.js cache "offhand-v2"
redesign.md original design notes
| Port | Process |
|---|---|
| 8000 | FastAPI (uvicorn default) |
| 5173 | python -m http.server serving ui/ |
| 27017 | MongoDB, if local |
make -f MakeFile lint # uv run ruff check .
uv run ruff format . # ruff configured: line-length 100, select E/F/INo test suite and no CI workflows in the repo — nothing under tests/ and no .github/. Bumping CACHE in ui/sw.js forces the service worker to reactivate after frontend edits.
- Deduct fingers at bet creation, matching what the prompt already claims
- Populate
expires_atand auto-forfeit expired bets - Hash passwords; stop returning the password field from
GET /user/{id} - Wire
update_user_aboutto a route - Tighten CORS before any deploy
No license file in the repo yet — all rights reserved by default until one is added.

