What you'll build

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:

What you'll learn

What you'll need

You 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.

What "the result" means here

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.

Get the code

git clone --depth 1 https://github.com/DenisovAV/flutter_gemma.git
cd flutter_gemma/codelabs/package-skills-flutter-gemma

It holds three apps:

Copy it out of the clone

Do 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_gemmarelease, 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.

Run it

flutter run

It shows one line of text and nothing else. That is the whole app:

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.

What a skill is

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:

  1. Discovery. At the start of a session the assistant reads only each skill's name and description.
  2. Activation. When a task matches a description, it reads the whole file.
  3. Execution. It follows the instructions while it does the task.

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.

Install them

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.

Antigravity

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.

Claude Code

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.

Every other assistant

Assistant

--agent

Installed to

Codex

codex

.agents/skills/

Cursor

cursor

.cursor/skills/

GitHub Copilot

copilot

.github/skills/

Cline

cline

.cline/skills/

OpenCode

opencode

.opencode/skills/

anything else

generic

.agents/skills/

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.

What landed

.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.

Commit them, or don't

Either works; pick one for the team.

The request

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).

Check the code

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.

The request

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.

Check the code

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 erroronMaxToolTurns 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:

Reference