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
| Field | Required | Description |
|---|---|---|
packages | yes | name@version refs. Each must be present in ailang.lock. |
config_import | yes (when packages listed) | module/path.TypeName of the config type passed to each extension. |
hooks_import | yes (when packages listed) | module/path.TypeName of the returned hooks type. |
output | no | Write path for the generated file (default: registry_generated.ail). |
module_name | no | Override 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
| Flag | Default | Description |
|---|---|---|
--config FILE | ailang.toml | Path to manifest |
--output FILE | from ailang.toml | Override 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:
- Strip
@version:my-ext-foo@1.0.0→my-ext-foo - Strip
motoko-ext-prefix if present (motoko_agent naming convention only):motoko-ext-exa-search→exa-search - Replace
-with_:exa-search→exa_search, ormy-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 lockreads the package directly from the local checkout, baking your absolute path intoailang.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 ref | Key in resolve() |
|---|---|
motoko-ext-compaction@0.2.0 | compaction |
motoko-ext-exa-search@0.4.1 | exa_search |
motoko-ext-mcp@1.0.0 | mcp |
motoko-ext-web-search@0.3.0 | web_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 sameconfig_importandhooks_importtypes. Multiple hook contracts require separate generated files (use--outputto 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.