Gymnasia: architecture of an AI agent that runs on the phone

Gymnasia: architecture of an AI agent that runs on the phone

What Gymnasia islink image

Gymnasia is a mobile personal-training app built with React Native and Expo. Inside it there are two AI agents: a conversational coach and a food estimator that works out calories and macronutrients from photos.

They run side by side, not one inside the other: neither is a subagent of the other, they never call each other, they live on different screens, and each has its own system prompt, its own tools and its own LLM provider. They share exactly one thing: the user's data stored on the phone.

The interesting part, and what this post is about, is that both run entirely on the device. There is no backend of our own: deciding what to do, running the tools and storing the data all happen inside the app itself, on the phone. The model is called straight from there with an API key the user supplies, what is usually called BYOK, Bring Your Own Key: every user brings their own key, and with it pays for and controls their own access to the provider. The user's data never leaves the phone except for one single exception, explained below.

This is the main post of the series. Below are the two architecture graphs, and at the end the series index with the posts that cover each part of the development.

The general agent: Gymnasia Coachlink image

Read top to bottom down the central column: that is what happens over time. The box on the left is not a sequence of steps: those are pieces that add up to one another and travel together in every call, hence the wide arrow. The one on the right is what happens when the model requests a tool. The light dashed line goes back: the result is appended to the history and the model is called again. The red one is the only thing that leaves the device. And the dashed-border boxes are the call to the model: that part is the provider's, not the app's code.
WHAT GOES INTO EVERY CALL WHEN THE MODEL REQUESTS A TOOL +++ yes no + tool_result round n+1 (max. 10)
System promptremote, cached
Tool cataloguewhat it can do
Historyeverything said so far
ProviderBYOK, set by the user
User (chat)writes a message
Request to the providerall of it, in a single call
Model responsetext and/or a tool request
Does it request a tool?
Final answerstreamed to the user
The app runs the toolon the phone
Local datadiet · routines · measurements · memory
create_feature_issuethe only tool that leaves the phone

Swipe the graph horizontally to see all of it.

Next step Goes into the call Goes back (loop) Leaves the device Outside your code

Gymnasia Coach is the app's general conversational agent. It is a BYOK agent: the user configures the API key of whichever LLM provider they prefer, and the app calls that provider directly from the device. There is no backend of our own: orchestration, tool execution and storage all happen inside the app itself.

Configuration and provider choicelink image

BYOK settings

The user saves their API key

They save keys for whichever providers they want and pick which one serves the chat. The chat and the food estimator are configured separately: each can run on a different provider.

Secure device storage
Remote system prompt

The prompt does not ship inside the app

The system prompt is downloaded from a public file in the repository, cached locally, and falls back to an embedded copy if the network fails.

Editable without shipping a release

That last part is not an architectural whim, it is a safety valve. If one day the agent is handing out nonsense like "go a week without eating to drop body fat", I want to be able to fix the instructions, that is, the system prompt, as fast as possible, not in two weeks, and certainly not depending on every user remembering to update the app. With the prompt served remotely, fixing it means editing a text file: the very next message anyone sends already uses the corrected version. The embedded copy exists only so the app keeps working offline.

The price is that this file becomes a trusted input for every user at once, so it has to be treated like code: reviewed, versioned and cached.

Provider calllink image

The app does not talk to one specific provider, it talks to "whichever provider is in play". Three are supported today, and the list is meant to grow:

OpenAIResponses API · SSE/XHR streaming
AnthropicMessages API · thinking enabled
GoogleGemini generateContent

Each provider has its own request/response adapter, because tool formats, streaming and content blocks differ from one to the next. Adding a new provider means writing one more adapter, not touching the agent: they all share the same agentic loop that comes next.

Agentic loop (tool use)link image

Step 1

Model responds

Streamed text and/or tool_use / function_call blocks.

Step 2

The app runs the tool

The model executes nothing: it only asks. The app is what executes, locally and against its own data.

Step 3

Result → model

Fed back as tool_result / function_call_output. Repeats until no more tools are requested.

The toolslink image

The coach does touch the user's data: it reads it and writes it. It can log today's weight, add a food to a meal or create a whole routine. What it cannot do is step outside that: what it is able to do is exactly the list of declared tools, and nothing more. They come in four families, and all of them run inside the phone.

🧠 Personal memory

Read and store what the user says about themselves: goal, injuries, preferences. This is what makes the coach remember from one conversation to the next.

🍽️ Diet

Search foods in the catalogue shipped with the app, read what has already been eaten on a date, and add foods to a meal.

🏋️ Training

Search exercises by muscle, equipment or difficulty, read the user's routines and create new ones.

📏 Measurements

Read and write body measurements by date: weight, body-fat percentage, girths.

The exception

create_feature_issue

The only tool that leaves the device: when the user asks for an app improvement, it opens an issue in the repository. It is the single path by which something written in the chat ends up off the phone, which is why it sits apart from the rest.

External

How each of these tools is declared, name, description, argument schema, and how that catalogue is translated into the format each provider expects is a whole post of its own, and it has one: How to declare reliable tools for OpenAI, Anthropic and Google.

Storage and outputlink image

No database

Everything on the device

Diet, routines, measurements and personal data live in the phone's local storage. The food and exercise catalogues are static files bundled with the app.

Exception

Opening an issue

The only exit point to an external service other than the LLM provider: create_feature_issue.

External
Platform caveat: the same agent running in a browser hits CORS. Some providers allow the direct call from the browser and some do not, and for those you need a local proxy during development. On the phone the problem does not exist. It is the kind of detail that never shows up in the agent design and shows up on day one of debugging it on the web.

The second agent: the food estimatorlink image

Same reading and same boxes as the coach graph. Watch the barcode detour: it is not an alternative to the estimate, it is one more turn of the loop. The exact data goes back to the model and the estimate is produced all the same, only with real numbers instead of a guess. The three dashed-border boxes are calls to the model.
WHAT GOES INTO EVERY CALL WHEN THE MODEL REQUESTS THE TOOL ++ yes no + tool_result round n+1 (max. 5)
System promptthe estimator's own
Its only toolscan_barcode
Providerchosen apart from the chat
User1–6 photos + optional text
Request to the providerall of it, in a single call
Model responselooks at the images
Does it request scan_barcode?
Estimatekcal and macros, with ranges
Second call to the modelonly if the user accepts: "give it back as JSON"
add_meal_food()→ the day's local diet
The app runs scan_barcodeon the phone
OpenFoodFactspublic API, off the device

Swipe the graph horizontally to see all of it.

Next step Goes into the call Goes back (loop) Leaves the device Outside your code

The food estimator pulls calories and macros out of a photo of the plate. It is not a subagent of the coach: the coach never calls it, and does not even know it exists. It is a second agent, with its own system prompt, its own tool and its own provider choice, opened from a different screen and writing into the same local data.

Inputlink image

User input

1–6 photos of the meal

Camera or gallery. It also accepts text (follow-up questions about the estimate), reusing the conversation context.

Its own providerlink image

The estimator does not inherit the chat's provider: it is chosen separately, in settings. And that makes sense, because the two agents are not competing for the same thing. You ask the coach to reason over text; you ask the estimator to look at a photo.

If the user changes nothing, the estimator starts on whichever provider currently gives the best cost/quality ratio for vision, and only looks elsewhere if that key is not configured. The general lesson: model choice belongs to the task, not to the app. As soon as a product has two AI uses with different cost profiles, tying them to the same provider means overpaying on the expensive one or underperforming on the cheap one.

Specialised system promptlink image

Visual nutritionist

Always estimates kcal, protein (g), carbs (g), fat (g) and total weight (g). Gives ranges when uncertain.

Classification

Determines whether it is a producto_comercial, a receta or a generic base food.

Structured output

If the user asks for "Devuelve json", it replies with JSON only: dish_name, calories_kcal, protein_g, carbs_g, fat_g.

Agentic loop with the barcode toollink image

Detection

Is there a barcode in the photo?

The prompt forces the model to use the tool if it detects an EAN/UPC in any of the images.

Only tool

scan_barcode(barcode)

Calls OpenFoodFacts (public API) with the scanned code and returns exact nutrition data for the product.

External · world.openfoodfacts.org

Commercial product confirmed

If scan_barcode was used, the classification is always producto_comercial, with exact rather than estimated data.

As in the coach, the tool result is fed back to the model and the loop repeats, up to 5 rounds, until a final answer is produced. That cap is not decorative: without it, a model that insists on calling the same tool again just spins, burning the user's tokens.

Persisting the resultlink image

Nothing is saved on its own

User confirmation

The estimate is shown first in natural language. Only when the user accepts it is a second answer requested, this time as JSON, and parsed.

add_meal_food

It is added to the local diet for the selected day and meal, in the same data the coach uses.

Variant: manual estimationlink image

No camera

The user describes a food in text

Conversational flow: the user names the food, the model asks for missing ingredients and quantities, computes values per 100 g or unit, the user confirms, and it returns the JSON to store in the food catalogue.

No tools · no images
Key design decision: the estimator and the coach share no provider, no prompt and no tools. They only share the data. Keeping them separate is what allows tuning the cost of each one independently and changing one without breaking the other.

The serieslink image

This post is the cover. Each part of the agent's development gets its own post, and they are all linked from here.

In the meantime, the project page is at maximofn.com/en/gymnasia and the code is open source, on GitHub.

Continue reading

Last posts -->

Have you seen these projects?

Gymnasia

Gymnasia Gymnasia
Expo
React Native
TypeScript
OpenAI
Anthropic

Fitness app with two agents that run entirely on the device, with no backend, so the user's data never leaves the phone. A BYOK conversational coach with local tools and a remote system prompt with offline fallback, plus a food estimator that pulls macronutrients out of a photo of the plate, with barcode scanning against OpenFoodFacts.

LangGraph Deep Researcher

LangGraph Deep Researcher LangGraph Deep Researcher
Python
LangGraph
FastAPI
React
TypeScript
Docker

Multi-agent research system built with LangGraph. A supervisor breaks your question down into topics and launches search sub-agents in parallel; each one compresses its findings before handing them to a writer agent that produces the final sourced markdown report. Live streaming over WebSockets, a configurable model per role and bring-your-own API keys that are never persisted server-side.

Tau

Tau Tau
Python
LangChain

Multi-agent tutoring system for secondary school students, with one agent per subject and course material written and validated by a team of teachers. It was used with real students at a private school in Spain and at a secondary school in Colombia.

View all projects -->
>_ Available for projects

Do you have an AI project?

Let's talk.

maximofn@gmail.com

Machine Learning and AI specialist. I develop solutions with generative AI, intelligent agents and custom models.

Do you want to watch any talk?

Last talks -->

Do you want to improve with these tips?

Last tips -->

Use this locally

Hugging Face spaces allow us to run models with very simple demos, but what if the demo breaks? Or if the user deletes it? That's why I've created docker containers with some interesting spaces, to be able to use them locally, whatever happens. In fact, if you click on any project view button, it may take you to a space that doesn't work.

Flow edit

Flow edit Flow edit

FLUX.1-RealismLora

FLUX.1-RealismLora FLUX.1-RealismLora
View all containers -->
>_ Available for projects

Do you have an AI project?

Let's talk.

maximofn@gmail.com

Machine Learning and AI specialist. I develop solutions with generative AI, intelligent agents and custom models.

Do you want to train your model with these datasets?

short-jokes-dataset

HuggingFace

Dataset with jokes in English

Use: Fine-tuning text generation models for humor

231K rows 2 columns 45 MB
View on HuggingFace →

opus100

HuggingFace

Dataset with translations from English to Spanish

Use: Training English-Spanish translation models

1M rows 2 columns 210 MB
View on HuggingFace →

netflix_titles

HuggingFace

Dataset with Netflix movies and series

Use: Netflix catalog analysis and recommendation systems

8.8K rows 12 columns 3.5 MB
View on HuggingFace →
View more datasets -->