Skip to content

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) needs authorization: Bearer <session_token> metadata, with the token from GoogleLogin. 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.units is 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 BodyComposition fields, and many other fields are optional for real presence.
  • Judged proposals carry a runink.ui.judgement.v1.Judgement and a judgement_notice (Luna’s own line, for example “not verified — …”).

IdentityService

RPCRequest → ResponseNotes
GoogleLoginGoogleLoginRequest{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.
WhoAmIWhoAmIRequest{} → WhoAmIResponse{user}User{id, email, display_name}
LogoutLogoutRequest{} → LogoutResponse{}Revokes the session on every replica. Logging out twice isn’t an error.

AvatarService

RPCRequest → ResponseNotes
CreateAvatarCreateAvatarRequest{name, video_b64, builtin_kind} → AvatarEither 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.
ListAvatarsListAvatarsRequest{} → ListAvatarsResponse{avatars}
SelectAvatarSelectAvatarRequest{avatar_id} → AvatarAppends a superseding selection record.
GetSelectedAvatarGetSelectedAvatarRequest{} → AvatarNotFound: 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

RPCRequest → ResponseNotes
SendMessageSendMessageRequest{text, source} → stream SendMessageChunksource is "text" or "voice".
ListHistoryListHistoryRequest{limit} → ListHistoryResponse{turns}ChatTurn{role, text, created_at, persona}

SendMessageChunk fields:

FieldMeaning
avatar_stateA cue for the creature (thinking → speaking)
text_deltaThe next piece of the reply
personaThe answering aspect: counsel, trainer, nutritionist, keeper, muse or naturalist. Sent once, on the first chunk that has text.
persona_sourcePERSONA_SOURCE_MODEL (model self-report) or PERSONA_SOURCE_BACKEND (chosen in Go)
draft_planA TrainingPlan draft from the deterministic plan action, to review. It is not saved and its id is empty.
beatOne ReasoningBeat{code, operands}, on its own chunk. It is never journaled. Clients must render an unknown code as nothing.
not_persistedOn the final chunk: the exchange could not be journaled.
doneThe final chunk

VoiceService

RPCRequest → ResponseNotes
TranscribeTranscribeRequest{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.
SynthesizeSpeechSynthesizeSpeechRequest{text, voice} → SynthesizeSpeechResponse{audio_content, mime_type, text}Unary. Returns WAV bytes (audio/wav) from ttsd. voice is only a cache key.

VisionService

RPCRequest → ResponseNotes
AnalyzeMealAnalyzeMealRequest{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.
ReviewWorkoutFormReviewWorkoutFormRequest{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

RPCRequest → ResponseNotes
DraftPlanDraftPlanRequest{goal, days_per_week, equipment, experience, constraints, session_minutes} → TrainingPlanDeterministic and not saved. experience is beginner, intermediate or advanced (empty means beginner).
RevisePlanRevisePlanRequest{plan, instruction} → TrainingPlanModel-backed and judged. On a dissent it returns the unchanged plan.
SavePlanSavePlanRequest{plan} → TrainingPlanRebuilds the plan against the catalogue, stores it, and activates it. Any client-sent verdict is dropped.
GetActivePlanGetActivePlanRequest{} → TrainingPlanNotFound: no training plan yet
ListPlansListPlansRequest{limit} → ListPlansResponse{plans}
SetActivePlanSetActivePlanRequest{id} → TrainingPlanSwitches the live plan without rewriting it.
SearchExercisesSearchExercisesRequest{query, body_parts, equipment, limit} → SearchExercisesResponse{exercises, attribution}Show attribution wherever results are listed.
LogSetLogSetRequest{set: LoggedSet} → LoggedSetOne set as performed. This is the only way a load enters the system.
ListSetsListSetsRequest{exercise_id, limit} → ListSetsResponse{sets}Newest first. An empty exercise_id means every exercise.
PlanFromImagesPlanFromImagesRequest{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:

ValueMeaning
LOAD_SOURCE_UNSPECIFIEDNever told a load. The client shows a dash.
LOAD_SOURCE_BODYWEIGHTA bodyweight movement
LOAD_SOURCE_USER_ENTEREDTyped by the user
LOAD_SOURCE_LAST_SESSIONCarried forward from what was actually lifted
LOAD_SOURCE_DERIVED_1RMComputed 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

RPCRequest → ResponseNotes
DraftMealPlanDraftMealPlanRequest{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.
SaveMealPlanSaveMealPlanRequest{plan} → MealPlanSaves and activates.
GetActiveMealPlanGetActiveMealPlanRequest{} → MealPlanNotFound: no meal plan yet
ListMealPlansListMealPlansRequest{limit} → ListMealPlansResponse{plans}
SetActiveMealPlanSetActiveMealPlanRequest{id} → MealPlan
ListPantryListPantryRequest{} → ListPantryResponse{items}
SavePantryItemSavePantryItemRequest{item} → PantryItemUp to 60 items
DeletePantryItemDeletePantryItemRequest{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

RPCRequest → ResponseNotes
LogEventLogEventRequest{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.
SetStepsGoalSetStepsGoalRequest{daily_steps} → GetScoreResponse1,000–100,000. The latest goal wins.
LevelUpLevelUpRequest{} → GetScoreResponseSpends points on the next level. FAILED_PRECONDITION when points are short or at level 10.
RecordActivityRecordActivityRequest{activity} → ActivityA finished walk, run or ride. No route.
ListActivitiesListActivitiesRequest{limit} → ListActivitiesResponse{activities}
GetScoreGetScoreRequest{} → 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:

FieldMeaning
habit_scoreThe rolling adherence score, 0.0–1.0 (14 days, 7-day half-life)
compute_tierbaseline, standard or priority, from the level
streaksOne Streak{kind, current_days, longest_days, logged_today, at_risk} per habit
steps_goalThe active goal (default 10,000)
points_balance, level, next_level_cost, earned_currentThe economy. next_level_cost is 0 at the maximum level.
traitsTrait{id, name, level, progress, next_at, flavor}, where id is wayfarer or strider
skillsSkill{id, persona, name, effect, unlocked, requirement, remaining}
avatar_stage, avatar_nextEvolution 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

RPCRequest → ResponseNotes
GetProfileGetProfileRequest{} → UserProfileIncludes latest_body_composition, which the server fills in on read.
UpdateProfileUpdateProfileRequest{profile} → UserProfileMerges. Only fields present on the request are written.
RecordBodyCompositionRecordBodyCompositionRequest{reading} → BodyComposition
ListBodyCompositionListBodyCompositionRequest{limit} → ListBodyCompositionResponse{readings}
RecordWearableDayRecordWearableDayRequest{day} → WearableDay
ListWearableDaysListWearableDaysRequest{limit} → ListWearableDaysResponse{days}
GetInnerContextGetInnerContextRequest{} → InnerContext
UpdateInnerContextUpdateInnerContextRequest{context} → InnerContextReplaces 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}.