Zema is a self-hosted eczema treatment tracker with a FastAPI backend, PostgreSQL database, and a separate CLI/agent runtime.
It tracks subjects, body locations, eczema episodes, taper protocol phases, treatment applications, due reminders, event timelines, and adherence.
Zema is designed as two cooperating parts:
zema-be: the backend API and system of record.zema-cli: a separate runtime image containing thezemaCLI.
The backend owns all domain logic. The CLI is a client/tooling layer that calls backend HTTP APIs, parses responses, renders terminal output, emits JSON, and returns stable exit codes.
Use zema as the preferred CLI command. The older czm command remains available as a compatibility alias.
User / Agent / Telegram / Hermes / OpenClaw
|
v
zema / czm CLI or zema-cli container
|
v
zema-be FastAPI backend
|
v
PostgreSQL
zema-beis the FastAPI backend service.zema-cliis the CLI/agent runtime service.postgresis the canonical datastore.- Docker Compose keeps the backend and CLI/agent runtime in separate services.
- The backend remains the source of truth for treatment, phase, due, and adherence logic.
- The CLI calls the backend over HTTP.
- Gateway code for Telegram, Hermes, OpenClaw, or similar tools should not run inside
zema-be.
app/ Backend API, domain services, models, scheduler
alembic/ Database migrations
tests/ Backend tests
cli/ Separate CLI package
cli/docs/ CLI-specific docs
cli/skills/ Agent Skills package
docker/ Backend and CLI Dockerfiles
docker-compose.yml Local postgres, zema-be, and profiled zema-cli services
The CLI package is still named czm-cli and its internal Python package is still under cli/src/czm_cli. The public command name is zema, with czm kept as a compatibility alias.
- Account-scoped authentication with username/password login, JWT access tokens, and hashed API keys.
- Subject and body-location management.
- Eczema episode lifecycle tracking.
- Taper protocol phases with phase history.
- Treatment application logging, editing, voiding, deleting, and listing.
- Operational due reminders through
/episodes/due. - Event history and timelines.
- Daily adherence calculation and persisted audit snapshots.
- In-process scheduler for phase progression.
- Dockerized backend, PostgreSQL, and separate CLI/agent runtime.
Runtime requirements:
- Backend Python:
>=3.11,<4.0 - CLI Python:
>=3.11; package metadata lists Python 3.11 and 3.12 support - Docker images:
python:3.11-slim - PostgreSQL required when running outside Docker
Build the images:
docker compose buildStart PostgreSQL and the backend:
docker compose up -d postgres zema-beCheck the services:
docker compose ps
docker compose logs --tail=100 zema-be
curl -sS http://localhost:28173/healthExpected health response:
{"status":"ok"}The backend is available at:
http://localhost:28173
Run the CLI container:
docker compose run --rm zema-cli zema --helpAuthenticated CLI container examples:
docker compose run --rm -e CZM_API_KEY="$CZM_API_KEY" zema-cli zema due list --json
docker compose run --rm -e CZM_API_KEY="$CZM_API_KEY" zema-cli zema adherence summary --last 30 --jsonInside Docker Compose, zema-cli uses:
CZM_BASE_URL=http://zema-be:28173
Run the Telegram bot as a separate profiled service:
export CZM_API_KEY="..."
export ZEMA_TELEGRAM_BOT_TOKEN="..."
export ZEMA_TELEGRAM_ALLOWED_CHAT_IDS="123456789"
docker compose --profile telegram up -d zema-telegram
docker compose logs -f zema-telegramzema-telegram uses the CLI image, talks to zema-be over HTTP, and exposes no public ports.
For a server that should survive reboots, run the Compose stack from a stable directory and keep secrets in a private .env file.
Create a persistent server directory:
sudo mkdir -p /srv/zema
sudo chown -R czmbot:czmbot /srv/zema
sudo -iu czmbot
cd /srv/zema
git clone https://github.com/adriankae/Eczema-Tracker.git
cd Eczema-TrackerCreate .env from the placeholder file:
cp .env.example .env
chmod 600 .env
nano .envFill in at least:
CZM_API_KEY=replace-with-your-zema-api-key
CZM_TIMEZONE=Europe/Berlin
ZEMA_TELEGRAM_BOT_TOKEN=replace-with-your-telegram-bot-token
ZEMA_TELEGRAM_ALLOWED_CHAT_IDS=123456789
ZEMA_TELEGRAM_ALLOWED_USER_IDS=
ZEMA_TELEGRAM_ALLOW_WRITES=true
ZEMA_TELEGRAM_ALLOW_ADHERENCE_REBUILD=falseDocker Compose uses CZM_TIMEZONE for both backend due-slot logic and Telegram/CLI runtime behavior. For phase-1 AM/PM due checks, zema-be receives this value as DEPLOYMENT_TIMEZONE.
Start the persistent backend and Telegram bot:
docker compose --profile telegram up -d postgres zema-be zema-telegramInspect the stack:
docker compose ps
docker compose logs --tail=100 zema-be
docker compose logs --tail=100 zema-telegram
curl -sS http://localhost:28173/healthEnsure Docker starts on boot:
sudo systemctl enable docker
sudo systemctl status dockerReboot test:
sudo rebootAfter reconnecting:
cd /srv/zema/Eczema-Tracker
docker compose ps
docker compose logs --tail=100 zema-telegram
curl -sS http://localhost:28173/healthThe long-running services use restart: unless-stopped, so Docker restarts them after reboot as long as Docker itself starts. Data persists in named Docker volumes:
zema-postgres-data
zema-location-images
Keep .env private. It contains secrets and should not be committed. Back up .env, the Postgres volume, and the location image volume. docker compose down stops containers but keeps named volumes; docker compose down -v deletes named volumes and destroys database/image data.
The local Docker Compose setup seeds a default account when the database is empty:
username: admin
password: admin
Override these with:
INITIAL_USERNAME
INITIAL_PASSWORD
Create an API key manually:
export CZM_BASE_URL="http://localhost:28173"
export ACCESS_TOKEN="$(
curl -sS "$CZM_BASE_URL/auth/login" \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"admin"}' \
| jq -r '.access_token'
)"
export CZM_API_KEY="$(
curl -sS "$CZM_BASE_URL/api-keys" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"name":"zema-cli"}' \
| jq -r '.plaintext_key'
)"Verify the API key:
curl -sS "$CZM_BASE_URL/auth/me" \
-H "X-API-Key: $CZM_API_KEY"The CLI can also create its config automatically with zema setup:
zema setup \
--username admin \
--password admin \
--api-key-name zema-cli \
--timezone Europe/Berlin \
--base-url http://localhost:28173zema setup logs in, creates an API key, and writes a config file under ~/.config/czm/config.toml or $XDG_CONFIG_HOME/czm/config.toml.
Install the CLI from the repository root:
python3 -m venv .venv
source .venv/bin/activate
python3 -m pip install -e cli
zema --help
czm --help
zema adherence --helpIf your pip index is unreachable, use PyPI explicitly:
PIP_INDEX_URL=https://pypi.org/simple python3 -m pip install -e cliIf zema is installed into a user bin directory that is not on PATH, activate the virtual environment or add the pip scripts directory shown by pip to your PATH.
CLI configuration precedence is:
CLI flags > CZM_* environment variables > config file
The CLI uses these environment variables:
CZM_BASE_URL
CZM_API_KEY
CZM_TIMEZONE
The default base URL is:
http://localhost:28173
See the detailed CLI docs in cli/docs/.
After the backend is running and the CLI has an API key:
zema subject create --display-name "Child A"
zema location create --code left_elbow --display-name "Left elbow"
zema location image set left_elbow ./left-elbow.jpg
zema episode create --subject "Child A" --location left_elbow
zema application log --episode 1
zema due list
zema events list --episode 1Notes:
zema application log --episode 1records a minimal application.- If omitted,
treatment_typedefaults toother. - Optional application fields include
--applied-at,--treatment-type,--treatment-name,--quantity-text, and--notes. - Location images are optional and can be added during creation or later with
zema location image set. - Subject and location references may be numeric IDs or resolvable names/codes.
Location image examples:
zema location create --code left_elbow --display-name "Left elbow" --image ./left-elbow.jpg
zema location image set left_elbow ./left-elbow.jpg
zema location image get left_elbow --output ./left-elbow.jpg
zema location image remove left_elbowZema 0.3.0 includes a Telegram frontend that runs outside the backend container:
Telegram
|
v
zema telegram run / zema-telegram
|
v
zema-be
|
v
PostgreSQL
The Telegram runtime uses explicit handlers and the same backend HTTP client layer as the CLI. It does not execute shell commands, does not support arbitrary /zema ... passthrough, and does not run inside zema-be.
Local setup:
zema setup telegram
zema telegram test
zema telegram runNon-interactive setup:
zema setup telegram \
--base-url http://localhost:28173 \
--api-key "$CZM_API_KEY" \
--bot-token "$ZEMA_TELEGRAM_BOT_TOKEN" \
--allowed-chat-id 123456789 \
--timezone Europe/Berlin \
--allow-writes \
--yesSetup notes:
- Create a Telegram bot token with BotFather.
- Send
/startto the bot during setup so Zema can discover chat IDs with TelegramgetUpdates. - Config remains under
~/.config/czm/config.tomlor$XDG_CONFIG_HOME/czm/config.toml. - Writes are enabled by default for allowlisted chats/users.
- Adherence rebuild remains disabled by default and must be explicitly enabled.
- Morning and evening reminders are enabled by default for newly created Telegram configs.
The primary Telegram UX is button-driven. Zema registers a Telegram command menu at runtime, shows inline menus on /start and /menu, and uses a persistent reply keyboard in private chats so common actions stay tappable without remembering slash commands. Group chats keep the quieter inline menu behavior.
The menu includes:
[Start episode] [Due now]
[Adherence] [Heal episode]
[Relapse episode] [Locations]
[Subjects]
Guided workflows include:
- Start episode with subject/location selection or creation.
- Create subject.
- Delete subject when it has no related episodes.
- Create location.
- Set or replace a location image by sending a Telegram photo.
- Log due treatment.
- Heal episode.
- Relapse episode.
- View adherence summary, calendar, missed days, and Telegram heatmap images for summary ranges.
- Rebuild adherence snapshots when
allow_adherence_rebuild=true.
Reminder behavior:
zema telegram runschedules reminders in the Telegram runtime, not inzema-be.- Morning reminders default to
07:00; evening reminders default to19:00. - Reminder times use
telegram.reminders.timezone, falling back to the CLI timezone. - Reminders use
/episodes/dueas the backend source of truth. - Reminder prompts include location images from
GET /locations/{location_id}/imagewhen configured and available. - Reminder prompts include
Log applicationonly whenallow_writes=true. Snoozesuppresses repeat Telegram reminders in memory for the configured snooze duration; it does not change backend due state and resets on bot restart.
Reminder config commands:
zema telegram config reminders show
zema telegram config reminders enable
zema telegram config reminders disable
zema telegram config reminders set-morning 07:00
zema telegram config reminders set-evening 19:00
zema telegram config reminders set-snooze 30
zema telegram config reminders images trueTyped slash commands remain available for power users:
/start
/menu
/help
/status
/subjects
/subject_create Child A
/locations
/location_create left_elbow Left elbow
/location_image_set left_elbow
/episodes
/episode 12
/episode_create subject:"Child A" location:left_elbow
/due
/log episode:12
/events episode:12
/timeline episode:12
/adherence 30
/adherence_calendar episode:12 days:30
/adherence_missed episode:12 days:30
/adherence_rebuild episode:12 from:2026-04-01 to:2026-04-30
Telegram security:
- At least one allowed chat ID is required.
- Optional allowed user IDs can further restrict access.
- Unknown chats/users are rejected before backend calls.
- Write actions require
allow_writes=true. - Adherence rebuild requires
allow_adherence_rebuild=true. - Secrets are masked in config display.
- Do not commit Telegram bot tokens or Zema API keys.
- Do not bake secrets into Docker images.
Telegram limitations:
- Conversation state is in-memory and resets on bot restart.
- Reminder snooze state is in-memory and resets on bot restart.
- Webhook mode is not implemented.
- There is no LLM or natural-language mode.
- There is no arbitrary CLI passthrough.
- Rich episode labels depend on fields returned by backend episode endpoints.
Adherence is exposed through backend APIs and zema adherence ... commands.
Dynamic adherence:
- Is the default GET behavior.
- Is read-only.
- Is calculated live from phase history, taper protocol, and valid applications.
- Does not write rows.
Persisted adherence:
- Is stored in
episode_daily_adherence. - Is returned when
persisted=trueor--persistedis used. - Reads stored rows only.
- May be empty before a rebuild has persisted snapshots.
Rebuild:
POST /adherence/rebuildandzema adherence rebuildpersist or update rows.- CLI rebuild requires
--fromand--to. - Rebuild without
episode_idrebuilds active, non-obsolete episodes only. - Broad all-episode rebuild with
active_only=falseis intentionally rejected in v1.
Schedule and scoring:
- Adherence snapshots use a fixed phase-start schedule for auditability.
/episodes/dueremains separate operational due/reminder logic.completed_applicationsis the raw valid logged application count for a day.credited_applications = min(completed_applications, expected_applications).- Score is
sum(credited_applications) / sum(expected_applications). - If there are no expected applications,
adherence_scoreisnull. - Telegram summary buttons also send a heatmap image: columns are dates, rows are location-first episode labels, colors represent completed/partial/missed/not-due/future, and 7/30 day views annotate cells as credited/expected.
Examples:
zema adherence summary --episode 1 --last 30 --json
zema adherence calendar --episode 1 --last 30
zema adherence missed --episode 1 --last 30 --include-partial
zema adherence rebuild --episode 1 --from 2026-04-01 --to 2026-04-30 --json
zema adherence summary --episode 1 --last 30 --persisted --jsonInteractive FastAPI docs are available when the backend is running:
http://localhost:28173/docs
Endpoint groups:
GET /health
POST /auth/login
GET /auth/me
POST /api-keys
GET /api-keys
POST /api-keys/{api_key_id}/revoke
POST /subjects
GET /subjects
GET /subjects/{subject_id}
POST /locations
GET /locations
POST /locations/{location_id}/image
GET /locations/{location_id}/image
DELETE /locations/{location_id}/image
POST /episodes
GET /episodes
GET /episodes/{episode_id}
POST /episodes/{episode_id}/heal
POST /episodes/{episode_id}/relapse
POST /episodes/{episode_id}/advance
GET /episodes/due
POST /applications
PATCH /applications/{application_id}
DELETE /applications/{application_id}
POST /applications/{application_id}/void
GET /episodes/{episode_id}/applications
GET /episodes/{episode_id}/events
GET /episodes/{episode_id}/timeline
GET /adherence/calendar
GET /adherence/summary
GET /adherence/missed
GET /episodes/{episode_id}/adherence
POST /adherence/rebuild
Authenticated API requests can use either:
Authorization: Bearer <jwt-access-token>
X-API-Key: <api-key>
Install backend dependencies from the repository root:
python3 -m venv .venv
source .venv/bin/activate
python3 -m pip install -e ".[dev]"Start dependencies with Docker:
docker compose up -d postgresRun migrations:
python3 -m alembic upgrade headRun the backend locally:
python3 -m app.serverFor most local manual testing, the Docker Quickstart is simpler because it starts PostgreSQL and zema-be with the expected environment.
Backend tests:
python3 -m pytest tests
python3 -m pytest tests/test_location_images.py
python3 -m pytest tests/test_adherence.py
python3 -m pytest tests/test_adherence_api.pyCLI tests:
python3 -m pytest cli/tests
python3 -m pytest cli/tests/test_adherence_cli.pyRun migrations manually:
python3 -m alembic upgrade headThe zema-be Docker image runs this automatically on startup:
alembic upgrade head && python -m app.serverCurrent migrations include the initial schema, episode_daily_adherence, and location image metadata.
Backend environment variables:
DATABASE_URL
APP_ENV
DEPLOYMENT_TIMEZONE
APP_PORT
ENABLE_SCHEDULER
JWT_SECRET
INITIAL_USERNAME
INITIAL_PASSWORD
LOCATION_IMAGE_DIR
LOCATION_IMAGE_MAX_BYTES
Docker Compose defaults:
DATABASE_URL=postgresql+psycopg://eczema:eczema@postgres:5432/eczema
APP_ENV=local
DEPLOYMENT_TIMEZONE=${CZM_TIMEZONE:-Europe/Berlin}
APP_PORT=28173
ENABLE_SCHEDULER=true
JWT_SECRET=change-me-in-production
INITIAL_USERNAME=admin
INITIAL_PASSWORD=admin
LOCATION_IMAGE_DIR=/data/location-images
LOCATION_IMAGE_MAX_BYTES=5242880
Location images are stored on the zema-be filesystem under LOCATION_IMAGE_DIR. Docker Compose mounts a named volume at /data/location-images so uploaded images survive container restarts.
CLI environment variables:
CZM_BASE_URL
CZM_API_KEY
CZM_TIMEZONE
ZEMA_TELEGRAM_BOT_TOKEN
ZEMA_TELEGRAM_ALLOWED_CHAT_IDS
ZEMA_TELEGRAM_ALLOWED_USER_IDS
ZEMA_TELEGRAM_ALLOW_WRITES
ZEMA_TELEGRAM_ALLOW_ADHERENCE_REBUILD
ZEMA_TELEGRAM_REMINDERS_ENABLED
ZEMA_TELEGRAM_REMINDER_MORNING_TIME
ZEMA_TELEGRAM_REMINDER_EVENING_TIME
ZEMA_TELEGRAM_REMINDER_SNOOZE_MINUTES
ZEMA_TELEGRAM_REMINDER_SEND_IMAGES
CLI config file locations:
~/.config/czm/config.toml
$XDG_CONFIG_HOME/czm/config.toml
Example CLI config:
base_url = "http://localhost:28173"
api_key = "your-api-key"
timezone = "Europe/Berlin"
[telegram]
bot_token = "123456:telegram-token"
allowed_chat_ids = [123456789]
allowed_user_ids = []
allow_writes = true
allow_adherence_rebuild = false
default_subject = ""
default_location = ""
command_mode = "buttons"
[telegram.reminders]
enabled = true
morning_time = "07:00"
evening_time = "19:00"
timezone = "Europe/Berlin"
send_location_images = true
snooze_minutes = 30Telegram setup/config and typed slash-command runtime:
zema setup telegram --help
zema telegram status
zema telegram test
zema telegram config show
zema telegram config reminders show
zema telegram run
zema config showSecrets are masked by default in config display. Use --show-secrets only in a trusted local terminal.
Agent and gateway integrations should call zema or czm externally, or run the zema-cli container as a tool.
Recommended agent pattern:
zema --json due list
zema --json adherence summary --last 30
zema --json application log --episode 1Do not place Telegram, Hermes, OpenClaw, or other gateway code inside the zema-be backend image. Keep the backend focused on API, persistence, and domain logic.
The repository includes an Agent Skills package under:
cli/skills/czm/
Manual Telegram smoke test:
docker compose up -d postgres zema-be
zema setup telegram
zema telegram test
zema telegram runDocker Telegram smoke test:
docker compose --profile telegram up -d zema-telegram
docker compose logs -f zema-telegramIn Telegram, test /start, /menu, /due, /adherence 30, and the main menu buttons.
python not found:
python3 --versionPip index problems:
PIP_INDEX_URL=https://pypi.org/simple python3 -m pip install -e clizema not on PATH:
source .venv/bin/activate
.venv/bin/zema --helpPort 28173 already in use:
lsof -i :28173Docker buildx warning:
- Docker Compose may warn that buildx is not installed.
- If the image still builds, you can continue.
- If builds fail, update Docker Desktop or install the buildx plugin.
Backend not ready:
docker compose ps
docker compose logs --tail=100 zema-be
curl -sS http://localhost:28173/healthMissing or invalid CZM_API_KEY:
- Run
zema setup, or recreate an API key through/auth/loginand/api-keys. - Remember that the CLI uses
X-API-Key, not the JWT bearer token.
Telegram bot does not answer:
- Confirm
ZEMA_TELEGRAM_BOT_TOKENis valid withzema telegram test. - Confirm
ZEMA_TELEGRAM_ALLOWED_CHAT_IDSincludes the chat you are using. - Send
/startto the bot after changing tokens or allowlists. - Check
docker compose logs -f zema-telegramwhen using Docker.
Persisted adherence is empty:
- This is expected before
zema adherence rebuild. - Dynamic adherence remains available without persisted rows.
No adherence rows:
- Requested dates must be covered by episode phase history.
- A newly created episode usually has phase history starting on its creation date.
Wrong checkout:
test -d cli && test -f app/adherence.py && test -f docker/api.Dockerfile && test -f docker/cli.Dockerfile && echo "integrated checkout"- Change the default
admin/admincredentials. - Change
JWT_SECRET. - Do not commit API keys.
- Do not bake secrets into Docker images.
- Use environment variables or secret management for deployments.
- Do not expose
zema-bepublicly without TLS, authentication, and reverse-proxy hardening.
The backend package version is tracked in pyproject.toml.
The CLI package is separate under cli/pyproject.toml.
See CHANGELOG.md for release notes.
- Telegram, Hermes, and OpenClaw gateway code is not included inside
zema-be. - The CLI does not own or duplicate business logic.
- The internal Python package rename from
czm_clitozemahas not been done. /episodes/dueis operational reminder logic, not historical adherence auditing.- The project intentionally avoids GraphQL, Celery, Kafka, external workers, CQRS, and event sourcing.