Single-container AI coach with agent API endpoints and UI polish

- Merge opencode-serve into the web container via entrypoint script
- Add /api/agent/* JSON endpoints for workouts, sets, checkins
- Rewrite fitness-trainer.md to use API instead of markdown files
- Pass recent workouts and check-ins as chat context to the coach
- Show current training phase on dashboard
- Clarify check-ins as morning check-ins (calories/steps = yesterday)
- Add NixOS deployment section to README
- Make all check-in fields explicitly optional in UI
This commit is contained in:
Jacob Hinkle 2026-06-29 10:50:01 -04:00
parent bfab3e8f01
commit 5584022a23
16 changed files with 419 additions and 114 deletions

View File

@ -12,7 +12,7 @@ Replaces the fitness-agent markdown-based training repo.
- **FastAPI** — async Python web framework - **FastAPI** — async Python web framework
- **Jinja2 + Pico.css** — server-rendered templates with minimal CSS framework (CDN) - **Jinja2 + Pico.css** — server-rendered templates with minimal CSS framework (CDN)
- **SQLite + SQLAlchemy 2.0 async** — database with aiosqlite driver - **SQLite + SQLAlchemy 2.0 async** — database with aiosqlite driver
- **opencode serve** — AI coach service (Big Pickle model, free). Runs as a separate process/container. - **opencode serve** — AI coach service (Big Pickle model, free). Runs alongside uvicorn in the same container, managed by a shell entrypoint.
## Key Files ## Key Files
- `app/main.py` — App factory, route registration, lifespan - `app/main.py` — App factory, route registration, lifespan
@ -20,6 +20,7 @@ Replaces the fitness-agent markdown-based training repo.
- `app/auth.py` — Password hashing, session management, `get_current_user` dependency - `app/auth.py` — Password hashing, session management, `get_current_user` dependency
- `app/models/` — SQLAlchemy ORM models - `app/models/` — SQLAlchemy ORM models
- `app/routers/` — Route handlers (one per feature) - `app/routers/` — Route handlers (one per feature)
- `app/routers/agent_api.py` — JSON API for the AI coach to create workouts/check-ins
- `scripts/schema.py` — DB initialization - `scripts/schema.py` — DB initialization
- `scripts/seed.py` — Seed data (exercises, phases) - `scripts/seed.py` — Seed data (exercises, phases)
- `opencode/fitness-trainer.md` — Agent config for AI coach (copied from fitness-agent) - `opencode/fitness-trainer.md` — Agent config for AI coach (copied from fitness-agent)
@ -38,13 +39,14 @@ uv run uvicorn app.main:app --reload # Dev server on :8000
- SSE streaming for chat responses - SSE streaming for chat responses
- Pico.css from CDN (no build step) - Pico.css from CDN (no build step)
- Chat messages stored in DB per session for history - Chat messages stored in DB per session for history
- Docker Compose for deployment (opencode serve service commented out until ready) - Docker Compose for deployment (opencode serve runs as background process in same container)
## Next Steps / TODOs ## Next Steps / TODOs
1. Seed exercises and phases (done via `python scripts/seed.py`) 1. Seed exercises and phases (done via `python scripts/seed.py`)
2. Add exercise progress chart (matplotlib or chart.js) 2. Add exercise progress chart (matplotlib or chart.js)
3. Enable opencode serve integration (uncomment docker-compose service) 3. Enable opencode serve integration (Dockerfile + entrypoint done, single-container approach)
4. Migrate existing markdown logs from fitness-agent repo into DB 4. Add agent API for DB writes (endpoints + agent config done)
5. Migrate Juggernaut training xlsx data into DB 5. Migrate existing markdown logs from fitness-agent repo into DB
6. Add calendar view for training history 6. Migrate Juggernaut training xlsx data into DB
7. PWA manifest + service worker for offline-capable mobile use 7. Add calendar view for training history
8. Update `fitness-trainer.md` agent config to work with DB-backed context instead of markdown files (done)

View File

@ -1,12 +1,20 @@
FROM node:22-alpine AS opencode-build
RUN apk add --no-cache curl && \
curl -fsSL https://opencode.ai/install | sh
FROM python:3.12-slim FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends curl && \
rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir uv
WORKDIR /app WORKDIR /app
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN uv pip install --system --no-cache-dir -r requirements.txt
COPY --from=opencode-build /usr/local/bin/opencode /usr/local/bin/opencode
COPY . . COPY . .
EXPOSE 8000 EXPOSE 8000
ENV OPENCODE_SERVE_URL=http://127.0.0.1:4096
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] ENV AGENT_API_KEY=dev-agent-key-change-in-production
RUN mkdir -p /root/.config && \
ln -s /app/opencode /root/.config/opencode && \
printf '#!/bin/sh\nopencode serve --host 127.0.0.1 --port 4096 &\nsleep 1\nexec uvicorn app.main:app --host 0.0.0.0 --port 8000\n' > /entrypoint.sh && chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

View File

@ -12,6 +12,7 @@ Track workouts, log daily check-ins, explore exercise history, and chat with an
- **AI Coach** — Chat interface backed by opencode (Big Pickle model, free) - **AI Coach** — Chat interface backed by opencode (Big Pickle model, free)
- **Multi-user** — Login-based, each user has independent data - **Multi-user** — Login-based, each user has independent data
- **Calendar view** — See your training history at a glance - **Calendar view** — See your training history at a glance
- **NixOS-ready** — Docker-based deploy with provided NixOS module
## Quick Start ## Quick Start
@ -35,6 +36,72 @@ Open http://localhost:8000, register a user, and you're ready.
docker compose up -d docker compose up -d
``` ```
## NixOS Deployment
A single container runs both the web app and the AI coach (opencode-serve) together, sharing the SQLite database on the same filesystem.
### 1. Build image
```bash
docker build -t fitness-web:latest .
```
### 2. NixOS module
Add `machines/cj/fitness-web.nix` to your nix_config:
```nix
{ serverIP, serverIP6 }: {
fitness-web = {
image = "fitness-web:latest";
ports = [ "8688:8000" ];
environment = {
TZ = "America/New_York";
SESSION_SECRET = "change-me-in-production";
};
volumes = [
"/serverdata/fitness-web/data:/app/data"
"/serverdata/fitness-web/opencode:/root/.config/opencode"
];
};
}
```
Wire it into `configuration.nix`:
```nix
virtualisation.oci-containers.containers = let
ips = { ... };
in {
# ... existing containers ...
fitness-web = (import ./fitness-web.nix ips).fitness-web;
};
```
### 3. Nginx reverse proxy
In `nginx.nix`, add a vhost:
```nix
"fitness.jhink.org" = simpleProxy 8688;
```
### 4. Firewall
Add to `allowedTCPPorts` in `firewall.nix`:
```nix
8688 # fitness-web
```
### 5. Deploy
```bash
nixos-rebuild switch --flake .#cj
```
Then access at https://fitness.jhink.org (or your chosen domain).
## Architecture ## Architecture
``` ```

View File

@ -6,3 +6,4 @@ DATA_DIR = Path(os.environ.get("DATA_DIR", PROJECT_ROOT / "data"))
DATABASE_PATH = str(DATA_DIR / "fitness.db") DATABASE_PATH = str(DATA_DIR / "fitness.db")
SESSION_SECRET = os.environ.get("SESSION_SECRET", "dev-secret-change-in-production") SESSION_SECRET = os.environ.get("SESSION_SECRET", "dev-secret-change-in-production")
OPENCODE_SERVE_URL = os.environ.get("OPENCODE_SERVE_URL", "http://localhost:4096") OPENCODE_SERVE_URL = os.environ.get("OPENCODE_SERVE_URL", "http://localhost:4096")
AGENT_API_KEY = os.environ.get("AGENT_API_KEY", "")

View File

@ -4,7 +4,7 @@ from fastapi.staticfiles import StaticFiles
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from app.config import DATA_DIR from app.config import DATA_DIR
from app.routers import auth, dashboard, workouts, exercises, checkins, profile, chat from app.routers import auth, dashboard, workouts, exercises, checkins, profile, chat, agent_api
from scripts.schema import init_db from scripts.schema import init_db
@ -27,3 +27,4 @@ app.include_router(exercises.router)
app.include_router(checkins.router) app.include_router(checkins.router)
app.include_router(profile.router) app.include_router(profile.router)
app.include_router(chat.router) app.include_router(chat.router)
app.include_router(agent_api.router)

143
app/routers/agent_api.py Normal file
View File

@ -0,0 +1,143 @@
from fastapi import APIRouter, Header, HTTPException, Depends
from pydantic import BaseModel
from sqlalchemy import select
from app.config import AGENT_API_KEY
from app.models.base import async_session
from app.models.user import User
from app.models.workout import Workout, WorkoutSet
from app.models.checkin import Checkin
router = APIRouter(prefix="/api/agent", tags=["agent"])
async def verify_agent(x_api_key: str = Header("")):
if AGENT_API_KEY and x_api_key != AGENT_API_KEY:
raise HTTPException(status_code=403, detail="invalid api key")
class CreateWorkoutRequest(BaseModel):
username: str
name: str
date: str
phase_id: int | None = None
notes: str = ""
class CreateSetRequest(BaseModel):
exercise: str
set_number: int
reps: int | None = None
weight: float | None = None
rpe: float | None = None
notes: str = ""
class CreateCheckinRequest(BaseModel):
username: str
date: str
feeling: str | None = None
weight_lb: float | None = None
calories: int | None = None
steps: int | None = None
sleep_hours: float | None = None
notes: str = ""
@router.post("/workouts")
async def agent_create_workout(
body: CreateWorkoutRequest,
_=Depends(verify_agent),
):
async with async_session() as session:
result = await session.execute(
select(User).where(User.username == body.username)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="user not found")
workout = Workout(
user_id=user.id,
phase_id=body.phase_id,
name=body.name,
date=body.date,
notes=body.notes,
status="plan",
)
session.add(workout)
await session.commit()
await session.refresh(workout)
return {"id": workout.id, "name": workout.name, "date": workout.date, "status": workout.status}
@router.post("/workouts/{workout_id}/sets")
async def agent_add_set(
workout_id: int,
body: CreateSetRequest,
_=Depends(verify_agent),
):
async with async_session() as session:
ws = WorkoutSet(
workout_id=workout_id,
exercise=body.exercise,
set_number=body.set_number,
reps=body.reps,
weight=body.weight,
rpe=body.rpe,
notes=body.notes,
)
session.add(ws)
await session.commit()
await session.refresh(ws)
return {"id": ws.id, "exercise": ws.exercise, "set_number": ws.set_number}
@router.put("/workouts/{workout_id}/complete")
async def agent_complete_workout(
workout_id: int,
_=Depends(verify_agent),
):
async with async_session() as session:
result = await session.execute(
select(Workout).where(Workout.id == workout_id)
)
workout = result.scalar_one_or_none()
if not workout:
raise HTTPException(status_code=404, detail="workout not found")
workout.status = "complete"
await session.commit()
return {"id": workout.id, "status": "complete"}
@router.post("/checkins")
async def agent_create_checkin(
body: CreateCheckinRequest,
_=Depends(verify_agent),
):
async with async_session() as session:
result = await session.execute(
select(User).where(User.username == body.username)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="user not found")
checkin = Checkin(
user_id=user.id,
date=body.date,
feeling=body.feeling,
weight_lb=body.weight_lb,
calories=body.calories,
steps=body.steps,
sleep_hours=body.sleep_hours,
notes=body.notes,
)
session.add(checkin)
await session.commit()
await session.refresh(checkin)
return {"id": checkin.id, "date": checkin.date}

View File

@ -8,6 +8,8 @@ from sqlalchemy import select, desc
from app.models.base import async_session from app.models.base import async_session
from app.models.user import User from app.models.user import User
from app.models.chat import ChatMessage from app.models.chat import ChatMessage
from app.models.workout import Workout
from app.models.checkin import Checkin
from app.auth import get_current_user from app.auth import get_current_user
from app.services.opencode_proxy import query_opencode from app.services.opencode_proxy import query_opencode
@ -48,12 +50,55 @@ async def chat_send(
message: str = Form(), message: str = Form(),
): ):
session_id = request.cookies.get("chat_session_id") or str(uuid.uuid4()) session_id = request.cookies.get("chat_session_id") or str(uuid.uuid4())
async with async_session() as session:
result = await session.execute(
select(Workout)
.where(Workout.user_id == user.id)
.order_by(desc(Workout.date))
.limit(5)
)
recent_workouts = result.scalars().all()
result = await session.execute(
select(Checkin)
.where(Checkin.user_id == user.id)
.order_by(desc(Checkin.date))
.limit(5)
)
recent_checkins = result.scalars().all()
workout_lines = []
for w in recent_workouts:
workout_lines.append(f" {w.date}{w.name} ({w.status})")
checkin_lines = []
for c in recent_checkins:
parts = []
if c.feeling:
parts.append(f"feeling={c.feeling}")
if c.weight_lb:
parts.append(f"weight={c.weight_lb}lb")
if c.calories:
parts.append(f"cal(yesterday)={c.calories}")
if c.steps:
parts.append(f"steps(yesterday)={c.steps}")
if c.sleep_hours:
parts.append(f"sleep={c.sleep_hours}h")
checkin_lines.append(f" {c.date}{' | '.join(parts)}")
user_context = ( user_context = (
f"Username: {user.username}. " f"Username: {user.username}. "
f"Weight: {user.weight_lb} lb. " f"Weight: {user.weight_lb} lb. "
f"Goals: {user.goals or 'Not specified'}. " f"Goals: {user.goals or 'Not specified'}. "
f"Medical: {user.medical_notes or 'None'}" f"Equipment: {user.equipment or 'Not specified'}. "
f"Medical: {user.medical_notes or 'None'}. "
f"Calorie goal: {user.calorie_goal or 'Not set'}. "
f"Step goal: {user.step_goal or 'Not set'}. "
) )
if recent_workouts:
user_context += "Recent workouts:\n" + "\n".join(workout_lines) + ". "
if recent_checkins:
user_context += "Recent check-ins:\n" + "\n".join(checkin_lines) + ". "
async def stream(): async def stream():
async with async_session() as session: async with async_session() as session:

View File

@ -1,4 +1,4 @@
from datetime import datetime, timezone from datetime import date, datetime, timezone
from fastapi import APIRouter, Request, Depends, Form from fastapi import APIRouter, Request, Depends, Form
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
@ -32,6 +32,7 @@ async def checkin_list(request: Request, user: User = Depends(get_current_user))
async def new_checkin_page(request: Request, user: User = Depends(get_current_user)): async def new_checkin_page(request: Request, user: User = Depends(get_current_user)):
return templates.TemplateResponse(request, "checkin_new.html", { return templates.TemplateResponse(request, "checkin_new.html", {
"user": user, "user": user,
"today": date.today().isoformat(),
}) })

View File

@ -5,7 +5,7 @@ from sqlalchemy import select, desc
from app.models.base import async_session from app.models.base import async_session
from app.models.user import User from app.models.user import User
from app.models.workout import Workout from app.models.workout import Phase, Workout
from app.models.checkin import Checkin from app.models.checkin import Checkin
from app.auth import get_current_user from app.auth import get_current_user
@ -38,8 +38,14 @@ async def dashboard(request: Request, user: User = Depends(get_current_user)):
) )
latest_checkin = result.scalar_one_or_none() latest_checkin = result.scalar_one_or_none()
result = await session.execute(
select(Phase).order_by(desc(Phase.start_date)).limit(1)
)
current_phase = result.scalar_one_or_none()
return templates.TemplateResponse(request, "dashboard.html", { return templates.TemplateResponse(request, "dashboard.html", {
"user": user, "user": user,
"recent_workouts": recent_workouts, "recent_workouts": recent_workouts,
"latest_checkin": latest_checkin, "latest_checkin": latest_checkin,
"current_phase": current_phase,
}) })

View File

@ -89,10 +89,16 @@ async def workout_detail(
) )
sets = result.scalars().all() sets = result.scalars().all()
phase = None
if workout.phase_id:
result = await session.execute(select(Phase).where(Phase.id == workout.phase_id))
phase = result.scalar_one_or_none()
return templates.TemplateResponse(request, "workout_detail.html", { return templates.TemplateResponse(request, "workout_detail.html", {
"user": user, "user": user,
"workout": workout, "workout": workout,
"sets": sets, "sets": sets,
"phase": phase,
}) })

View File

@ -1,37 +1,45 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}New Check-in{% endblock %} {% block title %}Morning Check-in{% endblock %}
{% block content %} {% block content %}
<h1>New Check-in</h1> <h1>Morning Check-in</h1>
<p><small>All fields are optional — fill in whatever you have each morning.</small></p>
<form method="post" action="/checkins/new"> <form method="post" action="/checkins/new">
<label> <label>
Date Date
<input type="date" name="date" required> <input type="date" name="date" value="{{ today }}" required>
<small>Morning of this check-in</small>
</label> </label>
<label> <label>
Feeling Feeling
<input type="text" name="feeling" placeholder="e.g., Good, Tired, Sore"> <input type="text" name="feeling" placeholder="e.g., Good, Tired, Sore">
<small>How are you feeling this morning?</small>
</label> </label>
<div class="grid"> <div class="grid">
<label> <label>
Weight (lb) Weight (lb)
<input type="number" name="weight_lb" step="0.1"> <input type="number" name="weight_lb" step="0.1">
<small>Morning weight</small>
</label> </label>
<label> <label>
Calories Calories
<input type="number" name="calories"> <input type="number" name="calories">
<small>Yesterday's total</small>
</label> </label>
<label> <label>
Steps Steps
<input type="number" name="steps"> <input type="number" name="steps">
<small>Yesterday's total</small>
</label> </label>
<label> <label>
Sleep (hours) Sleep (hours)
<input type="number" name="sleep_hours" step="0.5"> <input type="number" name="sleep_hours" step="0.5">
<small>Last night</small>
</label> </label>
</div> </div>
<label> <label>
Notes Notes
<textarea name="notes" rows="3"></textarea> <textarea name="notes" rows="3" placeholder="Anything else to note?"></textarea>
<small>Optional notes about yesterday or today</small>
</label> </label>
<button type="submit">Save Check-in</button> <button type="submit">Save Check-in</button>
</form> </form>

View File

@ -1,7 +1,8 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Check-ins{% endblock %} {% block title %}Check-ins{% endblock %}
{% block content %} {% block content %}
<h1>Check-ins</h1> <h1>Morning Check-ins</h1>
<p><small>Log what's on your mind and what happened yesterday — all fields optional.</small></p>
<a href="/checkins/new" role="button">New Check-in</a> <a href="/checkins/new" role="button">New Check-in</a>
{% if checkins %} {% if checkins %}
@ -11,8 +12,8 @@
<th>Date</th> <th>Date</th>
<th>Feeling</th> <th>Feeling</th>
<th>Weight</th> <th>Weight</th>
<th>Calories</th> <th>Calories <small>(yesterday)</small></th>
<th>Steps</th> <th>Steps <small>(yesterday)</small></th>
<th>Sleep</th> <th>Sleep</th>
</tr> </tr>
</thead> </thead>
@ -30,6 +31,6 @@
</tbody> </tbody>
</table> </table>
{% else %} {% else %}
<p>No check-ins yet.</p> <p>No check-ins yet. <a href="/checkins/new">Start your first morning check-in</a>.</p>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@ -18,11 +18,20 @@
</article> </article>
</div> </div>
{% if current_phase %}
<article>
<h3>Current Phase: {{ current_phase.name }}</h3>
<p>{{ current_phase.description }}</p>
{% if current_phase.notes %}<p><em>{{ current_phase.notes }}</em></p>{% endif %}
{% if current_phase.start_date %}<small>Started {{ current_phase.start_date }}</small>{% endif %}
</article>
{% endif %}
{% if latest_checkin %} {% if latest_checkin %}
<article> <article>
<h3>Latest Check-in</h3> <h3>Latest Morning Check-in</h3>
<p>{{ latest_checkin.date }} — {{ latest_checkin.feeling or 'No feeling recorded' }}</p> <p>{{ latest_checkin.date }} — {{ latest_checkin.feeling or 'No feeling recorded' }}</p>
<p>Weight: {{ latest_checkin.weight_lb or '—' }} lb | Calories: {{ latest_checkin.calories or '—' }} | Steps: {{ latest_checkin.steps or '—' }} | Sleep: {{ latest_checkin.sleep_hours or '—' }}h</p> <p>Weight: {{ latest_checkin.weight_lb or '—' }} lb | Calories <small>(yesterday)</small>: {{ latest_checkin.calories or '—' }} | Steps <small>(yesterday)</small>: {{ latest_checkin.steps or '—' }} | Sleep: {{ latest_checkin.sleep_hours or '—' }}h</p>
</article> </article>
{% endif %} {% endif %}
@ -49,12 +58,12 @@
</tbody> </tbody>
</table> </table>
{% else %} {% else %}
<p>No workouts yet. <a href="/workouts/new">Plan one now</a>.</p> <p>No workouts yet. Ask the <a href="/chat">AI Coach</a> to plan one.</p>
{% endif %} {% endif %}
<div class="grid"> <div class="grid">
<a href="/workouts/new" role="button" class="secondary">New Workout</a>
<a href="/checkins/new" role="button" class="secondary">New Check-in</a>
<a href="/chat" role="button" class="secondary">AI Coach</a> <a href="/chat" role="button" class="secondary">AI Coach</a>
<a href="/workouts" role="button" class="secondary">All Workouts</a>
<a href="/checkins" role="button" class="secondary">All Check-ins</a>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -3,6 +3,9 @@
{% block content %} {% block content %}
<h1>{{ workout.name }}</h1> <h1>{{ workout.name }}</h1>
<p><strong>Date:</strong> {{ workout.date }} | <strong>Status:</strong> {{ workout.status }}</p> <p><strong>Date:</strong> {{ workout.date }} | <strong>Status:</strong> {{ workout.status }}</p>
{% if phase %}
<p><strong>Phase:</strong> {{ phase.name }}</p>
{% endif %}
{% if workout.notes %} {% if workout.notes %}
<p>{{ workout.notes }}</p> <p>{{ workout.notes }}</p>
{% endif %} {% endif %}

View File

@ -1,21 +1,11 @@
services: services:
web: fitness-web:
build: . build: .
ports: ports:
- "8000:8000" - "8000:8000"
volumes: volumes:
- ./data:/app/data - ./data:/app/data
- ./opencode:/root/.config/opencode
environment: environment:
- DATABASE_PATH=/app/data/fitness.db
- SESSION_SECRET=change-me-in-production - SESSION_SECRET=change-me-in-production
- OPENCODE_SERVE_URL=http://opencode-serve:4096 - AGENT_API_KEY=dev-agent-key-change-in-production
# opencode-serve:
# image: opencode:latest
# entrypoint: ["opencode", "serve", "--host", "0.0.0.0", "--port", "4096"]
# ports:
# - "4096:4096"
# volumes:
# - ./opencode:/root/.config/opencode
# environment:
# - OPENCODE_SERVER_PASSWORD=your-password-here

View File

@ -1,23 +1,28 @@
--- ---
description: > description: >
Your personal fitness trainer. Plans workouts, tracks progress, adapts to how Your personal fitness trainer. Plans workouts, tracks progress, adapts to how
you're feeling, and logs everything to markdown. Use this agent for daily you're feeling, and logs everything via the web app API. Use this agent for
check-ins, workout reviews, and programming discussions. daily check-ins, workout reviews, and programming discussions.
mode: primary mode: primary
color: "#4ade80" color: "#4ade80"
--- ---
You are an experienced, adaptable personal trainer. Your client (the user) has You are an experienced, adaptable personal trainer. Your client (the user) has
provided their equipment, goals, and medical history in `inputs/`. Their provided their equipment, goals, and medical history through the fitness web
historical lifting data is in `inputs/Juggernaut training.xlsx`. app. Their training data is stored in the app's database — past workouts,
daily check-ins, exercise history, and current stats are all available from
there.
Your job is to guide them through their fitness journey. Be encouraging but Your job is to guide them through their fitness journey. Be encouraging but
honest. You are their single point of contact for training chat. honest. You are their single point of contact for training chat.
## Guidelines ## Guidelines
- Always consider their medical history (especially the distal radius fracture) - Always consider their medical history (especially the distal radius
and available equipment when programming fracture) and available equipment when programming
- The web app passes your client's current stats (weight, goals, medical
notes, recent workouts, recent check-ins) alongside each message. Use this
context to understand their situation.
- **Periodic research check:** Roughly once per week (or every few check-ins), - **Periodic research check:** Roughly once per week (or every few check-ins),
do a brief web search on current best practices relevant to their situation — do a brief web search on current best practices relevant to their situation —
e.g., distal radius fracture return-to-training, tendinopathy prevention, e.g., distal radius fracture return-to-training, tendinopathy prevention,
@ -30,9 +35,14 @@ honest. You are their single point of contact for training chat.
- If they're interested in a specific program methodology (Juggernaut, - If they're interested in a specific program methodology (Juggernaut,
Stronglifts 5x5, etc.), use their training history to pick up where they left Stronglifts 5x5, etc.), use their training history to pick up where they left
off or start a new cycle off or start a new cycle
- If they want something new, design intelligently using the programming - If they want something new, design intelligently using sound programming
principles in the `fitness-workout` skill principles
- **Left hand grip limitation:** Client is doing grip/rehab exercises 3x/day with their PT. In our workouts, minimize left hand grip demand (use straps for any pulling, avoid goblet squats, keep DB loads light). Check in early next week (Mon/Tue) about whether they feel ready to add more grip work back into sessions — they have weekly PT appointments each Wednesday for the next 3 weeks and will update accordingly. - **Left hand grip limitation:** Client is doing grip/rehab exercises 3x/day
with their PT. In our workouts, minimize left hand grip demand (use straps
for any pulling, avoid goblet squats, keep DB loads light). Check in early
next week (Mon/Tue) about whether they feel ready to add more grip work back
into sessions — they have weekly PT appointments each Wednesday for the next
3 weeks and will update accordingly.
- **During the reintroduction period (weeks 1-4 after a layoff or injury),** - **During the reintroduction period (weeks 1-4 after a layoff or injury),**
always program movements with **limited range of motion** — avoid end-range always program movements with **limited range of motion** — avoid end-range
positions (stretch at the bottom/top of any lift) for all exercises. This positions (stretch at the bottom/top of any lift) for all exercises. This
@ -40,70 +50,74 @@ honest. You are their single point of contact for training chat.
Mid-range movements only (e.g., Bulgarian split squats instead of deep Mid-range movements only (e.g., Bulgarian split squats instead of deep
squats, landmine press instead of full ROM OHP, step-ups instead of deep squats, landmine press instead of full ROM OHP, step-ups instead of deep
lunges). Apply this to squat, hinge, push, pull, and core movements alike. lunges). Apply this to squat, hinge, push, pull, and core movements alike.
- High-level training context lives in `plans/`. Read the relevant plan before
designing individual workouts and keep it up to date as context evolves
(progressing phases, new constraints, notable decisions). The plan describes
current phase, timeline, constraints, and progression criteria. Workout
Analysis sections should briefly reference the broader plan to show how the
session fits.
- Logs are written to `logs/workouts/` and `logs/checkins/`. Use
`grep`/`read`/`glob` to search past logs when they ask questions like "when
was the last time I did farmer's carry?"
## Check-in Workflow ## API — Creating Workouts and Check-ins
When they want to check in, follow this structure: You write workout plans and check-in logs directly to the database via the
web app's internal API. The API key is in the environment variable
`AGENT_API_KEY`. All endpoints are at `http://localhost:8000/api/agent/`.
Use `curl` with the API key header:
```bash
curl -s http://localhost:8000/api/agent/workouts \
-H "X-API-Key: $AGENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"username": "jacob", "name": "Upper Body A", "date": "2026-06-30", "notes": "..."}'
```
### Endpoints
**Create a workout plan:**
```
POST /api/agent/workouts
Body: { username, name, date, phase_id?, notes? }
```
**Add a set to a workout:**
```
POST /api/agent/workouts/{id}/sets
Body: { exercise, set_number, reps?, weight?, rpe?, notes? }
```
**Mark a workout complete:**
```
PUT /api/agent/workouts/{id}/complete
```
**Create a check-in log:**
```
POST /api/agent/checkins
Body: { username, date, feeling?, weight_lb?, calories?, steps?, sleep_hours?, notes? }
```
Always use the username from the context provided with each message.
## Check-in Flow
When the user wants to check in or discuss their training:
1. **Status check** — Ask how they're feeling: soreness, energy, injuries, 1. **Status check** — Ask how they're feeling: soreness, energy, injuries,
sleep, weight, motivation sleep, weight, motivation. Reference trends from the context.
2. **Nutrition & Steps** — Ask if they'd like to review/adjust their LoseIt! 2. **Nutrition & Steps** — Ask if they'd like to review or adjust their
calorie goal, and if they can report their average daily steps from their calorie goal and daily steps. Suggest adjustments based on weight trend
Google Pixel phone. Log these numbers in the check-in entry. If they're and activity level.
comfortable, suggest a small calorie goal adjustment based on their weight 3. **Review** — Look at their recent workouts (from context). Did they
trend and activity level. complete them? How did each exercise feel?
3. **Review** — Check the last planned workout log. Did they complete it? How 4. **Adjust** — Based on feedback + programming guidelines + history, suggest
did each exercise feel? Update the log with results if needed adjustments for the next session (weight, volume, exercise selection, or
4. **Adjust** — Based on feedback + programming guidelines + history, adjust rest day)
the next session (weight, volume, exercise selection, or rest day) 5. **Plan & Save** — Design the next workout with exercises, sets, reps,
5. **Plan** — Write a new workout plan to `logs/workouts/<YYYY-MM-DD>-<slug>.md` weights, and notes. **Create it in the database** using the API
with exercises, sets, reps, weights, and any notes. Present it to them (`POST /api/agent/workouts` followed by `POST /api/agent/workouts/{id}/sets`
6. **Log** — Write a brief check-in entry to `logs/checkins/<YYYY-MM-DD>-checkin.md` for each exercise). Present the plan to the user.
summarizing the conversation and any decisions made. This includes calorie 6. **Log the check-in** — After the conversation wraps, **create a check-in
goal, steps, weight, and workout results. entry** via the API (`POST /api/agent/checkins`) summarizing the key
7. **Commit** — After logging, use `git add logs/` and `git commit` with a decisions, metrics, and any adjustments made.
descriptive message summarizing the check-in, any plan changes, and key
metrics (weight, calories, steps). Do NOT push unless asked.
## Log format ## Session Analysis
Workout plan log: When discussing a specific workout, briefly note muscles targeted, the
```markdown overall goal for the session, and how it fits into their broader training
# Workout: <Name> context. Use the workout history from the context to reference progression
**Date:** <YYYY-MM-DD> and past performance.
**Program:** <program name or "Custom">
**Status:** Plan
## Analysis
<brief section listing muscles targeted, the overall goal for the session, and how it fits into the broader plan (reference `plans/`). Keep it concise 3-5 sentences max. Update if the plan changes mid-session due to pain or feedback.>
## Exercises
- <Exercise>: <sets>x<reps> @ <weight> <notes>
- ...
## Notes
<context for the session>
```
After completion, update the **Status** to "Complete" and add results inline:
```markdown
- Bench Press: 3x5 @ 185lb — completed (RPE 8)
```
Check-in log:
```markdown
# Check-in: <YYYY-MM-DD>
**Feeling:** <summary>
**Review:** <what was reviewed>
**Adjustments:** <changes made>
**Next session:** <reference to workout log>
```