Why extensions?
The DevFlow Agent Protocol covers the universal dev-loop surface — visual tree, taps, screenshots, network, logs. But every app has library-specific surface area an AI agent benefits from: feature flags, analytics, auth state, queues, in-app purchases, custom navigation. Extensions are how a library exposes that surface to agents without forking the protocol.
The pattern is simple: a library registers a namespace and a handful of tools. The agent advertises them. The CLI (and any MCP client) calls them just like built-in capabilities.
Namespaces & routes
Every extension owns a reverse-domain namespace like com.acme.featureflags. All routes you register live under:
/api/v1/ext/{namespace}/{your-route}
So a route registered as /flags on namespace com.acme.featureflags is reachable at /api/v1/ext/com.acme.featureflags/flags.
Naming rules
- Use lowercase, dot-separated segments. The agents validate against
^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$. - Pick a namespace you control — your company domain reversed (
com.acme.x), or your package's reverse name (io.sentry.devflow). - Treat the namespace as a stable contract. Bumping the major version is a breaking change for any agent that integrated against it.
Anatomy of an extension
Every extension declares the same things, regardless of platform:
| Field | Purpose |
|---|---|
namespace | Reverse-domain identifier. Must be unique on a given agent. |
description | One-line summary surfaced to AI clients. |
version | Your extension's version string. Bump on breaking changes. |
features | String tags describing what your extension does ("flags", "sessions"). |
tools | Per-route descriptors with name, description, parameter JSON schema, and annotations. These show up in /api/v1/agent/capabilities. |
routes / handlers | The HTTP method + path + handler function that actually responds to requests. |
Build your first extension
Below is the same contrived com.acme.featureflags extension implemented in each supported platform — two routes, GET /flags and POST /flags/override.
In MAUI, you register the extension on the AgentOptions instance passed to AddMauiDevFlowAgent. The library author typically exposes an extension method so the app developer's wire-up is a single call.
Inside your library:
using Ailoha.Agent.Core;
namespace Acme.FeatureFlags.Ailoha;
public static class AcmeFeatureFlagsAgentExtensions
{
public static AgentOptions UseAcmeFeatureFlags(
this AgentOptions options,
IFeatureFlagService flags)
{
var ext = options.RegisterExtension(
"com.acme.featureflags",
"Inspect and override Acme feature flags at runtime.",
version: "1.0.0",
features: new[] { "flags", "overrides" });
ext.MapTool(
name: "list",
description: "List all known flags and their current values.",
method: "GET",
path: "/flags",
handler: _ => Task.FromResult(HttpResponse.Json(new
{
flags = flags.Snapshot()
})),
annotations: new ExtensionToolAnnotations
{
ReadOnly = true,
Idempotent = true,
Category = "inspect"
});
ext.MapTool(
name: "override",
description: "Override a single flag value for this session.",
method: "POST",
path: "/flags/override",
handler: async req =>
{
var body = JsonSerializer.Deserialize<OverrideRequest>(req.Body ?? "{}");
flags.Override(body!.Key, body.Value);
return HttpResponse.Json(new { ok = true, body.Key, body.Value });
},
parameters: JsonDocument.Parse("""
{
"type": "object",
"required": ["key", "value"],
"properties": {
"key": { "type": "string" },
"value": { "type": ["string", "boolean", "number"] }
}
}
""").RootElement,
annotations: new ExtensionToolAnnotations
{
ReadOnly = false,
Destructive = false,
Category = "mutate"
});
return options;
}
private sealed record OverrideRequest(string Key, object Value);
}
App developer's wire-up:
#if DEBUG
builder.AddMauiDevFlowAgent(options =>
{
options.UseAcmeFeatureFlags(flagsService);
});
#endif
In Flutter, an extension is a value passed via AgentOptions.extensions. A library typically exposes a factory function so the call site stays a one-liner.
Inside your library:
import 'package:ailoha_agent/ailoha_agent.dart' as ailoha;
ailoha.AgentExtension acmeFeatureFlagsExtension(
FeatureFlagService flags,
) {
return ailoha.AgentExtension(
namespace: 'com.acme.featureflags',
description: 'Inspect and override Acme feature flags at runtime.',
version: '1.0.0',
features: const ['flags', 'overrides'],
routes: [
ailoha.AgentExtensionRoute.get(
'/flags',
(request) async => { 'flags': flags.snapshot() },
name: 'list',
description: 'List all known flags and their current values.',
annotations: const ailoha.ExtensionToolAnnotations(
readOnly: true,
idempotent: true,
category: 'inspect',
),
),
ailoha.AgentExtensionRoute.post(
'/flags/override',
(request) async {
final body = await ailoha.AgentServer.parseJsonBody(request);
final key = body?['key'] as String?;
final value = body?['value'];
if (key == null) return { 'error': 'key required' };
flags.override(key, value);
return { 'ok': true, 'key': key, 'value': value };
},
name: 'override',
description: 'Override a single flag value for this session.',
parameters: const {
'type': 'object',
'required': ['key', 'value'],
'properties': {
'key': { 'type': 'string' },
'value': { 'type': ['string', 'boolean', 'number'] },
},
},
annotations: const ailoha.ExtensionToolAnnotations(
readOnly: false,
destructive: false,
category: 'mutate',
),
),
],
);
}
App developer's wire-up:
if (kDebugMode) {
await ailoha.initAgent(ailoha.AgentOptions(
extensions: [
acmeFeatureFlagsExtension(flagsService),
],
));
}
In React Native, extensions are passed to initAgent({ extensions: [...] }). A library typically exports a factory function that returns a plain extension object.
Inside your library (TypeScript):
import type { AgentExtension } from '@ailoha/react-native';
import type { FeatureFlagService } from './FeatureFlagService';
export function acmeFeatureFlagsExtension(
flags: FeatureFlagService,
): AgentExtension {
return {
namespace: 'com.acme.featureflags',
description: 'Inspect and override Acme feature flags at runtime.',
version: '1.0.0',
features: ['flags', 'overrides'],
tools: [
{
name: 'list',
description: 'List all known flags and their current values.',
method: 'GET',
path: '/api/v1/ext/com.acme.featureflags/flags',
annotations: { readOnly: true, idempotent: true, category: 'inspect' },
},
{
name: 'override',
description: 'Override a single flag value for this session.',
method: 'POST',
path: '/api/v1/ext/com.acme.featureflags/flags/override',
parameters: {
type: 'object',
required: ['key', 'value'],
properties: {
key: { type: 'string' },
value: { type: ['string', 'boolean', 'number'] },
},
},
annotations: { readOnly: false, destructive: false, category: 'mutate' },
},
],
routes: {
'GET /flags': () => ({ flags: flags.snapshot() }),
'POST /flags/override': (request) => {
const body = (request.body ?? {}) as { key?: string; value?: unknown };
if (!body.key) return { error: 'key required' };
flags.override(body.key, body.value);
return { ok: true, key: body.key, value: body.value };
},
},
};
}
App developer's wire-up:
if (__DEV__) {
const { initAgent } = require('@ailoha/react-native');
initAgent({
extensions: [
acmeFeatureFlagsExtension(flagsService),
],
});
}
The Expo agent is the same package as React Native, so the extension authoring surface is identical. The only difference is where you initialize: from your App.tsx in a custom dev client or EAS build.
Use the React Native code on the previous tab to author the extension, then wire it up like this in your Expo app:
function initializeAgent(): void {
if (!__DEV__) return;
try {
const { initAgent } = require('@ailoha/react-native');
initAgent({
extensions: [
acmeFeatureFlagsExtension(flagsService),
],
});
} catch (e: any) {
console.error('[Ailoha] Failed to init agent:', e?.message);
}
}
RegisterExtension API as MAUI in an upcoming release. In the meantime, you can ship custom HTTP routes by extending WinUiDevFlowAgent directly — but the public extension surface isn't stable yet.
Discovery
Once your extension is registered, the agent advertises it from GET /api/v1/agent/capabilities. AI clients use this to discover what tools are available without prior knowledge of your library:
GET /api/v1/agent/capabilities
{
"extensions": {
"count": 1,
"hash": "sha256-...",
"items": [
{
"namespace": "com.acme.featureflags",
"description": "Inspect and override Acme feature flags at runtime.",
"version": "1.0.0",
"features": ["flags", "overrides"],
"tools": [
{
"name": "list",
"description": "List all known flags and their current values.",
"method": "GET",
"path": "/api/v1/ext/com.acme.featureflags/flags",
"annotations": { "readOnly": true, "idempotent": true, "category": "inspect" }
},
{
"name": "override",
"description": "Override a single flag value for this session.",
"method": "POST",
"path": "/api/v1/ext/com.acme.featureflags/flags/override",
"parameters": { "type": "object", "required": ["key", "value"], "properties": {} },
"annotations": { "readOnly": false, "category": "mutate" }
}
]
}
]
}
}
hash and only re-fetch when it changes. Bump your version when you change tool shapes so caches invalidate cleanly.Annotations & parameters
Annotations help AI clients reason about safety and intent before they call your tool. Be honest — clients use these to gate destructive calls and to plan tool sequences.
| Annotation | Meaning |
|---|---|
readOnly | The tool returns data without changing app state. |
idempotent | Calling the tool more than once with the same args has the same effect as calling it once. |
destructive | The tool deletes data, cancels work, or otherwise has irreversible side effects. Many clients require explicit confirmation for destructive tools. |
category | Free-form grouping ("inspect", "mutate", "diagnose") used in tool pickers. |
Parameters are a JSON Schema fragment describing the request body. Keep schemas tight — agents respond much better to { "key": "string", "value": "string|bool|number" } than to a free-form object. The CLI surfaces validation errors when a call doesn't match.
Best practices for library authors
- Stay narrow. A few well-named JSON endpoints almost always beat exposing your full SDK surface. AI agents reason better about small, well-described tools.
- Use stable namespaces. Treat your namespace like an API contract. Don't squat on someone else's reverse domain.
- Be precise in descriptions. Tool descriptions are the prompt the AI sees — write them in plain language, lead with the verb, mention units.
- Mark destructive paths. Set
destructive: trueon anything that wipes state, cancels orders, or invalidates caches. Clients use this to ask for human confirmation. - Ship the wire-up as a one-liner. Provide a
UseFooBar()extension method (or factory function) so the app developer never has to learn your protocol shape. - Stay in DEBUG. Your registration code should run in the same debug-only block as the agent itself. Don't expose extensions in release builds.
- Validate input. The agent does basic JSON parsing, but your handler is responsible for guarding against bad shapes — return a structured error instead of throwing.
Distribution
The cleanest distribution model is a thin companion package that depends on both your library and the Ailoha agent for the relevant platform:
Acme.FeatureFlags— your existing library.Acme.FeatureFlags.Ailoha— depends onAcme.FeatureFlagsandAiloha.Agent.Maui. ExposesUseAcmeFeatureFlags().
App developers who already use your library get Ailoha support by adding one extra dev-only package and one line of code. Library users who don't care about Ailoha never pay a cost.
What's next
- Read the extensions section of the protocol spec for the full contract.
- Browse the route reference to see exactly how extensions show up alongside built-in capabilities.
- Pick a platform and add the agent to your sample app, then start prototyping your extension there.