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-runPrint 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.0my-ext-foo
  2. Strip motoko-ext- prefix if present (motoko_agent naming convention only): motoko-ext-exa-searchexa-search
  3. Replace - with _: exa-searchexa_search, or my-ext-foomy_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, baking your absolute path into ailang.lock. That breaks for any other contributor or CI runner.

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.