DEV Community

Andrei Parfenov
Andrei Parfenov

Posted on

Building a Grounded LLM Planner for Swim Trainings on Nebius Serverless

Swimming is one of the few sports where structured training makes a measurable difference. Poor training structure slows progress and builds bad habits that are hard to undo. With the LA2028 Olympics approaching and a fresh wave of interest in competitive swimming, more amateur and age-group swimmers are taking their training seriously. The problem is that training plans have always lived behind a paywall: you either pay for an application or you guess.

Photo courtesy: Deepbluemedia

A good training plan is a decision about how to distribute intensity across weeks, which drills to pair together in a session, and a coaching layer that explains why you're doing what you're doing. These are exactly the kinds of structured reasoning tasks that modern LLMs handle well, but only if they're grounded in real coaching knowledge. This project puts that kind of planning into a serverless API that any swimmer can use, and any coach can shape with their own expertise.

GitHub repo: https://github.com/andreiparfenov/swimming-coach-serverless
End user demo: https://swimming-coach-iota.vercel.app

I was planning to use Vercel or Lambda for this API, but their per-request timeouts don't work for a pipeline that runs for minutes, so I tried a long-running Serverless Endpoint instead.

The system is built on two Nebius Serverless AI components working together: a Serverless Job that builds a curated coaching knowledge base, and a Serverless Endpoint that serves the actual planning API. Now, let’s walk through how each piece was actually built and deployed, commands included, so you can follow along or adapt it for your own project.

Step 1: Build the planning API locally first

Before touching any Nebius Serverless infrastructure, make sure the application is working entirely on your own machine. The app itself is a small FastAPI service with three chained agent calls:

async def generate_plan(profile: SwimmerProfile) -> TrainingPlan:
    macro = await run_periodization_agent(profile)
    weeks_raw = await run_session_agent(profile, macro)
    weeks_enriched = await run_coaching_agent(profile, weeks_raw)
    ...
Enter fullscreen mode Exit fullscreen mode

The periodization agent takes the swimmer's profile and decides the macro structure of the plan: week themes, intensity, and a volume curve. The session generator runs once per week and fills in the warmup, main set, and cooldown in pool lengths. The coaching notes agent batches every session into a single call and writes a short note for each one. Splitting the work this way, instead of asking a single prompt to do everything, keeps each call's output schema small and makes it far easier to work out which step has gone wrong when something does go wrong.

To run it locally:

cp env.example .env
# fill in NEBIUS_API_KEY in .env

pip install -r requirements.txt
uvicorn app.main:app --reload
Enter fullscreen mode Exit fullscreen mode

If you don't have a Token Factory API key, go to https://tokenfactory.nebius.com, sign in, and create a key under API keys. It's only shown once, so copy it straight away. NEBIUS_API_KEY is used to authenticate every agent call.

Then test it:

curl -X POST http://localhost:8000/generate-plan \
  -H "Content-Type: application/json" \
  -d '{
    "level": "intermediate",
    "goal": "endurance",
    "sessions_per_week": 3,
    "session_duration_minutes": 60,
    "pool_length": 25,
    "weeks": 4
  }'
Enter fullscreen mode Exit fullscreen mode

Step 2: Containerise and deploy the Endpoint

Once it works locally, the next step is packaging it for Nebius.

The Nebius CLI needs to be installed and authenticated (nebius auth login), and Docker needs to be logged in to whichever registry you're pushing to (docker login). Both are one-time setup steps.

Let’s package:

docker buildx build --platform linux/amd64 -t docker.io/<your-username>/swimcoach-api:latest --push .
Enter fullscreen mode Exit fullscreen mode

Nebius Endpoints need a subnet to attach to, which can be picked up automatically if you only have one:

SUBNET_ID=$(nebius vpc subnet list --format jsonpath='{.items[0].metadata.id}')
Enter fullscreen mode Exit fullscreen mode

Then create the Endpoint itself:

nebius ai endpoint create \
  --name swimcoach-api \
  --image docker.io/<your-username>/swimcoach-api:latest \
  --platform cpu-e2 \
  --preset 2vcpu-8gb \
  --public \
  --container-port 8000 \
  --subnet-id "$SUBNET_ID" \
  --env "NEBIUS_API_KEY=$NEBIUS_API_KEY" \
  --env "MODEL_ID=meta-llama/Llama-3.3-70B-Instruct"

Enter fullscreen mode Exit fullscreen mode

A CPU preset is enough here. The Endpoint doesn't run any model locally and only orchestrates calls out to Token Factory, so there's no benefit in paying for a GPU. cpu-e2 / 2vcpu-8gb is simply what this workload needs. You can run nebius ai endpoint create --help to see the full range of platforms and presets if you want to size it differently.

After two to three minutes, the endpoint moves to Running and prints a public address:

nebius ai endpoint get-by-name --name swimcoach-api --format json
Enter fullscreen mode Exit fullscreen mode
curl http://<public-ip>:8000/health
# {"status": "ok"}
Enter fullscreen mode Exit fullscreen mode

Step 3: Build the knowledge base as a Serverless Job

A language model will happily produce a plausible-sounding training volume instead of a correct one. So, before planning agents are asked to produce anything, they need a source of real coaching knowledge to ground their decisions.

This is where we'll use the second Nebius primitive. A Serverless Job runs once, offline, and is well-suited to a task like this because there's no need to keep it running afterward. Once it's done, the compute is released automatically. The job loads twelve hand-written coaching profiles (one per combination of swimmer level and training goal) from job/seed_data/training_principles.json (already included in the repo) and for each one makes a single call to Token Factory to compress the structured data into a short coaching summary suitable for prompt injection. The result is written to knowledge_base.json and uploaded to Nebius Object Storage.

The seed data is the part that actually determines whether the grounding means anything. training_principles.json was drafted to reflect widely published, publicly available swim coaching methodology documented in places like USMS's free training resources and British Swimming/Swim England's published coach education materials.

The Job needs its own credentials for Object Storage. A static access key is tied to a service account, so create one of each first:

PARENT_ID=<your-project-id> # find it via `nebius profile list`, then check the
                            # parent-id field for that profile in ~/.nebius/config.yaml

nebius iam service-account create --name swimcoach-storage-sa --parent-id "$PARENT_ID"
# → note the service account id from the output

nebius iam v2 access-key create --account-service-account-id "$SA_ID" --parent-id "$PARENT_ID"
# → aws_access_key_id is S3_ACCESS_KEY, secret is S3_SECRET_KEY

Enter fullscreen mode Exit fullscreen mode

With those in hand:

cd job
export NEBIUS_API_KEY=<your-tokenfactory-key>
export S3_ACCESS_KEY=<from-the-access-key-output-above>
export S3_SECRET_KEY=<from-the-access-key-output-above>

docker buildx build --platform linux/amd64 -f Dockerfile.job -t docker.io/<your-username>/swimcoach-job:latest --push .
Enter fullscreen mode Exit fullscreen mode
nebius ai job create \
  --name swimcoach-data-processor \
  --image docker.io/<your-username>/swimcoach-job:latest \
  --platform cpu-d3 \
  --preset 4vcpu-16gb \
  --env "NEBIUS_API_KEY=$NEBIUS_API_KEY" \
  --env "S3_ACCESS_KEY=$S3_ACCESS_KEY" \
  --env "S3_SECRET_KEY=$S3_SECRET_KEY" \
  --env "S3_BUCKET=swim-program"
Enter fullscreen mode Exit fullscreen mode

Check on it with:

nebius ai job logs <job-id> --follow
Enter fullscreen mode Exit fullscreen mode

A successful run enriches all twelve profiles and ends with Uploaded to s3://swim-program/knowledge_base.json.

Step 4: The Object Storage permission wall

Nebius Object Storage doesn't grant a service account access to a bucket simply because that service account belongs to the same project. Access has to be explicitly granted through a bucket policy rule, and that rule can only be scoped to a group, not directly to an individual service account. So the fix is a short sequence:

# 1. Work out which service account the storage key belongs to
nebius iam v2 access-key list --parent-id "$PARENT_ID" --format json
# match on status.aws_access_key_id, read spec.account.service_account.id

# 2. Put that service account into a group
nebius iam group create --name swim-program-writers --parent-id "$PARENT_ID"
nebius iam group-membership create --parent-id "$GROUP_ID" --member-id "$SERVICE_ACCOUNT_ID"

# 3. Grant the group write access on the bucket
nebius storage bucket update --id "$BUCKET_ID" \
  --bucket-policy-rules '[{"group_id": "'"$GROUP_ID"'", "paths": ["*"], "roles": ["storage.editor"]}]'
Enter fullscreen mode Exit fullscreen mode

I folded the whole sequence into the deployment script deploy_job.sh, which is written to check first and only create what's missing.

Step 5: Wiring the knowledge base into the prompts

The Endpoint doesn't read the knowledge base from Object Storage on every request. That would add an unnecessary network call to every/generate-plan request for data that rarely changes. Instead, it fetches knowledge_base.json once per container lifetime, on first use after the container starts, and keeps it in memory for as long as that process runs:

def _load() -> dict:
    global _knowledge_base
    if _knowledge_base is None:
        _knowledge_base = _fetch_from_object_storage()
        if _knowledge_base is None:
            with open(_LOCAL_KB_PATH) as f:
                _knowledge_base = json.load(f)
    return _knowledge_base
Enter fullscreen mode Exit fullscreen mode

If Object Storage credentials aren't configured, or the fetch fails for any reason, it falls back to a bundled copy committed in the repo, so local development with no S3 credentials at all still works, and a brief Object Storage outage doesn't take the Endpoint down with it.

This means refreshing the knowledge base after the Job runs doesn't need a rebuild or a new deployment. It needs the container to restart so it re-fetches on the next startup. Nebius Endpoints support exactly that with stop and start, separate from delete/create:

nebius ai endpoint stop <endpoint-id>
nebius ai endpoint start <endpoint-id>
Enter fullscreen mode Exit fullscreen mode

Results

The Job is CPU-only and finishes in one to three minutes. The Endpoint, also CPU-only, runs five or six sequential calls to Token Factory per /generate-plan request: one for periodization, one per week, and one batch call for coaching notes, so the total latency scales with the number of requested weeks. Measured end-to-end, a four-week plan takes roughly three minutes; a two-week plan takes about half that. That's a deliberate trade-off: each step's output is small and easy to validate on its own, at the cost of a request that takes minutes rather than seconds.

A small static frontend with plain HTML, CSS, and JavaScript turns the JSON response into a plan that reads the way a swimmer would actually read a workout on a whiteboard: warmup, main set, and cooldown, each set written out as reps, distance, and rest.

Generating a four-week plan makes seven Token Factory calls totalling roughly 9,000 tokens, at Llama 3.3 70B's rate of $0.13/1M input and $0.40/1M output. It’s about a quarter of a cent per plan. The Endpoint container is the main cost driver. It runs continuously regardless of traffic. Checked against Nebius's own cost calculator (nebius billing v1alpha1 calculator estimate), the smallest non-GPU preset (cpu-e2 / 2vcpu-8gb) comes out to roughly $1.19/day in compute.

#NebiusServerlessChallenge

Top comments (0)