Goal tracker with iteration-based email reminders. You set a goal, pick a cadence (week / 2 weeks / month), and the app emails you to check in. Responding to the email feeds a small history log so you can see the cycle of attempts over time.
Stack: Flask + SQLite + APScheduler, packaged as Docker. UI gated behind
Google OAuth (email allowlist). Includes a REST API and an MCP server so
you can manage goals from an LLM client (e.g. Claude Code) or any HTTP
tool — the API uses an independent X-API-Key so MCP/cron jobs keep
working without browser sessions.
| Piece | What it is |
|---|---|
mindbaboon.py |
Flask app (UI routes: /, /add, /edit, /settings) |
iteration.py |
Blueprint handling the iteration response flow (/iteration/<id>) |
api.py |
REST API blueprint (/api/...), API-key auth |
scheduler.py |
APScheduler + email sender, persistent via SQLAlchemyJobStore |
database.py |
SQLite helpers |
init_mindbaboon_db.py |
Idempotent schema init |
mcp_server/ |
Standalone MCP server wrapping the REST API |
Timers survive container restarts: APScheduler stores jobs in the
apscheduler_jobs table in the same SQLite DB as goals, so next_run_time
is recovered on boot. Missed fires within misfire_grace_time (60s) run
on startup; older misses are dropped (coalesce=True collapses repeats).
A separate idempotency guard in send_goal_reminder refuses to send a
reminder if one was already sent for the same goal within the last hour
— defense against restart races causing duplicate emails.
Production deployment runs on a Proxmox host (LXC or VM with Docker). Local Mac/dev box is only used to build and ship — never to "host" the service. The pieces:
- Copy
.env.exampleto.envand fill in SMTP creds and an API key:cp .env.example .env python -c "import secrets; print(secrets.token_hex(32))" # paste into MINDBABOON_API_KEY
- On the Proxmox-hosted target, in the project directory. Either pull the
prebuilt image from GHCR (recommended) or build from source:
The
docker compose -p mindbaboon pull && docker compose -p mindbaboon up -d # prebuilt (ghcr.io/petronijus/mindbaboon) # or, to build locally instead: docker compose -p mindbaboon up -d --build
-p mindbaboonis load-bearing — compose uses it to name the data volume (mindbaboon_mindbaboon_data). Changing it strands your existing DB. - Open
http://<proxmox-host>:5000to confirm the UI loads.
The scheduler uses a SQLite-backed job store with no inter-process locking. If you start the app twice against the same database (two containers, container + bare-metal process, two compose stacks), each process will independently fire every scheduled job and you'll get duplicate emails — both startup mail and reminder mail.
Symptoms and diagnostics:
# 1) Confirm there's exactly one container
docker ps --filter "name=mindbaboon"
# 2) Check the live process count from inside the container
docker exec <container> ps aux | grep python
# 3) Grep the logs — should see exactly one "STARTUP" line per real boot
docker logs <container> 2>&1 | grep STARTUP
# 4) Hit /api/health and note the "pid" + "hostname" fields. Repeated
# calls should always return the same pid until you restart. If pid
# flips between two values, that's two processes load-balancing.
curl http://<proxmox-host>:5000/api/health | jq '.pid, .hostname'The app also self-defends: startup emails sent within 5 minutes of an
earlier one are skipped (logged as WARNING - Skipping startup email),
and reminder emails for the same goal within an hour are skipped
(WARNING - Skipping reminder for goal X). If you see those warnings,
you have two instances and should track down the second one.
UI is gated behind Google OAuth 2.0 (authorization code flow with
PKCE). On first visit to any UI route, an unauthenticated user is
redirected to /login, signs in with Google, and the backend verifies:
- ID token signature against Google JWKS (via Authlib)
email_verified=trueclaim- Email is in
ALLOWED_EMAILS(lowercase exact match)
A 30-day Flask session cookie (__Host- prefixed, Secure+HttpOnly+
SameSite=Lax) is set on success. Logout is POST-only with CSRF token.
API endpoints (/api/...) are NOT gated by OAuth — they keep the
X-API-Key header check from api.py. This lets MCP servers, cron
jobs, and curl scripts keep working independently of browser sessions.
- https://console.cloud.google.com/ → APIs & Services → Credentials
- Create OAuth 2.0 Client ID, type Web application
- Authorized redirect URI:
https://<SERVER_HOST>/oauth2/callback - Drop
GOOGLE_OAUTH_CLIENT_ID+GOOGLE_OAUTH_CLIENT_SECRETinto.env - Set
[email protected],...andFLASK_SECRET_KEY=...
For belt-and-suspenders security, add a Cloudflare Access policy in the Zero Trust dashboard on the same hostname. Cloudflare gates the edge with your email allowlist before any request hits the docker host. The app's own OAuth still works for direct LAN/Tailscale access.
Security headers (HSTS, CSP, X-Frame-Options, etc.) are set by
Flask-Talisman. CSRF tokens via Flask-WTF on every POST form.
Rate limiting on /oauth2/callback via Flask-Limiter.
Base URL /api. Every endpoint except /api/health requires the
X-API-Key header.
| Method | Path | |
|---|---|---|
| GET | /api/health |
Scheduler state, job count, each job's next_run_time |
| GET | /api/goals?include_completed=false |
List goals |
| POST | /api/goals |
Create goal (+ schedule if iteration set) |
| GET | /api/goals/<id> |
Single goal |
| PATCH | /api/goals/<id> |
Update allowed fields |
| DELETE | /api/goals/<id> |
Delete goal and its history |
| POST | /api/goals/<id>/complete |
Record iteration completion |
| POST | /api/goals/<id>/snooze |
Pause reminders |
| POST | /api/goals/<id>/resume |
Resume and reschedule |
| GET | /api/goals/<id>/history |
Goal + iteration history |
Valid iteration values: "week", "2 weeks", "month". The
timedelta kwargs behind these keys live in config.py. Note that
"month" is implemented as weeks=4 (28 days), not a calendar month —
over a year this drifts by ~5 days. If you need true calendar-month
cadence, swap the IntervalTrigger for a CronTrigger.
Quick check after a restart:
curl http://<proxmox-host>:5000/api/health
# scheduler_running: true, jobs[] with future next_run_time, pid + hostnamemcp_server/ exposes the REST API as MCP tools so an LLM can create
goals, mark iterations done, snooze, and query state. Setup is in
mcp_server/README.md.
| Var | Purpose |
|---|---|
EMAIL_SMTP_SERVER, EMAIL_SMTP_PORT, EMAIL_USERNAME, EMAIL_PASSWORD |
SMTP for outbound mail |
DEFAULT_TO_ADDRESS |
Who receives reminders |
SERVER_HOST |
Hostname used in email reminder links |
MINDBABOON_API_KEY |
Required to call /api/... (except /health) |
FLASK_SECRET_KEY |
32-byte hex, signs session cookies. Hard-fail if missing |
GOOGLE_OAUTH_CLIENT_ID / GOOGLE_OAUTH_CLIENT_SECRET |
Google OAuth Web client |
ALLOWED_EMAILS |
Comma-separated lowercase emails permitted to sign in |
TZ |
Timezone for scheduler (default Europe/Prague) |
Never commit .env. It's in .gitignore. docker-compose.yml loads it
via env_file:.
Single SQLite DB at /app/data/mindbaboon.db inside the container,
backed by the mindbaboon_mindbaboon_data named volume when running
with -p mindbaboon. Tables:
goals— the goals themselves.is_silenced=1means the next scheduled tick will skip sending email (set when a reminder fires, cleared when the user responds via/iteration/<id>or/api/.../resume).goal_history— one row per completed iteration, with reflection textiteration_history— status transitions (Scheduled / yes / no)apscheduler_jobs— serialized jobs +next_run_time(binary blob, written by APScheduler)settings— key/value for things likedefault_emailand the global iteration slot (iteration_weekday/iteration_hour/iteration_minute)
FK ON DELETE CASCADE on goal_history and iteration_history is wired
up — get_db_connection() enables PRAGMA foreign_keys = ON, so deleting
a goal automatically removes its history rows.
The image is built and pushed to GHCR locally (not in CI):
echo "$GHCR_PAT" | docker login ghcr.io -u petronijus --password-stdin # PAT: write:packages
scripts/build-push.sh # builds + pushes ghcr.io/petronijus/mindbaboon:{VERSION,latest}Tagging vX.Y.Z (via the repo-release flow) then only validates the Docker
build in CI and cuts a GitHub release with notes — it does not publish the
image. The version is the VERSION file (also in the footer and /api/health).
Deploy pulls the prebuilt image (docker compose -p mindbaboon pull && up -d).
See CHANGELOG.md.
.env is gitignored and kept SOPS-encrypted in the private overlay
(private/config/env.sops) so it syncs across machines without plaintext in
git. With the overlay cloned into ./private and the 1Password CLI signed in,
run ./scripts/secrets-decrypt.sh to materialize .env (the age private key is
fetched from 1Password), or ./scripts/secrets-edit.sh to change it.
Prereqs: brew install sops age.