From match to trait: the inevitable refactor when tools multiply

The Part 1 agent loop had only two tools, so one match statement was enough:

match name {
    "read_file" => read_file(args).await,
    "list_files" => list_files(args).await,
    _ => Err(...),
}

Two branches are fine. Three start to feel cramped. By six, the dispatcher is a swamp of clones, retries, error formatting, and argument parsing that all looks almost-but-not-quite alike. The agent loop itself is still simple. The space around it is not.

The problem is not the model, not the prompt, but the dispatcher. Every new tool forces three edits:

  1. Add a branch in the match;
  2. Parse the JSON arguments into the right struct;
  3. Format the result into a string the model can read.

These three steps are nearly identical for every tool. Part 3 abstracts them into a trait. Eugene v0.3 introduces Skill, Registry, automatic JSON Schema, parallel calls, error classification, and retries. The loop stays the same; everything around it gets a name and a contract.

Rust AI Agent Skill Registry architecture: trait, registry, parallel dispatch


What a skill actually is

Inside the Claude Code source, each tool is its own folder: a name constant, an input schema, a description fragment, a typed entry point, and a few metadata flags the agent uses to decide whether the call is safe to run in parallel or needs user confirmation. That shape maps cleanly to Rust as a trait:

#[async_trait]
trait Skill: Send + Sync + 'static {
    type Input: DeserializeOwned + JsonSchema + Send + 'static;

    fn name(&self) -> &str;
    fn description(&self) -> &str;
    fn is_read_only(&self) -> bool { true }
    fn is_concurrency_safe(&self) -> bool { self.is_read_only() }

    async fn run(&self, input: Self::Input) -> Result<String, SkillError>;
}

The run return value is the string the model will see. is_read_only and is_concurrency_safe tell the agent whether the call can be parallelized. SkillError is the error taxonomy we will cover later.

The associated type Input is the load-bearing part. It lets each implementor carry a different input shape without runtime type tags. ReadFile has Input = ReadFileArgs; ListFiles has Input = ListFilesArgs. The run method receives the typed struct directly, so the function body does not need to parse JSON.

#[derive(Deserialize, JsonSchema)]
struct ReadFileArgs {
    /// File path relative to the project root.
    path: String,
}

struct ReadFile;

#[async_trait]
impl Skill for ReadFile {
    type Input = ReadFileArgs;
    fn name(&self) -> &str { "read_file" }
    fn description(&self) -> &str {
        "Read a UTF-8 text file from the current project."
    }
    async fn run(&self, input: ReadFileArgs) -> Result<String, SkillError> {
        // read + sandbox check
    }
}

The /// doc comment on the field is captured by schemars into the generated JSON Schema. The model and the next engineer to open the file see the same description. Renaming path to file_path is one edit; the model sees the new name on the next request.


JSON schema from the input struct

schemars derives the schema once, when the skill is registered, and the registry caches the result. The call is one line:

let schema = schemars::schema_for!(S::Input);

For ReadFileArgs, this produces:

{
  "type": "object",
  "properties": {
    "path": {
      "type": "string",
      "description": "File path relative to the project root."
    }
  },
  "required": ["path"]
}

schemars trims the Rust-specific metadata the model does not need, such as serde structural directives, yielding a compact schema that pulls exactly its own weight. Production agents do this because they do not want the model guessing field semantics.


A registry, not a match

The registry owns the dispatch. It holds a Vec<Box<dyn DynSkill>> indexed by name. DynSkill is the object-safe wrapper that lets the registry store skills with different Input types in one container, with a blanket impl that converts any Skill into a DynSkill:

#[async_trait]
trait DynSkill: Send + Sync {
    fn definition(&self) -> ToolDefinition;
    fn is_concurrency_safe(&self) -> bool;
    async fn run(&self, args: Value) -> Result<String, SkillError>;
}

#[async_trait]
impl<S: Skill> DynSkill for S {
    fn definition(&self) -> ToolDefinition {
        let schema = schema_for!(S::Input);
        ToolDefinition {
            name: self.name().to_string(),
            description: self.description().to_string(),
            input_schema: schema,
        }
    }

    fn is_concurrency_safe(&self) -> bool {
        self.is_concurrency_safe()
    }

    async fn run(&self, args: Value) -> Result<String, SkillError> {
        let input: S::Input = serde_json::from_value(args)?;
        self.run(input).await
    }
}

The match statement disappears. The branches it held were almost identical anyway: parse args, call a function, format the result. The trait absorbs all three.


Concurrent dispatch, for free

The Anthropic API lets the model issue multiple tool_use blocks in one assistant turn. Claude Code’s own system prompt says “Maximize use of parallel tool calls where possible to increase efficiency,” and the model takes that direction seriously. Ask Eugene “What’s in the src folder and what edition is Cargo.toml using?” and it may issue list_files and read_file("Cargo.toml") in the same turn.

Running those serially wastes wall time. Running them in parallel takes one more line in the registry:

async fn run_many(
    &self,
    calls: &[(String, String, Value)], // (id, name, args)
) -> Vec<(String, Result<String, SkillError>)> {
    let mut parallel = Vec::new();
    let mut serial = Vec::new();

    for (id, name, args) in calls {
        match self.index.get(name) {
            Some(&i) if self.skills[i].is_concurrency_safe() => {
                parallel.push(self.run_one(id.clone(), i, args.clone()));
            }
            Some(&i) => {
                serial.push(self.run_one(id.clone(), i, args.clone()));
            }
            None => { /* unknown tool */ }
        }
    }

    let mut results = join_all(parallel).await;
    for fut in serial {
        results.push(fut.await);
    }
    results
}

is_concurrency_safe defaults to is_read_only, which most first-cut skills are, so callers get the right behavior without thinking about it. When a skill needs to be sequential—a destructive edit, a stateful API client without internal locking—it overrides the flag and the registry falls back to serial dispatch for that call. With two read-only skills, the speedup is half a second; with ten, it is six. The model does not know any of this; it just sees that the agent answers faster.


Errors the model can recover from

Skills fail. The network drops. The user passes a path that does not exist. A safety check rejects an input that tried to escape the sandbox. Each of these has a different right response, and the skill author needs a way to express which is which.

#[derive(Debug, Error)]
enum SkillError {
    #[error("invalid arguments: {0}")]
    BadArgs(#[from] serde_json::Error),
    #[error("safety check failed: {0}")]
    Safety(String),
    #[error("transient failure (worth retrying): {0}")]
    Transient(String),
    #[error("tool failed: {0}")]
    Tool(String),
}
VariantAt faultNext step
BadArgsModelReturn as tool_result so the model can correct
SafetyModel / userRefuse and surface the reason to the user
TransientThe worldRetry with exponential backoff
ToolTool itselfReturn as is_error: true tool_result; model decides whether to try a different tool

This is the Result Architecture the source material describes: do not return raw strings from skills; return something the agent can reason about. The Rust type system gives you that for free; you only have to use the variants.


Retries for the transient slice

Transient failures get a small helper. Three attempts, exponential backoff, only on SkillError::Transient:

async fn with_retry<F, Fut, T>(policy: RetryPolicy, mut op: F) -> Result<T, SkillError>
where
    F: FnMut() -> Fut,
    Fut: std::future::Future<Output = Result<T, SkillError>>,
{
    let mut delay = policy.initial_delay;
    for attempt in 0..policy.max_attempts {
        match op().await {
            Ok(v) => return Ok(v),
            Err(e) if e.is_transient() && attempt + 1 < policy.max_attempts => {
                tokio::time::sleep(delay).await;
                delay = delay.mul_f64(policy.backoff);
            }
            Err(e) => return Err(e),
        }
    }
    unreachable!()
}

A 429 Too Many Requests from the Anthropic API is transient. A 400 Bad Request is not. A network connection reset is transient. A 500-class server error is transient too. Everything else short-circuits immediately, because retrying a bad call wastes the user’s time and the agent’s budget.


Hooks: cross-cutting concerns should not live inside skills

Adding retry logic to one skill is fine. Adding it to ten is repetition. The same is true for permission gating, request logging, output redaction, and result caching. Each is a cross-cutting concern that wants to wrap dispatch without living inside any individual skill. The Claude Code source threads executePreToolHooks and executePostToolHooks around every tool call for exactly this reason: that is where its CanUseToolFn permission gate runs, where user-defined shell hooks fire on lifecycle events, and where analytics get logged.

eugene-skills ports the pattern to Rust as a SkillHook trait:

#[async_trait]
pub trait SkillHook: Send + Sync + 'static {
    async fn before(
        &self,
        skill_name: &str,
        metadata: SkillMetadata,
        args: &Value,
    ) -> Result<HookOutcome, SkillError> { Ok(HookOutcome::Proceed) }

    async fn after(
        &self,
        skill_name: &str,
        args: &Value,
        result: Result<String, SkillError>,
    ) -> Result<String, SkillError> { result }
}

enum HookOutcome {
    Proceed,
    Modify(Value),
    Deny(String),
    Replace(String),
}

Proceed continues the call. Modify rewrites arguments before dispatch—handy for redaction or for injecting context the model could not have known to supply. Deny blocks dispatch entirely and the model receives an is_error result with the reason. Replace skips dispatch and substitutes a synthetic output, which is what a cache does on a hit.

The post-hook sees the result, error or success, and can rewrite it. That is where you would attach a logger, redact a path that should not have been printed, or wrap structured JSON inside a more model-friendly preamble.

Hooks register on the registry and run in registration order:

let mut registry = Registry::new();
registry
    .add(ListFiles)
    .add(ReadFile)
    .add(DeleteFile)
    .with_hook(PermissionGate::new(|_, md: SkillMetadata| !md.is_destructive));

PermissionGate is a built-in hook. It denies any call to a destructive skill unless the closure returns true. This is the Rust analogue of Claude Code’s CanUseToolFn. Real implementations pair it with a PermissionMode (the same enum Part 4’s eugene-state defines) to switch between “ask for every destructive call” and “edits are pre-approved” without touching individual skills.


Scaling past a handful of skills

A registry with three skills costs nothing in tokens. A registry with thirty starts to. Each tool definition carries its name, description, and full JSON Schema into every request. Past a certain density the model gets distracted by tools it does not need and the bill grows for prompt input that is mostly noise.

Claude Code’s solution is a ToolSearch tool. Most tools are flagged shouldDefer: their schemas are absent from the initial prompt, and the model has to call ToolSearch with a keyword query to surface them. A small searchHint (three to ten words, no period) attaches to each tool so the keyword match works against meaningful synonyms rather than just the tool name.

eugene-skills exposes the same hooks as defaults on the Skill trait:

fn search_hint(&self) -> Option<&str> { None }
fn should_defer(&self) -> bool { false }
fn aliases(&self) -> &[&'static str] { &[] }

A skill that opts in:

impl Skill for DeleteFile {
    type Input = DeleteFileArgs;
    fn name(&self) -> &str { "delete_file" }
    fn description(&self) -> &str { "Delete a file from the project." }
    fn should_defer(&self) -> bool { true }
    fn search_hint(&self) -> Option<&str> { Some("remove file rm") }
    fn aliases(&self) -> &[&'static str] { &["remove_file"] }
    // ...
}

The flags are there for the day the skill count crosses a threshold. Aliases are useful even at small scale: rename a skill in a later version, keep the old name as an alias for one release, and the model never has to know.


Three rules that keep skills honest

The source material distills the philosophy of working skills into three rules. They map onto Rust without modification.

Atomic. A skill does exactly one thing. read_file reads. list_files lists. There is no search_and_summarize skill, because that would be a small agent hiding inside a tool. Composition belongs in the agent loop, where the model can intervene between steps, see the intermediate result, and choose. When you find yourself writing a skill with the word “and” in the name, split it.

Result. A skill returns something the rest of the system can reason about. In Rust that is Result<String, SkillError>. The error variants distinguish kinds of failure. Rich return types (JSON-encoded structured data inside the string, or a custom Output type swapped in for String if you want to be fancier) let downstream consumers extract fields rather than parse free-form prose.

Safety. A skill assumes the input is hostile. read_file calls canonicalize and checks the result is still inside the sandbox before opening it. list_files does the same. A future web_fetch would validate the URL scheme, reject file://, and constrain the domain set. The model is not your attacker, but it has been trained on a corpus containing every adversarial prompt ever written and will sometimes parrot one. Defensive skills cost a handful of lines and prevent the kind of bug that turns into a CVE.


Eugene v0.3 in practice

The agent loop is recognizable from Part 1 with one change: dispatch goes through the registry.

let mut registry = Registry::new();
registry.add(ListFiles).add(ReadFile);

let tools: Vec<Tool> = registry
    .definitions()
    .into_iter()
    .map(Into::into)
    .collect();

// ... build the system prompt ...

for _ in 0..MAX_TURNS {
    let response = with_retry(policy, || async {
        send(&http, &api_key, &system_blocks, &tools, &messages).await
    }).await?;

    // append assistant message, call registry.run_many if it has tool_use blocks,
    // return final text if it does not
}

The loop does not change. The dispatcher does not change. The prompt builder does not change. The schema for the new tool flows from #[derive(JsonSchema)] straight to the model. That is the leverage a small abstraction gives you when it picks the right thing to abstract over.

Ask Eugene “Show me what’s in src and tell me what edition Cargo.toml uses” and it will issue both calls in one turn, the registry will dispatch them in parallel, and the model will compose the answer from both results. Two filesystem reads, one round trip to Claude that does not block on either.


A note on declarative skills

The source material spends a chapter on declarative skills: markdown files that describe a tool and a prompt fragment, loaded at agent startup, surfaced to the model as if they were code. The Claude Code CLI ships this idea as its SkillTool: slash commands the user defines as .md files, expanded into a full prompt at runtime. It is a useful pattern for letting non-engineers extend an agent without redeploying.

The Rust shape is straightforward: a MarkdownSkill that implements Skill, takes a free-form String input, and runs the loaded prompt as a sub-LLM call. The registry treats it like any other skill. The eugene crates do not include this yet because the value comes from accumulated skills, not from the loading mechanism. It is a one-evening project to bolt on once you have a few real skills to declare. Part 7’s MCP server is the closer cousin: same composition, but the skills live in another process.


What this reveals

A trait is a contract between the skill author and the agent runtime. The author writes a typed run with strong input validation. The runtime worries about JSON Schema, dispatch, parallel execution, retries, and error rendering. Neither side needs to know about the other’s concerns. That separation is what makes a registry-based agent grow without seizing up.

The associated type carries the typed input through the trait so each skill stays type-checked at compile time. The blanket impl into DynSkill is what lets the registry hold a heterogeneous mix. schemars is what closes the gap between the Rust struct and the wire format. async_trait is what lets all of this be async without ceremony. None of these tools are unique to agents; they are the standard Rust toolkit for ergonomic, type-erased plugin systems. Skills are just plugins with a well-defined caller.


What comes next

Part 4 takes the agent past a single loop. Some tasks need multiple steps that the model cannot plan in one breath: a planning phase, an execution phase, a verification phase, optional human-in-the-loop approval. The shape that survives is a state machine. Part 4 introduces eugene-state: a typed graph runner, checkpoints to SQLite so a long-running task survives a restart, and a way to pause for confirmation before any destructive step. The skills you wrote in Part 3 plug straight in.


The workspace

The polished version of the trait, the registry, the retry helper, the hook chain, and a fuller SkillMetadata (read-only / destructive / concurrency-safe) lives in the workspace as the eugene-skills crate. Sixteen unit tests cover dispatch, parallel execution, schema generation, retry behavior, metadata propagation, aliases, keyword search across hints, the deferred-loading split, and the four pre-hook outcomes (proceed / modify / deny / replace). Tool definitions come from the provider-neutral ToolDefinition newly added to eugene-core; the provider adapter in Part 6 will convert it to Anthropic’s Tool shape, OpenAI’s ChatCompletionTool shape, and so on. ⭐ Star on GitHub: eugene/crates/eugene-skills



Found this useful?

If this post helped you, please:

  • Clap / share / bookmark it so more Rust + AI developers can find it;
  • Follow Mengshou Programming for weekly Rust / AI engineering notes;
  • Leave a comment with your questions about skill design, concurrency safety, or permission gates;
  • Check out the AI programming assistant service to bring Claude Code-level productivity to your team.