Binary Skills

A binary skill is a compiled CLI tool that agents invoke via ion run. Unlike a plain skill — which is a static SKILL.md file with instructions — a binary skill is an executable that can perform actions, query external services, process data, and produce structured output.

Use a binary skill when the task requires more than instructions:

Plain skillBinary skill
Instructions the agent followsA tool the agent runs
Pure text, no dependenciesCan call APIs, read files, shell out
Distributed as a SKILL.mdDistributed as compiled binary + SKILL.md
Always availableRequires ion run to invoke

The SKILL.md of a binary skill tells the agent when and how to invoke the executable — the executable does the actual work.

Installing and running

Install from GitHub (Ion downloads the pre-built binary from the repo’s releases):

ion add owner/mytool

Run it:

ion run mytool [subcommand] [args...]

To override the inferred skill name:

ion add owner/mytool --name custom-name
ion run custom-name [args...]

Creating a binary skill

Scaffold a new binary skill project with a single command:

$ ion init --bin my-linter
 Cargo project scaffolded
 SKILL.md created
Set up GitHub Actions CI/CD? [Y/n]
 .github/workflows/ci.yml
 .github/workflows/release.yml
 .github/workflows/release-plz.yml

This creates a Cargo crate with src/main.rs (clap CLI with self subcommands pre-wired), build.rs (embeds the build target and copies SKILL.md), a starter SKILL.md, and GitHub Actions workflows for CI, releases, and automated version bumps.

Project structure

my-linter/
├── Cargo.toml          # declares ionem as a dependency
├── build.rs            # embeds target triple and SKILL.md
├── SKILL.md            # static skill description (bundled into binary)
└── src/
    └── main.rs         # clap CLI with self subcommands

Declare the project as a binary skill in Ion.toml:

[project]
type = "binary"
binary = "my-linter"   # optional, defaults to Cargo.toml package name

The ionem library

All Ion binary skills are expected to implement a standard self subcommand interface. The ionem crate provides this out of the box.

Add it to your project:

cargo add ionem
cargo add --build ionem --no-default-features

Wire it up in build.rs:

fn main() {
    ionem::build::emit_target();   // embeds TARGET env var
    ionem::build::copy_skill_md(); // copies SKILL.md into OUT_DIR
}

And in src/main.rs:

use ionem::self_update::SelfManager;

fn manager() -> SelfManager {
    SelfManager::new(
        "owner/my-linter", // GitHub repo
        "my-linter",       // binary name
        "v",               // tag prefix
        env!("CARGO_PKG_VERSION"),
        env!("TARGET"),    // injected by build.rs
    )
}

The self subcommand convention

Every binary skill must expose these subcommands:

ion run my-linter self skill    # prints SKILL.md to stdout (used by ion during install)
ion run my-linter self info     # version, target, path
ion run my-linter self check    # check for newer release on GitHub
ion run my-linter self update   # download and install latest release

ionem::SelfManager implements all four. Wire them in your match block and the skill is fully Ion-compatible.

Development workflow

Register your local checkout in dev mode — no release build needed:

# From your consumer project
$ ion add --dev ../my-linter
 Registered 'my-linter' (dev mode) — ion run forwards to cargo run

Every ion run my-linter [args] now calls cargo run -- [args] in the source directory, picking up your latest changes immediately.

Switch back to the published release:

$ ion add owner/my-linter --bin

Setting up CI

ion init --bin prompts to set up CI during scaffolding. For an existing Cargo project, run it directly from the project root:

ion ci

This reads the package name from Cargo.toml and writes four files:

FilePurpose
.github/workflows/ci.ymlFormat check, Clippy lint, tests on Ubuntu and macOS
.github/workflows/release.ymlBuilds release binaries for 4 targets on tag push
.github/workflows/release-plz.ymlOpens version-bump PRs on every push to main
release-plz.tomlChangelog format and publish = false (binaries ship via GitHub Releases, not crates.io)

If any of these files already exist, ion ci errors. Use --force to overwrite:

ion ci --force

What the release flow looks like

Once CI is in place, the release loop is:

  1. Merge commits to mainrelease-plz opens a version-bump PR with a generated changelog
  2. Merge the version-bump PR — release-plz creates a tag (e.g. v1.2.0)
  3. The tag triggers release.yml, which builds binaries for all four targets and attaches them to the GitHub Release

Users then install the published binary with ion add owner/my-linter.

Publishing a release

Tag a release — the scaffolded GitHub Actions workflow takes it from there:

git tag v1.0.0
git push origin v1.0.0

The release workflow builds and attaches binaries for four targets:

TargetFile
aarch64-apple-darwinmy-linter-1.0.0-aarch64-apple-darwin.tar.gz
x86_64-apple-darwinmy-linter-1.0.0-x86_64-apple-darwin.tar.gz
aarch64-unknown-linux-muslmy-linter-1.0.0-aarch64-unknown-linux-musl.tar.gz
x86_64-unknown-linux-muslmy-linter-1.0.0-x86_64-unknown-linux-musl.tar.gz

Ion detects the user’s platform and downloads the matching asset automatically.

Users install with:

ion add owner/my-linter

Asset naming convention

Release tarballs must follow this pattern for Ion to detect them:

{binary}-{version}-{target}.tar.gz

For example: my-linter-1.0.0-aarch64-apple-darwin.tar.gz

Each tarball should contain the binary at its root (not nested in a subdirectory).

Testing with scenario

The scenario crate is the standard test harness for Ion binary skills. It runs your CLI under controlled terminal conditions — with or without a TTY, at specific dimensions, with isolated env and working directories — so you can test both piped output and interactive behavior.

Add it to your [dev-dependencies]:

cargo add --dev scenario

Non-interactive tests

Use Scenario::new to build a run and .run() to execute it. The default terminal is piped (is_terminal() returns false in the child).

use scenario::Scenario;

const MY_LINTER: &str = env!("CARGO_BIN_EXE_my-linter");

#[test]
fn help_exits_successfully() {
    let output = Scenario::new(MY_LINTER)
        .args(["--help"])
        .run()
        .unwrap();

    assert!(output.success());
    assert!(output.stdout().contains("Usage:"));
}

#[test]
fn check_command_fails_on_bad_input() {
    let output = Scenario::new(MY_LINTER)
        .args(["check", "--strict"])
        .current_dir("/tmp/bad-project")
        .run()
        .unwrap();

    assert!(!output.success());
    assert!(output.stderr().contains("error:"));
}

Output provides:

MethodDescription
.success()true if exit code is 0
.exit_code()numeric exit code
.stdout()ANSI-stripped stdout
.stderr()ANSI-stripped stderr (empty in PTY mode)

PTY mode — testing TTY-aware output

Pass Terminal::pty(cols, rows) to run the child in a real pseudo-terminal. The child process sees is_terminal() == true, so color output, spinners, and TTY-gated prompts activate.

use scenario::{Scenario, Terminal};

#[test]
fn pty_output_has_color() {
    let output = Scenario::new(MY_LINTER)
        .args(["check"])
        .terminal(Terminal::pty(80, 24))
        .run()
        .unwrap();

    // PTY merges stdout + stderr into stdout
    assert!(output.stdout().contains("") || output.stdout().contains("error"));
}

Project fixtures

Project creates an isolated temp directory for each test — no shared state between runs.

use scenario::{Project, Scenario};

#[test]
fn check_passes_on_clean_project() {
    // Empty temp directory
    let project = Project::empty()
        .file("src/main.rs", "fn main() {}")
        .build()
        .unwrap();

    let output = Scenario::new(MY_LINTER)
        .args(["check"])
        .project(&project)   // sets current_dir to project.path()
        .run()
        .unwrap();

    assert!(output.success());
}

For repeatable fixtures, save template directories under tests/fixtures/ and instantiate them with Project::from_template:

tests/
└── fixtures/
    └── my-fixture/
        ├── template.toml       # declares variables (optional)
        ├── src/
        │   └── main.rs
        └── Cargo.toml
#[test]
fn check_on_fixture() {
    let project = Project::from_template("tests/fixtures/my-fixture")
        .build()
        .unwrap();

    let output = Scenario::new(MY_LINTER)
        .project(&project)
        .run()
        .unwrap();

    assert!(output.success());
}

Template files support Jinja2 variables. Declare them in template.toml:

[variables]
name = { description = "Package name", default = "my-package" }

Then pass values at build time: .var("name", "custom-name").

Interactive sessions

For commands with prompts or TUI menus, use .spawn() to get a Session. This requires Terminal::Pty.

use scenario::{Scenario, Terminal};

#[test]
fn interactive_init_accepts_defaults() {
    let project = Project::empty().build().unwrap();

    let mut session = Scenario::new(MY_LINTER)
        .args(["init"])
        .terminal(Terminal::pty(80, 24))
        .project(&project)
        .spawn()
        .unwrap();

    session.expect("Choose a preset:").unwrap();
    session.send_line("default").unwrap();

    session.expect("Created").unwrap();

    let output = session.wait().unwrap();
    assert!(output.success());
}

Key Session methods:

MethodDescription
.expect(pattern)Wait until output contains the string
.expect_regex(pattern)Wait until output matches a regex
.expect_not(pattern)Assert the pattern does NOT appear (500ms window)
.send_line(text)Type text and press Enter
.send_key(Key::Down)Send arrow keys, Ctrl+C, etc.
.visible_screen()Current terminal screen as a grid of lines
.visible_text()Current screen as newline-delimited text
.wait()Close stdin, wait for exit, return Output

expect and expect_regex advance a cursor — each call only searches output that appeared after the previous match, so they naturally sequence into a script.

For TUI tools that redraw the screen in place (rather than streaming lines), use .expect_screen() / .visible_text() to inspect what’s actually visible rather than the raw output stream.

Ion.toml reference

When users install your binary skill, it appears in their Ion.toml like this:

[skills]
my-linter = { type = "binary", source = "owner/my-linter", binary = "my-linter" }

In dev mode:

[skills]
my-linter = { type = "binary", source = "owner/my-linter", binary = "my-linter", dev = "../my-linter" }