ailoha.
Home/ Docs/ Building extensions
For library authors

Building extensions

Extensions let your library expose tools that AI agents can call directly. Define a reverse-domain namespace, declare a few tool descriptors, and ship the registration in your existing package — apps that already depend on you get Ailoha support for free.

All endpoints under /api/v1/ext/{namespace}/... Discovered via /api/v1/agent/capabilities JSON in, JSON out

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.

i
Read the extensions section of the protocol spec for the full contract. This page covers the practical authoring surface for each platform.

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:

FieldPurpose
namespaceReverse-domain identifier. Must be unique on a given agent.
descriptionOne-line summary surfaced to AI clients.
versionYour extension's version string. Bump on breaking changes.
featuresString tags describing what your extension does ("flags", "sessions").
toolsPer-route descriptors with name, description, parameter JSON schema, and annotations. These show up in /api/v1/agent/capabilities.
routes / handlersThe 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);
  }
}
!
The agent doesn't run in Expo Go. Make sure you're running a custom dev client or an EAS dev build — see the Expo setup guide.
i
Coming soon. The WinUI agent will adopt the same 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" }
          }
        ]
      }
    ]
  }
}
i
Clients cache extension metadata by 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.

AnnotationMeaning
readOnlyThe tool returns data without changing app state.
idempotentCalling the tool more than once with the same args has the same effect as calling it once.
destructiveThe tool deletes data, cancels work, or otherwise has irreversible side effects. Many clients require explicit confirmation for destructive tools.
categoryFree-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: true on 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 on Acme.FeatureFlags and Ailoha.Agent.Maui. Exposes UseAcmeFeatureFlags().

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