Skip to content

Skills API

Class-level reference for the AgentSkills subsystem: skill bundles, the scripts they expose as tools, and the capabilities those scripts declare. Core itself has no sandboxing and no execution limitations — every script runs unconfined until the optional robot_lab-sandbox gem is required, at which point it installs a confinement strategy (see ScriptTool.executor below). For the how-to, see Using Tools: Skill Scripts and Sandboxing and Building Robots: Composable Skills.

Two different things are called 'skills'

Template skills (RobotLab.build(skills: [:clarifier])) are ordinary prompt templates whose bodies are prepended to a robot's system prompt — see Robot: Skills. AgentSkills (this page) are SKILL.md bundles on disk, discovered from ~/.prompts/skills/, matched to a message by embedding similarity at run time, and capable of contributing executable tools. They share the word but not the mechanism.

graph LR
    subgraph "Discovery"
        C[AgentSkillCatalog<br/>~/.prompts/skills/]
        S[AgentSkill<br/>SKILL.md]
    end

    subgraph "Capability grant"
        D[Capabilities<br/>declared in front matter]
        CE[Capabilities.ceiling<br/>from config.sandbox]
        G[effective grant<br/>declared ∩ ceiling]
    end

    subgraph "Execution (core)"
        T[ScriptTool.from_path<br/>-> RobotLab::Tool]
        EX{ScriptTool.executor<br/>set?}
        UN[Open3.capture2e<br/>unconfined, no timeout]
    end

    subgraph "robot_lab-sandbox (optional gem)"
        SB{Sandbox.enabled?}
        SE[Sandbox::Seatbelt<br/>macOS]
        NU[Sandbox::Null<br/>passthrough]
    end

    R[Robot<br/>AgentSkillMatching] --> C
    C --> S
    S --> D
    S --> T
    D --> G
    CE --> G
    T --> EX
    EX -- "nil (default)" --> UN
    EX -- "installed" --> SB
    SB -- "off, or trust: core,<br/>or non-macOS" --> NU
    SB -- "on + macOS" --> SE
    G --> SE

RobotLab::AgentSkill

Immutable value object for one skill folder: a directory containing a SKILL.md with name and description front matter, plus optional scripts/, references/, and assets/ subdirectories.

Constructor

skill = RobotLab::AgentSkill.new("~/.prompts/skills/deploy-checker/SKILL.md")
Name Type Description
skill_md_path String, Pathname Path to the SKILL.md file itself, not the directory

Raises RobotLab::ConfigurationError when front matter is missing name or description (or either is blank) — and because a file with no --- block parses to an empty hash, a SKILL.md without front matter always raises. Malformed YAML raises Psych::SyntaxError instead, straight from YAML.safe_load. AgentSkillCatalog rescues both and skips the bundle; construct an AgentSkill directly and you get the exception.

Attributes

Attribute Type Description
name String Front-matter name; also the catalog lookup key (symbolized)
description String Front-matter description; the text matched against the user's message
path Pathname The skill directory (dirname of the SKILL.md path)
capabilities Capabilities Built from front matter via Capabilities.from_front_matter

instructions

skill.instructions  # => String

The SKILL.md body below the front matter, stripped. This is the text Robot::AgentSkillMatching prepends to the system prompt when the skill matches. Memoized.

scripts

skill.scripts  # => Array<Pathname>

Every file directly inside the skill's scripts/ directory, sorted. Returns [] when there is no scripts/ directory. Not recursive — subdirectories are skipped. Memoized.

script_tools

skill.script_tools  # => Array<RobotLab::Tool>

One RobotLab::Tool per script, built with ScriptTool.from_path and carrying this skill's capabilities and directory. Non-executable scripts are skipped (logged at warn and filtered out by filter_map), so this array can be shorter than scripts. Memoized.

These tools are appended to robot.local_tools for the duration of a matched run and removed again in the ensure block — see Robot Execution.


RobotLab::AgentSkillCatalog

Lazily-loaded registry of the skill folders under a root directory.

SKILLS_ROOT

RobotLab::AgentSkillCatalog::SKILLS_ROOT
# => #<Pathname:/Users/you/.prompts/skills>

~/.prompts/skills, expanded at load time. The path the process-level singleton scans.

instance / reset!

RobotLab::AgentSkillCatalog.instance  # => the singleton, scanning SKILLS_ROOT
RobotLab::AgentSkillCatalog.reset!    # => nil; next `instance` builds a fresh one

instance memoizes. reset! drops the memo — it exists so tests can point the catalog at a fixture directory by resetting and constructing an instance explicitly with a different root.

Constructor

catalog = RobotLab::AgentSkillCatalog.new("/path/to/skills")
Name Type Default Description
skills_root String, Pathname SKILLS_ROOT Directory to scan

Construction does no I/O; the scan happens on the first find/all.

find

catalog.find(:deploy_checker)   # => AgentSkill or nil
catalog.find("deploy-checker")  # => AgentSkill or nil

Look up by skill name (the front-matter name, symbolized) — not by directory name, and not by file path. Returns nil when not found.

all

catalog.all  # => Array<AgentSkill>

Every successfully-loaded skill.

Loading is lazy, thread-safe, and forgiving

The scan runs once, under a Mutex, on the first find or all. A missing root directory is not an error — the catalog is simply empty. A directory without a SKILL.md is skipped silently; a SKILL.md that raises ConfigurationError or Psych::SyntaxError is skipped with a warn ("AgentSkillCatalog: <message>, skipping <dir>"). One bad bundle never prevents the others from loading, and the scan is never retried.


RobotLab::Capabilities

What a skill's scripts may read, write, reach, and how long they may run.

A skill declares what it wants in SKILL.md front matter; the global sandbox: config declares the ceiling. The effective grant is the intersection of the two.

Constants

Constant Value Description
DEFAULT_TIMEOUT 60 Seconds, used when timeout is absent or non-positive
TRUST_LEVELS ["core", "external"] Any other value falls back to "external"

Constructor

RobotLab::Capabilities.new(
  fs_read: [], fs_write: [], network: false,
  timeout: DEFAULT_TIMEOUT, trust: "external"
)
Name Type Default Coercion
fs_read Array<String> [] Array(...) then to_s on each entry
fs_write Array<String> [] Same
network Boolean false Any truthy value becomes true
timeout Integer 60 to_i; anything not positive becomes DEFAULT_TIMEOUT
trust String "external" Must be in TRUST_LEVELS, else "external"

Every value is normalized in the constructor, so the readers fs_read, fs_write, network, timeout, and trust always return well-formed values — a malformed SKILL.md degrades to the safe default rather than raising.

from_front_matter

RobotLab::Capabilities.from_front_matter(front_matter_hash)  # => Capabilities

Build from a parsed SKILL.md front-matter hash, reading fs_read, fs_write, network, timeout, and trust. A nil front matter yields an all-defaults instance.

fm_value

RobotLab::Capabilities.fm_value(front_matter, :network, false)

Look up a front-matter key tolerating either string or symbol keys (string first, then symbol), returning default when both are nil. Exposed because from_front_matter uses it and skill-tooling may need the same leniency.

ceiling

RobotLab::Capabilities.ceiling                 # => from RobotLab.config.sandbox
RobotLab::Capabilities.ceiling(custom_config)

The maximum grant any skill may receive, read from the config's sandbox: section. When there is no sandbox section at all, the ceiling is Capabilities.new(fs_read: ["."]) — read-only access to the working directory, no writes, no network.

Note the ceiling never carries a trust — trust is a property of the skill, not of the ceiling, and intersect keeps the declared value.

core?

capabilities.core?  # => trust == "core"

A core skill is exempt from confinement: when robot_lab-sandbox is loaded, Sandbox.for returns a Null strategy for it regardless of platform or config. Reserve trust: core for bundles you wrote and audited.

intersect

grant = declared.intersect(RobotLab::Capabilities.ceiling)

The effective grant. Per field:

Field Rule
fs_read / fs_write A requested path survives only when it is a ceiling root or lives beneath one. Both sides are File.expand_pathed before comparison, so ~ and relative paths resolve first
network declared && ceiling — both must allow it
timeout The smaller of the two
trust The declared value, unchanged

Because the check is prefix-based on expanded paths, a ceiling of ["."] grants nothing outside the working directory even if a skill asks for /etc.


RobotLab::ScriptTool

Factory module that turns an executable script into a RobotLab::Tool. All methods are module functions.

ScriptTool.from_path

tool = RobotLab::ScriptTool.from_path(script_path, capabilities: nil, skill_dir: nil)
# => RobotLab::Tool, or nil
Name Type Default Description
script_path String, Pathname required The script file
capabilities Capabilities, nil nilCapabilities.new The skill's declared capabilities
skill_dir String, nil nil → the script's own directory Bundle root; always granted read access under Seatbelt

Returns nil when the file is not executable, logging "ScriptTool: <basename> is not executable, skipping" at warn. It never raises.

The generated tool takes a single optional args string parameter, which is Shellwords.split and appended to bash <script>. Its name comes from derive_name and its description from extract_description.

ScriptTool.executor

RobotLab::ScriptTool.executor          # => #call, or nil (the default)
RobotLab::ScriptTool.executor = obj    # any object responding to
                                        # call(cmd, capabilities:, skill_dir:)

The extension point core exposes for confinement. nil by default — core has no sandboxing of its own. robot_lab-sandbox, when required, sets this to RobotLab::Sandbox::Executor, which handles capabilities/timeout/cleanup itself. See that gem's docs for what it does when installed.

ScriptTool.execute

RobotLab::ScriptTool.execute(cmd, capabilities:, skill_dir:)  # => String

Run a command array and return its combined stdout+stderr, or an error string. Two paths:

  • executor is nil (the default) — Open3.capture2e, unconfined, no timeout.
  • executor is set — delegates entirely to executor.call(cmd, capabilities:, skill_dir:). Core no longer knows or cares what the executor does with capabilities or how (or whether) it bounds execution time.

ScriptTool.format_result

RobotLab::ScriptTool.format_result(output, status)  # => String
status Result
nil "Error (timed out):\n<output>"
success output verbatim
non-zero exit "Error (exit <N>):\n<output>"

Failures come back as text for the LLM, not exceptions — the model sees the error and can adapt.

ScriptTool.derive_name

RobotLab::ScriptTool.derive_name(Pathname.new("check-deploy.sh"))  # => "check_deploy"

Strips the final extension, replaces every run of non-alphanumerics with _, and trims leading/trailing underscores.

ScriptTool.extract_description

RobotLab::ScriptTool.extract_description(path)  # => String

The first non-shebang comment line in the file, with leading # and whitespace removed. Falls back to derive_name(path) when there is no comment or the file cannot be read.

#!/usr/bin/env bash
# Verifies a deployment's health before promoting it.   <- becomes the description

Confinement: robot_lab-sandbox

RobotLab::Sandbox, RobotLab::Sandbox::Seatbelt, and RobotLab::Sandbox::Null used to live here; they now ship in the separate robot_lab-sandbox gem, which core has no dependency on. Requiring it installs RobotLab::Sandbox::Executor as ScriptTool.executor:

  • Sandbox.enabled? — reads config.sandbox.enabled (default false).
  • Sandbox.for(grant, skill_dir:, macos: macos?) — picks Sandbox::Seatbelt on macOS, Sandbox::Null (passthrough) elsewhere or for trust: core grants.
  • Sandbox::Seatbelt — generates a deny-by-default sandbox-exec profile from the effective grant (fs_read/fs_write/network), wrapping the command as sandbox-exec -f <profile> <cmd...>. $HOME is never implicitly readable.
  • Sandbox::Nullwrap(cmd) returns cmd unchanged; cleanup is a no-op.
  • Sandbox::Executor — the piece that plugs into core: intersects the skill's Capabilities with Capabilities.ceiling, wraps and runs the command under the chosen strategy, and bounds it with the grant's timeout (Process.kill('-TERM', ...) on the process group on expiry).

Full reference lives in that gem's own docs.


See Also