diff --git a/rfcs/0008-mvp-skill-registry/0008-mvp-skill-registry.md b/rfcs/0008-mvp-skill-registry/0008-mvp-skill-registry.md new file mode 100644 index 0000000..915d384 --- /dev/null +++ b/rfcs/0008-mvp-skill-registry/0008-mvp-skill-registry.md @@ -0,0 +1,1269 @@ +# RFC 0008: Skill Registry + +| start_date | 2026-04-22 | +| :----------- | :--------- | +| mlflow_issue | https://github.com/mlflow/mlflow/issues/22833 | +| rfc_pr | https://github.com/mlflow/rfcs/pull/26 | + +| Author(s) | [Bill Murdock](https://github.com/jwm4) (Red Hat) | +| :--------------------- | :-- | +| **Date Last Modified** | 2026-07-16 | +| **AI Assistant(s)** | Claude Code, Codex | + +**Table of contents** + +- [Summary](#summary) +- [Basic example](#basic-example) +- [Motivation](#motivation) + - [The problem](#the-problem) + - [User journeys](#user-journeys) + - [Out of scope](#out-of-scope) +- [Detailed design](#detailed-design) + - [Entities and data model](#entities-and-data-model) + - [Status and lifecycle](#status-and-lifecycle) + - [Plugin import](#plugin-import) + - [Pull semantics](#pull-semantics) + - [Workspace scoping](#workspace-scoping) + - [Permissions](#permissions) + - [UI](#ui) + - [Trace integration](#trace-integration) + - [Package manager integration](#package-manager-integration) +- [Drawbacks](#drawbacks) +- [Alternatives](#alternatives) +- [Adoption strategy](#adoption-strategy) + +# Summary + +Add a Skill Registry to MLflow: a governed, metadata-first registry for +AI agent skills. The registry stores metadata and typed source +pointers (to Git repos, OCI registries, ZIP archives, etc.). It can +also store content directly via MLflow artifact storage, but the +primary design is metadata-first. It provides enterprise governance +on top of existing distribution mechanisms: lifecycle management, +usage analytics via traces, and federated discovery across sources. + +The registry manages two entity types under the `mlflow.genai` SDK +namespace (CLI: `mlflow skills`), following the top-level public SDK +pattern established by the MCP Server Registry (RFC-0004). Each has +full lifecycle (versioning, aliases, tags, status). Note: MLflow already has an +`mlflow skills` CLI group with `list` and `view` subcommands for +inspecting bundled Assistant skills. The registry subcommands +(`register`, `pull`, `install`, etc.) extend this existing group; +none of the new subcommand names conflict with the existing ones. +See [implementation-details.md: Python SDK and +CLI](implementation-details.md#python-sdk-and-cli) for details. +The two entity types are: + +- **Skills**: a directory containing a SKILL.md entry point plus + supporting files (scripts, templates, reference material). See the + [Agent Skills specification](https://agentskills.io/) for the + complete format definition. +- **Skill bundles**: versioned collections that group related skills + into a single governed, installable unit + +`mlflow skills pull` provides a harness-agnostic way to fetch +registered content from its source. Harness-specific installation +delegates to package managers (APM, Lola, or others via a plugin +interface) that already support cross-harness skill installation. +Existing Claude Code plugins can be imported as monolithic bundles: +MLflow registers their discovered skills, preserves the plugin source, +and warns about non-skill content that is pulled and installed alongside +the skills but does not receive individual registry entries in Phase 1. + +Trace integration supports both manual and automatic instrumentation. +`mlflow.skill_context()` lets SDK applications create SKILL spans +explicitly. For installed skills, the Phase 1 Claude Code autologger +uses the install-time trace manifest to create SKILL spans automatically. +Both paths annotate spans with registry coordinates, enabling adoption +tracking, deprecation impact analysis, per-skill cost attribution, and +regression detection. + +A follow-up RFC will add registry entries for non-skill bundle +members (e.g., subagents, MCP server references). + +# Basic example + +## Register a skill + +```python +import mlflow + +mlflow.genai.register_skill( + name="code-review", + version="1.0.0", + description="Reviews pull requests for correctness, style, and security", + source_type="git", + source="https://github.com/acme/agent-skills.git@v1.0.0", + subpath="code-review", +) +``` + +## Create a skill bundle + +```python +from mlflow.genai import SkillMemberRef + +mlflow.genai.create_skill_bundle_version( + name="pr-workflow", + version="1.0.0", + skills=[ + SkillMemberRef(name="code-review", version="1.0.0"), + SkillMemberRef(name="style-check", version="2.0.0"), + ], +) +``` + +## Import an existing plugin + +```bash +mlflow skills import \ + --source https://github.com/acme/plugins.git@v1.0.0 \ + --subpath pr-workflow \ + --plugin-format claude-code \ + --bundle-name pr-workflow \ + --version 1.0.0 +``` + +MLflow discovers and registers the plugin's skills as members of a +monolithic bundle. It preserves the Git source on the bundle and warns +about subagents, hooks, and MCP configurations that are not registered +in Phase 1. + +## Install and use + +```bash +# Install a skill bundle for Claude Code via a package manager +mlflow skills install-bundle --name pr-workflow --alias production \ + --harness claude-code + +# Or install a single skill through the same package-manager layer +mlflow skills install --name code-review --alias production \ + --harness claude-code +``` + +## Motivation + +### The problem + +AI agent skills are becoming a critical asset class in enterprise AI +platforms. A cross-harness portable format is emerging around SKILL.md: +a markdown file with structured instructions for the agent, supported +by Claude Code, Codex CLI, Cursor, GitHub Copilot, OpenClaw, Kilo +Code, Antigravity, and others. + +Today, skills are managed as ad-hoc files in Git repositories. This +works well for individual developers and small teams. GitHub provides +versioning, collaboration, and access control. + +However, enterprises face governance challenges that Git alone does not +address: + +1. **No status lifecycle.** Git has no concept of "this version is + approved for production use" vs. "this is deprecated." Teams resort + to branch naming conventions or external tracking to manage + promotion. + +2. **Fragmented discovery.** Skills may live in multiple Git repos, OCI + registries, or other distribution systems. There is no single + discovery layer across all of these. + +3. **No trace-to-skill linkage.** MLflow already traces agent + conversations (Claude Code via `mlflow autolog claude`, SDK + applications via framework autologgers such as + `mlflow.langchain.autolog()` and `mlflow.anthropic.autolog()`). + These traces capture LLM calls, tool use, and token consumption, + but there is no way to know which governed, versioned skill was + active during any part of a trace. This RFC introduces + `mlflow.skill_context()` for manual instrumentation and an + install-time trace manifest for automatic instrumentation via + harness autologgers (see [Trace integration](#trace-integration)). + Without a registry, organizations cannot answer questions like + "which skill versions are most used?" or "show me all traces where + the deprecated code-review v1.0 was loaded." + +4. **No cross-source pull mechanism.** Skills may be distributed via + Git, OCI registries, ZIP archives, or stored directly in MLflow. + There is no standard way to fetch content from all of these with a + single command. + +### User journeys + +These journeys illustrate the end-to-end workflows that the Skill +Registry enables. Each shows both CLI and UI paths. + +#### Register a skill bundle + +1. Register individual skill versions pointing to their sources: + ```bash + mlflow skills register --name code-review --version 1.0.0 \ + --source https://github.com/acme/agent-skills.git@v1.0.0 \ + --subpath code-review + mlflow skills register --name style-check --version 2.0.0 \ + --source https://github.com/acme/agent-skills.git@v2.0.0 \ + --subpath style-check + ``` + **SDK equivalent:** + ```python + import mlflow + + mlflow.genai.register_skill( + name="code-review", + version="1.0.0", + description="Reviews pull requests for correctness, style, and security", + source_type="git", + source="https://github.com/acme/agent-skills.git@v1.0.0", + subpath="code-review", + ) + ``` + **UI path:** Navigate to the Skills page, click "Register Skill," + fill in name, version, source type, and source URL, then submit. +2. Create a skill bundle version that pins these members: + ```bash + mlflow skills create-bundle-version --name pr-workflow --version 1.0.0 \ + --skill code-review:1.0.0 \ + --skill style-check:2.0.0 + ``` + **UI path:** Navigate to the Bundles tab, click "Create Bundle," + add members by searching and selecting from registered skills. +3. Transition the bundle version from draft to active: + ```bash + mlflow skills update-bundle-version --name pr-workflow \ + --version 1.0.0 --status active + ``` + **UI path:** Open the bundle version detail page, use the status + dropdown to change from "draft" to "active." +4. Set an alias for stable downstream resolution: + ```bash + mlflow skills set-bundle-alias --name pr-workflow \ + --alias production --version 1.0.0 + ``` + **UI path:** In the bundle detail page, click "Add Alias" and map + `production` to version `1.0.0`. + +#### Import an existing plugin as a bundle + +1. Import a Claude Code plugin from a remotely accessible source: + ```bash + mlflow skills import \ + --source https://github.com/acme/plugins.git@v1.0.0 \ + --subpath pr-workflow \ + --plugin-format claude-code \ + --bundle-name pr-workflow \ + --version 1.0.0 + ``` +2. MLflow fetches the plugin in the client environment and discovers + directories containing SKILL.md entry points. +3. MLflow registers each discovered skill as an embedded, source-less + skill version and records its path as `member_subpath` in a new + monolithic bundle version. The bundle retains the original plugin + source pointer. +4. If the plugin also contains subagents, hooks, or MCP configuration, + MLflow prints a warning that Phase 1 does not create individual + registry entries for non-skill content. The content remains in the + bundle and is included when the bundle is pulled or installed. +5. The created bundle and skills are available through the same + discovery, lifecycle, pull, and installation flows as manually + registered entries. + +#### Discover a skill for a specific purpose + +1. Search the registry by keyword: + ```bash + mlflow skills search --filter "name LIKE '%review%'" --status active + ``` + **UI path:** Navigate to the Skills page, type "review" in the + search bar, and filter by status "active" using the dropdown. +2. Browse the returned list of matching skills with names, + descriptions, and latest versions. + **UI path:** Scan the card-based list view. Each card shows the + skill name, description, latest version badge, status badge, and + tags. +3. Get details on a promising result: + ```bash + mlflow skills get --name code-review + ``` + **UI path:** Click a card to open the detail view with metadata, + version history, aliases, tags, and bundle memberships. +4. Inspect a specific version's source and metadata: + ```bash + mlflow skills get-version --name code-review --version 1.0.0 + ``` +5. Pull the skill locally to read the content and decide whether + it fits: + ```bash + mlflow skills pull --name code-review --version 1.0.0 \ + --destination ./review-skill + ``` + +#### Install a skill bundle, run the agent, browse traces + +1. Install the bundle using a package manager plugin: + ```bash + mlflow skills install-bundle --name pr-workflow --alias production \ + --harness claude-code + ``` + This resolves the bundle from the registry, pulls the bundle + content, delegates to the configured package manager (e.g., APM + or Lola) for harness-specific installation, and writes a trace + manifest (`mlflow-skills-manifest.json`) with installed registry + coordinates. A single skill uses the same package-manager layer: + ```bash + mlflow skills install --name code-review --alias production \ + --harness claude-code + ``` +2. Run the agent. The harness loads the installed skills during a + conversation. +3. Open the MLflow UI and navigate to the Traces page. Click the + "Skills" tab to filter for traces with SKILL spans. +4. Find the trace for the agent run. Skill invocations appear as + SKILL spans in the trace tree, annotated with registry coordinates + (skill name, version, workspace). +5. Click a SKILL span to see which registered skill version was used + and how long it took. Click the skill name link to navigate to the + skill's registry detail page. + +#### Evaluate two bundle versions with LLM judges + +MLflow's +[LLM judges](https://mlflow.org/docs/latest/genai/eval-monitor/scorers/) +can autonomously explore execution traces via MCP tools. Because +skill invocations produce traced SKILL spans, LLM judges can +analyze how skills were used during an agent run. + +1. Register a new version of the bundle with updated members: + ```bash + mlflow skills register --name code-review --version 2.0.0 \ + --source https://github.com/acme/agent-skills.git@v2.0.0 \ + --subpath code-review + mlflow skills create-bundle-version --name pr-workflow --version 2.0.0 \ + --skill code-review:2.0.0 \ + --skill style-check:2.0.0 + ``` +2. Install v1.0.0 and run it on a set of test inputs. Traces are + recorded in MLflow under experiment A. +3. Install v2.0.0 and run it on the same test inputs. Traces are + recorded under experiment B. +4. Use `mlflow.genai.evaluate()` with a `make_judge` scorer that + uses the `{{ trace }}` template variable to score both sets of + traces against quality criteria (correctness, helpfulness, safety). +5. Compare the evaluation results side by side in the MLflow UI to + determine whether v2.0.0 is an improvement. +6. If v2.0.0 is better, transition it to active and update the + production alias: + ```bash + mlflow skills update-bundle-version --name pr-workflow \ + --version 2.0.0 --status active + mlflow skills set-bundle-alias --name pr-workflow \ + --alias production --version 2.0.0 + ``` + +#### Compare agent performance with and without a skill + +A common evaluation scenario is measuring the impact of adding or +removing a skill from an agent's configuration. This uses the same +evaluation infrastructure as version comparison, but one experiment +runs the agent without the skill installed. + +1. Run the agent without the skill on a set of test inputs. Traces + are recorded in MLflow under experiment A (baseline). +2. Install the skill: + ```bash + mlflow skills install --name code-review --alias production \ + --harness claude-code + ``` +3. Run the same agent on the same test inputs. Traces are recorded + under experiment B. Skill invocations appear as SKILL spans in + the traces. +4. Use `mlflow.genai.evaluate()` with the same scorers on both + experiments: + ```python + baseline_results = mlflow.genai.evaluate( + data=baseline_traces, scorers=scorers, + ) + skill_results = mlflow.genai.evaluate( + data=skill_traces, scorers=scorers, + ) + ``` +5. Compare the evaluation results side by side in the MLflow UI. + The SKILL spans in experiment B's traces confirm which skill + version was active during each run, enabling attribution of any + quality differences. + +The same pattern works for comparing different skill sets: install +bundle A for one experiment, bundle B for another, and evaluate +both against the same inputs and scorers. + +#### Trace skill lineage to evaluation results + +After running evaluations, a user wants to know which registered +skill version was active during a traced agent run. The lineage +path flows through traces: evaluation results link to traces, and +traces contain SKILL spans annotated with registry coordinates. + +1. Run an agent with installed skills. Skill invocations produce + SKILL spans in the recorded traces (see + [Trace integration](#trace-integration)). +2. Run evaluation against the collected traces: + ```python + results = mlflow.genai.evaluate( + data=traces_df, + scorers=[correctness_scorer, helpfulness_scorer], + ) + ``` + Each row in `results.result_df` includes a `trace_id` linking + the evaluation result back to its source trace. +3. Find which skill versions were used in a specific evaluation + result: + ```python + trace = mlflow.get_trace(trace_id) + skill_spans = trace.search_spans(span_type="SKILL") + for span in skill_spans: + print(span.attributes["mlflow.skill.name"], + span.attributes["mlflow.skill.version"]) + ``` +4. Find all traces that used a specific skill version: + ```python + traces = mlflow.search_traces( + experiment_ids=[experiment_id], + filter_string=( + 'span.type = "SKILL"' + ' AND span.attributes.mlflow.skill.name = "code-review"' + ' AND span.attributes.mlflow.skill.version = "1.0.0"' + ), + ) + ``` + Evaluation results for these traces can then be retrieved via + their `trace_id` values. + +This two-step approach (query traces by skill attributes, then +retrieve associated evaluation results) works with the existing +MLflow tracing and evaluation APIs. Richer integration, such as +filtering evaluation results directly by skill version, is +follow-up work (see [Adoption strategy](#adoption-strategy)). + +#### CI pipeline for automated regression detection + +1. A CI job (e.g., GitHub Actions) triggers on pushes to the skill + source repo. +2. The job registers a new skill version from the updated source: + ```bash + mlflow skills register --name code-review --version 1.1.0 \ + --source https://github.com/acme/agent-skills.git@v1.1.0 \ + --subpath code-review + mlflow skills create-bundle-version --name pr-workflow --version 1.1.0 \ + --skill code-review:1.1.0 \ + --skill style-check:2.0.0 + ``` +3. The job installs the new bundle version and runs it against a + benchmark dataset, collecting traces in a dedicated MLflow + experiment. +4. The job runs + [LLM judge](https://mlflow.org/docs/latest/genai/eval-monitor/scorers/) + evaluation on the collected traces, producing scored results. +5. The job fetches the benchmark results from the previous production + version (stored as MLflow metrics or evaluation artifacts). +6. The job compares the new scores against the previous scores. If + any quality metric regresses beyond a configured threshold, it + sends an alert (Slack, email, or fails the CI check). +7. If no regression is detected, the job transitions the new version + to active and optionally updates the production alias. + +See [implementation-details.md: SDK and CLI code +examples](implementation-details.md#sdk-and-cli-code-examples) for +additional SDK examples including OCI subpath registration and +discovery/search operations. + +### Out of scope + +- **Registry entries for non-skill content.** Bundles can contain + non-skill content (e.g., subagents, MCP configurations) that is + pulled and installed alongside skills, but Phase 1 does not create + individual registry entries for non-skill members. A follow-up RFC + will add those entries. The registry backend is designed to be + extensible to these types. +- **Artifact storage as the only path.** The registry supports both + external source pointers (Git, OCI, ZIP) and direct artifact storage + (`source_type="mlflow"`). However, it is not an artifact-only store; + the metadata-first, source-pointer model remains the primary design. +- **Authoring or development tools.** The registry manages published + skills, not the process of writing them. +- **Format specification.** The registry is format-agnostic. It does + not define what a skill must contain or how it must be structured. + The SKILL.md convention is an ecosystem convention, not a registry + requirement. +- **Agent routing or orchestration.** The registry is a metadata and + governance layer. It does not decide which skills to invoke at + runtime or how agents compose capabilities. +- **MCP server hosting.** MCP server deployment and runtime management + are covered by the MCP Server Registry (RFC-0004) and the MCP + Gateway. +- **Prompts.** MLflow already has a Prompt Registry for versioned + prompt template management. Skills and prompts serve different + purposes: a skill is a directory containing a SKILL.md entry point + plus supporting files, with metadata controlling invocation. A + prompt provides templated text for structured generation. Skills may + reference prompts, but they belong in separate registries because + they have different lifecycles and different audiences (harness-based + agents vs. custom agentic code). +- **Custom harness adapters.** This RFC does not build per-harness + installation adapters. Instead, it delegates harness-specific + installation to existing package managers (APM, Lola) via a plugin + interface. + +## Detailed design + +### Entities and data model + +```mermaid +erDiagram +Skill ||--o{ SkillVersion : "has versions" +Skill ||--o{ SkillTag : "has tags" +Skill ||--o{ SkillAlias : "has aliases" +SkillBundle ||--o{ SkillBundleVersion : "has versions" +SkillBundle ||--o{ SkillBundleTag : "has tags" +SkillBundle ||--o{ SkillBundleAlias : "has aliases" +SkillBundleVersion ||--o{ SkillBundleVersionMember : "has members" +SkillBundleVersionMember }o--|| SkillVersion : "skill member" + +SkillBundleVersionMember { + string member_type + string member_name + string member_version + string member_subpath +} +``` + +#### Skill + +A skill is a directory containing a SKILL.md entry point plus +supporting files (scripts, templates, reference material). The +`Skill` entity is the logical governed asset, scoped to a workspace. +Key fields include `name` (unique within workspace), `display_name`, +`status` (read-only, derived from the parent-resolved version), +`latest_version` (read-only, highest active semver), and `aliases`. + +#### SkillVersion + +A versioned record containing a typed source pointer (`git`, `oci`, +`zip`, or `mlflow`), status, and tags. The `(name, version)` pair is +unique within a workspace. Source pointers and version strings are +immutable after creation; to point to different content, register a +new version. The optional `subpath` field identifies content within a +shared artifact (used with Git, OCI, and ZIP). The optional +`content_digest` field enables integrity verification. + +`register_skill()` creates the parent Skill when needed and otherwise +reuses the existing parent. If the target `(name, version)` already +exists, registration fails with an error. This matches the MCP Server +Registry behavior (`register_mcp_server()` in mlflow/mlflow#23696). + +#### SkillBundle + +A skill bundle groups related skills into a governed unit that maps +to the "plugin" concept in agent harnesses: a curated set of +capabilities that work together. Bundles are a first-class entity +rather than a tag-based grouping because they provide versioned +membership snapshots (reproducible point-in-time combinations), +bundle-level source pointers (a single OCI image or Git repo), +independent lifecycle (deprecate a bundle without deprecating its +members), and direct mapping to the harness plugin concept. Follows +the same top-level pattern as Skill: versions, tags, aliases, and +derived status. + +A follow-up RFC will add registry entries for non-skill bundle +members (e.g., subagents, MCP server references), enabling full +"plugin"-style bundles. +The member table schema includes a `member_type` field for forward +compatibility with this extension. + +#### SkillBundleVersion + +A versioned snapshot of a bundle's membership. A bundle version is +one of two kinds: + +- **Assembled:** captures member references for individual skills. + Each skill version has its own source. `pull` fetches members + individually. +- **Monolithic:** has its own source pointer (e.g., a single OCI + image or Git repo containing multiple skills) and member + references. Skill member versions may omit their own sources when + their content lives inside the bundle artifact. A source-less member + must provide `member_subpath` to identify where it lives inside the + bundle artifact. `pull` fetches the bundle artifact as a unit. + +A bundle version cannot have both a bundle-level source and skill +member versions with their own sources. This avoids confusion about +which source is authoritative for skill content. + +#### Aliases and tags + +All entity types use the same alias pattern: a frozen `(name, alias, +version)` tuple mapping a stable name (e.g., `production`) to a +specific version string. Tags are `(key, value)` pairs at both the +entity level and version level. + +Dataclass definitions, field tables, source type details, and +database schema for all entity types are in +[implementation-details.md](implementation-details.md). + +### Status and lifecycle + +This lifecycle aligns with the MCP Server Registry (RFC-0004). + +#### Per-version status + +Each `SkillVersion` and `SkillBundleVersion` has an independent +status: + +```mermaid +stateDiagram-v2 + [*] --> draft + draft --> active : publish + draft --> deleted : discard + active --> draft : unpublish + active --> deprecated + deprecated --> active : re-activate + deprecated --> deleted +``` + +| State | Meaning | Downstream surfacing | +|---|---|---| +| `draft` | Registered but not yet ready for downstream use | Not surfaced to consumers | +| `active` | Ready for downstream use | Surfaced to discovery, traces, consumers | +| `deprecated` | Still functional but no longer recommended | Surfaced with deprecation signal | +| `deleted` | Soft-deleted; preserved internally for history, no longer active | Not surfaced by normal get/search/list APIs | + +New versions default to `draft` upon creation. + +Allowed transitions: + +| From | To | +|---|---| +| `draft` | `active`, `deleted` | +| `active` | `draft`, `deprecated` | +| `deprecated` | `active`, `deleted` | + +`draft` allows a version to be registered and reviewed before being +made visible to consumers. `active` can return to `draft` (unpublish) +for cases where a version needs to be pulled back for further review. +`deprecated` can return to `active` (re-activate) for cases where a +deprecation was premature. `deleted` is terminal. + +Normal version delete operations (`delete_skill_version` and +`delete_skill_bundle_version`) transition the version to `deleted` +rather than physically removing the version row. Active versions must +first be unpublished or deprecated before they can be deleted. +Deleting a version also removes aliases that point to that version. + +Top-level entity delete operations (`delete_skill` and +`delete_skill_bundle`) are administrative hard deletes that remove the +parent and cascade to child rows, following the Model Registry +registered-model pattern. These operations are subject to +referential-integrity checks: a skill version referenced by a bundle +version cannot be physically removed until the referencing bundle +version is removed or otherwise no longer references it. Normal +retirement should use version deprecation or version soft delete +rather than top-level hard delete. + +#### Entity-level status + +`Skill.status` and `SkillBundle.status` are read-only. They are +derived from the parent-resolved version: the highest semantic version +among `active` versions if one exists, otherwise the highest semantic +version among non-`deleted` non-`active` versions. Deleted versions +never drive parent status. This follows the MCP Server Registry +pattern (RFC-0004). + +#### `latest_version` resolution + +Version strings must follow [semantic versioning](https://semver.org/) +(e.g., `1.0.0`, `2.1.0-beta.1`). `get_latest_skill_version(name)` +returns the highest semantic version among `active` versions if one +exists, otherwise the highest semantic version among non-`deleted` +non-`active` versions. Prerelease identifiers participate in +semantic-version ordering, while build metadata does not. +`latest_version` is a read-only computed field on the parent entity +(not manually pinnable); aliases cover the use case of pointing a +stable name (e.g., `production`) at a specific version. + +The alias name `latest` is reserved: `set_skill_alias(..., +alias="latest", ...)` is rejected, while +`get_skill_version_by_alias(..., alias="latest")` is treated as a +convenience alias for `get_latest_skill_version(...)`. + +The same rule applies to skill bundles: +`set_skill_bundle_alias(..., alias="latest", ...)` is rejected, while +`get_skill_bundle_version_by_alias(..., alias="latest")` delegates to +`get_latest_skill_bundle_version(...)`. + +This aligns with the MCP Server Registry (RFC-0004). + +### Plugin import + +`mlflow skills import` is a client-side convenience operation for +registering an existing harness-specific plugin as a monolithic bundle. +Phase 1 supports the Claude Code plugin layout. +Additional input formats can be added later without changing the +registry data model. + +Before importing, users can call `mlflow skills introspect` or the SDK +`introspect_bundle()` function to preview the skills and unregistered +non-skill content that MLflow discovers. Introspection is read-only, +accepts either a local path or a remotely accessible source, and does not +create registry records. Import still requires a remote source so the +registered bundle retains a pullable source pointer. + +The client fetches the plugin from a Git, OCI, ZIP, or MLflow artifact +source and inspects it locally. It discovers directories containing a +SKILL.md entry point, creates embedded skill versions without individual +source pointers, and records each directory as the membership +`member_subpath`. It then creates a monolithic bundle version whose +source fields preserve the original plugin location. + +Subagents, hooks, MCP configurations, and other non-skill content remain +in the source artifact but are not registered as entities or members in +Phase 1. The import result reports a warning for each skipped category. +Import does not install the plugin, generate a downstream manifest, or +translate an MLflow bundle into another bundle format. + +The bundle version must have a valid semantic version, supplied by the +caller or read from supported plugin metadata. Embedded skills use the +bundle version. Import never overwrites or reuses an existing skill or +bundle version. The client checks for naming and version conflicts +before creating registry records and fails the import if any target +`(name, version)` already exists. + +See [implementation-details.md: Plugin +import](implementation-details.md#plugin-import) for the SDK return +type, CLI mapping, discovery rules, and conflict behavior. + +### Pull semantics + +`pull` is a client-side operation. The SDK reads the source pointer +from the registry via the REST API, then fetches content directly +from the source system to the caller's local filesystem. The registry +server is not involved in content transfer. `pull` is +source-type-aware: + +| Source type | Pull behavior | +|---|---| +| `git` | `git clone` or `git archive` of the referenced path/ref | +| `oci` | `oci pull` of the referenced image/tag; if `subpath` is set, extract only that path from the image | +| `zip` | HTTP download and extract; if `subpath` is set, extract only that path from the archive | +| `mlflow` | Download the version's MLflow-managed artifact directory tree using the same artifact APIs and credentials as other MLflow artifact operations | + +**Single skill pull.** Fetches the content at the skill version's +`source` to the destination directory. If `subpath` is set, only the +content at that path within the artifact is extracted. Returns an +error if the skill version has no source; source-less embedded skill +versions are pullable only through their containing monolithic +bundle. + +**Skill bundle pull.** For monolithic bundles, fetch the bundle +artifact as a single unit to the destination directory. For assembled +bundles, pull each member individually from its own `source` to a +subdirectory of the destination, named by the member's name. If a +skill member in an assembled bundle has no `source`, the pull fails +rather than producing a partial local bundle. + +If `content_digest` is set, `pull` verifies the fetched content +matches the digest and returns an error on mismatch. This +verification is client-side. The server stores the digest as metadata +but does not re-verify artifact store contents on each request. + +`pull` is harness-agnostic. It downloads content but does not generate +harness-specific manifests or place files in harness-specific +directories. Harness-specific installation is handled by package +manager plugins (see [Package manager +integration](#package-manager-integration)). + +See [implementation-details.md: Pull semantics +details](implementation-details.md#pull-semantics-details) for source +authentication mechanisms, error handling, and credential management. + +### Workspace scoping + +All skill registry operations are workspace-scoped, following MLflow's +existing workspace-aware registry patterns (model registry, MCP +registry). Cross-workspace sharing is out of scope for this RFC and +should be solved at the platform level across all MLflow registries. + +### Permissions + +The skill registry integrates with MLflow's existing permission +framework (READ / EDIT / MANAGE), applied at the `Skill` and +`SkillBundle` level. Versions, tags, aliases, and memberships inherit +permissions from their parent entity. + +| Permission | Operations | +|---|---| +| `READ` | Search entities, get versions, resolve aliases, list tags and memberships | +| `EDIT` | Create entities, create versions, set tags, update description, status transitions (activate, deprecate), set aliases. Mapped to `can_update` in MLflow's permission framework. | +| `MANAGE` | Delete aliases, delete tags, soft-delete versions, hard-delete entities, manage permissions. Mapped to `can_delete` in MLflow's permission framework. | + +This follows the same pattern as the model registry and MCP Server +Registry (RFC-0004). +- **Creator gets MANAGE.** When a user creates an entity (skill or + bundle), they automatically receive MANAGE permission, following + the MLflow model registry pattern. + +### UI + +The Skills page lives under the GenAI workflow in the MLflow sidebar, +alongside Experiments, Prompts, MCP Servers, and AI Gateway. + +#### List view + +The list view shows skills and bundles together using a card-based +layout consistent with the MCP Server Registry (RFC-0004). Each card +displays: + +- Entity type badge (skill or bundle) +- Name and optional display name +- Description (truncated to 2-3 lines) +- Latest version badge (e.g., "v1.0.0") +- Status badge with color coding: draft (gray), active (green), + deprecated (amber) +- Source type indicator (Git, OCI, ZIP, MLflow) +- Tag chips + +The filter bar provides: + +- **Type dropdown**: skill, bundle +- **Status dropdown**: draft, active, deprecated +- **Source type dropdown**: git, oci, zip, mlflow +- **Search**: by name or description + +A "Register Skill" button (with a dropdown for bundle) initiates +registration. + +#### Detail view: skills + +The detail view for an individual skill shows: + +- **Metadata section**: name, display name, description, status, + workspace, source type, created by, created at, last updated +- **Version table**: Version, Registered at, Status, Source type, + Created by, Description. Clicking a version row navigates to the + version detail page showing source, subpath, content digest, and + tags. +- **Aliases**: alias name to version mapping (e.g., + `production -> 1.0.0`) +- **Tags**: key-value list with edit controls +- **Bundle memberships**: list of bundles that include this skill, + with links to each bundle's detail page +- **Related traces**: link to the GenAI Traces page filtered by this + skill's name, showing recent SKILL spans that reference this skill + +#### Detail view: bundles + +The bundle detail view shows: + +- **Metadata section** (as above) +- **Members table** for the selected bundle version: Name, Pinned + version, Source type, Status. Each row links to the member skill's + detail page. +- **Version history table**: Version, Registered at, Status, Created + by, Member count +- **Aliases and tags** (as above) + +#### Trace integration display + +The GenAI Traces page includes a "Skills" tab alongside the existing +"Prompts" tab, showing SKILL spans for each trace. The trace detail +view displays SKILL spans with registry coordinates (skill name, +version, workspace) and links to the skill's registry detail page. +Skill version detail pages surface related traces using the same +association data. + +### Trace integration + +MLflow already traces agent conversations across multiple frameworks: +Claude Code (via `mlflow autolog claude`), SDK applications (via +framework autologgers such as `mlflow.langchain.autolog()` and +`mlflow.anthropic.autolog()`), and others. These traces capture LLM +calls, tool use, and timing as a tree of spans. The skill registry +closes the observability loop by letting agent developers indicate +which registered skill is active during each part of a trace. + +#### `mlflow.skill_context()` context manager + +The primary instrumentation API is a context manager that creates a +span of type `SKILL` and attaches registry coordinates as span +attributes: + +```python +with mlflow.skill_context( + name="code-review", version="1.0.0" +) as span: + # All spans created inside this block (including those from + # autologgers) become children of this SKILL span. + result = llm.chat([{"role": "user", "content": "Review this code..."}]) +``` + +The context manager creates a span with `mlflow.skill.name`, +`mlflow.skill.version`, and `mlflow.skill.workspace` +attributes that link the span back to a specific skill version in +the registry. See [implementation-details.md: skill_context() span +attributes](implementation-details.md#skill_context-span-attributes) +for the full attribute table. + +#### Skill stacks via nesting + +Skills can invoke other skills. Nesting `skill_context()` calls +produces a skill stack in the trace tree: + +``` ++-- Span: "code-review" (type: SKILL, version: 1.0.0) +| +-- Span: ChatCompletion (type: LLM) +| +-- Span: "style-check" (type: SKILL, version: 2.0.0) +| | +-- Span: ChatCompletion (type: LLM) +| +-- Span: ChatCompletion (type: LLM) +``` + +Walking up the ancestor chain and collecting SKILL spans reconstructs +the skill stack for any span. + +#### Framework autologger compatibility + +Because `skill_context()` creates a standard MLflow span, it works +with existing framework autologgers without modification. When a +framework autologger (LangChain, OpenAI, Anthropic, etc.) creates a span +inside a `skill_context()` block, that span automatically becomes a +child of the SKILL span. + +#### Automatic harness instrumentation + +Phase 1 extends the Claude Code autologger to recognize skill +invocations and create SKILL spans automatically. The autologger reads +the `mlflow-skills-manifest.json` written during installation, maps the +harness-local skill name to its registered `{workspace, name, version}` +coordinates, and creates a SKILL span around the invocation. LLM and +tool spans produced while the skill is active become children of that +span. + +Automatic instrumentation runs in the process that owns the active +trace, so it can preserve correct parent-child relationships without +cross-process trace correlation. It does not perform a registry lookup +during invocation. A missing, malformed, or unmatched manifest entry +does not interrupt the agent run or other autologging; it only prevents +creation of a registry-linked SKILL span for that invocation. + +The manifest and instrumentation contract are harness-neutral so other +harness autologgers can adopt them later, but Claude Code integration is +the automatic tracing implementation delivered in Phase 1. See +[implementation-details.md: Automatic trace +instrumentation](implementation-details.md#automatic-trace-instrumentation). + +#### Registry validation + +`skill_context()` does not validate that the named skill exists in +the registry at call time. Validating on every invocation would add +latency and create a hard dependency on registry availability. The +trace records the `{workspace, name, version}` coordinates +regardless; the MLflow UI performs a best-effort lookup when +displaying traces and shows a "not found in registry" indicator if +the coordinates do not resolve. + +#### Workspace resolution + +When `skill_context()` is called, the workspace is resolved from +the `mlflow-skills-manifest.json` written by the install commands. +The manifest contains the workspace for each installed skill. +For SDK users calling `skill_context()` directly without a manifest, +the workspace defaults to the current MLflow tracking URI's workspace +context, consistent with other MLflow operations. + +#### Relationship to MCP trace linking + +The MCP Registry (RFC-0004) uses after-the-fact, trace-level +association (`link_mcp_server_versions_to_trace()`). Skills use +span-level, inline annotation because skills are ambient (active +during inference) and can nest. Both approaches produce trace +metadata that the MLflow UI can display together. + +### Package manager integration + +Rather than building custom harness adapters for each agent harness, +the skill registry delegates harness-specific installation to existing +package managers that already support cross-harness skill +installation. This avoids duplicating work that projects like +[APM](https://github.com/microsoft/apm) and +[Lola](https://github.com/LobsterTrap/lola) already handle well, and +lets the MLflow community benefit from their evolving harness support. + +The Phase 1 registry boundary is intentionally narrow. MLflow creates +registry entries only for skills within a bundle; non-skill content +(e.g., subagents, MCP configurations) remains in the bundle source and +is included when the bundle is pulled or installed, but does not receive +individual registry entries. A follow-up RFC will add registry entries +for non-skill member types. MLflow resolves bundles into concrete skill +sources or local paths, then passes those to an existing package manager +for installation. + +#### Installation commands + +Both harness-aware installation commands delegate to a configured +package manager plugin: + +1. **Single-skill install** (`mlflow skills install`): MLflow resolves + one registered skill and materializes its content locally, then calls + the plugin's `install_skill()` operation. + +2. **Bundle install** (`mlflow skills install-bundle`): MLflow resolves + a bundle and materializes its content locally (including any non-skill + content in monolithic bundles), then calls the plugin's + `install_bundle()` operation. + +MLflow owns registry and source resolution plus the trace manifest. The +package manager owns all harness-specific behavior, including directory +placement and any package-manager or harness manifest generation. Both +installation commands require a `--harness` argument, which MLflow +passes to the plugin. Users who only want to download content without +installing it into a harness use the package-manager-free +`mlflow skills pull` command. + +**Harness selection.** The `--harness` argument is required on both +installation commands. While some package managers can auto-detect the +target harness (APM detects harness signals in the project; Lola installs +to all detected assistants by default), detection behavior varies across +plugins and can produce surprising results. A required argument keeps +the MLflow interface predictable regardless of which plugin is +configured. + +**Reproducible installation.** MLflow defines a small resolution lock, +`mlflow-skills.lock`, that records the exact registry coordinates and +installation inputs selected by an install command: entity type, name, +resolved version, workspace, package manager, harness, and scope. Aliases +are resolved before they are written. Passing `--lock-file` to either +installation command writes or updates this file; `mlflow skills +install --from-lock` replays it by resolving the pinned registry versions +and delegating them to the recorded package manager. + +Package-manager lockfiles complement the MLflow resolution lock rather +than replace it. APM provides `apm.lock.yaml` with resolved commits and +content hashes. Lola provides version-constraint files (`.lola-req`) and +ref pinning but no lockfile. The MLflow lock makes registry resolution +reproducible across both plugins; a package-manager lock can additionally +capture package-manager-specific layout and integrity information. + +#### Package manager plugin interface + +Package manager plugins are registered via Python entrypoints (group +`mlflow.skill_package_managers`), so third-party plugins can be +installed via `pip install` without modifying MLflow core. + +```python +class PackageManagerPlugin: + def install_skill( + self, + name: str, + local_path: str, + harness: str, + scope: str = "project", + ) -> PackageManagerInstallResult: + """Install a single skill from a local path. + Returns the installed path and harness-local skill name.""" + ... + + def install_bundle( + self, + bundle_name: str, + member_paths: dict[str, str], + harness: str, + bundle_path: str | None = None, + scope: str = "project", + ) -> PackageManagerInstallResult: + """Install a bundle from local paths. For monolithic bundles, + bundle_path is the complete artifact root, including opaque + non-skill content. Returns the installed path and mapping from + registry skill names to harness-local names.""" + ... + + def supported_harnesses(self) -> list[str]: + """Return list of harness identifiers this plugin supports.""" + ... +``` + +#### Source resolution flow + +When `mlflow skills install-bundle` is invoked: + +1. MLflow resolves the bundle version from the registry (by name + + version or alias). +2. MLflow materializes a local path for each member skill according to + the bundle kind: + - For an assembled bundle, MLflow pulls each member from its own + source to a local temporary directory. + - For a monolithic bundle, MLflow pulls the bundle source once and + retains the complete pulled root as `bundle_path`, including opaque + non-skill content, while resolving each skill path from that root + and the member's `member_subpath`. +3. MLflow passes the skill-name-to-local-path mapping and, for a + monolithic bundle, the complete `bundle_path` to the configured + package manager plugin. The plugin handles harness-specific placement + of the entire bundle and returns the actual harness-local name for + each installed skill. +4. MLflow writes `mlflow-skills-manifest.json` using the returned + harness-local names and the corresponding registry coordinates. +5. If requested, MLflow updates the resolution lock with the exact + bundle version and installation inputs. + +When `mlflow skills install` is invoked for a single skill, MLflow +resolves the version, pulls its content to a local temporary directory, +passes that path to the configured plugin's `install_skill()` operation, +and writes the trace manifest using the harness-local name returned by +the plugin after installation succeeds. If requested, it then updates +the resolution lock with the exact skill version and installation inputs. + +#### Trace manifest + +Both installation commands write an `mlflow-skills-manifest.json` file +that records installed registry coordinates. In Phase 1, the Claude +Code autologger consumes this manifest for automatic SKILL span +creation: + +```json +{ + "manifest_version": "1.0", + "skills": { + "code-review": { + "name": "code-review", + "version": "1.0.0", + "workspace": "default" + }, + "style-check": { + "name": "style-check", + "version": "2.0.0", + "workspace": "default" + } + } +} +``` + +Project-scoped installs write the manifest at the project root. +User-scoped installs write it in the MLflow user configuration +directory. Project entries take precedence over user entries with the +same harness-local skill name. See [implementation-details.md: +Automatic trace +instrumentation](implementation-details.md#automatic-trace-instrumentation) +for discovery, matching, span lifecycle, and failure behavior. + +### Implementation details + +Database schema (table definitions), store interface (method +signatures), entity dataclass definitions, REST API endpoints, +pagination/filtering, SDK convenience functions, and CLI mapping are +in [implementation-details.md](implementation-details.md). + +## Drawbacks + +- **Source pointer validity.** For external sources (git, oci, zip), + the registry cannot guarantee pointers remain valid. The optional + `content_digest` field mitigates content tampering but does not + prevent link rot. Users who need self-contained storage can use + `source_type="mlflow"` to store content directly in MLflow artifact + storage. + +- **Artifact upload atomicity.** Client-side artifact upload and skill + version creation are separate operations. The client performs + best-effort cleanup when version creation fails, but an artifact + backend without deletion support can retain unreferenced uploaded + files until garbage collection. + +- **Package manager dependency.** Full harness-specific installation + requires a package manager plugin (APM, Lola, or similar). Users + who do not install a package manager can still use `mlflow skills + pull` for harness-agnostic content download. + +- **Automatic tracing coverage.** Phase 1 automatic instrumentation is + implemented for Claude Code. Other harnesses can use manual + `skill_context()` instrumentation until their autologgers adopt the + manifest contract. + +# Alternatives + +## Store skill artifacts only in MLflow (no source pointers) + +Make MLflow artifact storage the sole storage mechanism, with no +support for external source pointers. + +Rejected because most organizations already manage skills in Git or +OCI. Source pointers federate across existing distribution mechanisms +without requiring migration. The current design supports both: +`source_type="mlflow"` for direct artifact storage alongside +`source_type="git"`, `"oci"`, and `"zip"` for external sources. + +## Use Git alone (no registry) + +Continue using Git repositories as the sole mechanism for skill +management. + +This is sufficient for individual developers and small teams. This RFC +proposes a governance layer on top of Git for enterprises that need +status lifecycle, trace-to-skill linkage, and federated discovery. +The two approaches are complementary. + +## Build custom harness adapters in MLflow + +Build per-harness installation adapters within MLflow (as proposed in +the earlier version of this RFC). + +Rejected in favor of delegating to existing package managers. APM and +Lola already support 8+ and 6+ harnesses respectively, and their +harness support evolves independently of MLflow releases. Building +custom adapters would duplicate this work and create an ongoing +maintenance burden as harness plugin formats evolve. The plugin +interface allows MLflow to integrate with any package manager without +coupling to a specific one. + +## Use APM or Lola directly without a registry + +Use a client-side package manager (APM, Lola, or `gh skill install`) +as the sole mechanism for skill management. + +These tools solve the client-side "make it portable and reproducible" +problem well. However, they are not server-side registries and do not +provide the governance and observability features that enterprises +need: + +- **Lifecycle management.** No concept of draft, active, deprecated, + or deleted status. No way to signal consumers that a skill version + is deprecated or approved for production. +- **Rich discovery.** Limited search and metadata capabilities. No + centralized catalog with tags, descriptions, and compatibility + information. +- **Trace integration.** No connection between installed skills and + runtime execution traces. No way to answer "which skill version + was active during this agent run?" +- **RBAC and workspace scoping.** No per-user or per-team access + controls. No visibility boundaries between teams or projects. + +The skill registry and package managers are complementary: the +registry provides the server-side governance, discovery, and +observability layer, while package managers handle client-side +installation and harness-specific adaptation. + +# Adoption strategy + +New feature, not a breaking change. Phased rollout: + +- **Phase 1 (this RFC):** Skill and SkillBundle entities, store, + REST API, SDK, CLI, UI, `mlflow skills pull`, + plugin import, package-manager-backed single-skill and + bundle installation, the package manager plugin interface, + the `mlflow-skills.lock` resolution lock, + `mlflow.skill_context()` for manual trace integration, the install-time + trace manifest, and automatic SKILL spans in the Claude Code + autologger. +- **Phase 2 (follow-up RFC):** Add individual registry entries for + non-skill bundle members (e.g., subagents, MCP server references). + +# Open questions + +- **Security scan results.** Structured scan metadata on version + entities (scan type, pass/fail status, tool, date) would be valuable + for skill governance. However, the same need applies to MCP servers + (RFC-0004) and other registered assets. This should be addressed as a + cross-registry capability rather than a skill-specific feature, so + that all registries share a consistent scan result model. diff --git a/rfcs/0008-mvp-skill-registry/implementation-details.md b/rfcs/0008-mvp-skill-registry/implementation-details.md new file mode 100644 index 0000000..fca36e2 --- /dev/null +++ b/rfcs/0008-mvp-skill-registry/implementation-details.md @@ -0,0 +1,1845 @@ +# RFC-0008: Skill Registry Implementation Details + +This document contains implementation-level specifications for +RFC-0008 (Skill Registry). It covers database schema, entity +dataclasses, store interface method signatures, REST API endpoints, +pagination/filtering, SDK convenience functions, CLI mapping, package +manager plugin interface, and trace integration details. These details +support implementers; the main RFC covers the design rationale. + +## Database schema + +Tables are created via a single Alembic migration. All tables are +workspace-scoped. + +### `skills` + +| Column | Type | Notes | +|--------|------|-------| +| `workspace` | `String(63)` | PK, default `'default'` | +| `name` | `String(256)` | PK | +| `display_name` | `String(256)` | mutable human-readable label | +| `description` | `String(5000)` | | +| `created_by` | `String(256)` | | +| `last_updated_by` | `String(256)` | | +| `creation_timestamp` | `BigInteger` | millis since epoch | +| `last_updated_timestamp` | `BigInteger` | millis since epoch | + +### `skill_versions` + +| Column | Type | Notes | +|--------|------|-------| +| `workspace` | `String(63)` | PK, FK | +| `name` | `String(256)` | PK, FK | +| `version` | `String(256)` | PK, valid semantic version | +| `version_major` | `Integer` | extracted from validated semantic version | +| `version_minor` | `Integer` | extracted from validated semantic version | +| `version_patch` | `Integer` | extracted from validated semantic version | +| `version_prerelease_sort_key` | `String(512)` | lexicographically sortable encoding of prerelease identifiers | +| `display_name` | `String(256)` | mutable human-readable label | +| `source_type` | `String(20)` | nullable; `git`, `oci`, `zip`, `mlflow` | +| `source` | `String(2048)` | nullable pointer to skill content | +| `subpath` | `String(2048)` | nullable; path within the artifact | +| `content_digest` | `String(512)` | optional integrity digest | +| `status` | `String(20)` | default `'draft'` | +| `created_by` | `String(256)` | | +| `last_updated_by` | `String(256)` | | +| `creation_timestamp` | `BigInteger` | millis since epoch | +| `last_updated_timestamp` | `BigInteger` | millis since epoch | + +FK: `(workspace, name)` references `skills`, CASCADE delete. This +supports administrative hard deletion of the parent `Skill`; normal +version deletion is a status transition to `deleted` and does not +physically remove the version row. + +**Semantic version ordering**: `version_major`, `version_minor`, +`version_patch`, and `version_prerelease_sort_key` are materialized +from the validated semantic version string at write time. The +prerelease sort key is a lexicographically sortable encoding of the +prerelease identifiers, following the approach in the MCP Server +Registry implementation (mlflow/mlflow#23952). Release versions +encode to a sentinel that sorts above all prerelease encodings, so +full semver precedence is resolved in SQL without application-level +tie-breaking. Build metadata is ignored for precedence. + +**Index**: `ix_skill_versions_latest_lookup` on `(workspace, name, +status, version_major, version_minor, version_patch)` supports +latest-resolution lookups. The prerelease sort key is not indexed +because the major/minor/patch prefix provides sufficient pruning. + +### `skill_tags` + +| Column | Type | Notes | +|--------|------|-------| +| `workspace` | `String(63)` | PK, FK | +| `name` | `String(256)` | PK, FK | +| `key` | `String(256)` | PK | +| `value` | `Text` | | + +### `skill_version_tags` + +| Column | Type | Notes | +|--------|------|-------| +| `workspace` | `String(63)` | PK, FK | +| `name` | `String(256)` | PK, FK | +| `version` | `String(256)` | PK, FK | +| `key` | `String(256)` | PK | +| `value` | `Text` | | + +### `skill_aliases` + +| Column | Type | Notes | +|--------|------|-------| +| `workspace` | `String(63)` | PK, FK | +| `name` | `String(256)` | PK, FK | +| `alias` | `String(256)` | PK | +| `version` | `String(256)` | target version string | + +### `skill_bundles` + +| Column | Type | Notes | +|--------|------|-------| +| `workspace` | `String(63)` | PK, default `'default'` | +| `name` | `String(256)` | PK | +| `display_name` | `String(256)` | mutable human-readable label | +| `description` | `String(5000)` | | +| `created_by` | `String(256)` | | +| `last_updated_by` | `String(256)` | | +| `creation_timestamp` | `BigInteger` | millis since epoch | +| `last_updated_timestamp` | `BigInteger` | millis since epoch | + +### `skill_bundle_versions` + +| Column | Type | Notes | +|--------|------|-------| +| `workspace` | `String(63)` | PK, FK | +| `name` | `String(256)` | PK, FK | +| `version` | `String(256)` | PK, valid semantic version | +| `version_major` | `Integer` | extracted from validated semantic version | +| `version_minor` | `Integer` | extracted from validated semantic version | +| `version_patch` | `Integer` | extracted from validated semantic version | +| `version_prerelease_sort_key` | `String(512)` | lexicographically sortable encoding of prerelease identifiers | +| `display_name` | `String(256)` | mutable human-readable label | +| `source_type` | `String(20)` | optional; `git`, `oci`, `zip`, `mlflow` | +| `source` | `String(2048)` | optional pointer to bundle artifact | +| `subpath` | `String(2048)` | nullable; path within the artifact | +| `content_digest` | `String(512)` | optional integrity digest | +| `status` | `String(20)` | default `'draft'` | +| `created_by` | `String(256)` | | +| `last_updated_by` | `String(256)` | | +| `creation_timestamp` | `BigInteger` | millis since epoch | +| `last_updated_timestamp` | `BigInteger` | millis since epoch | + +FK: `(workspace, name)` references `skill_bundles`, CASCADE delete. +Semantic version ordering and index follow the same pattern as +`skill_versions`. + +### `skill_bundle_version_members` + +| Column | Type | Notes | +|--------|------|-------| +| `workspace` | `String(63)` | PK | +| `bundle_name` | `String(256)` | PK, FK to `skill_bundle_versions` | +| `bundle_version` | `String(256)` | PK, FK to `skill_bundle_versions` | +| `member_type` | `String(20)` | PK; `skill` in Phase 1 (reserved for future extension) | +| `member_name` | `String(256)` | PK | +| `member_version` | `String(256)` | PK | +| `member_subpath` | `String(2048)` | nullable; member path inside bundle artifact | + +FK: `(workspace, bundle_name, bundle_version)` references +`skill_bundle_versions`, CASCADE delete. When `member_type` is +`skill`, a FK to `skill_versions` enforces referential integrity +with RESTRICT delete. + +The `member_type` column is included for forward compatibility with +a follow-up RFC that will add registry entries for non-skill bundle +members (e.g., subagents, MCP server references). In this RFC, all +registered members have `member_type='skill'`. + +### `skill_bundle_tags` + +| Column | Type | Notes | +|--------|------|-------| +| `workspace` | `String(63)` | PK, FK | +| `name` | `String(256)` | PK, FK | +| `key` | `String(256)` | PK | +| `value` | `Text` | | + +### `skill_bundle_version_tags` + +| Column | Type | Notes | +|--------|------|-------| +| `workspace` | `String(63)` | PK, FK | +| `name` | `String(256)` | PK, FK | +| `version` | `String(256)` | PK, FK | +| `key` | `String(256)` | PK | +| `value` | `Text` | | + +### `skill_bundle_aliases` + +| Column | Type | Notes | +|--------|------|-------| +| `workspace` | `String(63)` | PK, FK | +| `name` | `String(256)` | PK, FK | +| `alias` | `String(256)` | PK | +| `version` | `String(256)` | target bundle version string | + +**Workspace handling.** All tables use `(workspace, ...)` as the leading +primary key components. Single-tenant deployments use `'default'`. + +**Timestamps.** Set at the application layer via +`get_current_time_millis()`, not via DDL defaults. + +**Deletion semantics.** The registry follows the mixed deletion pattern +used by the Model Registry and RFC-0004: + +- Top-level entity delete operations (`delete_skill` and + `delete_skill_bundle`) are administrative hard deletes. They + physically remove the parent row and cascade to child rows, subject + to referential-integrity checks. +- Version delete operations (`delete_skill_version` and + `delete_skill_bundle_version`) are soft deletes. They set + `status='deleted'` when allowed by the lifecycle transition rules, + update `last_updated_timestamp`, remove aliases that point to the + deleted version, and exclude the version from normal + get/search/list/latest resolution. Active versions must first be + unpublished or deprecated before they can be deleted. +- The `deleted` status is terminal. Internal audit or provenance paths + may retain enough metadata to explain historical traces and bundle + snapshots, but deleted versions are not surfaced to consumers. + +## Entity dataclasses + +### Skill entity + +```python +from dataclasses import dataclass, field +from enum import StrEnum + + +class SkillStatus(StrEnum): + DRAFT = "draft" + ACTIVE = "active" + DEPRECATED = "deprecated" + DELETED = "deleted" + + +@dataclass +class Skill: + name: str + display_name: str | None = None + description: str | None = None + workspace: str | None = None + status: SkillStatus | None = None # read-only, derived from parent-resolved version + tags: dict[str, str] = field(default_factory=dict) + aliases: dict[str, str] = field(default_factory=dict) # read-only; populated from skill_aliases table, e.g. {"production": "1.2.0"} + latest_version: str | None = None # read-only, shared latest-resolution rule + created_by: str | None = None + last_updated_by: str | None = None + creation_timestamp: int | None = None + last_updated_timestamp: int | None = None +``` + +| Field | Type | Description | +|---|---|---| +| `name` | `str` | Stable logical asset name, unique within a workspace | +| `display_name` | `str` | Mutable human-readable label for UI display | +| `status` | `SkillStatus` | Read-only; derived from the parent-resolved version: highest active semantic version if present, otherwise highest non-deleted non-active semantic version | +| `aliases` | `dict[str, str]` | Stable version pointers (e.g., `{"production": "1.2.0"}`); read-only, populated from `skill_aliases` table | +| `latest_version` | `str` | Read-only; highest semantic version among `active` versions if one exists, otherwise highest non-`deleted` non-`active` version | +| `workspace` | `str` | Visibility boundary | + +### SkillVersion entity + +```python +class SkillSourceType(StrEnum): + GIT = "git" + OCI = "oci" + ZIP = "zip" + MLFLOW = "mlflow" + + +@dataclass +class SkillVersion: + name: str + version: str + display_name: str | None = None + source_type: SkillSourceType | None = None + source: str | None = None + subpath: str | None = None + status: SkillStatus = SkillStatus.DRAFT + content_digest: str | None = None + tags: dict[str, str] = field(default_factory=dict) + aliases: list[str] = field(default_factory=list) + workspace: str | None = None + created_by: str | None = None + last_updated_by: str | None = None + creation_timestamp: int | None = None + last_updated_timestamp: int | None = None +``` + +| Field | Type | Description | +|---|---|---| +| `version` | `str` | Publisher-supplied version string. Semantic versioning is required (e.g., `1.0.0`, `2.1.0-beta.1`) | +| `display_name` | `str` | Mutable human-readable label for UI display | +| `source_type` | `SkillSourceType` | Optional distribution mechanism: `git`, `oci`, `zip`, `mlflow` | +| `source` | `str` | Pointer to the content in the source system. Required for standalone pull. May be omitted only when the version's content lives within a bundle-level artifact, in which case the containing bundle membership identifies the embedded content path | +| `subpath` | `str` | Optional path within the artifact where this skill's content lives. See subpath usage table below | +| `content_digest` | `str` | Optional digest for integrity verification (e.g., `sha256:abc123...`). Aligns with OCI digest terminology | +| `status` | `SkillStatus` | Per-version lifecycle: `draft`, `active`, `deprecated`, `deleted` | +| `aliases` | `list[str]` | Alias names currently pointing at this version (read-only, projected from alias table) | + +### SkillVersion field details + +**Source type extensibility.** The `source_type` enum is intentionally +small for the initial implementation. New source types (e.g., `s3`, +`azure-blob`, `opensharing`) can be added without schema changes +since the column stores a string value. In particular, the +[OpenSharing](https://github.com/OpenSharing-IO/OpenSharing) protocol +(Linux Foundation) defines AgentSkill as a first-class asset type +using the same SKILL.md directory structure. An `opensharing` source +type would let the registry govern and track skills whose content is +shared via OpenSharing's credential-vending protocol. + +**Subpath usage by source type.** The `subpath` field separates "what +to download" from "where inside the downloaded content the relevant +asset lives." Its applicability varies by source type: + +| Source type | `subpath` usage | +|---|---| +| `oci` | Path within the OCI image (e.g., `plugins/code-review`). Used when multiple skills share a single image. | +| `zip` | Path within the archive (e.g., `plugins/code-review`). Used when multiple skills share a single archive. | +| `git` | Path within the repository (e.g., `code-review`). Used when the skill content is not at the repository root. The `source` field contains the clone URL with `@` suffix; `subpath` locates the content within the repo. | +| `mlflow` | Not used. The artifact path is scoped to the specific skill version at upload time. | + +**Git source format.** For `source_type="git"`, `source` is a Git +clone URL with an `@` suffix to identify the branch, tag, or +commit (e.g., `https://github.com/acme/agent-skills.git@v1.0.0`). +The `subpath` field identifies the path within the repository where +the skill content lives (e.g., `code-review`). This separates the +clone target, the ref, and the content path into distinct fields +rather than relying on hosting-provider-specific tree URL conventions. +Mutable refs (branches, tags) are allowed; `content_digest` can be +used to detect content drift when the ref changes. + +**MLflow artifact storage (`source_type="mlflow"`).** In addition to +external source pointers, the registry supports storing skill content +directly in MLflow's artifact storage. This serves users who do not +have external Git/OCI infrastructure, who want agent capabilities +stored alongside their models, or who operate in airgapped +environments where external sources are not reachable. + +Content is stored as a directory tree of individual files under an +artifact path, consistent with how MLflow stores model artifacts. For +example, a skill with a SKILL.md, scripts, and reference material is +stored as separate artifacts under a version-specific prefix: + +``` +skills/code-review/1.0.0/ + SKILL.md + scripts/analyze.sh + scripts/lint-config.json + reference/style-guide.md +``` + +The `source` field contains the artifact URI resolved by MLflow's +artifact storage (for example, +`mlflow-artifacts:/skills/code-review/1.0.0/sha256-abc123/` when using +the artifact proxy, or a direct artifact-store URI otherwise). +`source_type="mlflow"` means "stored in MLflow-managed artifact +storage," not a specific URI scheme. Pull downloads the directory tree +from the artifact store. The MLflow UI can browse individual files +within a stored skill version when artifact proxying is enabled. + +**Client-side upload flow.** Direct artifact storage is implemented by +the `register_skill(content_path=...)` SDK/CLI convenience path rather +than a new skill-registry upload endpoint: + +1. The client validates the local directory and preflights that the + path can be registered. +2. The client computes `content_digest` over a canonical representation + of the directory. Regular files are ordered by normalized relative + path; both paths and file contents participate in the digest. + Symlinks are rejected, and empty directories are not preserved. +3. The client checks the target `(name, version)`. If the version + already exists, registration fails with an error. This matches + the MCP Server Registry behavior (`register_mcp_server()`). +4. The client uploads each file through MLflow's existing artifact APIs + to a digest-qualified, version-specific artifact prefix. +5. After upload succeeds, the client creates the `SkillVersion` with + `source_type="mlflow"`, the resolved artifact URI, and the computed + digest. + +The upload and registry write are not atomic. If version creation fails, +the client makes a best-effort attempt to delete the uploaded prefix when +the artifact backend supports deletion. Any remaining files are +unreferenced orphaned artifacts and may be removed by artifact-store +garbage collection. A concurrent writer can still win after preflight; +the losing client follows the same cleanup behavior. + +**Version uniqueness.** The combination of `(name, version)` is unique +within a workspace. A skill version represents a single logical +version of a capability; `source_type` and `source` describe where to +find it but are not part of its identity. + +**Content integrity.** The optional `content_digest` field stores a +digest of the skill content at registration time (e.g., +`sha256:abc123...`). For `source_type="mlflow"`, the client computes +the digest before upload and stores it on the version; on pull, the +client recomputes the digest over the downloaded content and rejects +the result if it does not match, detecting out-of-band modification +of the underlying artifact store. For external source types (git, oci, +zip), `content_digest` is also client-supplied: for OCI sources, this is +the native image digest; for Git sources, a digest of the file contents +at the pinned commit; for ZIP sources, a digest of the archive. The +registry stores the digest but does not verify it on read; verification +is the consumer's responsibility. + +**Immutability contract.** `source_type`, `source`, `subpath`, +`content_digest`, and `version` are immutable after creation. To point +to different content, register a new version. Mutable fields +(`display_name`, `status`, `tags`) can be updated independently. + +### SkillBundle entity + +A skill bundle groups related skills into a governed unit that maps +to the "plugin" concept in agent harnesses. Follows the same +top-level pattern as Skill: versions, tags, and aliases. + +```python +@dataclass +class SkillBundle: + name: str + display_name: str | None = None + description: str | None = None + workspace: str | None = None + status: SkillStatus | None = None # read-only, derived from parent-resolved version + tags: dict[str, str] = field(default_factory=dict) + aliases: dict[str, str] = field(default_factory=dict) # read-only; populated from skill_bundle_aliases table + latest_version: str | None = None # read-only, shared latest-resolution rule + created_by: str | None = None + last_updated_by: str | None = None + creation_timestamp: int | None = None + last_updated_timestamp: int | None = None +``` + +`SkillBundle.status` is read-only and uses the same parent-resolved +version rule as `Skill`: highest active semantic version if present, +otherwise highest non-deleted non-active semantic version. Latest +version resolution follows the same fallback. + +### SkillBundleVersion entity + +A versioned snapshot of a skill bundle's membership. In Phase 1, all +members are skills. + +```python +@dataclass +class SkillMemberRef: + name: str + version: str + member_subpath: str | None = None + + +@dataclass +class SkillBundleVersion: + name: str + version: str + display_name: str | None = None + source_type: SkillSourceType | None = None + source: str | None = None + subpath: str | None = None + content_digest: str | None = None + status: SkillStatus = SkillStatus.DRAFT + tags: dict[str, str] = field(default_factory=dict) + skills: list[SkillMemberRef] = field(default_factory=list) + aliases: list[str] = field(default_factory=list) + workspace: str | None = None + created_by: str | None = None + last_updated_by: str | None = None + creation_timestamp: int | None = None + last_updated_timestamp: int | None = None +``` + +### SkillBundleVersion field details + +**Version uniqueness.** The combination of `(name, version)` is unique +within a workspace. + +**Bundle-level source.** A bundle version is either monolithic or +assembled, never both: + +- **Monolithic:** has its own `source_type`, `source`, `subpath`, + and `content_digest`, pointing to a single artifact (e.g., an OCI + image or Git repo) that contains the complete bundle. `pull` + fetches the bundle artifact as a unit. Member skill versions may + omit their own `source` because the bundle artifact is the + authoritative source. Every source-less member must provide a + membership `member_subpath` identifying where it lives inside the + bundle artifact. +- **Assembled:** has individual member references. Each skill member + has its own source. `pull` fetches members individually. If a skill + member has no source, `pull` fails rather than producing a partial + local bundle. For assembled bundles, `member_subpath` must be null + because the member's own `source` and `subpath` identify its + content. + +The API rejects attempts to set `member_subpath` on a membership whose +member version has its own source. It also rejects a source-less member +of a monolithic bundle when `member_subpath` is missing or empty. + +**Immutability contract.** The member list and source fields of a +bundle version are immutable after creation. To change the set of +members or source pointer, register a new bundle version. Mutable +fields (`display_name`, `status`, `tags`) can be updated independently. + +Correctness of the artifact layout is the publisher's responsibility; +the registry does not validate artifact contents at registration time. + +A member can appear in multiple bundles and multiple bundle versions. +Membership is at the version level, so a bundle version is a +reproducible snapshot of "these specific skill versions work together." + +## Store interface + +The store interface follows the mixin pattern established by the MCP +Server Registry (RFC-0004). Methods raise `NotImplementedError` rather +than using `@abstractmethod`, allowing stores that do not support +skills (e.g., `FileStore`) to work without stubbing every method. + +In the store interface, `delete_*` methods on top-level entities are +hard deletes, while `delete_*_version` methods are soft deletes that +transition the version to `deleted`. + +```python +from mlflow.store.tracking import SEARCH_MAX_RESULTS_DEFAULT + + +NOT_SET = object() + + +class SkillRegistryMixin: + # Methods raise NotImplementedError rather than using @abstractmethod, + # following the GatewayStoreMixin pattern. This allows stores that don't + # support skills (e.g., FileStore) to work without stubbing every method. + + # --- Skill operations --- + + def create_skill( + self, name: str, + display_name: str | None = None, + description: str | None = None, + ) -> Skill: + raise NotImplementedError(self.__class__.__name__) + + def get_skill(self, name: str) -> Skill: + raise NotImplementedError(self.__class__.__name__) + + def search_skills( + self, + filter_string: str | None = None, + max_results: int = SEARCH_MAX_RESULTS_DEFAULT, + order_by: list[str] | None = None, + page_token: str | None = None, + ) -> PagedList[Skill]: + raise NotImplementedError(self.__class__.__name__) + + def update_skill( + self, + name: str, + display_name: str | None = NOT_SET, + description: str | None = NOT_SET, + ) -> Skill: + raise NotImplementedError(self.__class__.__name__) + + def delete_skill(self, name: str) -> None: + raise NotImplementedError(self.__class__.__name__) + + # --- SkillVersion operations --- + + def create_skill_version( + self, + name: str, + version: str, + display_name: str | None = None, + source_type: str | None = None, + source: str | None = None, + subpath: str | None = None, + content_digest: str | None = None, + ) -> SkillVersion: + raise NotImplementedError(self.__class__.__name__) + + def get_skill_version( + self, name: str, version: str, + ) -> SkillVersion: + raise NotImplementedError(self.__class__.__name__) + + def get_skill_version_by_alias( + self, name: str, alias: str, + ) -> SkillVersion: + raise NotImplementedError(self.__class__.__name__) + + def get_latest_skill_version(self, name: str) -> SkillVersion: + raise NotImplementedError(self.__class__.__name__) + + def search_skill_versions( + self, + name: str, + filter_string: str | None = None, + max_results: int = SEARCH_MAX_RESULTS_DEFAULT, + order_by: list[str] | None = None, + page_token: str | None = None, + ) -> PagedList[SkillVersion]: + raise NotImplementedError(self.__class__.__name__) + + def update_skill_version( + self, + name: str, + version: str, + display_name: str | None = NOT_SET, + status: SkillStatus | None = NOT_SET, + ) -> SkillVersion: + raise NotImplementedError(self.__class__.__name__) + + def delete_skill_version( + self, name: str, version: str, + ) -> None: + raise NotImplementedError(self.__class__.__name__) + + # --- Skill tag operations --- + + def set_skill_tag( + self, name: str, key: str, value: str, + ) -> None: + raise NotImplementedError(self.__class__.__name__) + + def delete_skill_tag(self, name: str, key: str) -> None: + raise NotImplementedError(self.__class__.__name__) + + def set_skill_version_tag( + self, name: str, version: str, + key: str, value: str, + ) -> None: + raise NotImplementedError(self.__class__.__name__) + + def delete_skill_version_tag( + self, name: str, version: str, key: str, + ) -> None: + raise NotImplementedError(self.__class__.__name__) + + # --- Skill alias operations --- + + def set_skill_alias( + self, name: str, alias: str, version: str, + ) -> None: + raise NotImplementedError(self.__class__.__name__) + + def delete_skill_alias( + self, name: str, alias: str, + ) -> None: + raise NotImplementedError(self.__class__.__name__) + + # --- SkillBundle operations --- + + def create_skill_bundle( + self, name: str, + display_name: str | None = None, + description: str | None = None, + ) -> SkillBundle: + raise NotImplementedError(self.__class__.__name__) + + def get_skill_bundle(self, name: str) -> SkillBundle: + raise NotImplementedError(self.__class__.__name__) + + def search_skill_bundles( + self, + filter_string: str | None = None, + max_results: int = SEARCH_MAX_RESULTS_DEFAULT, + order_by: list[str] | None = None, + page_token: str | None = None, + ) -> PagedList[SkillBundle]: + raise NotImplementedError(self.__class__.__name__) + + def update_skill_bundle( + self, + name: str, + display_name: str | None = NOT_SET, + description: str | None = NOT_SET, + ) -> SkillBundle: + raise NotImplementedError(self.__class__.__name__) + + def delete_skill_bundle(self, name: str) -> None: + raise NotImplementedError(self.__class__.__name__) + + # --- SkillBundleVersion operations --- + + def create_skill_bundle_version( + self, + name: str, + version: str, + display_name: str | None = None, + skills: list[SkillMemberRef] | None = None, + source_type: str | None = None, + source: str | None = None, + subpath: str | None = None, + content_digest: str | None = None, + ) -> SkillBundleVersion: + raise NotImplementedError(self.__class__.__name__) + + def get_skill_bundle_version( + self, name: str, version: str, + ) -> SkillBundleVersion: + raise NotImplementedError(self.__class__.__name__) + + def get_skill_bundle_version_by_alias( + self, name: str, alias: str, + ) -> SkillBundleVersion: + raise NotImplementedError(self.__class__.__name__) + + def get_latest_skill_bundle_version( + self, name: str, + ) -> SkillBundleVersion: + raise NotImplementedError(self.__class__.__name__) + + def search_skill_bundle_versions( + self, + name: str, + filter_string: str | None = None, + max_results: int = SEARCH_MAX_RESULTS_DEFAULT, + order_by: list[str] | None = None, + page_token: str | None = None, + ) -> PagedList[SkillBundleVersion]: + raise NotImplementedError(self.__class__.__name__) + + def update_skill_bundle_version( + self, + name: str, + version: str, + display_name: str | None = NOT_SET, + status: SkillStatus | None = NOT_SET, + ) -> SkillBundleVersion: + raise NotImplementedError(self.__class__.__name__) + + def delete_skill_bundle_version( + self, name: str, version: str, + ) -> None: + raise NotImplementedError(self.__class__.__name__) + + # --- SkillBundle tag operations --- + + def set_skill_bundle_tag( + self, name: str, key: str, value: str, + ) -> None: + raise NotImplementedError(self.__class__.__name__) + + def delete_skill_bundle_tag( + self, name: str, key: str, + ) -> None: + raise NotImplementedError(self.__class__.__name__) + + def set_skill_bundle_version_tag( + self, name: str, version: str, + key: str, value: str, + ) -> None: + raise NotImplementedError(self.__class__.__name__) + + def delete_skill_bundle_version_tag( + self, name: str, version: str, key: str, + ) -> None: + raise NotImplementedError(self.__class__.__name__) + + # --- SkillBundle alias operations --- + + def set_skill_bundle_alias( + self, name: str, alias: str, version: str, + ) -> None: + raise NotImplementedError(self.__class__.__name__) + + def delete_skill_bundle_alias( + self, name: str, alias: str, + ) -> None: + raise NotImplementedError(self.__class__.__name__) +``` + +The alias name `latest` is reserved for both skills and skill bundles. +The corresponding `set_*_alias()` methods reject it. Alias lookup with +`latest` delegates to `get_latest_skill_version()` or +`get_latest_skill_bundle_version()` rather than reading a stored alias +row. + +For update fields, omitting a parameter leaves the stored value unchanged, +while passing `None` to a nullable field explicitly sets the field to +`null`. + +## SDK convenience functions + +The `mlflow.genai` namespace provides convenience functions that +combine store operations, matching the top-level public SDK pattern +established by `mlflow.genai.register_mcp_server()` in RFC-0004. + +```python +from dataclasses import dataclass + +import mlflow + + +def register_skill( + *, + name: str, + version: str, + display_name: str | None = None, + description: str | None = None, + source_type: str | None = None, + source: str | None = None, + subpath: str | None = None, + content_path: str | None = None, + content_digest: str | None = None, +) -> SkillVersion: + """Register a skill version. Auto-creates the parent Skill if + it does not exist and otherwise reuses the existing parent. If the + version already exists, an MlflowException is raised. This matches + the MCP Server Registry behavior (register_mcp_server). If + content_path is provided, the client uploads the files through + existing MLflow artifact APIs and sets source_type, source, and + content_digest. content_path is mutually exclusive with source_type, + source, subpath, and content_digest.""" + + +def create_skill( + *, + name: str, + display_name: str | None = None, + description: str | None = None, +) -> Skill: ... + + +def get_skill(*, name: str) -> Skill: ... + + +def search_skills( + *, + filter_string: str | None = None, + max_results: int = 100, + order_by: list[str] | None = None, + page_token: str | None = None, +) -> PagedList[Skill]: ... + + +def update_skill( + *, + name: str, + display_name: str | None = NOT_SET, + description: str | None = NOT_SET, +) -> Skill: ... + + +def delete_skill(*, name: str) -> None: ... + + +def create_skill_version( + *, + name: str, + version: str, + display_name: str | None = None, + source_type: str | None = None, + source: str | None = None, + subpath: str | None = None, + content_digest: str | None = None, +) -> SkillVersion: ... + + +def get_skill_version(*, name: str, version: str) -> SkillVersion: ... + + +def get_skill_version_by_alias(*, name: str, alias: str) -> SkillVersion: ... + + +def get_latest_skill_version(*, name: str) -> SkillVersion: ... + + +def search_skill_versions( + *, + name: str, + filter_string: str | None = None, + max_results: int = 100, + order_by: list[str] | None = None, + page_token: str | None = None, +) -> PagedList[SkillVersion]: ... + + +def update_skill_version( + *, + name: str, + version: str, + display_name: str | None = NOT_SET, + status: str | None = NOT_SET, +) -> SkillVersion: ... + + +def delete_skill_version(*, name: str, version: str) -> None: ... + + +def create_skill_bundle( + *, + name: str, + display_name: str | None = None, + description: str | None = None, +) -> SkillBundle: ... + + +def create_skill_bundle_version( + *, + name: str, + version: str, + display_name: str | None = None, + skills: list[SkillMemberRef] | None = None, + source_type: str | None = None, + source: str | None = None, + subpath: str | None = None, + content_digest: str | None = None, +) -> SkillBundleVersion: ... + + +def get_skill_bundle(*, name: str) -> SkillBundle: ... + + +def search_skill_bundles( + *, + filter_string: str | None = None, + max_results: int = 100, + order_by: list[str] | None = None, + page_token: str | None = None, +) -> PagedList[SkillBundle]: ... + + +def update_skill_bundle( + *, + name: str, + display_name: str | None = NOT_SET, + description: str | None = NOT_SET, +) -> SkillBundle: ... + + +def delete_skill_bundle(*, name: str) -> None: ... + + +def get_skill_bundle_version( + *, name: str, version: str, +) -> SkillBundleVersion: ... + + +def get_skill_bundle_version_by_alias( + *, name: str, alias: str, +) -> SkillBundleVersion: ... + + +def get_latest_skill_bundle_version(*, name: str) -> SkillBundleVersion: ... + + +def search_skill_bundle_versions( + *, + name: str, + filter_string: str | None = None, + max_results: int = 100, + order_by: list[str] | None = None, + page_token: str | None = None, +) -> PagedList[SkillBundleVersion]: ... + + +def update_skill_bundle_version( + *, + name: str, + version: str, + display_name: str | None = NOT_SET, + status: str | None = NOT_SET, +) -> SkillBundleVersion: ... + + +def delete_skill_bundle_version(*, name: str, version: str) -> None: ... + + +def set_skill_tag(*, name: str, key: str, value: str) -> None: ... + +def delete_skill_tag(*, name: str, key: str) -> None: ... + +def set_skill_version_tag(*, name: str, version: str, key: str, value: str) -> None: ... + +def delete_skill_version_tag(*, name: str, version: str, key: str) -> None: ... + +def set_skill_alias(*, name: str, alias: str, version: str) -> None: ... + +def delete_skill_alias(*, name: str, alias: str) -> None: ... + +def set_skill_bundle_tag(*, name: str, key: str, value: str) -> None: ... + +def delete_skill_bundle_tag(*, name: str, key: str) -> None: ... + +def set_skill_bundle_version_tag(*, name: str, version: str, key: str, value: str) -> None: ... + +def delete_skill_bundle_version_tag(*, name: str, version: str, key: str) -> None: ... + +def set_skill_bundle_alias(*, name: str, alias: str, version: str) -> None: ... + +def delete_skill_bundle_alias(*, name: str, alias: str) -> None: ... + + +@dataclass(frozen=True) +class PluginImportWarning: + category: str + path: str + message: str + + +@dataclass(frozen=True) +class IntrospectedSkill: + name: str + path: str + + +@dataclass +class PluginIntrospectionResult: + bundle_name: str | None + version: str | None + skills: list[IntrospectedSkill] + warnings: list[PluginImportWarning] + + +@dataclass +class PluginImportResult: + bundle_version: SkillBundleVersion + skill_versions: list[SkillVersion] + warnings: list[PluginImportWarning] + + +@dataclass(frozen=True) +class InstalledSkill: + registry_name: str + harness_local_name: str + installed_path: str + + +@dataclass +class PackageManagerInstallResult: + installed_path: str + skills: list[InstalledSkill] + + +@dataclass(frozen=True) +class MlflowSkillLockEntry: + entity_type: str + name: str + version: str + workspace: str + package_manager: str + harness: str + scope: str + + +def introspect_bundle( + *, + source: str, + plugin_format: str, + source_type: str | None = None, + subpath: str | None = None, +) -> PluginIntrospectionResult: + """Inspect a local or remote plugin without modifying the registry.""" + + +def import_bundle( + *, + source: str, + plugin_format: str, + bundle_name: str | None = None, + version: str | None = None, + source_type: str | None = None, + subpath: str | None = None, +) -> PluginImportResult: + """Import a plugin as a monolithic bundle. + + Fetches and inspects the plugin in the client environment, registers + discovered skills, preserves the plugin source on the bundle version, + and returns warnings for non-skill content that is included in the + bundle but does not receive individual registry entries in Phase 1. + """ + + +def install_skill( + *, + name: str, + harness: str, + version: str | None = None, + alias: str | None = None, + package_manager: str | None = None, + scope: str = "project", + lock_file: str | None = None, +) -> PackageManagerInstallResult: + """Resolve a skill and install it through a package manager plugin. + The harness argument is required to keep behavior predictable + across plugins. If lock_file is provided, record the exact resolved + version and installation inputs for replay.""" + + +def install_bundle( + *, + name: str, + harness: str, + version: str | None = None, + alias: str | None = None, + package_manager: str | None = None, + scope: str = "project", + lock_file: str | None = None, +) -> PackageManagerInstallResult: + """Resolve a bundle and install it through a package manager plugin. + For monolithic bundles, non-skill content is included in the + installed artifact. The harness argument is required to keep + behavior predictable across plugins. If lock_file is provided, + record the exact resolved version and installation inputs for + replay.""" + + +def install_from_lock( + *, lock_file: str = "mlflow-skills.lock", +) -> list[PackageManagerInstallResult]: + """Replay exact skill and bundle versions from an MLflow resolution + lock using the recorded package manager, harness, and scope.""" + + +def pull( + *, + name: str | None = None, + bundle: str | None = None, + version: str | None = None, + alias: str | None = None, + destination: str = ".", +) -> str: + """Pull skill or bundle content from registered sources to a + local directory. Specify name for a single skill or bundle + for a skill bundle.""" + + +# Example usage: +version = mlflow.genai.register_skill(name="code-review", version="1.0.0", source_type="git", source="...") +servers = mlflow.genai.search_skills(filter_string="status = 'active'") +``` + +For SDK update methods, `NOT_SET` means "leave unchanged" while `None` +means "clear this nullable field". This mirrors the store-layer update +contract so callers can distinguish partial updates from explicit +nulling. + +`pull` is implemented in the SDK/CLI layer, not the store mixin. The +client calls `get_skill_version` (or resolves an alias) to obtain the +source pointer, then fetches content locally using source-type-specific +logic (git clone, OCI pull, ZIP download, or MLflow artifact download). +This keeps the store as a pure data-access layer. + +## REST API + +The REST API is implemented as a FastAPI router using RESTful nested +resource paths. It is exposed under both `/api/3.0/mlflow/skills` and +`/ajax-api/3.0/mlflow/skills`, plus the corresponding static-prefix +variants, following the MCP Server Registry (RFC-0004) pattern. + +There is no skill-registry content-upload endpoint. The client-side +`register_skill(content_path=...)` helper uploads through existing +MLflow artifact APIs, then uses the version-creation endpoint below to +store the resulting artifact URI and digest. + +### Skill endpoints + +All paths relative to the logical skills router prefix. + +| Method | Path | Description | +|---|---|---| +| `POST` | `/` | Create a skill | +| `GET` | `/` | Search skills | +| `GET` | `/{name}` | Get skill by name | +| `PATCH` | `/{name}` | Update skill fields | +| `DELETE` | `/{name}` | Hard-delete skill (cascades, subject to references) | +| `POST` | `/{name}/versions` | Create a skill version | +| `GET` | `/{name}/versions` | Search versions | +| `GET` | `/{name}/versions/{version}` | Get a specific version | +| `PATCH` | `/{name}/versions/{version}` | Update version | +| `DELETE` | `/{name}/versions/{version}` | Soft-delete a version (`status='deleted'`) | +| `POST` | `/{name}/tags` | Set a skill-level tag | +| `DELETE` | `/{name}/tags/{key}` | Delete a skill-level tag | +| `POST` | `/{name}/versions/{version}/tags` | Set a version-level tag | +| `DELETE` | `/{name}/versions/{version}/tags/{key}` | Delete a version tag | +| `POST` | `/{name}/aliases` | Set an alias | +| `GET` | `/{name}/aliases/{alias}` | Resolve alias to `SkillVersion` | +| `DELETE` | `/{name}/aliases/{alias}` | Delete an alias | + +Similarly, skill bundle endpoints are exposed under both +`/api/3.0/mlflow/skill-bundles` and +`/ajax-api/3.0/mlflow/skill-bundles`. + +### Skill bundle endpoints + +All paths relative to the logical skill-bundles router prefix. + +| Method | Path | Description | +|---|---|---| +| `POST` | `/` | Create a skill bundle | +| `GET` | `/` | Search skill bundles | +| `GET` | `/{name}` | Get bundle by name | +| `PATCH` | `/{name}` | Update bundle fields | +| `DELETE` | `/{name}` | Hard-delete bundle (cascades versions and memberships) | +| `POST` | `/{name}/versions` | Create a bundle version with members | +| `GET` | `/{name}/versions` | Search bundle versions | +| `GET` | `/{name}/versions/{version}` | Get a specific bundle version | +| `PATCH` | `/{name}/versions/{version}` | Update bundle version status | +| `DELETE` | `/{name}/versions/{version}` | Soft-delete a bundle version (`status='deleted'`) | +| `POST` | `/{name}/tags` | Set a bundle-level tag | +| `DELETE` | `/{name}/tags/{key}` | Delete a bundle-level tag | +| `POST` | `/{name}/versions/{version}/tags` | Set a bundle version tag | +| `DELETE` | `/{name}/versions/{version}/tags/{key}` | Delete a bundle version tag | +| `POST` | `/{name}/aliases` | Set a bundle alias | +| `GET` | `/{name}/aliases/{alias}` | Resolve bundle alias to version | +| `DELETE` | `/{name}/aliases/{alias}` | Delete a bundle alias | + +### Pagination and filtering + +Search endpoints use page-token-based pagination and `filter_string` +expressions following existing MLflow conventions. + +**Skills and bundles:** `name LIKE '%review%'`, `status = 'active'`, +`tags.team = 'platform'` + +**Versions (all entity types):** `status = 'active'`, +`source_type = 'git'`, `tags.approved = 'true'` + +### Request and response models + +Request models contain only the mutable fields; resource identifiers +come from path parameters: + +```python +from pydantic import BaseModel, Field + + +class CreateSkillRequest(BaseModel): + name: str + display_name: str | None = None + description: str | None = None + + +class UpdateSkillRequest(BaseModel): + display_name: str | None = None + description: str | None = None + + +class CreateSkillVersionRequest(BaseModel): + version: str + display_name: str | None = None + source_type: str | None = None + source: str | None = None + subpath: str | None = None + content_digest: str | None = None + + +class UpdateSkillVersionRequest(BaseModel): + display_name: str | None = None + status: str | None = None + + +class CreateSkillBundleRequest(BaseModel): + name: str + display_name: str | None = None + description: str | None = None + + +class UpdateSkillBundleRequest(BaseModel): + display_name: str | None = None + description: str | None = None + + +class SkillMemberRefPayload(BaseModel): + name: str + version: str + member_subpath: str | None = None + + +class CreateSkillBundleVersionRequest(BaseModel): + version: str + display_name: str | None = None + skills: list[SkillMemberRefPayload] | None = None + source_type: str | None = None + source: str | None = None + subpath: str | None = None + content_digest: str | None = None + + +class UpdateSkillBundleVersionRequest(BaseModel): + display_name: str | None = None + status: str | None = None + + +class AliasResponse(BaseModel): + alias: str + version: str + + +class SetAliasRequest(BaseModel): + alias: str + version: str + + +class SetTagRequest(BaseModel): + key: str + value: str + + +class SkillVersionResponse(BaseModel): + name: str + version: str + display_name: str | None = None + source_type: str | None = None + source: str | None = None + subpath: str | None = None + content_digest: str | None = None + status: str = "draft" + aliases: list[str] = Field(default_factory=list) + tags: dict[str, str] = Field(default_factory=dict) + created_by: str | None = None + last_updated_by: str | None = None + creation_timestamp: int | None = None + last_updated_timestamp: int | None = None + + +class SkillResponse(BaseModel): + name: str + display_name: str | None = None + description: str | None = None + status: str | None = None + latest_version: str | None = None + aliases: list[AliasResponse] = Field(default_factory=list) + tags: dict[str, str] = Field(default_factory=dict) + created_by: str | None = None + last_updated_by: str | None = None + creation_timestamp: int | None = None + last_updated_timestamp: int | None = None + + +class SkillBundleVersionResponse(BaseModel): + name: str + version: str + display_name: str | None = None + source_type: str | None = None + source: str | None = None + subpath: str | None = None + content_digest: str | None = None + status: str = "draft" + skills: list[SkillMemberRefPayload] = Field(default_factory=list) + aliases: list[str] = Field(default_factory=list) + tags: dict[str, str] = Field(default_factory=dict) + created_by: str | None = None + last_updated_by: str | None = None + creation_timestamp: int | None = None + last_updated_timestamp: int | None = None + + +class SkillBundleResponse(BaseModel): + name: str + display_name: str | None = None + description: str | None = None + status: str | None = None + latest_version: str | None = None + aliases: list[AliasResponse] = Field(default_factory=list) + tags: dict[str, str] = Field(default_factory=dict) + created_by: str | None = None + last_updated_by: str | None = None + creation_timestamp: int | None = None + last_updated_timestamp: int | None = None +``` + +`Skill.aliases` is modeled as a `dict[str, str]` in the entity layer +for convenience, while REST responses expose aliases as +`list[AliasResponse]` to keep the payload shape explicit and +consistent with the MCP Server Registry (RFC-0004). + +## Python SDK and CLI + +The `mlflow.genai` module exposes the public registry functions, +delegating to `MlflowClient`, plus client-side import, pull, and +package-manager installation operations that compose those registry +functions. Skill-specific entity and request types are also re-exported +from `mlflow.genai`. The `mlflow skills` CLI command group provides the +same operations from the command line: + +| CLI subcommand | SDK function | Description | +|---|---|---| +| `mlflow skills register` | `register_skill()` | Register a skill version (auto-creates parent) | +| `mlflow skills get` | `get_skill()` | Get skill metadata | +| `mlflow skills search` | `search_skills()` | Search skills | +| `mlflow skills get-version` | `get_skill_version()` | Get a specific version | +| `mlflow skills update-version` | `update_skill_version()` | Update version status | +| `mlflow skills set-alias` | `set_skill_alias()` | Set a version alias | +| `mlflow skills set-tag` | `set_skill_tag()` | Set a tag | +| `mlflow skills pull` | `pull()` | Pull content to local filesystem | +| `mlflow skills install` | `install_skill()` | Install one skill through a package manager plugin | +| `mlflow skills install-bundle` | `install_bundle()` | Install a bundle through a package manager plugin | +| `mlflow skills install --from-lock` | `install_from_lock()` | Replay exact registry versions from an MLflow resolution lock | +| `mlflow skills create-bundle` | `create_skill_bundle()` | Create a skill bundle | +| `mlflow skills create-bundle-version` | `create_skill_bundle_version()` | Create a bundle version with members | +| `mlflow skills get-bundle` | `get_skill_bundle()` | Get bundle metadata | +| `mlflow skills search-bundles` | `search_skill_bundles()` | Search bundles | +| `mlflow skills search-bundle-versions` | `search_skill_bundle_versions()` | Search bundle versions | +| `mlflow skills set-bundle-alias` | `set_skill_bundle_alias()` | Set a bundle alias | +| `mlflow skills set-bundle-tag` | `set_skill_bundle_tag()` | Set a bundle-level tag | +| `mlflow skills set-bundle-version-tag` | `set_skill_bundle_version_tag()` | Set a bundle version tag | +| `mlflow skills update-bundle-version` | `update_skill_bundle_version()` | Update bundle version status | +| `mlflow skills introspect` | `introspect_bundle()` | Preview a local or remote plugin without registry writes | +| `mlflow skills import` | `import_bundle()` | Import a plugin as a monolithic bundle | + +**Existing `mlflow skills` CLI group.** MLflow already has an +`mlflow skills` CLI group (`mlflow/cli/skills.py`) with two +subcommands: `list` (list bundled Assistant skills) and `view` +(view details of a bundled skill). These inspect the skills that +ship with the MLflow installation (under `mlflow/assistant/skills/`), +not registry-managed skills. The registry subcommands above extend +this existing group. None of the registry subcommand names conflict +with `list` or `view`, so both sets coexist: `list`/`view` operate +on locally bundled skills, while `search`/`get`/`register`/`pull` +and the other registry subcommands operate on the server-side +registry. + +## Plugin import + +Plugin import is implemented in the SDK and CLI layer. There is no +dedicated REST import endpoint: the client fetches and inspects the +source locally, then calls the existing skill and bundle creation APIs. +The registry server does not fetch user-supplied plugin URLs. + +### Read-only preview + +`introspect_bundle()` and `mlflow skills introspect` run the same plugin +discovery used by import but do not create or modify registry records. +They accept either a local path or a remote Git, OCI, ZIP, or MLflow +artifact source and return the discovered skill names and paths, +available plugin name and version metadata, and warnings for +unregistered non-skill content. A local path must not specify +`source_type`; remote +sources use an explicit source type or the same unambiguous syntax +inference as import. Introspection does not require the plugin to provide +a name or version because those values are only required when importing. + +### Phase 1 input format + +Phase 1 supports the Claude Code plugin layout. The caller passes +`plugin_format="claude-code"`; automatic format detection and additional +input formats are follow-up work. The importer: + +1. Fetches the Git, OCI, ZIP, or MLflow artifact source using the same + source-type-aware logic as `pull`. +2. Applies `subpath`, when provided, to select the plugin root. +3. Reads `.claude-plugin/plugin.json` when present to obtain supported + plugin metadata such as name and version. Explicit `bundle_name` and + `version` arguments take precedence. +4. Discovers skill directories under `skills/` that contain a SKILL.md + entry point. The SKILL.md name is used when present; otherwise the + directory name is used. +5. Detects non-skill plugin content, including subagents, hooks, and MCP + configuration, for warning purposes only. + +The resulting bundle name and version must be available after explicit +arguments and plugin metadata are considered. The version must be a +valid semantic version. When `source_type` is omitted, the client infers +it from the source syntax and fails if the source type is ambiguous. + +### Registration behavior + +For each discovered skill, the importer creates a `SkillVersion` whose +version defaults to the bundle version and whose `source_type`, `source`, +and `subpath` are null. The importer records the skill's plugin-relative +directory as `SkillMemberRef.member_subpath`. + +After registering the embedded skills, the importer creates one +monolithic `SkillBundleVersion` with the original `source_type`, +`source`, and `subpath`, plus member references for all discovered +skills. This preserves a pullable link to the complete original plugin +while keeping Phase 1 registry entries limited to skills. + +The import fails if no skills are discovered. It also preflights all +target `(name, version)` pairs and fails if any skill or bundle version +already exists. It never overwrites or reuses an existing version. A +caller resolves a conflict by choosing a different bundle version or +renaming the conflicting skill before import. + +### Warnings and result + +Subagents, hooks, MCP configurations, and unrecognized content remain +in the plugin artifact but are not registered. Each discovered skipped +category produces a `PluginImportWarning` containing its category, +path, and an explanation that Phase 1 does not create registry entries +for non-skill content (though the content remains in the bundle). The CLI +prints these warnings after registration. The SDK returns them together +with the created bundle and skill versions in `PluginImportResult`. + +Import translates an existing plugin into MLflow's registry +representation, creating registry entries for discovered skills while +preserving the complete plugin source. It does not install the plugin, +generate a downstream manifest, or translate an MLflow bundle into a +downstream bundle format. + +## MLflow resolution lock + +Package managers receive materialized local paths, so their own +lockfiles cannot by themselves reconstruct which MLflow registry +versions produced those paths. When `lock_file` is supplied to +`install_skill()` or `install_bundle()`, MLflow writes or updates an +`mlflow-skills.lock` resolution lock after installation succeeds. + +Each entry records the entity type (`skill` or `bundle`), name, exact +resolved version, workspace, selected package manager, harness, and +scope. Aliases and `latest` are resolved before writing the entry and +are never stored in place of a version. A bundle entry does not repeat +its members because bundle membership is immutable and can be recovered +from the exact bundle version. + +A resolution lock is scoped to one workspace. Appending an entry from a +different workspace fails, and replay requires the configured MLflow +client to target the recorded workspace. + +`install_from_lock()` reads the entries, resolves the exact versions +through the currently configured MLflow client, materializes their +content, and delegates to the recorded package manager. Normal registry +visibility and lifecycle rules apply during replay, so an unavailable or +deleted version causes the replay to fail rather than silently installing +different content. Package-manager lockfiles may additionally capture +package-manager-specific layout, cached sources, and integrity metadata. +The CLI `--from-lock` mode uses the recorded installation inputs and is +mutually exclusive with name, version, alias, package manager, harness, +scope, and lock-writing options. + +## Package manager plugin interface + +Package manager plugins are registered via Python entrypoints (group +`mlflow.skill_package_managers`), so third-party plugins can be +installed via `pip install` without modifying MLflow core. + +In Phase 1, these plugins receive resolved skills or bundle content +(which may include non-skill content in monolithic bundles). They +install the content using an existing package manager. The package +manager handles placement of all content, including non-skill files +that do not have individual registry entries. It returns the actual +harness-local name of every installed skill so MLflow can write an +accurate trace manifest even when the package manager renames or prefixes +skills. The result must contain exactly one `InstalledSkill` for every +requested registry skill; missing or duplicate mappings fail the install +before MLflow writes its trace manifest or resolution lock. + +Both `mlflow skills install` and `mlflow skills install-bundle` require +a package manager plugin. The caller can select a plugin explicitly, or +MLflow uses the configured default. If no plugin is selected or +available, installation fails with guidance to install or configure one; +`mlflow skills pull` remains available without a package manager. + +### Plugin protocol + +```python +class PackageManagerPlugin: + def install_skill( + self, + name: str, + local_path: str, + harness: str, + scope: str = "project", + ) -> PackageManagerInstallResult: + """Install a single skill from a local path. + Returns the installed path and harness-local skill name. + + Args: + name: registry skill name + local_path: local directory with skill content + harness: target harness (e.g., "claude-code", "cursor") + scope: "project" (cwd) or "user" (home directory) + """ + ... + + def install_bundle( + self, + bundle_name: str, + member_paths: dict[str, str], + harness: str, + bundle_path: str | None = None, + scope: str = "project", + ) -> PackageManagerInstallResult: + """Install a bundle from local paths. member_paths maps registry + skill names to local paths. For a monolithic bundle, bundle_path + is the complete artifact root and must be installed as a unit. + Returns the installed path and harness-local skill names. + + Args: + bundle_name: registry bundle name + member_paths: {skill_name: local_path} for each member + harness: target harness (e.g., "claude-code", "cursor") + bundle_path: complete monolithic bundle root, or None for an + assembled bundle + scope: "project" or "user" + """ + ... + + def supported_harnesses(self) -> list[str]: + """Return list of harness identifiers this plugin supports. + E.g., ["claude-code", "cursor", "codex-cli", "copilot"].""" + ... +``` + +### Entrypoint registration + +```toml +# In the package manager plugin's pyproject.toml: +[project.entry-points."mlflow.skill_package_managers"] +apm = "apm_mlflow:ApmPlugin" +lola = "lola_mlflow:LolaPlugin" +``` + +### Bundle installation flow + +When `mlflow skills install-bundle` is invoked: + +1. **Resolve:** MLflow calls `get_skill_bundle_version()` (or alias + resolution) to obtain the bundle version and its member list. +2. **Materialize member paths:** + - For an assembled bundle, MLflow pulls each member skill to its own + local temporary directory using source-type-aware logic (Git clone, + OCI pull, ZIP download, or MLflow artifact download). + - For a monolithic bundle, MLflow pulls the bundle-level source once + using the same source-type-aware logic and retains the complete root + as `bundle_path`, including opaque non-skill content. For each + member, it resolves a local path by joining the pulled bundle root + with `member_subpath`. Every monolithic member must provide a + non-empty `member_subpath`; installation fails if the path is missing, + escapes the pulled bundle root after normalization, or does not + contain the embedded skill. +3. **Delegate:** MLflow passes `member_paths` and, for a monolithic + bundle, `bundle_path` to the configured package manager plugin via + `install_bundle()`. The plugin installs the complete monolithic bundle + or the assembled skills and returns each skill's harness-local name. +4. **Manifest:** MLflow writes `mlflow-skills-manifest.json`, keyed by + the returned harness-local names and populated with the corresponding + registry coordinates. +5. **Resolution lock:** If `lock_file` was supplied, MLflow atomically + updates it with the exact resolved bundle version and installation + inputs after the install and manifest write succeed. + +### Single-skill installation flow + +When `mlflow skills install` is invoked: + +1. **Resolve:** MLflow calls `get_skill_version()`, alias resolution, or + latest resolution to obtain the registered source pointer. `version` + and `alias` are mutually exclusive; omitting both selects the + system-defined latest version. +2. **Pull:** MLflow pulls the skill content to a local temporary + directory using the same source-type-aware logic as `pull`. +3. **Delegate:** MLflow passes the skill name and local path to the + configured package manager plugin via `install_skill()`. The plugin + owns harness-specific behavior, scope handling, directory placement, + naming, and any generated package-manager or harness metadata. An + explicit harness selection from the caller is passed through to the + plugin, which returns the actual harness-local skill name. +4. **Manifest:** After the plugin reports success, MLflow writes or + updates `mlflow-skills-manifest.json` under the returned harness-local + name with the resolved registry coordinates. +5. **Resolution lock:** If `lock_file` was supplied, MLflow atomically + updates it with the exact resolved skill version and installation + inputs after the install and manifest write succeed. + +## Pull semantics details + +**Source availability.** The registry stores source pointers but does +not cache or proxy content. If a source is unreachable or the content +has been deleted, pull fails with an error that surfaces the +underlying failure from the source system (e.g., Git clone failure, +OCI pull 404, HTTP download error, MLflow artifact download error). +Source availability is the publisher's responsibility. For assembled +bundle pulls, if one member's source is unavailable, the entire pull +fails rather than producing a partial result. + +**Source authentication.** The registry server stores source pointers +but does not validate source accessibility at registration time and is +not involved in content transfer at pull time. Authentication to +external sources is handled entirely by the client environment: + +| Source type | Authentication mechanism | +|---|---| +| `git` | Standard Git credential resolution: SSH keys (`~/.ssh/`), Git credential helpers (`git-credential-manager`, `git-credential-store`), `.netrc`, and `GIT_SSH_COMMAND`. Private repos work if the caller's Git is configured to access them. | +| `oci` | OCI registry credential resolution: Docker config (`~/.docker/config.json`), registry-specific credential helpers, and container runtime auth. Private registries work if the caller has a valid login session. | +| `zip` | No authentication support. ZIP sources must be publicly accessible URLs. For private content, use `git` or `oci` source types instead. | +| `mlflow` | MLflow artifact storage authentication, using the same credentials as other MLflow API calls. | + +The registry does not store, proxy, or manage source credentials. +Pull failures due to authentication errors are surfaced to the caller +with the underlying error from the source system. + +## skill_context() span attributes + +The `skill_context()` context manager creates a span with the +following attributes: + +| Attribute | Value | Description | +|---|---|---| +| `mlflow.skill.name` | Skill name | Registry name of the active skill | +| `mlflow.skill.version` | Version string | Registered version | +| `mlflow.skill.workspace` | Workspace name | Resolved from the install manifest, falling back to the current tracking URI's workspace context | + +These three attributes form the `{workspace, name, version}` +coordinates that link the span back to a specific skill version in +the registry. + +## Automatic trace instrumentation + +Automatic instrumentation uses the install-time +`mlflow-skills-manifest.json` to map harness-local skill invocations to +registered skill coordinates. Phase 1 implements this behavior in the +Claude Code autologger. The manifest format is harness-neutral so other +harness integrations can adopt the same contract later. + +### Manifest writing and discovery + +Installation commands write or update the manifest after all requested +skills have been installed successfully. Each entry is keyed by the +harness-local skill name returned by the package manager plugin and +contains the registered `workspace`, `name`, and resolved `version`. +Aliases are resolved before the manifest is written and are not stored +in place of versions. + +Project-scoped installation writes the manifest at the project root. +User-scoped installation writes it in the MLflow user configuration +directory. Project entries take precedence over user entries with the +same harness-local skill name. + +For a monolithic bundle, installation writes an entry for every +registered embedded skill resolved through its `member_subpath`. For an +assembled bundle, it writes an entry for every installed member skill. +The bundle itself does not produce a SKILL span because tracing is at +the invoked-skill level. + +### Claude Code invocation matching + +The Phase 1 Claude Code autologger matches harness skill invocations +against manifest entries by skill name. When a match is found, it +creates a span with: + +- span type `SKILL` +- span name equal to the harness-local skill name +- `mlflow.skill.name`, `mlflow.skill.version`, and + `mlflow.skill.workspace` attributes from the manifest + +LLM and tool spans produced while the skill is active become children +of the SKILL span. + +If a matching SKILL span with the same registry coordinates is already +active because application code used `mlflow.skill_context()`, the +autologger reuses that active context and does not create a duplicate +SKILL span. + +### Failure behavior + +Automatic instrumentation does not contact the registry during skill +invocation and does not add runtime latency or create a dependency on +registry availability. + +A missing manifest, malformed manifest, or unmatched skill name never +interrupts the agent run or other autologging; it only prevents +creation of a registry-linked SKILL span for the affected invocation. +Skills copied into a harness without an MLflow installation command +have no manifest entry and are not linked automatically; callers can +still use `mlflow.skill_context()` manually. + +## SDK and CLI code examples + +### Register skills from an OCI artifact with subpath + +```python +import mlflow +from mlflow.genai import SkillMemberRef + +mlflow.genai.register_skill( + name="code-review", + version="1.0.0", + source_type="oci", + source="ghcr.io/acme/agent-plugin:v1.0.0", + subpath="skills/code-review", +) + +mlflow.genai.register_skill( + name="test-coverage", + version="2.1.0", + source_type="oci", + source="ghcr.io/acme/agent-plugin:v1.0.0", + subpath="skills/test-coverage", +) + +# Assembled bundle: each member has its own source +bundle_version = mlflow.genai.create_skill_bundle_version( + name="pr-workflow", + version="1.0.0", + skills=[ + SkillMemberRef(name="code-review", version="1.0.0"), + SkillMemberRef(name="test-coverage", version="2.1.0"), + ], +) + +# Monolithic bundle from a single OCI image. Embedded member +# versions are registered without their own sources. +mlflow.genai.register_skill( + name="embedded-review", + version="1.0.0", +) + +bundle_version = mlflow.genai.create_skill_bundle_version( + name="pr-workflow-mono", + version="1.0.0", + source_type="oci", + source="ghcr.io/acme/agent-plugin:v1.0.0", + skills=[ + SkillMemberRef(name="embedded-review", version="1.0.0", + member_subpath="skills/embedded-review"), + ], +) +``` + +### Discover and consume skills + +```python +# Search for active skill versions +versions = mlflow.genai.search_skill_versions( + name="code-review", + filter_string="status = 'active'", +) + +# Search for active skill bundles +bundles = mlflow.genai.search_skill_bundles( + filter_string="status = 'active'", +) + +# Get a specific version +version = mlflow.genai.get_skill_version( + name="code-review", + version="1.0.0", +) +# version.source_type == "git" +# version.source == "https://github.com/acme/agent-skills.git@v1.0.0" +# version.subpath == "code-review" + +# Resolve by alias +version = mlflow.genai.get_skill_version_by_alias( + name="code-review", + alias="production", +) + +# Get a bundle version and its pinned members +bundle_version = mlflow.genai.get_skill_bundle_version( + name="pr-workflow", + version="1.0.0", +) +# bundle_version.skills == [SkillMemberRef(name="code-review", version="1.0.0"), ...] + +# Resolve a bundle alias +bundle_version = mlflow.genai.get_skill_bundle_version_by_alias( + name="pr-workflow", + alias="production", +) +```