Skip to main content

Extension Packages

Extension packages let you build a plugin ecosystem for any AILANG application — independently-versioned packages that each register one unit of pluggable behaviour at compile time.

Why a generator? AILANG resolves all import statements at compile time: there is no dynamic module loading. To wire a variable set of extensions without hardcoding their import paths, you declare them in ailang.toml and run ailang generate-extension-registry to emit a static-import dispatch file. Regenerate whenever the extension list changes, then commit the result.

The feature is general-purpose. The motoko-ext- prefix in the examples reflects motoko_agent's naming convention; see Using with motoko_agent for the full walkthrough.


Convention: the register module contract​

Every extension package must export a single register_with_config function from a module named <package>/register:

-- my-ext-foo/register.ail
module my-ext-foo/register

import my-app-abi (AppHooks) -- your project's hooks type
import my-app-config (RuntimeConfig) -- your project's config type

export func register_with_config(cfg: RuntimeConfig) -> AppHooks ! {} {
-- return a hooks record wiring the extension's handlers
}

The config and hooks types are defined by the host application. All extensions for the same host share the same config_import / hooks_import types — that pair is your extension ABI.


ailang.toml schema​

[extensions]
packages = [
"my-ext-foo@1.0.0",
"my-ext-bar@2.3.1",
]
config_import = "my-app-config.RuntimeConfig" # module/path.TypeName
hooks_import = "my-app-abi.AppHooks" # module/path.TypeName
output = "src/ext/registry_generated.ail" # default: registry_generated.ail
FieldRequiredDescription
packagesyesname@version refs. Each must be present in ailang.lock.
config_importyes (when packages listed)module/path.TypeName of the config type passed to each extension.
hooks_importyes (when packages listed)module/path.TypeName of the returned hooks type.
outputnoWrite path for the generated file (default: registry_generated.ail).
module_namenoOverride the generated module declaration (default: output path minus .ail).

Validation: if any packages are listed, both config_import and hooks_import are required — the manifest will reject an incomplete [extensions] block.


Running the generator​

# 1. Add packages to [extensions] and pin them
ailang lock

# 2. Generate the dispatch file
ailang generate-extension-registry

# 3. Verify it compiles
ailang check src/ext/registry_generated.ail

# 4. Commit both
git add ailang.lock src/ext/registry_generated.ail

Flags​

FlagDefaultDescription
--config FILEailang.tomlPath to manifest
--output FILEfrom ailang.tomlOverride write location (module name still derived from the TOML output field)
--dry-run—Print to stdout, don't write
# Preview without writing
ailang generate-extension-registry --dry-run

What gets generated​

For two extensions with config_import = "my-app-config.RuntimeConfig" and hooks_import = "my-app-abi.AppHooks":

-- GENERATED by ailang generate-extension-registry
-- Source: ailang.toml [extensions]
-- Do not edit. Regenerate: ailang generate-extension-registry

module src/ext/registry_generated

import my-ext-foo/register (register_with_config) as register_foo
import my-ext-bar/register (register_with_config) as register_bar
import my-app-config (RuntimeConfig)
import my-app-abi (AppHooks)
import std/option (Option, Some, None)

export func resolve(name: string, cfg: RuntimeConfig) -> Option[AppHooks] {
if name == "foo" then Some(register_foo(cfg))
else if name == "bar" then Some(register_bar(cfg))
else None
}

Short name derivation​

The generator derives the string key used in resolve() and the import alias:

  1. Strip @version: my-ext-foo@1.0.0 → my-ext-foo
  2. Strip motoko-ext- prefix if present (motoko_agent naming convention only): motoko-ext-exa-search → exa-search
  3. Replace - with _: exa-search → exa_search, or my-ext-foo → my_ext_foo

For packages not using the motoko-ext- convention, step 2 is a no-op and the full package name (minus @version, with - replaced) becomes the key.

The generated file is deterministic​

It depends only on ailang.toml and ailang.lock, so two identical inputs always produce byte-identical output. Commit it alongside the lock file — builds stay reproducible without running the generator in CI.


Using with motoko_agent​

motoko_agent is the reference host application for this feature. The full setup:

1. ABI package: motoko-ext-abi​

The ABI package publishes two types shared by all extensions:

-- motoko-ext-abi/types.ail
module motoko-ext-abi

-- The return type every extension must produce
export type ExtensionHooks = {
on_describe_tools: Option[(string, string) -> list[ToolSpec] ! {IO}],
-- ... other hook slots
}

Every motoko-ext-* package depends on motoko-ext-abi. Bumping ExtensionHooks is a major version of motoko-ext-abi.

2. Extension package structure​

motoko-ext-compaction/
ailang.toml ← declares motoko-ext-abi as dep
register.ail ← exports register_with_config
compaction.ail ← the actual compaction logic

ailang.toml for an extension:

[package]
name = "motoko-ext-compaction"
version = "0.2.0"
edition = "2025"

[dependencies]
"motoko-ext-abi" = "1.0.0"
"motoko_agent/src" = { path = "../motoko_agent" } # dev: path dep; published: registry

[exports]
modules = ["motoko-ext-compaction/register"]
-- motoko-ext-compaction/register.ail
module motoko-ext-compaction/register

import motoko-ext-abi (ExtensionHooks)
import src/core/config (RuntimeConfig)
import motoko-ext-compaction/compaction (compact_step)

export func register_with_config(cfg: RuntimeConfig) -> ExtensionHooks ! {} {
{
on_describe_tools: Some(compact_step.describe_tools),
-- wire other hooks
}
}

3. Host application ailang.toml​

[package]
name = "motoko_agent/src"
version = "0.5.0"
edition = "2025"

[dependencies]
"sunholo/motoko_ext_abi" = "1.0.0"

[extensions]
packages = [
"sunholo/motoko_ext_compaction@0.2.0",
"sunholo/motoko_ext_exa_search@0.4.1",
]
config_import = "src/core/config.RuntimeConfig"
hooks_import = "src/core/ext/types.ExtensionHooks"
output = "src/core/ext/registry_generated.ail"

Use registry versions, not local paths. Declaring "sunholo/motoko_ext_foo" = "0.1.1" resolves the package from the AILANG package registry — the lock file gets "source": "registry" and the build is portable across machines.

The { path = "../ailang-packages/packages/motoko-ext-foo" } form exists for package-author dev loops (editing the package and the host together): ailang lock reads the package directly from the local checkout and records the path relative to your ailang.toml. That is portable only when the dependency lives in the same repo (e.g. sim/ and ai/ side by side); a sibling checkout like ../ailang-packages is absent for other contributors and CI runners, so the build breaks there.

Before opening a PR or shipping a release, swap path-based deps to registry versions and re-lock. See the path vs registry checklist below.

4. Generated file location​

src/core/ext/registry_generated.ail — imported by the agent loop wherever extension hooks are looked up:

import src/core/ext/registry_generated (resolve)

-- Usage in agent_loop_v2.ail:
let hooks = resolve(extension_name, cfg)

5. Workflow for adding a new extension to motoko_agent​

Recommended (AILANG ≥ 0.18.5): scaffold the extension package in one command, then wire it into the host:

# 1. Scaffold the package (in your ailang-packages clone)
cd ../ailang-packages
ailang init motoko-extension \
--name <namespace>/motoko_ext_<short> \
--tools "Tool1,Tool2" \
--effects "FS,Process,Env"
# → creates packages/motoko-ext-<short>/ with 5 files, ready to type-check

# 2. Edit packages/motoko-ext-<short>/<short>.ail to fill in your tool's logic

# 3. Wire into motoko_agent's ailang.toml (path-dep during dev; swap to
# registry version before opening a PR):
# [dependencies]
# "<namespace>/motoko_ext_<short>" = { path = "../ailang-packages/packages/motoko-ext-<short>" }
# [extensions]
# packages = [ ..., "<namespace>/motoko_ext_<short>@0.1.0" ]

# 4. Re-lock + regenerate dispatch
cd ../motoko_agent
ailang lock
ailang generate-extension-registry

# 5. Type-check + commit
make check_core
git add ailang.toml ailang.lock src/core/ext/registry_generated.ail
git commit -m "Add motoko-ext-<short> extension"

For a step-by-step walkthrough including the contents of each generated file, see Build Your First motoko Extension.

Manual scaffolding (any AILANG version): see the appendix in the build tutorial for the explicit file-by-file walkthrough.

Path vs registry checklist​

Before opening a PR (or any time you want a portable lock file), verify your [dependencies] block is registry-resolved:

# 1. Inspect the lock file — every package should have "source": "registry"
jq '[.packages[] | {name, version, source}]' ailang.lock

# 2. If any have "source": "path" with an absolute path under your home dir,
# edit ailang.toml to declare the registry version instead:
# WRONG: "sunholo/motoko_ext_foo" = { path = "../ailang-packages/packages/motoko-ext-foo" }
# RIGHT: "sunholo/motoko_ext_foo" = "0.1.1"

# 3. Re-lock and verify
ailang lock
jq '[.packages[] | select(.source=="path")]' ailang.lock
# Output should be `[]` — no path-sourced packages

# 4. Type-check still passes
ailang check src/core/<your_module>.ail # or your project's equivalent

Why the trap exists. In package-author dev loops you frequently want to edit a package and immediately consume the change in the host. The { path = ... } form does that without a publish round-trip. But once you stop iterating, the path is dead weight — it makes the lock file non-portable and breaks any external clone (PR reviewer, CI runner, fresh contributor).

The fix is mechanical (TOML edit + ailang lock) but easy to forget. Make this part of your pre-PR checklist.

Short name table for motoko_agent​

Package refKey in resolve()
motoko-ext-compaction@0.2.0compaction
motoko-ext-exa-search@0.4.1exa_search
motoko-ext-mcp@1.0.0mcp
motoko-ext-web-search@0.3.0web_search

Using with other projects​

The generator is not motoko-specific. Any AILANG application with pluggable behaviour can use the same pattern. The only motoko-specific detail is the motoko-ext- prefix shortcut in short-name derivation — for other naming conventions the full package name (minus version, dashes replaced with underscores) becomes the key.

Example for a hypothetical CMS application:

[extensions]
packages = [
"acme-plugin-markdown@1.0.0",
"acme-plugin-syntax-highlight@0.5.0",
]
config_import = "cms/config.SiteConfig"
hooks_import = "cms/plugin-abi.PluginHooks"
output = "src/plugins/registry_generated.ail"

Generated keys: acme_plugin_markdown, acme_plugin_syntax_highlight.

If you want cleaner keys, use module_name to override the module declaration and prefix-strip your names before @version doesn't apply — or adopt a naming convention where the key-relevant part comes after a stable prefix you can strip. Alternatively, name your packages my-plugin-markdown@1.0.0 and the key is simply my_plugin_markdown.


Limitations (v0.17.1)​

  • All extensions in a single [extensions] block must share the same config_import and hooks_import types. Multiple hook contracts require separate generated files (use --output to give each one a different path).
  • The resolve() function does a linear scan; for large extension sets consider a map-based wrapper in your host code.
  • Only the motoko-ext- prefix is stripped automatically. Custom prefix stripping is not yet configurable.