A Flutter package for building AI agents with memory, tools, and multi-agent orchestration. Define your agent's personality, give it tools, and let it handle conversations — including delegating sub-tasks across a chain of specialized agents.
This is a monorepo containing the following packages:
| Package | pub.dev | Description |
|---|---|---|
agenix |
Core — agents, tools, LLM interface, in-memory data store | |
agenix_firebase |
Firebase (Firestore + Storage + Auth) data store backend |
The core agenix package ships with DataStore.inMemory() — a zero-dependency store for testing and prototyping. For production persistence, add a backend package:
# Core only (no Firebase):
dependencies:
agenix: ^4.0.0
# With Firebase persistence:
dependencies:
agenix: ^4.0.0
agenix_firebase: ^1.0.0import 'package:agenix/agenix.dart';
// In-memory (no extra package needed):
final store = DataStore.inMemory();
// Firebase (requires agenix_firebase):
import 'package:agenix_firebase/agenix_firebase.dart';
final store = FirebaseDataStore();This separation means apps that don't use Firebase never pull in the Firebase SDK.
Migrating from agenix 3.x? Replace
DataStore.firestoreDataStore()withFirebaseDataStore()and addagenix_firebaseto your pubspec. See the core CHANGELOG for details.
- Architecture Overview
- Installation
- Quick Start
- Core Concepts
- Error Handling
- API Reference
- Usage Architectures
- Examples
- Maintainers
┌─────────────────────────────────────────────────────────────┐
│ Your Flutter App │
│ │
│ Agent.create(llm, dataStore, name, role) │
│ │ │
│ ▼ │
│ ┌─────────┐ generateResponse() ┌───────────────┐ │
│ │ Agent │ ◄─────────────────────► │ LLM │ │
│ │ │ │ (Gemini / │ │
│ │ │ │ Custom) │ │
│ └────┬────┘ └───────────────┘ │
│ │ │
│ ┌────┴──────────────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────┐ ┌──────────┐ ┌────────────────┐ │
│ │Tools │ │DataStore │ │Agent Registry │ │
│ │ │ │(InMemory/ │ │(Multi-Agent │ │
│ │ │ │ Firebase/ │ │ Orchestration) │ │
│ │ │ │ Custom) │ │ │ │
│ └──────┘ └──────────┘ └────────────────┘ │
└─────────────────────────────────────────────────────────────┘
The agent receives a user message, builds a structured prompt (including conversation history from the DataStore and available tools from the ToolRegistry), sends it to the LLM, and parses the response into one of three actions:
- Direct response — returns text to the user
- Tool invocation — runs one or more tools, optionally iterating up to 5 times before producing a final answer
- Agent delegation — hands the task to a chain of other agents, each passing its output to the next
Add to your pubspec.yaml:
dependencies:
agenix: ^4.0.0If you need Firebase persistence, also add:
agenix_firebase: ^1.0.0Then run:
flutter pub getCreate assets/system_data.json with your agent's personality and background knowledge:
{
"name": "Lens",
"role": "A helpful assistant for the Acme platform",
"personality": "Friendly, concise, and knowledgeable",
"instructions": "Always greet the user by name when possible"
}Add the asset to your pubspec.yaml:
flutter:
assets:
- assets/system_data.jsonimport 'package:agenix/agenix.dart';
final agent = await Agent.create(
dataStore: DataStore.inMemory(),
llm: LLM.geminiLLM(
apiKey: 'YOUR_API_KEY',
modelName: 'gemini-2.0-flash',
),
name: 'Assistant',
role: 'General purpose assistant for the platform.',
);For Firebase persistence:
import 'package:agenix_firebase/agenix_firebase.dart';
final agent = await Agent.create(
dataStore: FirebaseDataStore(),
llm: LLM.geminiLLM(apiKey: 'YOUR_API_KEY', modelName: 'gemini-2.0-flash'),
name: 'Assistant',
role: 'General purpose assistant for the platform.',
);final response = await agent.generateResponse(
convoId: 'conversation-1',
userMessage: AgentMessage(
content: 'What is the weather like today?',
isFromAgent: false,
generatedAt: DateTime.now(),
),
);
print(response.content);flutter run -d chrome --dart-define=GEMINI_API_KEY=your-key-hereThe Agent is the central class. It wires together an LLM, a DataStore, a ToolRegistry, and an AgentScope.
final agent = await Agent.create(
dataStore: DataStore.inMemory(),
llm: LLM.geminiLLM(apiKey: key, modelName: 'gemini-2.0-flash'),
name: 'Support Agent',
role: 'Handles customer support queries for the e-commerce platform.',
failureMode: FailureMode.throwError, // or FailureMode.gracefulMessage (default)
onError: (error, stack) => logger.severe('Agent error', error, stack),
scope: AgentScope.global, // default — or create isolated scopes
registrationPolicy: RegistrationPolicy.throwIfExists, // default
);Key methods:
| Method | Description |
|---|---|
generateResponse(convoId, userMessage) |
Send a user message and get back an AgentMessage from the agent |
getMessages(conversationId) |
Retrieve all messages in a conversation |
getAllConversations() |
List all conversations for the current user |
deleteConversation(conversationId) |
Delete a conversation and its messages |
dispose() |
Unregister the agent from its scope |
Parameters for generateResponse:
| Parameter | Type | Default | Description |
|---|---|---|---|
convoId |
String |
required | Conversation identifier |
userMessage |
AgentMessage |
required | The user's message |
memoryLimit |
int |
10 |
Max previous messages loaded as context |
metaData |
Object? |
null |
Opaque pass-through for auth tokens, tenant IDs, etc. |
The LLM abstract class defines the contract for language model providers. Agenix ships with Gemini; implement the interface for other providers.
// Built-in Gemini
final llm = LLM.geminiLLM(
apiKey: 'YOUR_API_KEY',
modelName: 'gemini-2.0-flash',
config: LlmConfig(
temperature: 0.2, // Low for structured JSON output
maxOutputTokens: 2048,
topP: 0.95,
topK: 40,
jsonMode: true, // Request native JSON output mode
timeout: Duration(seconds: 60),
),
);Implementing a custom LLM:
class MyCustomLLM implements LLM {
@override
final String modelId = 'my-model-v1';
@override
final LlmConfig config;
MyCustomLLM({this.config = const LlmConfig()});
@override
Future<String> generate({
required String prompt,
String? systemInstruction,
Uint8List? rawData,
String mimeType = 'image/png',
}) async {
// Call your model API here
// Must return a JSON string matching one of:
// {"response": "..."}
// {"tools": "tool1, tool2", "parameters": {...}}
// {"agents_chain": ["agent1", "agent2"]}
}
}The DataStore abstract class handles conversation persistence. Messages are saved after each generateResponse call, and loaded as context for future prompts.
Built-in implementations:
| Package | DataStore | Use Case |
|---|---|---|
agenix |
DataStore.inMemory() |
Testing, prototyping, or non-persistent apps |
agenix_firebase |
FirebaseDataStore() |
Production apps with Firebase backend |
Firebase setup: Add agenix_firebase to your pubspec, ensure Firebase.initializeApp() is called before creating the data store, and sign in a user via firebase_auth.
Implementing a custom DataStore:
class PostgresDataStore extends DataStore {
@override
Future<void> saveMessage(String convoId, AgentMessage msg, {Object? metaData}) async {
// INSERT INTO messages ...
}
@override
Future<List<AgentMessage>> getMessages(String conversationId, {int? limit, Object? metaData}) async {
// SELECT * FROM messages WHERE convo_id = ? ORDER BY generated_at LIMIT ?
}
@override
Future<void> deleteConversation(String conversationId, {Object? metaData}) async {
// DELETE FROM messages WHERE convo_id = ?
}
@override
Future<List<Conversation>> getConversations({Object? metaData}) async {
// SELECT DISTINCT convo_id, last_message, last_message_time FROM ...
}
}Tools let the agent perform actions beyond conversation — API calls, database queries, calculations, anything.
Lifecycle:
User Message → LLM decides tool is needed → Agent runs tool → Tool returns ToolResponse
→ Agent either returns result OR iterates (up to 5 rounds of tool calls)
class NewsTool extends Tool {
NewsTool() : super(
name: 'news_tool',
description: 'Fetches the latest news headlines.',
);
@override
Future<ToolResponse> run(Map<String, dynamic> params) async {
final headlines = await NewsApi.fetchHeadlines();
return ToolResponse(
toolName: name,
isRequestSuccessful: true,
message: 'Here are today\'s headlines: ${headlines.join(", ")}',
needsFurtherReasoning: true, // Agent will synthesize a natural-language answer
);
}
}class WeatherTool extends Tool {
WeatherTool() : super(
name: 'weather_tool',
description: 'Gets current weather for a given location.',
parameters: [
ParameterSpecification(
name: 'location',
type: 'string',
description: 'City name or coordinates.',
required: true,
),
ParameterSpecification(
name: 'units',
type: 'string',
description: 'Temperature unit.',
required: false,
defaultValue: 'celsius',
enumValues: ['celsius', 'fahrenheit'],
),
],
);
@override
Future<ToolResponse> run(Map<String, dynamic> params) async {
final location = params['location'] as String;
final units = params['units'] as String? ?? 'celsius';
final weather = await WeatherApi.get(location, units: units);
return ToolResponse(
toolName: name,
isRequestSuccessful: true,
message: 'Weather in $location: ${weather.temp}° ${weather.condition}',
data: weather.toMap(), // Optional structured data for chaining
);
}
}agent.toolRegistry.registerTool(NewsTool());
agent.toolRegistry.registerTool(WeatherTool());
// Dynamically remove a tool
agent.toolRegistry.unregisterTool('weather_tool');| Field | Type | Description |
|---|---|---|
toolName |
String |
Name of the tool that produced this response |
isRequestSuccessful |
bool |
Whether the tool operation succeeded |
message |
String |
Human-readable result shown to the user |
data |
Map? |
Structured data for agent chaining or further reasoning |
needsFurtherReasoning |
bool |
When true, the agent makes a second LLM call to synthesize the tool output into a natural-language answer |
When the LLM determines a task requires multiple specialists, it returns an agents_chain. Agenix automatically delegates sub-tasks across agents, passing each agent's output as input to the next.
// Create specialized agents
final newsAgent = await Agent.create(
dataStore: DataStore.inMemory(),
llm: llm,
name: 'News Agent',
role: 'Fetches and summarizes news articles.',
);
final favouritesAgent = await Agent.create(
dataStore: DataStore.inMemory(),
llm: llm,
name: 'Favourites Agent',
role: 'Manages user favourites: add, remove, and list.',
);
final orchestrator = await Agent.create(
dataStore: DataStore.inMemory(),
llm: llm,
name: 'Orchestrator',
role: 'Main user-facing agent. Delegates to News Agent and Favourites Agent.',
);
// Register tools on each agent as needed
newsAgent.toolRegistry.registerTool(NewsTool());
favouritesAgent.toolRegistry.registerTool(AddFavouriteTool());
favouritesAgent.toolRegistry.registerTool(ListFavouritesTool());How chaining works:
User: "Save the top headline to my favourites"
┌──────────────┐ ┌────────────┐ ┌──────────────────┐
│ Orchestrator │────►│ News Agent │────►│ Favourites Agent │
│ │ │ │ │ │
│ Decides chain│ │ Fetches │ │ Saves headline │
│ [News, Favs] │ │ headlines │ │ to favourites │
└──────────────┘ └────────────┘ └──────────────────┘
│ │
│ output passes as │
│ input to next ──────┘
│
▼
Final response
back to user
Safety guardrails:
- Cycle detection — if an agent appears twice in the same chain, a
ConfigExceptionis thrown - Depth limiting — chains are capped at 5 levels deep (
kMaxChainDepth)
By default, all agents register in AgentScope.global and can discover each other for chaining. Use custom scopes to isolate agent groups:
// Isolated scope for testing
final testScope = AgentScope();
final agentA = await Agent.create(
dataStore: DataStore.inMemory(),
llm: llm,
name: 'Agent A',
role: 'Test agent A.',
scope: testScope,
);
final agentB = await Agent.create(
dataStore: DataStore.inMemory(),
llm: llm,
name: 'Agent B',
role: 'Test agent B.',
scope: testScope,
);
// Agents in testScope can chain to each other, but NOT to agents in AgentScope.globalRegistrationPolicy controls what happens when an agent name collides:
| Policy | Behavior |
|---|---|
throwIfExists |
Throws ConfigException (default — catches accidental duplicates) |
replace |
Silently replaces the existing agent |
ignore |
Keeps the existing agent, discards the new one |
Agenix uses a sealed exception hierarchy. Every exception is an AgenixException, so you can exhaustively match on the type:
try {
final response = await agent.generateResponse(
convoId: 'convo-1',
userMessage: message,
);
} on LlmTimeoutException catch (e) {
// LLM call exceeded the configured timeout
} on ResponseParseException catch (e) {
// LLM returned malformed output after retries
print('Raw output: ${e.rawOutput}');
} on ToolNotFoundException catch (e) {
// LLM referenced a tool that isn't registered
print('Missing tool: ${e.toolName}');
} on ToolExecutionException catch (e) {
// A registered tool threw during execution
print('Tool ${e.toolName} failed: ${e.message}');
} on AgentNotFoundException catch (e) {
// Agent chain referenced a non-existent agent
} on DataStoreException catch (e) {
// Persistence operation failed
} on NotAuthenticatedException {
// Firebase operation without a signed-in user (from agenix_firebase)
} on ConfigException catch (e) {
// Invalid configuration (bad system_data.json, duplicate agent name, etc.)
}FailureMode controls the behavior at the generateResponse boundary:
| Mode | Behavior |
|---|---|
FailureMode.gracefulMessage |
Returns an AgentMessage with isError: true (default — safe for UI) |
FailureMode.throwError |
Rethrows the typed AgenixException (use when you want full control) |
The onError callback fires in both modes, so you can always log errors centrally:
final agent = await Agent.create(
// ...
failureMode: FailureMode.gracefulMessage,
onError: (error, stack) => crashlytics.recordError(error, stack),
);| Class | Package | Description |
|---|---|---|
Agent |
agenix |
Core agent with LLM, memory, tools, and multi-agent orchestration |
AgentScope |
agenix |
Isolates groups of agents that can discover and chain to each other |
LLM |
agenix |
Abstract interface for language model providers |
LlmConfig |
agenix |
Provider-neutral generation settings (temperature, tokens, timeout, etc.) |
DataStore |
agenix |
Abstract interface for conversation persistence |
AgentMessage |
agenix |
A message in a conversation (user or agent) |
Conversation |
agenix |
Summary of a conversation (last message, timestamp, ID) |
Tool |
agenix |
Abstract class to extend for custom tools |
ParameterSpecification |
agenix |
Defines a tool parameter (name, type, required, default, enum) |
ToolResponse |
agenix |
Result returned from a tool execution |
ToolRegistry |
agenix |
Per-agent registry for managing available tools |
FirebaseDataStore |
agenix_firebase |
Firestore + Storage + Auth data store implementation |
| Enum | Values | Description |
|---|---|---|
FailureMode |
throwError, gracefulMessage |
Controls error surfacing behavior |
RegistrationPolicy |
throwIfExists, replace, ignore |
Controls duplicate agent name handling |
AgenixException (sealed)
├── LlmException
│ └── LlmTimeoutException
├── ResponseParseException
├── ToolNotFoundException
├── ToolExecutionException
├── AgentNotFoundException
├── DataStoreException
│ └── NotAuthenticatedException
└── ConfigException
| Constant | Value | Description |
|---|---|---|
kMaxToolIterations |
5 |
Max tool→observe→re-prompt cycles per turn |
kMaxParseRetries |
2 |
Max corrective re-prompts for malformed JSON |
kMaxChainDepth |
5 |
Max depth for agent chain delegation |
The simplest setup — one agent handling all user interactions.
┌──────────┐ ┌───────┐ ┌───────┐ ┌───────────┐
│ Flutter │────►│ Agent │────►│ LLM │ │ DataStore │
│ UI │◄────│ │◄────│ │ │(InMemory/ │
└──────────┘ │ │ └───────┘ │ Firebase) │
│ │──── save/load ────►└───────────┘
└───────┘
Best for: chatbots, Q&A apps, customer support widgets.
The agent can call external APIs through tools.
┌──────────┐ ┌───────┐ ┌───────┐
│ Flutter │────►│ Agent │────►│ LLM │
│ UI │◄────│ │◄────│ │
└──────────┘ │ │ └───────┘
│ │
│ ToolRegistry
│ ├── WeatherTool ──► Weather API
│ ├── NewsTool ──► News API
│ └── DbTool ──► Database
└───────┘
Best for: apps where the agent needs to fetch real-time data or trigger actions.
Multiple specialized agents collaborating on complex tasks.
┌──────────┐ ┌──────────────┐
│ Flutter │────►│ Orchestrator │
│ UI │◄────│ │
└──────────┘ └──────┬───────┘
│ delegates via agents_chain
┌─────────┼─────────┐
▼ ▼ ▼
┌─────────┐ ┌────────┐ ┌───────────┐
│ Search │ │ Booking│ │ Favourites│
│ Agent │ │ Agent │ │ Agent │
│ + tools │ │ + tools│ │ + tools │
└─────────┘ └────────┘ └───────────┘
Best for: complex platforms where different domains require specialized knowledge and tools.
Use InMemoryDataStore and scoped agents for fast, isolated development.
final scope = AgentScope();
final agent = await Agent.create(
dataStore: DataStore.inMemory(), // No Firebase needed
llm: LLM.geminiLLM(apiKey: key, modelName: 'gemini-2.0-flash'),
name: 'Test Agent',
role: 'Agent under test.',
scope: scope, // Isolated from production agents
);| Example | Description |
|---|---|
| Multi-Agent System | Three agents (Orchestrator, News, Favourites) working together |
| Firebase Example | Single agent with tools and Firebase persistence |
| Custom DataStore | Implementing your own persistence backend |
In this example three agents collaborate:
- Orchestrator — communicates with the end user
- News Agent — handles News API operations
- Favourites Agent — manages user favourites (add, remove, list)
multi_agents_system.mov
An agentic app powered by "Lens" — an AI agent with a specific personality that can answer platform questions and perform tasks using tools.

