API reference
The API is gRPC (package luna.v1, source
grpc/api/proto/luna/v1/luna.proto). Native clients speak HTTP/2 gRPC, and
the browser speaks gRPC-Web. Both are served on the same port as the web
bundle. There is no REST API.
Conventions
- Auth. Every RPC except
IdentityService/GoogleLogin(and reflection) needsauthorization: Bearer <session_token>metadata, with the token fromGoogleLogin. See Access and sessions. - Per-account. Every read and write is scoped to the session’s account. There is no parameter to name another user.
- Metric on the wire, always.
UserProfile.unitsis for display only. - Streaming RPCs send progress as
ReasoningBeats and put the result on the final chunk (done = true). A stream that closes cleanly with no result is a failure, not an empty success. - Zero means “not reported” on
BodyCompositionfields, and many other fields areoptionalfor real presence. - Judged proposals carry a
runink.ui.judgement.v1.Judgementand ajudgement_notice(Luna’s own line, for example “not verified — …”).
IdentityService
| RPC | Request → Response | Notes |
|---|---|---|
GoogleLogin | GoogleLoginRequest{google_id_token} → GoogleLoginResponse{session_token, expires_at, user, needs_onboarding} | Public. Verifies the Google ID token and the allowlist. needs_onboarding is true until the first avatar is bound. |
WhoAmI | WhoAmIRequest{} → WhoAmIResponse{user} | User{id, email, display_name} |
Logout | LogoutRequest{} → LogoutResponse{} | Revokes the session on every replica. Logging out twice isn’t an error. |
AvatarService
| RPC | Request → Response | Notes |
|---|---|---|
CreateAvatar | CreateAvatarRequest{name, video_b64, builtin_kind} → Avatar | Either builtin_kind (a painted form such as spectral_owl) or a video_b64 map from AvatarState name to base64 video. An AVATAR_STATE_IDLE loop is required. Only the first avatar ever made is auto-selected. |
ListAvatars | ListAvatarsRequest{} → ListAvatarsResponse{avatars} | |
SelectAvatar | SelectAvatarRequest{avatar_id} → Avatar | Appends a superseding selection record. |
GetSelectedAvatar | GetSelectedAvatarRequest{} → Avatar | NotFound: no avatar selected before onboarding. |
Avatar{id, name, video_loop_urls, selected, created_at, builtin_kind}.
video_loop_urls is empty for painted forms. AvatarState is
IDLE | LISTENING | THINKING | SPEAKING.
Painted builtin_kind values: spectral_owl, spectral_cat,
spectral_raven, spectral_fox, spectral_ferret, spectral_fairy,
spectral_woodpecker, spectral_monkey, spectral_tiger, spectral_crane,
spectral_mantis, spectral_bear, spectral_husky, spectral_aussie,
spectral_wisp.
ChatService
| RPC | Request → Response | Notes |
|---|---|---|
SendMessage | SendMessageRequest{text, source} → stream SendMessageChunk | source is "text" or "voice". |
ListHistory | ListHistoryRequest{limit} → ListHistoryResponse{turns} | ChatTurn{role, text, created_at, persona} |
SendMessageChunk fields:
| Field | Meaning |
|---|---|
avatar_state | A cue for the creature (thinking → speaking) |
text_delta | The next piece of the reply |
persona | The answering aspect: counsel, trainer, nutritionist, keeper, muse or naturalist. Sent once, on the first chunk that has text. |
persona_source | PERSONA_SOURCE_MODEL (model self-report) or PERSONA_SOURCE_BACKEND (chosen in Go) |
draft_plan | A TrainingPlan draft from the deterministic plan action, to review. It is not saved and its id is empty. |
beat | One ReasoningBeat{code, operands}, on its own chunk. It is never journaled. Clients must render an unknown code as nothing. |
not_persisted | On the final chunk: the exchange could not be journaled. |
done | The final chunk |
VoiceService
| RPC | Request → Response | Notes |
|---|---|---|
Transcribe | TranscribeRequest{pcm, sample_rate} → stream TranscribeChunk{note, result, done} | Raw 16 kHz mono PCM, up to 16 MiB. result is a TranscribeResponse{text} on the final chunk. Field 1 is reserved. |
SynthesizeSpeech | SynthesizeSpeechRequest{text, voice} → SynthesizeSpeechResponse{audio_content, mime_type, text} | Unary. Returns WAV bytes (audio/wav) from ttsd. voice is only a cache key. |
VisionService
| RPC | Request → Response | Notes |
|---|---|---|
AnalyzeMeal | AnalyzeMealRequest{image, note} → stream AnalyzeMealChunk{beat, result, done, not_persisted} | A JPEG or PNG. The result is AnalyzeMealResponse{analysis, persona="nutritionist", ingredients, total, judgement, judgement_notice}. On a DISSENT, analysis is a non-recommendation and the breakdown is empty. |
ReviewWorkoutForm | ReviewWorkoutFormRequest{frames, exercise} → stream ReviewWorkoutFormChunk{beat, result, done, not_persisted} | Still frames, at most 3. The result is ReviewWorkoutFormResponse{feedback, persona="trainer", judgement, judgement_notice}. |
MealIngredient{name, grams, calories, protein_g, carbs_g, fat_g}. All
values are photo-based estimates. In every vision chunk, the deprecated
note field (4) is no longer written.
TrainingService
| RPC | Request → Response | Notes |
|---|---|---|
DraftPlan | DraftPlanRequest{goal, days_per_week, equipment, experience, constraints, session_minutes} → TrainingPlan | Deterministic and not saved. experience is beginner, intermediate or advanced (empty means beginner). |
RevisePlan | RevisePlanRequest{plan, instruction} → TrainingPlan | Model-backed and judged. On a dissent it returns the unchanged plan. |
SavePlan | SavePlanRequest{plan} → TrainingPlan | Rebuilds the plan against the catalogue, stores it, and activates it. Any client-sent verdict is dropped. |
GetActivePlan | GetActivePlanRequest{} → TrainingPlan | NotFound: no training plan yet |
ListPlans | ListPlansRequest{limit} → ListPlansResponse{plans} | |
SetActivePlan | SetActivePlanRequest{id} → TrainingPlan | Switches the live plan without rewriting it. |
SearchExercises | SearchExercisesRequest{query, body_parts, equipment, limit} → SearchExercisesResponse{exercises, attribution} | Show attribution wherever results are listed. |
LogSet | LogSetRequest{set: LoggedSet} → LoggedSet | One set as performed. This is the only way a load enters the system. |
ListSets | ListSetsRequest{exercise_id, limit} → ListSetsResponse{sets} | Newest first. An empty exercise_id means every exercise. |
PlanFromImages | PlanFromImagesRequest{images, note} → stream PlanFromImagesChunk{beat, plan, done, judgement, judgement_notice} | Reads a written programme from photos. Unmatched movements are dropped. Not saved. On a dissent plan is unset. |
TrainingPlan{id, name, goal, days, rationale, active, created_at, weekly_sets_by_body_part, progression_mechanic, status_note, judgement, judgement_notice}. The server computes weekly_sets_by_body_part and
progression_mechanic.
TrainingDay{name, focus, exercises, estimated_minutes}.
PlannedExercise{exercise_id, name, target, equipment, sets, reps, rest, notes, steps, secondary_muscles, set_plan, superset_group}. exercise_id must be a
catalogue id. set_plan is the per-set ladder, and when present
len(set_plan) == sets. A non-zero superset_group shared by several
exercises means they are performed back to back.
ExerciseSet{index, reps, reps_text, load_kg, load_source, warmup}, and
LoadSource is one of:
| Value | Meaning |
|---|---|
LOAD_SOURCE_UNSPECIFIED | Never told a load. The client shows a dash. |
LOAD_SOURCE_BODYWEIGHT | A bodyweight movement |
LOAD_SOURCE_USER_ENTERED | Typed by the user |
LOAD_SOURCE_LAST_SESSION | Carried forward from what was actually lifted |
LOAD_SOURCE_DERIVED_1RM | Computed from a stated one-rep max |
LoggedSet{id, exercise_id, plan_id, day_name, set_index, reps, load_kg, performed_at, rpe, warmup, note}. rpe runs from 1 to 10, and 0 means not
stated.
NutritionService
| RPC | Request → Response | Notes |
|---|---|---|
DraftMealPlan | DraftMealPlanRequest{body_mass_kg, direction, preferences, meals_per_day, example_day} → stream DraftMealPlanChunk{beat, result, done} | Needs an active training plan. body_mass_kg = 0 falls back to the profile. direction is lose, maintain or gain. Fields 1–7 are reserved. Not saved. |
SaveMealPlan | SaveMealPlanRequest{plan} → MealPlan | Saves and activates. |
GetActiveMealPlan | GetActiveMealPlanRequest{} → MealPlan | NotFound: no meal plan yet |
ListMealPlans | ListMealPlansRequest{limit} → ListMealPlansResponse{plans} | |
SetActiveMealPlan | SetActiveMealPlanRequest{id} → MealPlan | |
ListPantry | ListPantryRequest{} → ListPantryResponse{items} | |
SavePantryItem | SavePantryItemRequest{item} → PantryItem | Up to 60 items |
DeletePantryItem | DeletePantryItemRequest{id} → DeletePantryItemResponse{} | Writes a tombstone. The store is append-only. |
MealPlan{id, daily_targets, example_meals, rationale, active, created_at, training_plan_id, example_day, body_mass_kg_used, baseline_source, judgement, judgement_notice}.
DailyTarget{day, training_focus, calories, protein_g, carbs_g, fat_g, meal_split}. meal_split appears only with the Meal Timing skill.
Meal{name, description, calories, protein_g, carbs_g, fat_g, ingredients, meal_type}. ingredients is filled on example meals only, and it adds up
exactly to the meal.
PantryItem{id, name, brand, serving_note, serving_grams, calories, protein_g, carbs_g, fat_g, photo_derived, created_at}.
HabitService
| RPC | Request → Response | Notes |
|---|---|---|
LogEvent | LogEventRequest{kind, value, occurred_at} → LogEventResponse{event_id} | value depends on the kind: a step count, workout done (1/0), or a meal-adherence fraction from 0.0 to 1.0. |
SetStepsGoal | SetStepsGoalRequest{daily_steps} → GetScoreResponse | 1,000–100,000. The latest goal wins. |
LevelUp | LevelUpRequest{} → GetScoreResponse | Spends points on the next level. FAILED_PRECONDITION when points are short or at level 10. |
RecordActivity | RecordActivityRequest{activity} → Activity | A finished walk, run or ride. No route. |
ListActivities | ListActivitiesRequest{limit} → ListActivitiesResponse{activities} | |
GetScore | GetScoreRequest{} → GetScoreResponse |
HabitKind is HABIT_KIND_STEPS, HABIT_KIND_WORKOUT,
HABIT_KIND_MEAL_ADHERENCE or HABIT_KIND_STEPS_ADHOC. Ad-hoc steps are
added to the day’s maximum rather than folded into it.
GetScoreResponse fields:
| Field | Meaning |
|---|---|
habit_score | The rolling adherence score, 0.0–1.0 (14 days, 7-day half-life) |
compute_tier | baseline, standard or priority, from the level |
streaks | One Streak{kind, current_days, longest_days, logged_today, at_risk} per habit |
steps_goal | The active goal (default 10,000) |
points_balance, level, next_level_cost, earned_current | The economy. next_level_cost is 0 at the maximum level. |
traits | Trait{id, name, level, progress, next_at, flavor}, where id is wayfarer or strider |
skills | Skill{id, persona, name, effect, unlocked, requirement, remaining} |
avatar_stage, avatar_next | Evolution stage 0–3, and the cheapest missing requirement for the next stage |
Activity{id, kind, started_at, moving_seconds, elapsed_seconds, distance_km, note}. kind is walk, run or cycle.
ProfileService
| RPC | Request → Response | Notes |
|---|---|---|
GetProfile | GetProfileRequest{} → UserProfile | Includes latest_body_composition, which the server fills in on read. |
UpdateProfile | UpdateProfileRequest{profile} → UserProfile | Merges. Only fields present on the request are written. |
RecordBodyComposition | RecordBodyCompositionRequest{reading} → BodyComposition | |
ListBodyComposition | ListBodyCompositionRequest{limit} → ListBodyCompositionResponse{readings} | |
RecordWearableDay | RecordWearableDayRequest{day} → WearableDay | |
ListWearableDays | ListWearableDaysRequest{limit} → ListWearableDaysResponse{days} | |
GetInnerContext | GetInnerContextRequest{} → InnerContext | |
UpdateInnerContext | UpdateInnerContextRequest{context} → InnerContext | Replaces the whole record. An empty message clears it. |
UserProfile fields (all optional except as noted): body_mass_kg,
height_cm, direction, units, birth_year, sex,
dietary_preferences, equipment (repeated), constraints, updated_at,
latest_body_composition, profession, current_focus,
routine_environment, goal_body_mass_kg.
BodyComposition records measured_at, weight_kg, body_fat_pct, bmi,
fat_free_mass_kg, subcutaneous_fat_pct, visceral_fat_index (unitless),
body_water_pct, skeletal_muscle_pct, bone_mass_kg, bmr_kcal,
muscle_mass_kg, protein_pct, metabolic_age_years, source (manual,
scale_photo or health_connect), device and photo_derived.
WearableDay{id, day, sleep, activity, stress, source, device, photo_derived, recorded_at}. day is the user’s local day. Each of SleepReport,
ActivityReport and StressReport carries an insights field written in
Go and a verbatim, untrusted device_note. ActivityReport.steps is never
journaled as a habit.
InnerContext{baseline, stressors, strategies, updated_at}.