The offline chat that Getting Started builds by hand — except you do not write it. Your coding assistant does, after reading the instructions flutter_gemma ships for it. Then you ask for a second feature, the model changing the app's colour by calling a Dart function, and watch the assistant reach for a different set of instructions to write it.
By the end you will have:
flutter_gemma bundles, installed for the assistant you usedart run skills@ get finds skills, and where it puts them for each assistantYou do not need to have done Getting Started. It helps to have seen it: Steps 3 and 4 here ask your assistant for code that codelab explains line by line.
An assistant does not write the same code twice. Two people following this codelab — or one person running it twice — get different files, so this codelab never asks you to compare your code with a reference diff. Each task ends with a list of things the code must do, each one a trap the skills exist to prevent. The step apps are one correct answer to check against, not the answer.
git clone --depth 1 https://github.com/DenisovAV/flutter_gemma.git
cd flutter_gemma/codelabs/package-skills-flutter-gemma
It holds three apps:
step_01_starter/ — the app you start fromstep_03_chat/ — one correct answer to Step 3complete/ — one correct answer to Step 4Do this before anything else:
cp -R step_01_starter ~/gemma_skills
cd ~/gemma_skills
The reason is the repository you just cloned. It has skills of its own, for the people who maintain flutter_gemma — release, build-native, review-pr and two more, in .claude/skills/ at its root — and some assistants look further up than the folder you open. Claude Code loads project skills from the directory a session starts in and from every parent up to the repository root, so a session started inside the clone would offer your assistant instructions for publishing this package alongside the ones for using it. A copy outside the clone has only what you install into it.
flutter run
It shows one line of text and nothing else. That is the whole app:
flutter_gemma and flutter_gemma_litertlm are already dependencies, and nothing imports them yet. They are there for Step 2: the skills CLI finds skills by scanning an app's dependencies, so an app that does not depend on the package has nothing to install. flutter run has also resolved them, which the CLI needs — if you skip this run, do a flutter pub get before Step 2.minSdk 30, iOS 15 and its three memory entitlements, the two macOS entitlements and the post_install block in macos/Podfile, and the script tag in web/index.html. Getting Started explains each one. They are here so that when the assistant's code fails, it is the code — not a missing entitlement that looks the same from outside.One macOS caveat carries over from Getting Started: with Swift Package Manager enabled and no other CocoaPods plugin in the app, Flutter drops the Podfile, its post_install block never runs, and the first model load fails with nothing in the error pointing at CocoaPods. Run flutter config --no-enable-swift-package-manager for this project.
A clean run on your platform is all this step asks for.
A skill is a folder with a SKILL.md in it: a short description of when it applies, then rules, working code, and the mistakes that fail silently. The format is an open standard, Agent Skills, and the assistants above read it the same way, in three stages:
Stage 2 runs entirely on the description, so flutter_gemma's skills describe tasks and symptoms rather than APIs — "offline chat, running Gemma...", "a reply comes back empty". Seven ship in the package, one per area: inference, function calling, RAG, speech, MediaPipe, ONNX and built-in AI.
From ~/gemma_skills:
dart run skills@ get --all
On a fresh app this stops:
Could not auto-detect agent and none selected. Use --agent to specify one of: antigravity, claude, cline, codex, copilot, cursor, opencode, generic
The CLI detects an assistant by the folder it keeps in a project — .agents/, .claude/, .cursor/ and so on — and a new app has none. Name yours. --all installs every skill without asking; leave it out and, in a terminal, the CLI lets you pick.
dart run skills@ get --all --agent antigravity
[generic] Installed flutter-gemma-builtin-ai
[generic] Installed flutter-gemma-speech
[generic] Installed flutter-gemma-function-calling
[generic] Installed flutter-gemma-rag
[generic] Installed flutter-gemma-onnx
[generic] Installed flutter-gemma-inference
[generic] Installed flutter-gemma-mediapipe
Installed 7 skill(s) for generic at .agents/skills.
antigravity is an alias. The CLI files it as generic — the name it prints — and writes to .agents/skills/, which is where Antigravity looks for a workspace's skills. Open ~/gemma_skills itself as the workspace: the skills live under the workspace root, so a workspace opened on a parent folder does not see them.
To check, open the Agent panel (Cmd/Ctrl + L) — or start the Antigravity CLI with agy — and ask:
Summarize the flutter_gemma skills available in this project.
dart run skills@ get --all --agent claude
[claude] Installed flutter-gemma-builtin-ai
[claude] Installed flutter-gemma-speech
[claude] Installed flutter-gemma-function-calling
[claude] Installed flutter-gemma-rag
[claude] Installed flutter-gemma-onnx
[claude] Installed flutter-gemma-inference
[claude] Installed flutter-gemma-mediapipe
Installed 7 skill(s) for claude at .claude/skills.
The CLI adds one line to each skill's header for Claude Code, user-invocable: false. It takes the skills out of the / menu: you never call them by name, and the model loads them on its own when a task matches. To check they are there, ask the same question:
Summarize the flutter_gemma skills available in this project.
If a Claude Code session was already open in this folder, restart it. Claude Code picks up skills added while it runs, but only in a skills folder that existed when the session started — and .claude/skills/ did not.
Assistant |
| Installed to |
Codex |
|
|
Cursor |
|
|
GitHub Copilot |
|
|
Cline |
|
|
OpenCode |
|
|
anything else |
|
|
Copilot is the one assistant the CLI never detects, even after its folder exists: .github/ serves CI and plenty else, so the CLI will not guess. Always pass --agent copilot.
.agents/skills/ (or .claude/skills/, .cursor/skills/ ...)
flutter-gemma-builtin-ai/SKILL.md
flutter-gemma-function-calling/SKILL.md
flutter-gemma-inference/SKILL.md
flutter-gemma-inference/references/platform-setup.md
flutter-gemma-mediapipe/SKILL.md
flutter-gemma-onnx/SKILL.md
flutter-gemma-rag/SKILL.md
flutter-gemma-speech/SKILL.md
.config/dart_skills/skills_config.json
Open flutter-gemma-inference/SKILL.md. The first screen of it — the numbered rules — is the best preparation for Step 3, because those rules are what you will be checking.
skills_config.json records what was installed and from which package, with a hash of each skill. It is how the CLI tells an update from a local edit later.
Either works; pick one for the team.
.config/dart_skills/, so everyone's assistant reads the same instructions and the CLI can track updates for all of you..config/dart_skills/ and the skills folder, and have each developer run dart run skills@ get after cloning.Give your assistant this, as written:
Add an offline chat to this app with flutter_gemma. Use Gemma 4 E2B from
litert-community on Hugging Face: download it once with a progress bar, open
straight on the chat on later launches, and stream the reply.
It names no API on purpose: you are finding out whether the assistant knows them.
Notice what it reads first. The request is exactly what flutter-gemma-inference describes, so that is the skill it should load. Most assistants show a skill being read — as a tool call, or a line in the plan. If yours does not say, ask it which skills it used; and if it wrote code without one, tell it to use the flutter-gemma skills in this project. An assistant can skip a skill it should have loaded, and then you are back to whatever it remembers from training.
Let it finish, then run the app. The download is 2.6 GB (2.0 GB on the web).
Go down this list against what your assistant wrote. Each item is one rule from the skill, and each is a mistake that compiles, runs, and fails without telling you why. The wrong version is what an assistant without the skill tends to write, because it looks right.
1. Both packages are imported.
import 'package:flutter_gemma/flutter_gemma.dart';
import 'package:flutter_gemma_litertlm/flutter_gemma_litertlm.dart';
The engine package does not re-export the core. Import only the second and FlutterGemma is an undefined name — the one mistake on this list the compiler does catch.
2. The engine is registered.
await FlutterGemma.initialize(inferenceEngines: [LiteRtLmEngine()]);
The core registers no engine of its own. Leave this out and the app builds, the download works, and the first getActiveModel() throws a StateError asking for an engine package.
3. The file type is declared.
await FlutterGemma.installModel(
modelType: widget.model.modelType,
fileType: ModelFileType.litertlm,
).fromNetwork(widget.model.url).withProgress((percent) {
if (mounted) setState(() => _percent = percent);
}).install();
The declared type — not the .litertlm at the end of the URL — decides which engine opens the file, and it defaults to ModelFileType.task. Without that line the download succeeds and getActiveModel() throws "No inference engine can handle this model": the only engine you registered does not handle .task.
4.
maxTokens
is not the reply length.
final inference = await FlutterGemma.getActiveModel(maxTokens: 1024);
final chat = await inference.createChat(
modelType: widget.model.modelType,
maxOutputTokens: 256,
);
maxTokens is the whole context window — prompt, history and reply together. An assistant told to keep replies short reaches for maxTokens: 100; on .litertlm that is raised back to 1024 with a warning in the log, and the replies are as long as before. The reply is capped with maxOutputTokens.
5. The user's message says it is from the user.
await chat.addQueryChunk(Message.text(text: text, isUser: true));
isUser defaults to false. Without it the message is not a user turn and the reply comes back empty — no exception, nothing in the log.
6. What was opened is closed.
A model holds native memory, and so does a chat. Whatever the assistant opened needs a close(), at the latest when the screen goes away.
7. There is no token.
Gemma 4 E2B is not gated, so a correct answer has no Hugging Face token anywhere. When a gated model does need one, the skill has it read with String.fromEnvironment — never written into the source.
step_03_chat/ passes every item. If yours does too, you have the same app in different words.
Let the user change the app's colour by asking in the chat, for example "make it
green". The model should call a Dart function to do it.
This time the assistant should load a different skill, flutter-gemma-function-calling. Again the request names no API; what matches is that skill's description — "letting an on-device model call the app's own Dart functions". The description even says where its edge is: "For plain chat, use flutter-gemma-inference." Two skills, one app, and the assistant picks by task.
Run it and ask for a colour. Then ask for one that does not exist — "make it mauvish" — because item 4 below is about what happens then.
1. The chat is told it may call functions.
final chat = await inference.createChat(
tools: const [ColorTool.declaration],
supportsFunctionCalls: true,
modelType: widget.model.modelType,
maxOutputTokens: 256,
);
tools alone is not enough. Without supportsFunctionCalls: true no call is parsed and only a debug warning is logged — and on Gemma 4 the declarations still reach the model, so it answers with its raw tool-call JSON in the middle of the reply text. The colour never changes, and the user reads JSON.
2.
modelType
is passed.
On a device, a .litertlm chat uses the installed model's type when you leave it out. The web engine falls back to ModelType.gemmaIt instead, and Gemma 4's calls then arrive as raw text. Passing it everywhere costs nothing.
3. The tool describes an action.
static const name = 'change_color';
static const declaration = Tool(
name: name,
description: "Change the app's colour theme.",
parameters: {
'type': 'object',
'properties': {
'color': {
'type': 'string',
'description': 'A colour name, for example red, green or purple.',
},
},
'required': ['color'],
},
);
The model matches the user's words against the description — the same way your assistant matched your request against a skill's. Every parameter is described too.
4. Errors are results.
This is what "make it mauvish" tests. A tool should never throw:
if (color == null) {
return ToolOutcome({
'error': 'unknown colour: $requested',
'known': colors.keys.toList(),
});
}
The model can read an error and recover — tell the user which colours exist, or pick the nearest. An exception from the tool is reported to the model and then rethrown on the stream, which ends the turn. In complete/ this lives in lib/color_tool.dart, apart from the UI, so a test pins it without a model: flutter test there runs it.
5. The loop is the built-in one.
await for (final chunk in chat.generateChatResponseWithTools(
onToolCall: _runTool,
onMaxToolTurns: () => _insertNote('Stopped after 8 tool calls.'),
)) {
if (chunk is TextResponse) {
buffer.write(chunk.token);
// ...and show the buffer in the reply bubble.
}
}
It calls the tool, feeds the result back, and continues until the model answers in text. A hand-written loop is allowed, but then it has to switch over all four ModelResponse subtypes: the type is sealed, and a switch that leaves out ThinkingResponse does not compile. Whichever loop it is, reaching the turn limit ends the stream without an error — onMaxToolTurns is the only signal.
complete/ passes every item.
The skills ship inside the package and are versioned with it, so upgrading flutter_gemma can change them. After an upgrade, run:
dart run skills@ get
In a terminal it lists every skill with its state and lets you pick which to update. Without one — in a script, or when your assistant runs it — it prints the same list and stops. Here it is for a Claude Code install with one skill edited by hand:
Available skills from package:flutter_gemma:
flutter-gemma-rag (Local edits)
flutter-gemma-builtin-ai (Update available)
flutter-gemma-function-calling (Update available)
flutter-gemma-inference (Update available)
flutter-gemma-mediapipe (Update available)
flutter-gemma-onnx (Update available)
flutter-gemma-speech (Update available)
Rerun with `--skill <name>`, or `--all` to install, update, or remove the given skills.
Local edits is the hash in skills_config.json at work: that skill no longer matches what was installed. Look for it before you reach for --all, because --all
installs the package's copy of every skill and overwrites local edits without asking. Rules of your own are safer in a skill of your own than inside an installed one.
The other six say Update available although nothing changed: version 1.0.1 of the CLI reports that for every Claude Code skill, even straight after installing it. The same untouched install in .agents/skills/ prints All skills are up to date.
Two more commands worth knowing:
dart run skills@ list # what is installed, for which assistant, from which package
dart run skills@ prune # remove skills whose package is no longer a dependency
An outdated skill is worse than none, because an assistant follows it with confidence. flutter_gemma's CI compiles every Dart block in its skills against the packages on every pull request, so the skills that match your installed version are the ones to have.
You have an app your assistant wrote, and a way to check its work without reading every line of it. The same seven skills cover the rest of the package, and the same habit — ask, see which skill it loads, check against the rules — works for each:
flutter-gemma-ragflutter-gemma-speechflutter-gemma-builtin-ai.task or ONNX models — flutter-gemma-mediapipe, flutter-gemma-onnx