Agent-First Slug

Rust slug generation with explicit caller configuration for path and URL path segments.

Ask your agent: “Add agent-first-slug to my project and use it to slugify titles for URL path segments.”

Start by choosing the target surface: a local filesystem path segment, a URL path segment, or a legacy slug format you need to preserve. The examples below show complete config values so the behavior is visible at the call site.

Install the Library

cargo add agent-first-slug --no-default-features

Install the CLI

# prebuilt binary
brew install agentfirstkit/tap/afslug   # macOS / Linux
scoop bucket add agentfirstkit https://github.com/agentfirstkit/scoop-bucket && scoop install afslug   # Windows

# or from crates.io
cargo install agent-first-slug

Prebuilt archives are also available from GitHub Releases.

CLI

afslug generates and validates slugs, emitting one AFDATA protocol event per run. JSON is the default; YAML and plain output are also available.

afslug slugify "Hello, 世界!"
# {"kind":"result","result":{"changed_from_input":true,"code":"slugify","slug":"hello-世界"},"trace":{}}

afslug slugify "Hello, World!" --output plain
# kind=result result.changed_from_input=true result.code=slugify result.slug=hello-world

afslug validate "my-slug" --policy url-path

slugify exposes the SlugConfig surface as flags — delimiter, case, truncation, character set, dot handling, validation, and an empty-slug fallback (afslug slugify --help lists them); validate checks an existing value as a local or URL path segment. Transliteration stays library-only: its static replacement map cannot be built from CLI arguments.

Agent Skill

Use skills/agent-first-slug/SKILL.md to teach a coding agent when to choose the Rust library or the default-only afslug CLI, and how to preserve stable identifier behavior.

Default Unicode Slugs

use agent_first_slug::{slugify, SlugConfig};

let config = SlugConfig::default();

assert_eq!(slugify("Hello", &config)?.slug, "hello");
assert_eq!(slugify("Hello World", &config)?.slug, "hello-world");
assert_eq!(slugify("Hello,  world!!!", &config)?.slug, "hello-world");
assert_eq!(slugify("-- already -- spaced --", &config)?.slug, "already-spaced");
assert_eq!(slugify("!!!", &config)?.slug, "");
# Ok::<(), agent_first_slug::SlugError>(())

Unicode letters and numbers are preserved by default:

use agent_first_slug::{slugify, SlugConfig};

let config = SlugConfig::default();

assert_eq!(
    slugify("現在的Nobody,未來的Somebody!", &config)?.slug,
    "現在的nobody-未來的somebody"
);
assert_eq!(
    slugify("牙好,胃口就好,身体倍儿棒,吃嘛嘛香。", &config)?.slug,
    "牙好-胃口就好-身体倍儿棒-吃嘛嘛香"
);
assert_eq!(slugify("お元気ですか?", &config)?.slug, "お元気ですか");
# Ok::<(), agent_first_slug::SlugError>(())

Local Filesystem Path Segment

Use this when the slug will be one segment in a local path. Slugify each segment separately; do not pass a full path through one slug call.

Requirements for this target:

use agent_first_slug::{
    slugify, AllowedCharacterSet, DotHandlingPolicy, EmptyOutputPolicy, SlugConfig,
    SlugValidationPolicy, TransliterationPolicy,
};

let config = SlugConfig {
    replacement_delimiter: '-',
    lowercase_enabled: true,
    max_slug_chars: None,
    allowed_character_set: AllowedCharacterSet::UnicodeAlphanumericCharacters,
    dot_handling_policy: DotHandlingPolicy::ReplaceAllDots,
    transliteration_policy: TransliterationPolicy::None,
    validation_policy: SlugValidationPolicy::LocalPathSegment,
    empty_output_policy: EmptyOutputPolicy::UseFallbackSlug("fallback".to_string()),
};

assert_eq!(slugify("Ubuntu 16.04", &config)?.slug, "ubuntu-16-04");
assert_eq!(slugify("你好,世界", &config)?.slug, "你好-世界");
assert_eq!(slugify("!!!", &config)?.slug, "fallback");
# Ok::<(), agent_first_slug::SlugError>(())

ASCII-only path segment with fallback:

use agent_first_slug::{
    slugify, AllowedCharacterSet, DotHandlingPolicy, EmptyOutputPolicy, SlugConfig,
    SlugValidationPolicy, TransliterationPolicy,
};

let config = SlugConfig {
    replacement_delimiter: '-',
    lowercase_enabled: true,
    max_slug_chars: None,
    allowed_character_set: AllowedCharacterSet::AsciiAlphanumericCharacters,
    dot_handling_policy: DotHandlingPolicy::ReplaceAllDots,
    transliteration_policy: TransliterationPolicy::None,
    validation_policy: SlugValidationPolicy::LocalPathSegment,
    empty_output_policy: EmptyOutputPolicy::UseFallbackSlug("fallback".to_string()),
};

assert_eq!(slugify("Hello 世界", &config)?.slug, "hello");
assert_eq!(slugify("Ubuntu 16.04", &config)?.slug, "ubuntu-16-04");
assert_eq!(slugify("你好,世界", &config)?.slug, "fallback");
# Ok::<(), agent_first_slug::SlugError>(())

URL Path Segment

Use this when the slug will be one segment in a URL path. The returned slug is raw UTF-8; percent-encode it or use a URL library’s path-segment API when building the final URL.

Requirements for this target:

use agent_first_slug::{
    slugify, AllowedCharacterSet, DotHandlingPolicy, EmptyOutputPolicy, SlugConfig,
    SlugValidationPolicy, TransliterationPolicy,
};

let config = SlugConfig {
    replacement_delimiter: '-',
    lowercase_enabled: true,
    max_slug_chars: None,
    allowed_character_set: AllowedCharacterSet::UnicodeLettersAndDecimalDigits,
    dot_handling_policy: DotHandlingPolicy::PreserveDotsBetweenDecimalDigits,
    transliteration_policy: TransliterationPolicy::None,
    validation_policy: SlugValidationPolicy::UrlPathSegment,
    empty_output_policy: EmptyOutputPolicy::KeepEmptySlug,
};

assert_eq!(slugify("Ubuntu 16.04", &config)?.slug, "ubuntu-16.04");
assert_eq!(slugify("T.U.S.F.G.E.3.0.8", &config)?.slug, "t-u-s-f-g-e-3.0.8");
assert_eq!(slugify(".18 increased ! ", &config)?.slug, "18-increased");
assert_eq!(slugify("お元気ですか?", &config)?.slug, "お元気ですか");
# Ok::<(), agent_first_slug::SlugError>(())

Dot Handling

use agent_first_slug::{slugify, DotHandlingPolicy, SlugConfig};

let replace_all_dots = SlugConfig {
    dot_handling_policy: DotHandlingPolicy::ReplaceAllDots,
    ..SlugConfig::default()
};
let preserve_all_dots = SlugConfig {
    dot_handling_policy: DotHandlingPolicy::PreserveAllDots,
    ..SlugConfig::default()
};
let preserve_version_dots = SlugConfig {
    dot_handling_policy: DotHandlingPolicy::PreserveDotsBetweenDecimalDigits,
    ..SlugConfig::default()
};

assert_eq!(slugify("Ubuntu 16.04", &replace_all_dots)?.slug, "ubuntu-16-04");
assert_eq!(slugify("A.B..C", &preserve_all_dots)?.slug, "a.b..c");
assert_eq!(slugify("T.U.S.F.G.E.3.0.8", &preserve_version_dots)?.slug, "t-u-s-f-g-e-3.0.8");
# Ok::<(), agent_first_slug::SlugError>(())

Transliteration

Transliteration is caller-provided, so legacy behavior can be expressed without a named preset in the library.

use agent_first_slug::{
    slugify, AllowedCharacterSet, SlugConfig, TransliterationPolicy,
};

static MAP: &[(&str, &str)] = &[("Æ", "AE"), ("東京", "Tokyo")];
let config = SlugConfig {
    allowed_character_set: AllowedCharacterSet::AsciiAlphanumericCharacters,
    transliteration_policy: TransliterationPolicy::StaticReplacementMap(MAP),
    ..SlugConfig::default()
};

assert_eq!(slugify("Æther 東京", &config)?.slug, "aether-tokyo");
# Ok::<(), agent_first_slug::SlugError>(())

Truncation And Empty Output

max_slug_chars counts Unicode scalar values; any trailing delimiter the cut exposes is then stripped. Empty handling runs after truncation.

A fallback is inserted as written rather than run through the pipeline, but UseFallbackSlug still requires it to satisfy the same configuration — character set, delimiter, dot policy, case and length. A fallback that does not is a configuration error, not a slug: without that check an ASCII-only, length-capped configuration could return an arbitrary Unicode string and report it as validated. UseVerbatimFallbackSlug waives it for a value that has to match something already stored, and checks only the target surface.

use agent_first_slug::{slugify, EmptyOutputPolicy, SlugConfig, SlugError};

let truncated = SlugConfig {
    max_slug_chars: Some(8),
    ..SlugConfig::default()
};
let fallback = SlugConfig {
    max_slug_chars: Some(8),
    empty_output_policy: EmptyOutputPolicy::UseFallbackSlug("untitled".to_string()),
    ..SlugConfig::default()
};
let legacy = SlugConfig {
    max_slug_chars: Some(8),
    empty_output_policy: EmptyOutputPolicy::UseVerbatimFallbackSlug(
        "Legacy Name".to_string(),
    ),
    ..SlugConfig::default()
};

assert_eq!(slugify("Long Example", &truncated)?.slug, "long-exa");
assert_eq!(slugify("!!!", &fallback)?.slug, "untitled");
assert_eq!(slugify("!!!", &legacy)?.slug, "Legacy Name");

// Too long for this configuration's own budget, so it is refused rather than
// returned as if it had been generated.
let over_budget = SlugConfig {
    empty_output_policy: EmptyOutputPolicy::UseFallbackSlug("far-too-long".to_string()),
    ..fallback
};
assert!(matches!(
    slugify("!!!", &over_budget),
    Err(SlugError::FallbackViolatesConfig { .. })
));
# Ok::<(), agent_first_slug::SlugError>(())

What Changed, And What To Check Before Upgrading

Two rules that affect generated slugs changed, because both let a configuration mean something other than what it said. If you have slugs already stored, run the old and new versions over your corpus and diff before upgrading.

Case mapping now runs before filtering. It ran after, and Unicode case mapping is not one scalar for one scalar: İ lowercases to i plus a combining dot, and that dot ended up in the slug even though no character set here would have kept it. İstanbul was i̇stanbul; it is now i-stanbul. Only inputs whose case mapping expands are affected — every scalar in a slug is now one the character set admits, so allowed_character_set describes the output again.

A replacement_delimiter the configuration would keep is refused. With a as the delimiter, alpha beta and lpha beta both produced lphabet: the run boundary was skipped because the output already ended in a, and the trim then ate real letters off real words. Configurations using -, _, ~ or any other character the filter removes are unaffected.

Nothing normalizes input, then or now — see the skill for what that means for a stable-identifier contract.

Validation Only

use agent_first_slug::{validate_slug, SlugError, SlugValidationPolicy};

assert_eq!(validate_slug("safe-name", SlugValidationPolicy::LocalPathSegment), Ok(()));
assert_eq!(
    validate_slug("a/b", SlugValidationPolicy::LocalPathSegment),
    Err(SlugError::PathSegmentSeparator { character: '/' })
);
assert_eq!(
    validate_slug("a?b", SlugValidationPolicy::UrlPathSegment),
    Err(SlugError::UrlPathSegmentReservedCharacter { character: '?' })
);

Agent-First Slug v0.7: A Config That Describes Its Output

Three rules let a SlugConfig describe something other than what came out of it: case mapping ran after filtering, a delimiter could be a character the filter kept, and a fallback was validated against the target surface but never against the configuration it stood in for. All three are fixed, and two of them change generated slugs.

Agent-First Slug v0.6: A Shape, Not a Sentence

afslug's CLI is now compiled from a closed-world registry: one source for argv parsing, typed values, legal combinations, help, and docs/cli.md. clap is gone. The visible payoff is that a constraint which used to live in a help sentence and a runtime check is now a shape the parser enforces and `--help` lists.

Agent-First Slug v0.5: Discovery Without Guessing

An agent meeting a CLI for the first time does the same three things: asks what it can do, asks what version it is, and runs something. afslug answered the first two in prose an agent had to parse and the third by mixing results and errors into one stream. v0.5 makes all three structured. `afslug --help` returns a scoped result — this command level only, globals marked global, `--recursive` for the whole tree. `afslug --version` returns a result, not a sentence. And `--output-to` decides whether errors land on stderr beside the results or join them in one ordered stream.

Agent-First Slug v0.4: The Tool Installs Its Own Skill

A slug library is a few hundred lines; the hard part was never the transliteration table. v0.1 made every policy explicit at the call site so the code states which surface it targets. But explicit policy only pays off if the agent reaches for afslug and knows how it reasons — knowledge that used to sit in a README the agent might never open. v0.4 ships that knowledge as a skill the binary installs itself: `afslug skill install` drops the Agent-First Slug skill into Codex, Claude Code, opencode, or Hermes, bundling SKILL.md and its Codex agent interface as one tree. And `afslug --version` now answers in the shared structured protocol — name, display name, version, and the git SHA it was built from — the same shape every agent-first CLI reports.

Agent-First Slug v0.1.0

The first release of Agent-First Slug — a Rust slug library where every policy is set explicitly by the caller, with no hidden defaults that differ by surface.