Skip to main content

Creating .atool Packages (Tools and Agents)

Practical guide to authoring Alexandria packages. Grounded in the code in crates/alex-package/ and crates/alex-cli/src/commands/install.rs.

There is no separate .aagent format. Alexandria has a single unified package format — .atool — and the kind field in the manifest distinguishes tools, skills, and agents. Agents ship as .atool files with "kind": "agent".

Manifests use schema v2. Schema v1 packages (with llm-runtime, llm-backend, bundle kinds, or top-level model / model_hint fields) are rejected by the installer with a clear migration error — run alex-sdk migrate to upgrade.


1. On-disk format​

An .atool file is a gzipped tar archive with atool.json at the root.

mytool-0.1.0.atool # gzipped tar
├── atool.json # required manifest, JSON
├── bin/mytool # files referenced by manifest.files[]
├── share/schema.proto # optional supporting files
└── ...

Source of truth: crates/alex-package/src/lib.rs (pack, verify, install).

Naming convention (informal — the installer does not enforce it):

{name}-{version}.atool
{name}_{version}_{arch}.atool # if the binary is arch-specific

2. The atool.json manifest​

Defined in crates/alex-package/src/manifest.rs. Minimum required keys:

FieldTypeRequiredNotes
schema_versionstringyesAlways "2".
namestringyes"vendor/name" or just "name".
versionstringyesSemVer.
kindstringyesOne of tool, skill, agent.
descriptionstringyes
configobjectyesShape depends on kind — see below.
authorstringno
licensestringno
requires_alexandriastringnoMinimum Alexandria version.
dependenciesarrayno[{ "name": "...", "version": "..." }] — version is required.
filesarraynoFiles included; see §2.1.
permissionsobjectnoprovides_tools, needs_tools, suggested_role.
signatureobjectnoEd25519 signing block; absent = unsigned.

2.1 files[] entries​

{
"archive_path": "bin/mytool", // path inside the tarball
"install_path": "tools/mytool", // where to extract, relative to data_dir
"executable": true, // applies 0o755 on Unix
"sha256": "" // pack() fills this in automatically
}

pack() computes and overwrites the SHA-256 for every declared file at pack time. verify() re-checks them on the install path.

2.2 config shape per kind​

The config object is { "kind": "<same-as-top-level>", ...fields }. The nested kind is the serde tag from Config (alias PackageConfig).

Tool (Kind::Tool → ToolConfig):

"config": {
"kind": "tool",
"binary": "bin/mytool", // required, relative to archive root
"default_port": 7800, // Option<u16>: null = unset, 0 = error
"transport": "http", // "http" | "sse"
"args": ["--flag"],

// Optional k8s tier — only meaningful when k8s_image is non-empty.
"k8s_image": "registry.example.com/mytool:1.2.3",
"k8s_capabilities": ["fs.read", "net"],
"k8s_port": 9000, // default 9000
"k8s_transport": "grpc", // "grpc" | "http" | "sse"; default "grpc"
"k8s_resources": {
"requests": { "cpu": "100m", "memory": "128Mi" },
"limits": { "cpu": "500m", "memory": "512Mi" }
},
"k8s_min_warm": 1, // 0 = scale-to-zero (planned)
"k8s_idle_timeout_seconds": 300
}

Skill (Kind::Skill → SkillConfig):

"config": {
"kind": "skill",
"system_prompt": "...",
"allowed_tools": ["..."],
"llm": "claude-opus-4-7", // Option<String>: null = orchestrator default
"tags": ["research", "writing"]
}

Agent (Kind::Agent → AgentConfig):

"config": {
"kind": "agent",
"system_prompt": "You are ...",
"allowed_tools": ["pdf-parser", "web-search"],
"llm": "claude-opus-4-7", // Option<String>: null = orchestrator default
"history_limit": 50,

// Composition — sub-agents, sub-skills, and ref'd tools.
"components": [
{
"name": "summariser",
"id": "acme/summariser@0.1.0",
"kind": "skill",
"config": { "kind": "skill", "system_prompt": "...", "llm": null }
},
{ "ref": "acme/web-search@1.0.0" }
],
"install": {
"flatten": {
"system_prompt": "concat", // "concat" | "root_wins" | "error_on_conflict"
"allowed_tools": "union" // "union" | "root_wins"
}
}
}

llm field rules: null means no preference — the orchestrator picks. During flatten, the root agent's llm always wins (including null), so sub-component preferences never override the package author's choice.

Components: inline components with kind: "tool" are rejected at validate time. Tools must always be RefComponent (the { "ref": "..." } form). Only sub-agents and sub-skills can be inline.


3. Authoring a tool package​

3.1 Directory layout​

Lay out a source directory matching the archive_paths you will declare:

mytool-src/
├── atool.json
└── bin/
└── mytool # the daemon binary

3.2 atool.json​

{
"schema_version": "2",
"name": "acme/mytool",
"version": "0.1.0",
"kind": "tool",
"description": "Example MCP tool daemon",
"author": "ACME",
"license": "Apache-2.0",
"config": {
"kind": "tool",
"binary": "bin/mytool",
"default_port": 7800,
"transport": "http",
"args": []
},
"files": [
{
"archive_path": "bin/mytool",
"install_path": "tools/mytool/bin/mytool",
"executable": true
}
],
"permissions": {
"provides_tools": ["mytool"],
"suggested_role": "worker"
}
}

3.3 Pack the archive​

There is no alexandria pack CLI subcommand today. The packing API lives in alex_package::pack. To produce a .atool from a layout dir, drive it from a small Rust binary or a test, e.g.:

use std::path::Path;

fn main() -> anyhow::Result<()> {
alex_package::pack(
Path::new("./mytool-src"),
Path::new("./mytool-0.1.0.atool"),
)?;
Ok(())
}

pack() does three things:

  1. Reads mytool-src/atool.json.
  2. Reads each files[].archive_path, computes SHA-256, writes it back into the manifest entry.
  3. Emits a gzipped tar archive with atool.json first, then each declared file.
let manifest = alex_package::verify(Path::new("./mytool-0.1.0.atool"))?;
println!("{} {}", manifest.name, manifest.version);

verify() extracts to a tempdir, re-parses atool.json, and re-hashes every file with a non-empty sha256. Mismatch ⇒ bail!.

3.5 Install​

alexandria install ./mytool-0.1.0.atool

What this does (crates/alex-cli/src/commands/install.rs):

  • Calls alex_package::verify() on the archive.
  • Calls alex_package::install(), which extracts every declared file to <data_dir>/<install_path>. Files marked executable get 0o755.
  • For tool-kind packages only, writes a sentinel copy of atool.json to <tools_dir>/<short_name>/atool.json so alexandria tool start/stop/status can recognise the tool as Alexandria-managed. <short_name> is the segment after the / in vendor/name.

You can also install from a registry:

alexandria install acme/mytool@0.1.0 --registry https://registry.alexandria.dev

Resolution order for the registry URL: --registry flag → REGISTRY_URL env → config.registries[].url (first enabled) → default https://registry.alexandria.dev.

3.6 Run​

alexandria tool start mytool # spawns config.binary, writes pid file
alexandria tool status
alexandria tool stop mytool

tool start looks up <tools_dir>/mytool/atool.json, resolves the binary at <tools_dir>/mytool/<config.binary>, spawns it with config.args, and waits up to ~4 s for <run_dir>/mytool.sock to appear.


4. Authoring an agent package​

Same archive shape. The only differences are kind, config, and that there is no binary to run (agents are config, not daemons).

myagent-src/
├── atool.json
└── prompts/
└── system.md # optional, if you want the prompt as a file

atool.json:

{
"schema_version": "2",
"name": "acme/research-agent",
"version": "0.1.0",
"kind": "agent",
"description": "Research assistant agent",
"config": {
"kind": "agent",
"system_prompt": "You are a senior research assistant. ...",
"allowed_tools": ["web-search", "pdf-parser"],
"llm": "claude-opus-4-7",
"history_limit": 100
},
"permissions": {
"needs_tools": ["web-search", "pdf-parser"],
"suggested_role": "coordinator"
}
}

Pack and install identically to a tool. The installer does not write a sentinel for agent-kind packages — only tools get the tools_dir/<name>/atool.json sentinel because only tools have a daemon lifecycle.

Tools listed in permissions.needs_tools require admin approval before the agent can actually use them; this is surfaced in the install summary.

The system_prompt in AgentConfig is a string — if your prompt is large, keep it in a file inside the archive (e.g. prompts/system.md), declare it under files[], and have your loader read it from the install path. The built-in AgentConfig currently stores the prompt inline.


5. Composing agents (replaces v1 bundles)​

Schema v2 removes the bundle kind. Multi-component packages are expressed instead as an agent with components — the agent is the root, and sub-agents, sub-skills, and ref'd tools are its children.

{
"schema_version": "2",
"name": "acme/data-pack",
"version": "1.0.0",
"kind": "agent",
"description": "Research agent that delegates to a summariser sub-skill and uses a ref'd PDF tool",
"dependencies": [
{ "name": "acme/pdf-parser", "version": ">=0.1.0" }
],
"config": {
"kind": "agent",
"system_prompt": "You research and summarise.",
"allowed_tools": ["pdf-parser"],
"llm": "claude-opus-4-7",
"history_limit": 50,
"components": [
{
"name": "summariser",
"id": "acme/summariser@0.1.0",
"kind": "skill",
"config": {
"kind": "skill",
"system_prompt": "Summarise in three bullets.",
"llm": null,
"tags": ["summarisation"]
}
},
{ "ref": "acme/pdf-parser@0.1.0" }
],
"install": {
"flatten": {
"system_prompt": "concat",
"allowed_tools": "union"
}
}
}
}

Flatten install (--mode=flatten)​

When installed with --mode=flatten, the installer collapses the component tree into a single composed agent:

  • system_prompt: concat (default) joins each component's prompt under a ## <component_name> header, with the root's block last; root_wins keeps only the root's prompt; error_on_conflict aborts the install if any child differs.
  • allowed_tools: union (default) takes the set-union and deduplicates; root_wins keeps only the root's list.
  • llm and history_limit: always root-wins. Sub-component preferences are discarded.
  • Delegate tools: every inline sub-agent generates a synthetic delegate.<name> tool the root can dispatch to. Ref components do not generate delegate tools — they're external and the root must list them in allowed_tools directly.

The flatten layer also detects dependency conflicts across components: if two sub-trees pin incompatible versions of the same dependency, the install aborts with a clear error.


6. Reference​

WhatWhere
Pack / verify / installcrates/alex-package/src/lib.rs
Manifest typescrates/alex-package/src/manifest.rs
Flatten / runtime layercrates/alex-package/src/runtime.rs
Signing / verificationcrates/alex-package/src/sign.rs
alexandria installcrates/alex-cli/src/commands/install.rs
alexandria tool …crates/alex-cli/src/commands/tool.rs
Round-trip testcrates/alex-package/src/lib.rs pack_verify_install_roundtrip

If you change the manifest schema, update both the Rust types and any tools that emit atool.json. There is no separate schema file — the Rust types are the schema.