tinyskills is a host-independent Rust library for agentskills.io-style skill
bundles. It parses SKILL.md and WORKFLOW.md, normalizes metadata, discovers
bundles deterministically, resolves scope collisions, inventories and safely
reads resources, and materializes skills embedded in a host binary.
The crate deliberately does not decide where a product stores skills, whether a workspace is trusted, how skills are executed, or how changes are announced. Embedding applications provide their roots and retain those policy decisions.
use tinyskills::{DiscoveryRoot, SkillScope, discover};
let skills = discover([
DiscoveryRoot::new("/home/me/.agents/skills", SkillScope::User),
DiscoveryRoot::new("./.agents/skills", SkillScope::Project),
]);
for skill in skills {
println!("{}: {}", skill.name, skill.description);
}- YAML frontmatter parsing with scalar or sequence
allowed-tools - current
WORKFLOW.md/SKILL.mdand legacyskill.jsondiscovery - recursive, deterministic scans that reject symlinked directories/manifests
- explicit scope precedence for builtin, legacy, user, project, and profile roots
- traversal-, symlink-, size-, and UTF-8-safe resource reads
- validated materialization and tamper detection for compile-time bundles
- network-free registry catalog logic: Hermes catalog parsing,
SKILL.mddownload-URL derivation (GitHub, ClawHub, skills.sh), entry lookup, and search filtering - opt-in collision policies (
CollisionPolicy,TieBreak::LastWins, excluded scopes) so root order can decide equal-scope ties - authoring:
slugify, YAML rendering, andscaffold_bundle(containment-checked create/edit with body preservation and resource dirs) - defensive
remove_bundle(slug validation, symlink rejection, canonical containment) - fetched single-document installs:
validate_fetched_document,redact_url, and an atomicwrite_installed_documentwith rollback TriggerPatternparsing and matching fortriggers:frontmatterread_documentfor size-bounded, symlink-free, UTF-8 reads of documents and sidecars- supply-chain scan of untrusted skill text:
scan_skillover aScanDocumentand its bundled files returnspass/warn/blockfindings (invisible/bidi/zero-width code points, hard-coded credentials, and escaping resource paths block; agent-addressed text, fetch-and-exec pipelines, and undeclared MCP references warn), plussanitize_catalogue_textfor rendering untrusted text into a prompt as data - product slug bounds:
SlugRules(length cap, reserved names, truncation, aPunctuationRulethat drops or folds punctuation, and a fallback slug for a name with no alphanumerics) forslugify_withandvalidate_slug - a flat, line-based
SKILL.mdparser and renderer:parse_flat,render_flat,split_frontmatter, andFlatSkill::scan_documentfor the scan document_digest: the sha256 of one rendered document, for pinning an installed copy (not the same value asBundledSkill::digest)- authoring budgets:
validate_description_chars(counted in characters) andcheck_frontmatter_size(bytes in the frontmatter block) materialize_tree: rebuild a<root>/<dir>/tree inside an open parent directory handle from inline documents and open bundle directory handles, skipping symlinks, with checked names, bounded depth, and a per-file size cap. It takescap_stdhandles (re-exported) and resolves no path, so opening the handles is the caller's job; the new tree is built beside the old one and swapped in on successSkillRegistry(featureregistry): cached, searchable skill catalogs (the Hermes index, host-supplied static libraries) with stale-while-revalidate reads, single-flight refresh, ranked paged search, facets, and guardedSKILL.mdfetches. Network I/O is host-supplied throughRegistryTransport; the crate links no HTTP clientread_skill_archive(featurearchive): reads a.zip/.skill,.tar, or.tar.gzupload into itsSKILL.md, root directory, and bundled files. It refuses traversal, absolute or backslash paths, symlinks and hard links, and nested archives, and checks entry-count and expanded-size caps before reading any content
UserBundle::new validates an in-memory file tree before it can be published:
portable relative names, case-insensitive collisions, file/directory conflicts,
and file-count and byte budgets. It preserves original document and resource
bytes, including empty and binary files. Files and manifests have deterministic
ordering; the bundle digest includes the selected primary document and every
file name and byte. It is a content identifier, not an access grant.
use tinyskills::{BundleDocument, BundleFile, BundleLimits, UserBundle};
let bundle = UserBundle::new(
BundleDocument::Skill,
"---\nname: example\ndescription: Example skill\n---\nHello\n".to_owned(),
vec![BundleFile {
path: "references/empty.txt".to_owned(),
bytes: Vec::new(),
}],
BundleLimits::default(),
)?;
assert_eq!(bundle.file("references/empty.txt"), Some(&[][..]));
# Ok::<(), tinyskills::BundleContentError>(())BundleStore is a separate asynchronous persistence contract for authored
bundles. It defines conditional publication and deletion, reads, and paged
listing. Implementations bind authority at construction, retain typed backend
error sources, and check that authority even for missing items and empty pages.
A committed head is authoritative; later cleanup failures must not imply that
publication rolled back. Arc forwarding preserves this contract.
This crate does not yet provide a native document/blob implementation of
BundleStore, a compiled module for these operations, desktop import receipts,
or a read-only materialization cache. The fetched-catalog CatalogStore is a
different contract. The staged owner design and remaining authority requirements
are described in the user bundle plan.
parse_skill_str and parse_skill read frontmatter as YAML. Discovery uses
them, and they accept anything agentskills.io allows: sequences, nested
metadata, allowed-tools lists.
parse_flat reads one key: value per line and keeps only name,
description, category, and version, holding every other line, trimmed, in
extra_frontmatter so a scan can see it. The first non-empty category or
version wins; an empty one is kept as an extra line. Blank frontmatter lines
are discarded. Use it when a host stores, digests, and re-serves the document
itself: the body is kept byte for byte, and render_flat writes name,
description, a category and version only when non-empty, and then the
extra lines in stored order. parse_flat → render_flat → parse_flat is a fixed point on the parsed value, not on the text: the
rendered document is canonical, so blank lines, key case, key order, and
whitespace around a line are not reproduced. render_flat cannot be made to
emit a second claim on a field or close the block early. A host pinning
installs with document_digest should digest render_flat output.
SkillRegistry answers catalog queries for a host UI or agent tool. It is off
by default; enable it with features = ["registry"].
use std::sync::Arc;
use tinyskills::{
CatalogStore, EntryKey, HermesIndexSource, RegistryEntry, RegistryError,
RegistryTransport, SkillQuery, SkillRegistry, StaticSource,
};
async fn browse(
transport: impl RegistryTransport + 'static,
packaged_entries: Vec<RegistryEntry>,
store: impl CatalogStore + 'static,
) -> Result<(), RegistryError> {
let registry: Arc<SkillRegistry> = SkillRegistry::builder(transport)
.source(HermesIndexSource::hermes())
.baseline(StaticSource::new("library", "Packaged library", packaged_entries))
.store(store)
.featured(["apple-notes"])
.build();
let page = registry.search(&SkillQuery::text("notes")).await?;
println!("{} matching skills", page.total);
let document = registry.fetch_document(&EntryKey::new("apple-notes")).await?;
if document.is_blocked() { /* host policy decides */ }
Ok(())
}- Reads are stale-while-revalidate. A catalog within its time-to-live
(default one hour) answers as
Live; a stale one answers asCachedand is refreshed once in the background when a tokio runtime is running (ReadPolicy::AllowStale, the default), or refreshed first underReadPolicy::RequireFresh. Concurrent refreshes of a source share one fetch. - Failures keep the catalog held (
Cached, withSourceStatus::last_error) and pause refreshes for the larger ofRegistryTimeouts::cooldownand the upstream'sRetry-After. With nothing cached, baselines answer asLocalFallback; with no baseline the typedRegistryErroris returned.RegistryError::kind()is a stablesnake_casecode. - Search matches the fields
filter_catalogmatches and ranks featured ids, then exact name, name prefix, name substring and other fields, installable entries first. Pages are 1-based, 25 entries by default, at most 100. - Documents:
fetch_documentresolves the entry's URL (skills.shentries probe their repo's conventional directories concurrently, then list its tree once), fetches it within 15 s and 1 MiB, validates it, parses it withparse_flat, digests it and scans it. AClawHub409 isUpstreamAmbiguous; aLobeHubagent isNoDirectDownload. Nothing is written to disk and a block verdict is reported, not enforced.fetch_skill_documentdoes the same for a URL a user pastes. - The serializable request and response types (
SkillQuery,SkillPage,SkillDetail,SourceStatus, ...) are a versioned contract (REGISTRY_CONTRACT_VERSION) a host forwards over its own RPC.
The host implements RegistryTransport::send for one HTTP exchange. Every
request has already passed the guard: https only (plain http to loopback
only with FetchPolicy::allow_loopback_http), host resolved through the
Resolver, every address public, and redirects re-validated hop by hop (at
most five). The transport must:
- connect only to an address in
TransportRequest::pinned, using the URL's host for TLS and theHostheader; - not follow redirects, and return
3xxresponses as they are; - set
TransportResponse::final_urlto the requested URL; - send every header in
TransportRequest::headersand applyconnect_timeoutto connection setup; - return the body lazily through
BodyChunks, so the registry can stop at a size limit.
A sketch of a reqwest adapter (host code, not part of this crate):
struct ReqwestTransport;
impl RegistryTransport for ReqwestTransport {
fn send(&self, request: TransportRequest) -> BoxFuture<'_, Result<TransportResponse, TransportError>> {
Box::pin(async move {
let url = reqwest::Url::parse(&request.url).map_err(|e| TransportError::Io(e.to_string()))?;
let host = url.host_str().unwrap_or_default().to_owned();
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.no_proxy()
.connect_timeout(request.connect_timeout)
.resolve_to_addrs(&host, &request.pinned)
.build()
.map_err(|e| TransportError::Connect(e.to_string()))?;
let method = match request.method {
HttpMethod::Head => reqwest::Method::HEAD,
_ => reqwest::Method::GET,
};
let mut builder = client.request(method, url);
for (name, value) in &request.headers {
builder = builder.header(name, value);
}
let response = builder.send().await.map_err(|e| {
if e.is_timeout() { TransportError::Timeout } else { TransportError::Connect(e.without_url().to_string()) }
})?;
let headers = response
.headers()
.iter()
.filter_map(|(k, v)| Some((k.to_string(), v.to_str().ok()?.to_owned())))
.collect();
Ok(TransportResponse::new(response.status().as_u16(), request.url, headers, Box::new(ReqwestBody(response))))
})
}
}
struct ReqwestBody(reqwest::Response);
impl BodyChunks for ReqwestBody {
fn next_chunk(&mut self) -> BoxFuture<'_, Result<Option<Vec<u8>>, TransportError>> {
Box::pin(async move {
self.0.chunk().await.map(|c| c.map(|b| b.to_vec())).map_err(|e| TransportError::Io(e.without_url().to_string()))
})
}
}Run from the repository root:
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo build --all-targets --all-features
cargo test --all-features
cargo test --features registryThe registry tests run offline. tests/fixtures/hermes/skills-sample.json
holds 300 real entries from the Hermes skills index, captured on 2026-10-07,
covering every upstream source it aggregates (ClawHub, skills.sh,
LobeHub, GitHub, NVIDIA, OpenAI, Anthropic, HuggingFace, gstack, browse.sh,
and Hermes' built-in and optional skills).
GPL-3.0-only. See LICENSE.
tinyskills-drivers (an unpublished workspace crate that depends on
tinystoragedrivers-core only) puts the host's storage under the crate:
DocumentCatalogStore keeps fetched registry catalogs as documents
(tinyskills_catalogs/<registry>), and BlobBundles keeps bundle files as a
blob tree (<prefix>/<bundle id>/<path>). Discovery needs real directories, so
the host calls BlobBundles::materialize to rebuild a cache directory from the
blob tree; the write goes through materialize_tree with the new
MaterializeSource::Files. The host picks the backend and the tenant scope.
The crate ships no file-backed catalog store; MemoryCatalogStore covers tests and hosts without storage.