Skip to content

Latest commit

 

History

History
1544 lines (1292 loc) · 85 KB

skill-format.mdx

File metadata and controls

1544 lines (1292 loc) · 85 KB
title Skill Format
description SKILL.md field grammar — Agent Skills (agentskills.io) base + a metadata.gaia namespace, permission model, security tiers, and Hermes/OpenClaw compatibility
icon puzzle-piece
**Grounds on (exists today):** [`src/gaia/skills/`](https://github.com/amd/gaia/tree/main/src/gaia/skills) — the shipped runtime: `format.py` (parser/writer/validator), `manager.py` (`SkillManager`, discovery + precedence), `loader.py` (`/` registration), `permissions.py` (grammar + connector bridge), `cli.py` (`gaia skill`), `sets.py` (the `skills:` / `skill_sets:` grammar), `migrate.py` (OpenClaw/Hermes → GAIA, [#692](#692)) · [`src/gaia/agents/base/agent.py`](https://github.com/amd/gaia/blob/main/src/gaia/agents/base/agent.py) (`Agent.load_skill`, `unload_skill`, `SKILL_DIRS`, `skill_manager`, `loaded_skills`, `SKILL_MANIFEST`, `load_skill_set`, `select_skill_set`) · [`src/gaia/agents/base/tools.py`](https://github.com/amd/gaia/blob/main/src/gaia/agents/base/tools.py) (`@tool`, `_TOOL_REGISTRY`) · [`src/gaia/agents/registry.py`](https://github.com/amd/gaia/blob/main/src/gaia/agents/registry.py) (`KNOWN_TOOLS`, `namespaced_agent_id`) · [`src/gaia/connectors/providers/base.py`](https://github.com/amd/gaia/blob/main/src/gaia/connectors/providers/base.py) (`ConnectorRequirement`) · [`src/gaia/ui/routers/agents.py`](https://github.com/amd/gaia/blob/main/src/gaia/ui/routers/agents.py) (`/api/agents`, the panel to mirror)

Also exists (hub side): workers/agent-hub/src/ — the skill catalog lane, the skills/ R2 namespace, POST /publish/skill validating this grammar server-side, and the pre-publish audit gate (#2467).

Still proposed (not written yet): the /api/skills router and Agent UI panel, gaia skill update, and the permission sandbox for local-capability domains (#1019). Every such symbol is marked PROPOSED where it appears.

Shipped since: the marketplace verbs gaia skill search|install|remove|publish plus keygen/trust, install-time tier-ceiling enforcement, and the declarative skills: block (#2467); plus gaia skill migrate (#692).

**Component:** the `SKILL.md` field grammar — the on-disk contract for GAIA's skill ecosystem (issue [#691](#691)).

Module: gaia.skills — shipped (#888). The parser, validator, discovery, tool loader, permission grammar, and gaia skill list|info|create|import|export all exist. The contract is grounded on the live tool registry in gaia.agents.base.tools.

Status: Format = decided. Runtime = Phase 1 + the marketplace shipped (loader, CLI, publish/install, signature verification, install-time tier ceilings, declarative skills: / skill_sets:); the local-capability sandbox and the REST/UI surface remain proposed. The companion Agent Skills spec owns the integration surface (discovery, scoping, progressive disclosure); this document is authoritative for the field grammar.

Target consumers: #887 (skill auto-synthesis), #553 (self-improving agent), #1451 (tool-loader Part 3), #647 (skill marketplace), #648 (OEM bundling), #462 (Agent Manifest).


Why this exists

A GAIA agent's capabilities are compiled in. Tools arrive through hardcoded mixins (RAGToolsMixin, BrowserToolsMixin, … catalogued in KNOWN_TOOLS, registry.py:38) wired into the agent class and shipped inside the wheel. A user who wants a new capability has no path short of forking the agent. Skills make a capability portable: a folder with a SKILL.md an agent loads only when relevant.

The Agent Skills spec answers why skills, and how an agent composes them. This document answers a narrower, higher-stakes question: what exactly goes in the frontmatter. That matters because the frontmatter is a contract, not a private detail — several landed and in-flight features read or emit these fields:

  • #887 (skill auto-synthesis) and #553 (self-improving agent) will emit SKILL.md files via skill extraction. The field names they write must be the field names this spec defines.
  • #1451 (tool-loader Part 3) reads the tools_required field to union a recipe's tools into per-turn tool selection — a hard cross-spec dependency already written down in tool-loader.mdx.
  • #647 (marketplace) and #648 (OEM bundling) need a stable definition of "a skill" and its trust tiers to publish and pre-bundle.

So the single thing this spec must nail down is a frontmatter schema that is (a) the open standard, so the existing skill ecosystem works in GAIA unchanged; (b) a strict superset that carries GAIA's typed tools, permissions, and trust tiers; and (c) stable enough that the consumers above can build against it without churn.

Prior art (credit before contribution)

GAIA does not invent a skill format. It adopts one and extends the frontmatter. The base and the compatible formats:

Source What it contributes
Agent Skills (used by Claude Code) The base standard GAIA adopts: required name + description, optional license + metadata (an arbitrary map; "make key names reasonably unique"), a folder of SKILL.md + scripts//references//assets/, and progressive disclosure (metadata → body → resources).
Hermes Agent (Nous Research) The metadata.hermes namespace pattern — vendor-specific fields nested under metadata.<vendor> so standard runtimes ignore them losslessly.
OpenClaw / ClawHub The same metadata.openclaw namespace pattern, confirmed by the format owner.
Claude Code skills The reference implementation of the Agent Skills standard; already credited in Agent Skills.

GAIA's contribution is the metadata.gaia namespace: typed @tool declarations, a <domain>:<level> permission grammar bridged to the connector grant model, three install-time security tiers, and the tools_required recipe contract — all expressed inside the standard's metadata map, so a GAIA skill remains a valid standard skill.


Decided design

The schema is the standard's required base plus a single GAIA-owned namespace. A standard runtime reads the base and ignores metadata.gaia; GAIA reads both.

Adopted base (Agent Skills standard)

Key Standard GAIA Notes
name required required ≤64 chars, lowercase alphanumeric + single hyphens, no leading/trailing/consecutive hyphen, must match the skill's directory name.
description required required ≤1024 chars. The trigger signal the model reads to decide relevance.
license optional optional Always MIT for GAIA skills (matches the repository license).
metadata optional optional Arbitrary map. GAIA's fields live under metadata.gaia.
version — superset Top-level. The standard has no top-level version; GAIA adds one (Hermes/OpenClaw also carry it). A tolerated superset key — standard runtimes ignore it.
**`compatibility` and `allowed-tools` are deliberately excluded from GAIA's adopted base.** The Agent Skills standard defines both as optional, but they overlap GAIA's structured fields (`allowed-tools` ↔ `metadata.gaia.permissions` + `tools`; `compatibility` ↔ `metadata.gaia.requirements`) and neither Hermes nor OpenClaw uses them. A standard skill that *happens* to set them still parses — **GAIA simply ignores those two keys**; permissions and requirements come solely from `metadata.gaia`.

The metadata.gaia namespace

Everything GAIA-specific is nested here. Omit the whole block and the skill is a valid instruction-only skill (see Off-states).

metadata:
  gaia:
    security_tier: verified        # verified | community | experimental (default: experimental)
    permissions:                   # <domain>:<level>[:scope] — see Permission model
      - network:read:*.brave.com
    requirements:                  # all optional; defaults = no constraint
      model: ">=7B"                # minimum parameter count (advisory)
      context: ">=8K"              # minimum context window
      python: ">=3.10"
      dependencies: [requests>=2.31]
      node_dependencies: ["@brave/brave-search-mcp-server"]
      env_vars: [BRAVE_API_KEY]
      hardware: { npu: optional, gpu_vram: ">=0GB" }
    tools:                         # tools this skill PROVIDES (its own @tool fns)
      - name: search_web
        description: Search the web for current information
        parameters:
          query: { type: string, required: true }
          max_results: { type: integer, required: false, default: 5 }
        returns: { type: object }
        atomic: false              # maps to @tool(atomic=...)
    tools_required: []             # registry tools this skill CONSUMES (recipe contract)
    pairs_with: []                 # skills this one is combined with (mutual; unknown names ignored)

tools vs tools_required — never conflate them

These are two different fields with two different consumers, and collapsing them breaks the recipe contract:

Field Meaning Who reads it
tools The skill's own @tool-decorated functions (provided capability). Maps to the @tool decorator / _TOOL_REGISTRY (tools.py:16, :19). The loader, to register and validate the skill's tools.
tools_required Names of existing registry tools a recipe skill consumes (e.g. query_documents, read_file). The skill ships no code for them. #887 / #1451 — the tool-loader unions these into per-turn selection.

The field name tools_required is locked as the cross-spec contract with #887/#1451. A skill may have either, both, or neither.

Field reference

Field Type Required Description
name string yes Skill identifier; matches directory name. See naming.
description string yes Trigger signal (≤1024 chars).
license string no Always MIT for GAIA skills.
version string no (recommended) SemVer. Top-level GAIA superset, recommended for publishing; 0.0.0 (unversioned) blocks publishing.
metadata.gaia.security_tier string no verified | community | experimental. Default experimental.
metadata.gaia.permissions list no <domain>:<level>[:scope] grants. Default: none.
metadata.gaia.requirements object no Model / context / deps / env / hardware constraints. Default: none.
metadata.gaia.tools list no Provided @tool declarations. Default: none (instruction-only).
metadata.gaia.tools_required list no Registry tool names consumed (recipe contract). Default: [].
metadata.gaia.pairs_with list no Skills this one is usually combined with on one request. Mutual, shown in the skill catalogue and named by load_skill; names that are not installed are ignored. See Companion skills. Default: [].

Manifest-side grammar: skills, skill_sets, default_skill_set

The fields above describe one skill. An agent declares which skills it composes in its gaia-agent.yaml, using three top-level keys (#2466):

# Always-on: loaded on every launch, whichever set is active.
skills:
  - mailbox-hygiene                 # plain-string form
  - name: incident-review           # mapping form
    version: ">=0.1.0"
    required: false

# Named, mutually-exclusive bundles. Exactly ONE is active per launch.
skill_sets:
  personal: [inbox-triage, newsletter-digest]
  work: [inbox-triage, meeting-scheduling]

# Required whenever skill_sets is non-empty.
default_skill_set: personal
Field Type Required Description
skills list no Always-on skill references. Default: [].
skill_sets map no Set name → skill references. Default: {}.
default_skill_set string only when skill_sets is non-empty Which set applies when nothing selects one.

Each entry in skills or a set's list is either a skill name or a mapping:

Key Type Required Description
name string yes A skill name per naming.
version string no SemVer or range, enforced at load against the installed skill's version — see below.
required bool no Default true. A required: false skill that is missing or version-incompatible is logged and skipped; either failure on a required one fails the launch.

Set names use the same slug shape as skill names, capped at 32 chars — a lowercase alphanumeric start and end with internal hyphens:

^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$

Sets overlap; they do not partition. The same skill may appear in several sets — that is how a shared capability stays available across contexts. What a set may not do is re-declare a skill already in the always-on skills: list; that is a contradiction (an always-on skill loads for every set) and fails to parse.

Every malformed shape fails at manifest-parse time with a message naming the fix — a list that repeats a name, an empty set, a default_skill_set naming an undeclared set, an unrecognized key in a reference mapping, a non-boolean required. There is no partial acceptance: an agent either declares a coherent set of skills or does not load.

**`version:` is enforced at load.** The range is matched against the installed skill's frontmatter `version`, and a mismatch is fail-loud: a `required` entry raises and the agent does not launch, an optional one is skipped with the reason logged. A skill with no `version` in its frontmatter cannot satisfy a pin — otherwise a local edit would silently shadow a pinned hub install. A range that names no version (`>=v2`, `1.2.x`) is rejected at manifest-parse time with the shapes above, rather than treated as "any" — so the verdict never depends on which skills happen to be installed on the machine doing the parsing.

What #2467 still owns is resolution across installable versions — choosing which version to fetch from the marketplace. Checking the one version actually installed happens today.

Selection semantics — the order a set is chosen in, the selector hook, and the fail-loud behaviour on an undeclared name — belong to the integration surface and live in Agent Skills → Skill sets. The grammar above is implemented once, in gaia/skills/sets.py, and consumed by both the manifest validator and the runtime, so the two cannot drift.

Naming

Skill names follow the Agent Skills rule: ^[a-z0-9]+(-[a-z0-9]+)*$, ≤64 chars, and must equal the skill's directory name (so web-research/SKILL.md has name: web-research). No leading, trailing, or consecutive hyphens. Names are unique within a discovery root; precedence between roots is defined in Agent Skills → Discovery.

Permission model

Permissions follow <domain>:<level> with an optional :scope qualifier. There are no implicit grants — a skill declares every domain it touches.

Domain Levels Maps to Runtime state
network read, write, none Connector scope (see bridge below). Bridged — resolves to a ConnectorRequirement
mcp connect, none Connector scope (MCP-server connectors). Bridged — resolves to a ConnectorRequirement
filesystem read, write, none Local-capability domain (new; needs the skill sandbox). Refused at load
shell execute, none One named CLI, scoped: shell:execute:gh. Bridged — grants that binary; reads run, writes ask
database read, write, none Local-capability domain (new). Refused at load
desktop control, none Local-capability domain (new). Refused at load
env read, none Environment beyond declared env_vars. Refused at load
program run, none One named program the skill's own code starts: program:run:soffice. Bridged — through gaia.skills.context.run_program only; no shell, scrubbed environment
scratch write, none A fresh temporary folder per tool call. Takes no scope. Bridged — through gaia.skills.context.temp_dir and friends; deleted when the call returns

Scopes narrow a grant: filesystem:read:~/.gaia/data/**, network:read:*.brave.com, shell:execute:gh. An unscoped grant covers the whole domain — except mcp:connect, shell:execute and program:run, where a scope is mandatory (below). Grammar violations — an empty entry, a missing level, an unknown domain, or a level the domain does not define — fail loudly at parse time.

**A local-capability permission is refused, not sandboxed.** Until the sandbox lands ([#1019](#1019)), loading a skill that declares `filesystem` / `database` / `desktop` / `env` — or a bare, unscoped `shell:execute` — raises `SkillPermissionError` naming the offending grants. GAIA will not load a grant it cannot enforce, and will not pretend to enforce one.

<domain>:none is inert in both directions: it produces no requirement and never triggers a refusal, because an explicit denial asks for less than the default. filesystem:none therefore loads fine.

Bridge to the connector grant model — no parallel ledger

Agent Skills already states that a skill carrying permissions is granted to an agent reusing the connectors per-agent grant model. This spec makes the mapping concrete rather than inventing a second grant system:

  • network:* and mcp:connect resolve, at scope-time, to a ConnectorRequirement(connector_id, scopes, reason) (connectors/providers/base.py:23) — the same frozen primitive agents already declare via REQUIRED_CONNECTORS (agent.py:297). The skill's permissions become the agent's connector requirements when the skill is loaded — appended to a per-instance copy, never to the ClassVar, so one agent's skill cannot leak requirements into a sibling.
  • filesystem:*, database:*, desktop:*, env:* are new local-capability domains with no connector equivalent. Until the skill sandbox lands (PROPOSED — see Phased build) a skill declaring one is refused outright — at every security tier, verified included. A tier is a provenance claim, not a sandbox.
  • shell:execute:<binary> has its own bridge — see Bring your own CLI. It is not routed through a connector and does not need the sandbox, because the grant is one named binary held to a three-tier subcommand table: reads run, a short list of writes asks the user per call, and every escalation class is refused outright.

Per-agent scoping reuses the namespaced_agent_id keying (builtin: / custom:sha256: / installed:, registry.py:392) that already isolates agent grants today.

Two decisions the runtime made where this spec was ambiguous

The bridge above did not say what happens when a permission's scope has no counterpart in the connector catalog. The implementation (permissions.py) settled both cases, and these are now part of the contract:

  1. mcp:connect must name a catalog connector. A bare mcp:connect is a validation error, not a wildcard — the error lists the available connector ids and points at gaia connectors list. So is mcp:connect:<unknown-id>. An unscoped MCP grant would mean "connect to any MCP server", which is exactly the ambient authority the permission model exists to prevent.
  2. An unmatched network:* scope resolves to a reserved network pseudo-connector-id. Nothing in the catalog represents raw outbound HTTP, so network:read:*.brave.com becomes ConnectorRequirement(connector_id="network", scopes=("read:*.brave.com",), …). If the scope does name a real catalog connector, the requirement targets that connector instead.
**The `network` pseudo-requirement is a declaration surface, not enforcement.** It records what egress a skill asked for so `gaia skill info` and a future egress policy can read it. **Phase 1 does not enforce network egress** — a loaded skill's code can reach any host its process can. Do not treat a `network:read:*.brave.com` grant as a firewall.

Bring your own CLI — shell:execute:<binary>

Some capabilities are a CLI, not a connector. GitHub triage does not need an MCP server; it needs gh, which already owns its auth and its rate limits. A skill declares the one binary it runs:

permissions:
  - shell:execute:gh
tools_required:
  - run_shell_command

Loading that skill grants gh only, to that skill's session, on that agent instance — the grant lives on the agent (mirroring how skill-derived connector requirements shadow the REQUIRED_CONNECTORS ClassVar), never in a module-level allowlist, so it can never widen a sibling agent's shell. unload_skill revokes it; a binary stays granted while another loaded skill still declares it.

Four rules make the grant safe enough to bridge before the sandbox exists:

  1. The binary must be policed. BINARY_POLICIES (skills/binaries.py) maps each declarable binary to its subcommand table. A binary with no entry cannot be declared — the refusal names what is declarable.
  2. Subcommands are an allowlist, in three tiers. Reads run with no prompt: for gh, issue list|view|status, pr list|view|diff|checks|status, repo list|view, release list|view, run list|view, label list, search …, auth status, and gh api for GET. A short list of writes is confirmable — issue create|comment|edit, pr comment, label create|edit — meaning the user is shown the exact command and answers yes / no / always-for-that-command-class. Everything absent is refused, so gh auth token (prints the credential), gh alias / gh extension / gh config / gh codespace (arbitrary code or a remote shell), and the irreversible verbs (pr merge, issue close, label delete, repo delete) are blocked without being enumerated — and are never offered as a prompt. Collapsing "refuse" into "ask" is what turns a gate into a habit; the tiers stay separate for that reason.
  3. Flags that escalate are refused, even on a confirmable write. gh api rejects -X/--method with anything but GET, rejects -f/-F/--field/ --raw-field/--input (a body implies a write), and rejects graphql. A write rejects --body-file (uploads a local file's contents), --editor (hangs a stdin-less agent), and --web. The flag is checked before the action, so a write carrying one never reaches a prompt.
  4. The binary must be installed, unless something substitutes for it. load_skill checks PATH and refuses when the binary is missing, because a skill that loads without the tool it documents produces confident answers from no data — the failure this bridge exists to prevent. Two things soften that:
    • A policy that names a substitute (today only pytest, which usually lives in a virtualenv rather than on PATH) loads without the grant, and its prompt says the command is unavailable and what to do instead — for pytest, call pytest.main from execute_python_file.
    • A BinarySetup entry on the policy lets the agent install the CLI and drive its browser sign-in itself, each behind its own approval prompt, so the refusal names an action instead of ending the conversation. See Setting up a CLI a skill needs.

The existing shell guardrails still apply on top: command chaining and redirection (&&, ;, >, backticks, $()) are rejected before the policy runs, and each segment of a pipeline is validated separately.

The grant replaces the per-call prompt for READS only. run_shell_command is in Agent.confirmation_required_tools(), so every call normally raises a confirmation modal. A triage is five to ten reads — five to ten modals attended, and unattended a 100% failure rate, since a console with nobody to ask denies. So a read fully covered by an active grant skips the prompt: the user already consented, in front matter, to one binary and a read-only table. A confirmable write does not: the grant declares which writes may be offered, and the user still approves each one. Everything else is unchanged — Agent._call_is_pre_authorized asks the host's skill_grant_covers_call hook, which answers False unless every segment of the command is a granted binary running an allowed read. pwd still prompts. So does gh issue list | head: consent was for gh, not for a pipeline. An agent that has loaded no such skill has no way to answer yes, and its gate is byte-identical.

Adding a CLI is data plus a SKILL.md. Supporting GitLab is a "glab" entry in BINARY_POLICIES and a skill that declares shell:execute:glab — no new branch in the shell tool, no change to the permission grammar.

Security tiers

Three tiers set the install-time permission ceiling and grant behavior.

Tier Signing At install Ceiling (enforced)
verified AMD-signed, audited Auto-grant, no prompt All declared permissions pre-approved.
community Publisher-signed, automated scans Dangerous grants prompt network:read, network:write, mcp:connect, shell:execute.
experimental None Explicit opt-in (--allow-experimental) network:read only.

Promotion path: experimental → community → verified. A publisher signs to reach community; an AMD audit request reaches verified. Re-audit on every version bump.

A claim is not a tier. gaia skill install computes min(claimed tier, tier the signature earned) and enforces the ceiling against that, so an unsigned skill can never install as verified no matter what its front matter says. A key's trust-store role decides what it attests: publisher → community, amd → verified. The trust store ships empty and no AMD key is bundled, so out of the box every skill installs experimental — the honest floor rather than a placeholder that makes the top tier look anchored. See gaia skill install.

The dangerous set the spec names — shell:execute, desktop:control, database:write — is retained as authoritative. shell:execute:<binary> is reachable today through the policed binary bridge and prompts at community; desktop:control and database:write are unbridged local capabilities, so they are still refused before any tier check.

**Tiers are NOT the governance decision system.** The governance layer's `DecisionType = Literal["ALLOW", "REVIEW", "BLOCK"]` ([`governance/schemas.py:17`](https://github.com/amd/gaia/blob/main/src/gaia/governance/schemas.py)) is a **run-time, per-action** verdict. Security tiers are an **install-time trust ladder** that sets the *ceiling* a skill's permissions may reach. They are complementary, not the same axis: a `verified` skill still has each action adjudicated at run time by governance.

The install-time ceiling now ships (#2467): signatures are verified, the claimed tier is collapsed to the attested one, and a permission above the resulting ceiling refuses the install. What remains deferred to Phase 2 is the run-time sandbox for local-capability domains — which is why those domains are refused outright rather than granted at any tier.

**A `verified` stamp is provenance, not containment.** v1 skills are instruction + connector-bridged only. Do not read a tier as evidence that a sandbox is policing what the skill's code does at run time — no such sandbox exists yet ([#1019](#1019)).

Pre-publish security audit

Every skill passes an automated audit before it can enter the hub catalog (#2468).

Skills are contributed by pull request — there is no self-serve publish path (the hub's publish endpoint is gated on a maintainer-held token secret), so the PR check is the gate: BLOCK fails it, REVIEW holds the PR for maintainer sign-off, ALLOW passes. Submit under skills/community/<name>/; see CLI Reference → Skills.

Authors run the same engine locally, so a clean local audit is the whole requirement:

gaia skill audit ./my-skill/                    # ALLOW / REVIEW / BLOCK + file:line findings
gaia skill audit ./my-skill/ --tier community   # would it clear the tier I want to claim?
gaia skill audit ./my-skill/ --json             # the payload the publish path attaches

Exit codes are distinct so CI can branch: 0 allow, 5 review, 6 block, 4 the skill could not be parsed. A skill the audit cannot read is never passed — there is nothing to audit and nothing to publish.

Four analyzers run over the directory. Nothing imports the skill, runs its code, or touches the network, so auditing a hostile skill is itself safe — this is a static gate, and the runtime sandbox remains Phase 2.

Analyzer What it looks for
Code (tools.py, scripts/, any *.py) Dangerous constructs regardless of what was declared: eval/exec, shell=True, dynamic import, pickle.loads, native libraries, credential-file reads, bulk os.environ harvesting, encoded payloads fed to exec. AST-based, so os.system in a docstring is not a finding and from os import system as sh is.
Permission truth Diffs the domains the code actually touches against metadata.gaia.permissions. An undeclared domain, an insufficient level (declared read, performs a write), use despite an explicit <domain>:none, or a write outside a declared scope are all hard findings — the declaration is what the consent prompt and the Phase 2 sandbox are built on. Over-declaration is advisory: asking for less than you declared cannot hurt the user.
Supply chain Imports of packages the manifest never declares, dependencies fetched from outside a package index (git+, a URL, a path), and unpinned versions — an unpinned dependency means the code audited today is not the code that runs tomorrow.
Instruction body Prompt-injection and jailbreak patterns in the Markdown body and the description. Both reach the model: the description from discovery onward, the body once the skill triggers.

Tier-scaled rigor. The same findings produce different verdicts depending on the tier a skill claims, which is what makes the tiers above mean something at publish time:

Claimed tier Verdict
experimental Advisory — findings are surfaced but do not gate. critical still blocks: there is no tier at which shipping known-malicious code is acceptable.
community high forces REVIEW, critical blocks.
verified medium forces REVIEW, high blocks — and the automated gate never grants it. A spotless skill claiming verified earns REVIEW: "cleared the robot, awaiting the human."

A skill cannot be stamped a tier whose gate it did not clear. The report lists the tiers its findings actually cleared, and the hub compares the claimed tier against that list — so an unearned claim is refused, not recorded. verified never appears in that list, which is what stops a publisher self-stamping the top tier; the AMD audit is what adds it.

A new version re-earns its verdict. The report is bound to the skill name, the version, and a digest of the audited bytes, so a report cannot be replayed onto a different publish. CI re-runs the full audit on every PR touching a skill, with no "already audited" short-circuit.

**The gate proves consistency, not trustworthiness.** The audit report is a publisher-supplied form part with no attestation, so the binding checks close *replay and accident* — stale reports, forgotten re-audits, careless tier bumps — but not *forgery*. A hostile publisher can fabricate a report whose every field agrees, including the digest over their own files.

That is why a recorded verdict says attestation: "publisher-asserted". Read it as "self-consistent", never as "AMD vouches for this". Making a verdict unforgeable needs an attestation the publisher cannot mint — report signing with a CI-held key (#1710), or the hub running the audit itself — not a stricter parse.

Disclosure. Findings name file:line and the fix, but the offending source text is withheld from every report unless a caller passes --show-snippets locally. In CI the per-finding detail goes to the repo's private Security → Code scanning tab as SARIF; the PR comment carries verdicts, tiers, and counts only. REVIEW currently refuses the publish rather than admitting it — the quarantine lane and its maintainer sign-off workflow are #2675.

Tool registration

A skill's tools map onto the existing decorator and registry. On load, the runtime imports the skill's tools.py and registers each @tool under a <skill-name>/<tool-name> namespace into _TOOL_REGISTRY to prevent collisions when an agent composes multiple skills — the same namespacing Agent Skills describes. The skill's tools.py uses the standard @tool decorator unchanged:

# web-research/tools.py
from gaia.agents.base.tools import tool

@tool
def search_web(query: str, max_results: int = 5) -> dict:
    """Search the web for current information."""
    ...

The manifest is the contract, in both directions. The loader rejects the skill — registering nothing — when any of these holds:

Mismatch Why it fails
A declared tool has no @tool function The manifest advertises capability the code does not deliver. If the function exists but is undecorated, the error says so.
tools.py registers a tool the manifest does not declare The manifest is what a user audits before granting; an undeclared tool is an unaudited one.
Parameter names differ from the signature The error names which side each stray parameter came from.
A parameter's required flag contradicts the signature's default Silent drift between docs and behavior.
A parameter's declared type contradicts the annotation Only checked when the decorator could infer a type — an un-inferable annotation has nothing to contradict.
tools declared but no tools.py on disk Either add the module or drop tools to make it instruction-only.
tools.py fails to import The error names the exception and points at requirements.dependencies.

Every failure restores _TOOL_REGISTRY (and sys.modules) byte-for-byte — there is no partial load, as Agent Skills requires. Shipping a tools.py while declaring no tools is not an error: the module is simply not imported, and the skill loads instruction-only.

CLI

Shipped verbs (gaia/skills/cli.py) — authoring and local management. Full flag reference in CLI Reference → Skills.

gaia skill list [--json] [--root agent-bundled|user|claude-import]
gaia skill info <name> [--json] [--body]
gaia skill create <name> [--dir DIR] [--description TEXT] [--with-tools] [--force]
gaia skill import <folder|zip|https-url> [--name NAME] [--force] [--allow-experimental]
gaia skill export <name> [--output FILE.zip]
gaia skill audit <dir> [--json] [--output FILE] [--sarif FILE] [--path-prefix DIR]
                       [--tier TIER] [--fail-on SEVERITY] [--show-snippets]
gaia skill migrate <source> [--from openclaw|hermes|auto] [--out DIR] [--name NAME]
                            [--force] [--dry-run] [--json]

Exit codes are distinct so scripts can branch: 0 ok, 2 usage, 3 not found, 4 invalid (including a discovery root containing a malformed skill), and — for audit — 5 review, 6 block. REVIEW and BLOCK are separate codes so CI can hold a skill for sign-off without reporting it as rejected.

Marketplace verbs (#2467 / #647) — these talk to the Agent Hub's skills lane:

gaia skill search "web search" [--json] [--hub-url URL]
gaia skill install <name[@semver-range]> [--allow-experimental] [--force] [--yes]
gaia skill remove <name>
gaia skill lock [--check|--relock] [--json]
gaia skill publish ./my-skill/ [--unsigned] [--audit-report FILE] [--dry-run]
gaia skill keygen [--key-name NAME] [--force]
gaia skill trust {list|add <key> [--role publisher|amd]|remove <key-id>}

install accepts a SemVer range and takes the highest published version satisfying it. A range admits a prerelease only if one of its targets names a prerelease of the same major.minor.patch, such as >=1.3.0-rc.1. An omitted version, *, or latest selects the highest stable release, or the highest prerelease when no stable release exists. The same applies to any and an empty range. Installs are recorded in ~/.gaia/skills/skill-lock.json with the requested range, the resolved version, the artifact SHA-256, a digest of the installed files, the signing key, and the tier actually installed at.

lock reads that record back: --check (the default) reports version, tier, content, and presence drift between the lock and the disk, exiting 4 when anything differs; --relock re-records the current state after an intentional change. A community / verified skill whose files drifted refuses to load and cannot be relocked — those bytes are not the ones its signature covered. Nor can one with no digest recorded yet (an entry from a pre-digest build): stamping the current bytes there would assert a signature nothing checked, so those reinstall instead. An unreadable lock refuses every user-root skill, local ones included — it cannot say which are attested.

Still PROPOSED — update (marketplace):

gaia skill update web-research                   # PROPOSED
**The proposed verbs are absent, not stubbed.** `gaia skill update` is not a command that prints "not implemented" — it does not parse, so a user cannot discover a verb that cannot work. [#691](#691 acceptance asked for `list`/`install`/`remove`: all three now ship.

What the hub enforces at publish

The Agent Hub Worker serves POST /publish/skill and a skill catalog lane (#2467), and gaia skill publish is the client that calls it.

That endpoint re-validates this grammar server-side before a skill may enter the catalog. It is a gatekeeper, not a replacement for the loader's validation above: a skill can be locally valid and still be refused publication. Rejections name the offending field.

  • name and description are required; name matches ^[a-z0-9]+(-[a-z0-9]+)*$ and is ≤64 chars, description is ≤1024.
  • version is required to publish (optional in the format itself, where a skill may be unversioned) and must be SemVer. 0.0.0 is refused — the unversioned sentinel can never become an immutable release.
  • metadata.gaia.security_tier must be one of verified / community / experimental, and the tier is gated by the pre-publish security audit (#2468) — community and verified cannot publish without a cleared report, and a skill cannot claim a tier whose gate it did not clear.
  • permissions, tools, tools_required, and requirements must match the shapes above; the catalog entry carries them so an installer can show the permission preview before anything is written to disk.

Skill names share one namespace with agent ids, so a name already taken by a published agent is refused (and vice versa).

See Agent Skills → Agent Hub integration for the catalog shape and the storage/route layout.

Declarative consumption — the skills: block

An agent names the skills it composes in its gaia-agent.yaml, and any Agent subclass resolves that block at construction — so a custom agent under ~/.gaia/agents/<id>/ consumes an installed hub skill with no code change:

skills:
  - name: web-research
    version: ">=1.0.0"
    required: true
  - name: incident-review
    version: ">=0.1.0"
    required: false      # optional enhancement; the agent runs without it

Resolution is local — gaia skill install puts the skill in a discovery root and this reads what is there; an agent never pulls code from the network at startup. Ordering mirrors agent dependencies:: a skill providing a tool another declares in tools_required loads first, declaration order breaks ties, and a cycle raises rather than picking a winner. A missing required: true skill raises with the gaia skill install command that fixes it; a missing optional one is logged and skipped.

The skill_sets: grammar is #2466. It expands a set into SkillRequirement values and resolves them through the same path, so there is one resolver and one place that enforces conflicts.

Agent UI dashboard support

Acceptance #4 requires a skills management panel. The proposed /api/skills router mirrors the existing /api/agents router (ui/routers/agents.py: list :130, detail :174, export :184, import :222) — the same list/detail/install/remove shape, so the panel reuses the agents-panel conventions rather than inventing new ones.

GET    /api/skills                 # list installed skills (+ tier, version)   PROPOSED
GET    /api/skills/{name}          # skill detail (manifest, permissions)      PROPOSED
POST   /api/skills/install         # install from registry/path                PROPOSED
DELETE /api/skills/{name}          # remove                                    PROPOSED

There is no /api/skills router today — the dashboard is greenfield. The data it needs now exists (SkillManager.list_skills(), and gaia skill list --json / gaia skill info --json already emit the list and detail shapes), so the router is a thin wrapper rather than new machinery. Until it ships the panel is absent, not broken (see Off-states).

Cross-format compatibility & migration

Three formats, one namespace pattern. GAIA reads the standard base directly and treats Hermes/OpenClaw as migration sources, not runtime targets.

Format Where its fields live GAIA support
Agent Skills (agentskills.io) base + (GAIA) metadata.gaia Native, lossless. A bare standard skill loads unchanged.
Hermes (Nous Research) metadata.hermes Migrate via gaia skill migrate --from hermes.
OpenClaw / ClawHub metadata.openclaw Migrate via gaia skill migrate --from openclaw.

A single shipped tool — gaia skill migrate --from {hermes,openclaw,auto} (migrate.py, #692) — reads the foreign namespace and writes a metadata.gaia block. Every migrated skill lands experimental, re-earning trust via the same reset_security_tier() gaia skill import uses. Migration emits a report of fields needing manual review, and the output is round-tripped through parse_skill() before install, so a migration can never emit a SKILL.md the parser rejects.

Field-by-field: OpenClaw → GAIA

Fields GAIA models fully are consumed — they move into metadata.gaia and leave the vendor block. Anything partially or un-modeled is preserved under metadata.<vendor> and named in the report, which is what makes the vendor-namespace pattern lossless in practice rather than in principle.

OpenClaw field GAIA target Disposition
requires.env: [VAR] requirements.env_vars Consumed
primaryEnv: VAR requirements.env_vars Consumed
envVars: [{name|key, …}] requirements.env_vars (names) Preserved — per-variable descriptions are not modeled
requires.tools: [t] tools_required Consumed
requires.bins: [git] permissions: [shell:execute:git] Refused unless the binary is in BINARY_POLICIES
requires.anyBins: [a, b] permissions: [shell:execute:a, …] Refused unless policed — and GAIA has no any-of form, so each is declared
requires.config: [path] permissions: [filesystem:read:path] Refused — local capability
install: [{node|uv|pip: pkg}] requirements.{node_,}dependencies Preserved — GAIA never runs install steps
install: [{brew|go|shell: …}] — Preserved + reported
emoji, homepage, os, always, skillKey, nix, config — Preserved + reported
name (any case/underscores) name (lowercase-with-hyphens) Coerced + reported
version: v2.1 version: 2.1.0 Coerced to SemVer + reported
**`requires.env` is a requirement, not a permission.** It declares which variables must *exist*; it grants no access, so it maps to the advisory `requirements.env_vars` rather than the `env:read` domain. Treating it as a permission would make nearly every real OpenClaw skill unmigratable for a field GAIA already has a home for.

Field-by-field: Hermes → GAIA

Hermes field GAIA target Disposition
requires.tools: [t] tools_required Consumed
requires.env: [VAR] requirements.env_vars Consumed
requires.bins / requires.config shell / filesystem permissions Refused — unpoliced binary / local capability
category, tags, author — Preserved + reported

The shapes published skills actually use

Validated against 26 real published ClawHub skills (each commit-pinned, with its source URL, license, and digest recorded in tests/fixtures/openclaw_skills/PROVENANCE.md). The documented schema is not what the registry contains, so the migrator reads all of these:

Divergence seen in the wild How migration handles it
Vendor fields directly under metadata, no namespace key Detected by field shape and migrated from where they are
Vendor fields at the top level of the frontmatter Same
The namespace hoisted to a top-level clawdis: key Same
Aliases metadata.clawdbot / metadata.clawdis Treated as openclaw
The same payload duplicated under two aliases First-wins, with a note — merging would double-count every requirement
No frontmatter at all (~5% of the registry) A per-skill blocker; the rest of the batch still migrates
envVars entries keyed key rather than name Both read
name disagreeing with its directory The migrated skill installs under its (coerced) name, satisfying GAIA's name-==-directory rule

Of the 26, 13 migrate cleanly and 13 are refused — 10 for a local capability (they shell out) and 3 for having no frontmatter. That ratio is the honest shape of the corpus, not a shortfall: a skill whose whole purpose is running git or tmux cannot be bridged until the sandbox lands.

**No runtime adapter in v1.** The prior draft promised an `OpenClawAdapter` that wrapped foreign handlers in `@tool` at load time (`from gaia.skills.openclaw import OpenClawAdapter`). **That is retracted** — v1 is migration-only. A runtime adapter is deferred to a later phase; do not build against `OpenClawAdapter`.

Relationship to neighbors

  • Agent Skills — owns the architecture and integration surface (discovery roots, progressive disclosure, scoping, the skill/tool/agent distinction). This doc owns the field grammar. Where they overlap, that spec is canonical for the compatibility surface and this one for the schema; the two are reconciled (see Correcting the prior spec).
  • Agent Manifest (#462) — a gaia-agent.yaml references skills by name + version range; SKILL.md is the skill-level complement. The keys are specified in manifest-side grammar above; how a set is selected per launch is in Agent Skills → Skill sets.
  • Agent bundles — skill packaging relates to the existing agent bundle format (.zip + bundle.json, BUNDLE_FORMAT_VERSION = 1, export_import.py:40).
  • Builder template — scaffolds agent.py today (builder/template.py); emitting SKILL.md is a future extension, not current behavior.
  • OEM bundling (#648) — needs a stable definition of "a skill" to pre-configure hardware SKUs. A SKU pre-bundles skills as verified-tier entries in a discovery root; the directory anatomy and tier defaults this doc fixes are that definition.

Example skills

Six worked examples. The three for existing agents (acceptance #6) declare only tool names that exist in the current registry. All parse as YAML; tool skills round-trip against their tools.py.

**Several examples declare permissions GAIA refuses.** `rag-search` (4a) and `file-operations` (4c) declare `filesystem:*`. These are **valid manifests** — they parse and validate — but `load_skill` refuses them until the sandbox lands ([#1019](#1019)), because GAIA will not load a grant it cannot enforce. They are kept here as the format's target state; the examples that load **today** are the instruction-only ones (1, 3), the `network`/`mcp`-scoped ones (2, 4b), and the migrated `git-status` (6) — `git` now has a `BINARY_POLICIES` entry, so its grant is enforceable and loads.

Proves a bare Agent Skills SKILL.md loads with no GAIA fields — defaulting to experimental, no tools, no permissions.

---
name: incident-review
description: Walk through a production incident postmortem. Use when the user mentions an outage, incident, or postmortem.
---

# Incident Review

1. Establish the timeline from first alert to resolution.
2. Separate root cause from contributing factors.
3. Draft action items with owners — never "the team".

The restructured schema: standard base + metadata.gaia.

---
name: web-search
description: Search the web via the Brave Search API. Use when the user asks about current events or external facts.
license: MIT
version: 1.0.0
metadata:
  gaia:
    security_tier: verified
    permissions:
      - network:read:*.brave.com
    requirements:
      python: ">=3.10"
      dependencies:
        - requests>=2.31
      env_vars:
        - BRAVE_API_KEY
    tools:
      - name: search_web
        description: Search the web for current information
        parameters:
          query: { type: string, required: true }
          max_results: { type: integer, required: false, default: 5 }
        returns: { type: object }
        atomic: false
---

# Web Search

A self-contained Brave Search wrapper — `search_web` is this skill's own
`@tool`, backed by the skill's `tools.py`.

A distilled procedure that consumes existing registry tools. Each tools_required name resolves against a real registry tool (query_documents from RAGToolsMixin, read_file from FileIOToolsMixin; remember from the tool-loader CORE set).

---
name: triage-support-ticket
description: Triage an inbound support ticket end to end. Use when the user pastes a support ticket or asks to triage one.
version: 0.2.0
metadata:
  gaia:
    security_tier: experimental
    tools_required:
      - query_documents
      - read_file
      - remember
---

# Triage a Support Ticket

1. Pull matching policy docs with `query_documents`.
2. Read any attached log with `read_file`.
3. Record the disposition with `remember` for follow-up.

Acceptance #6 — a skill mapping the rag mixin's real tools.

---
name: rag-search
description: Search and answer over indexed documents. Use when the user asks about the contents of uploaded files.
version: 1.0.0
metadata:
  gaia:
    security_tier: verified
    permissions:
      - filesystem:read
    tools:
      - name: query_documents
        description: Answer a question over all indexed documents
        parameters:
          query: { type: string, required: true }
        returns: { type: object }
      - name: search_indexed_chunks
        description: Return the top matching chunks for a pattern
        parameters:
          pattern: { type: string, required: true }
        returns: { type: object }
      - name: list_indexed_documents
        description: List documents currently in the index
        parameters: {}
        returns: { type: object }
---

# RAG Search

Use `query_documents` for direct answers; `search_indexed_chunks` to inspect
evidence; `list_indexed_documents` to see what is available.

Acceptance #6 — the browser mixin's real tools (search_web, fetch_page, download_file).

---
name: web-research
description: Research the open web. Use when the user asks about current events or facts not in the documents.
version: 1.0.0
metadata:
  gaia:
    security_tier: verified
    permissions:
      - network:read
    tools:
      - name: search_web
        description: Search the web for current information
        parameters:
          query: { type: string, required: true }
        returns: { type: object }
      - name: fetch_page
        description: Fetch and extract the readable text of a URL
        parameters:
          url: { type: string, required: true }
        returns: { type: object }
      - name: download_file
        description: Download a file from a URL to local disk
        parameters:
          url: { type: string, required: true }
        returns: { type: object }
---

# Web Research

Search first, then fetch the best results; download only when the user asks for
the file itself.

Acceptance #6 — the file_io mixin's real tools.

---
name: file-operations
description: Read, write, and edit files on the local workspace. Use when the user asks to inspect or modify files.
version: 1.0.0
metadata:
  gaia:
    security_tier: community
    permissions:
      - filesystem:read
      - filesystem:write:./**
    tools:
      - name: read_file
        description: Read the contents of a file
        parameters:
          file_path: { type: string, required: true }
        returns: { type: object }
      - name: write_file
        description: Write content to a file
        parameters:
          file_path: { type: string, required: true }
          content: { type: string, required: true }
        returns: { type: object }
      - name: edit_file
        description: Replace a span of text in an existing file
        parameters:
          file_path: { type: string, required: true }
          old_content: { type: string, required: true }
          new_content: { type: string, required: true }
        returns: { type: object }
---

# File Operations

Read before you write; prefer `edit_file` for in-place changes over rewriting a
whole file with `write_file`.

A Hermes skill (left) and what gaia skill migrate --from hermes produces (right) — the modeled metadata.hermes fields become metadata.gaia, and the result lands experimental. Unmodeled ones (category) stay under metadata.hermes; experimental being the omitted default, the emitted file need not restate it (gaia skill info always shows the effective tier).

# BEFORE — Hermes
---
name: pdf-extract
description: Extract structured data from a PDF.
version: 1.0.0
metadata:
  hermes:
    category: documents
    requires:
      tools: [read_file]
---

# Extract from PDF
...
# AFTER — gaia skill migrate --from hermes
---
name: pdf-extract
description: Extract structured data from a PDF.
version: 1.0.0
metadata:
  gaia:
    security_tier: experimental
    tools_required:
      - read_file
---

# Extract from PDF
...

An OpenClaw skill and the mapping migrate computes for it. metadata.openclaw requirements map to metadata.gaia fields and trust resets to experimental.

**This skill migrates.** `bins: [git]` maps to `shell:execute:git`, and `git` has a `BINARY_POLICIES` entry gating its subcommands, so `gaia skill migrate` writes the AFTER block below. A binary with no policy is still reported **unmigratable with that reason** rather than written — a skill needing local access is never silently stripped of the permission to make the migration succeed. `docker` is the example that still refuses.
# BEFORE — OpenClaw
---
name: git-status
description: Summarize the working tree status.
version: 0.3.0
metadata:
  openclaw:
    requires:
      bins: [git]
---

# Git Status
...
# AFTER — gaia skill migrate --from openclaw
---
name: git-status
description: Summarize the working tree status.
version: 0.3.0
metadata:
  gaia:
    security_tier: experimental
    permissions:
      - shell:execute:git
---

# Git Status
...

Off-states (safe floors)

Every degraded condition lands on a conservative, well-defined floor — nothing breaks because a GAIA feature is absent.

Condition Floor behavior
Bare standard skill (only name + description) Loads as an instruction-only skill: security_tier: experimental, no tools, no permissions. The most restrictive defaults. Matches Agent Skills → compatibility rule.
No metadata.gaia block All GAIA fields take their omitted-defaults (above). A standard runtime ignores metadata.gaia entirely → lossless both directions.
compatibility / allowed-tools present Ignored (not part of GAIA's adopted base). The skill still parses.
tools_required names a tool unknown to the framework Not yet checked. The Phase 1 validator coerces tools_required to strings and stops there — no catalog lookup runs, because the active registry is assembled dynamically (see Open question 1). The field is inert until #1451 consumes it. When the check does land, an unknown name fails loudly; a name that is valid but simply not active in the current agent's registry stays scoping, not a defect — omitted from selection and logged, never silently invented.
Skill directory fails to parse Skipped from discovery and surfaced — logged at ERROR, listed in SkillManager.discovery_errors, printed by gaia skill list, which exits 4 — except under a read-only claude-import root, where it is a warning logged once and the exit stays 0. Never quietly missing.
Two roots ship the same skill name The higher-precedence copy wins and the loser stays visible via SkillManager.shadowed() / gaia skill info. Precedence is auditable, not silent.
A skill body references a file outside its own directory Rejected — SkillManager.resource_path refuses traversal, as does .zip import.
Enforcement layers not yet shipped /api/skills and the sandbox degrade to absent, not permissive: an unenforceable permission refuses the load rather than loading unguarded. Install-time tier ceilings now ship.
Tier signal unavailable Falls to experimental — the most-restrictive tier — never to a more-permissive default. Imported and migrated skills are re-stamped experimental regardless of the tier they claim, and an unsigned or untrusted-key install is collapsed to experimental however high it claims.

Future-gated phases are additive with precedence. The tools_required signal that #1451 consumes returns [] cleanly until #887 lands (tool-loader.mdx), so a recipe skill authored today is valid but inert until its consumer ships — it never blocks or errors.


Correcting the prior spec

This document revises an earlier draft of skill-format.mdx. The most important correction:

The base standard is Agent Skills (agentskills.io), not OpenClaw. The prior draft framed "OpenClaw / ClawHub" as the external standard to be compatible with. In fact agentskills.io (as used by Claude Code) is the base GAIA adopts; OpenClaw is just one of several compatible third-party formats, alongside Hermes. This reconciles skill-format.mdx with Agent Skills, which had already flagged "OpenClaw/ClawHub" as a placeholder from an earlier draft.

The second structural correction: GAIA-specific fields move from the top level into metadata.gaia. The prior draft put permissions / security_tier / requirements / tools at the top level, which collides with the standard's namespace and breaks lossless cross-runtime parsing. Nesting them under metadata.gaia mirrors the convergent prior art (Hermes metadata.hermes, OpenClaw metadata.openclaw).

**Retractions — do not build against these:**
  • OpenClawAdapter runtime adapter is dropped from v1. Compatibility is migration-only — via gaia skill migrate, which ships (#692); it is the runtime adapter that is deferred, not the migration path.
  • The flat top-level schema is superseded. permissions, security_tier, requirements, and tools are now under metadata.gaia. Top-level version is the only GAIA superset key outside metadata.
  • compatibility and allowed-tools are not part of GAIA's base. The prior reliance on allowed-tools for permission scoping is removed; permissions come from metadata.gaia.permissions.
  • The five-root discovery list is superseded by three. Project-local ./.gaia/skills/ is deferred — see Agent Skills → Discovery. The registry-lock root is not a fourth root: gaia skill install writes ~/.gaia/skills/skill-lock.json beside the skills it installs, so the lock is provenance over the existing user root, not a new discovery location.
  • Still greenfield, still PROPOSED: the local-capability permission sandbox and run-time enforcement, /api/skills and the UI panel, and gaia skill update. Do not build against these.

No longer a retraction — these now exist. From #888: gaia.skills, SkillManager, Agent.load_skill, ~/.gaia/skills/, and gaia skill list|info|create|import|export. From #2467: gaia skill search|install|remove|publish (plus keygen/trust), bundle signing, install-time tier-ceiling enforcement, and skill-lock.json. From #692: gaia skill migrate and gaia.skills.migrate. An earlier revision of this document said the #888 symbols were "verified absent on main"; that is no longer true of any of these sets.


KPIs

A format contract is judged by conformance and losslessness, not runtime speed. The bar:

KPI Type Target
Cross-spec field-name match Headline (contract) tools_required (and every other emitted field) is byte-identical across #887, #1451, and this spec — zero drift.
Lossless round-trip Correctness A bare agentskills.io skill loads in GAIA unchanged; a GAIA skill's standard fields parse in a standard runtime with metadata.gaia ignored, no parse break.
Migration fidelity Correctness gaia skill migrate output validates against this schema and lands experimental. (Met — verified against 26 real published ClawHub skills.)
No hallucinated runtime Grounding Every cited symbol resolves on main or is marked PROPOSED. (Met — the Phase 1 symbols now resolve in src/gaia/skills/.)
Example validity Grounding Every example parses as YAML; each tool skill's tools block round-trips against its tools.py.
Acceptance coverage Completeness 100% of #691's acceptance criteria map to a section (see Traceability).

Phased build

The format (Phase 0) and the runtime core (Phase 1) have shipped; the remaining phases are additive. Each phase holds the prior floor until it lands.

Phase 0 — Format contract (SHIPPED)

Success criteria:

  • Schema decided: adopted base + metadata.gaia; tools vs tools_required separated; compatibility/allowed-tools excluded.
  • Agent Skills reconciled — mapping table nests metadata.gaia.*, the placeholder Note becomes a Division-of-authority Note, and allowed-tools/compatibility are marked excluded.
  • All example skills parse; the three acceptance-#6 skills use only real registry tool names.
  • Traceability covers 100% of #691 acceptance criteria.

Phase 1 — Loader, validator, discovery, CLI core (SHIPPED, #888)

What landed:

  • gaia.skills.format — SKILL.md parser, writer, and validator (Skill, SkillTool, SkillRequirements, GaiaMetadata, parse_skill, parse_skill_file, parse_skill_metadata, validate_skill). Round-trip is identity: unknown top-level keys, other metadata.<vendor> namespaces, and unknown metadata.gaia keys are all preserved, so passing a foreign skill through GAIA loses nothing. compatibility / allowed-tools / disallowed-tools parse and are ignored, exactly as specified above.
  • gaia.skills.manager — SkillManager over three roots (agent-bundled → user → claude-import), progressive disclosure (discover = frontmatter, load = + body, resource_path = on-demand files, traversal-checked), auditable shadowing (shadowed()), surfaced parse failures (discovery_errors), and optional hot-reload via start_watching().
  • gaia.skills.loader — imports tools.py and registers each @tool as <skill>/<tool> in _TOOL_REGISTRY, cross-checking the manifest against the real signatures. No partial load: any failure restores the registry byte-for-byte.
  • gaia.skills.permissions — the <domain>:<level>[:scope] grammar, the connector bridge for network/mcp, and the loud refusal of local-capability domains.
  • gaia.skills.sets (#2466) — the declarative skills: / skill_sets: / default_skill_set: manifest grammar (see Manifest-side grammar), parsed once and shared by gaia.hub.manifest and the base Agent, plus per-launch selection (SKILL_MANIFEST, select_skill_set, load_skill_set). An undeclared set name raises SkillSetError rather than falling back. The email agent is the reference consumer.
  • Agent.load_skill / unload_skill / SKILL_DIRS / skill_manager / loaded_skills — plus get_skills_system_prompt(), auto-discovered by _get_mixin_prompts(), which returns "" when no skill is loaded so every existing agent's prompt stays byte-identical.
  • gaia skill list|info|create|import|export.

Success criteria — met:

  • A tool skill loads and its tools register and call under <skill>/<tool>.
  • A bare standard skill loads instruction-only at security_tier: experimental.
  • Validation fails loudly on a tools↔tools.py mismatch — missing tool, undeclared extra, parameter-name mismatch, required/optional flip, or type contradiction — with no partial load.

Deliberately deferred out of Phase 1: tier-ceiling enforcement, the project-local ./.gaia/skills/ root, and network-egress enforcement. (The install-time half of the tier ceiling has since shipped with the marketplace — see Phase 4.)

Phase 2 — Permission enforcement + load-time tier ceilings + sandbox (PROPOSED, #1019)

The connector bridge shipped early, in Phase 1: network:* / mcp:connect already resolve to ConnectorRequirement at load-time. The install-time tier ceiling shipped with the marketplace (Phase 4). What remains is the sandbox that makes local-capability domains loadable at all, the load-time ceiling — which covers a skill that never passed through install, e.g. one from gaia skill create/import or copied into a root by hand — and network-egress policy over the declared network requirements.

Success criteria:

  • A hand-placed skill requesting a permission above its tier ceiling fails to load (installing one already fails).
  • filesystem/database/desktop/env route to the sandbox and load instead of being refused (shell:execute:<binary> already loads through the policed binary bridge).
  • A declared network:<level>:<scope> actually constrains egress.

Phase 3 — Agent UI dashboard (PROPOSED; #701)

/api/skills mirroring /api/agents, plus the skills management panel.

gaia skill migrate --from {hermes,openclaw,auto} was slated for this phase and shipped ahead of it (#692) — it needs no UI surface, so it did not wait for one. Visual import remains part of this phase: it is the same migrator behind a panel that does not exist yet.

Success criteria:

  • The panel lists installed skills with tier + version and supports install/remove (acceptance #4).
  • The panel offers OpenClaw import over gaia.skills.migrate, reusing the shipped mapper rather than a second implementation.

Phase 4 — Marketplace (SHIPPED, #2467) + consumer wiring (PROPOSED; #887 / #1451)

Shipped: the hub's skills lane (POST /publish/skill, the skills/ R2 namespace, GET /index.json filtered by type: "skill"), gaia skill search|install|remove|publish, Ed25519 bundle signing with a trust store, install-time tier ceilings, skill-lock.json, and the declarative skills: block. The pre-publish security audit gate is #2468 — publish calls it and fails loudly when its engine is absent rather than publishing un-audited.

Still proposed: gaia skill update; #887 emits conformant SKILL.md; #1451 unions tools_required.

Success criteria:

  • A published skill installs by name@<range>, lock-tracked, and loads into an agent — including a non-bundled one. (Met.)
  • An unsigned skill cannot install as verified; experimental needs --allow-experimental; the per-tier permission ceiling is honored. (Met.)
  • #887-synthesized files validate against this schema unchanged.
  • #1451 unions a matched skill's tools_required ahead of semantic results (the contract in tool-loader.mdx).

Open questions

The five design forks (base standard, metadata.gaia nesting, top-level version, migration-only v1, excluding compatibility/allowed-tools) are locked by the maintainer and are recorded as settled above. What remains are implementation-level questions for whoever builds the runtime:

  1. tools_required validation timing. Fail-vs-skip is not open — GAIA's fail-loudly policy settles it: a name unknown to the framework is a manifest defect and fails loudly; a name valid but inactive in the current agent's registry is scoping (omitted + logged, not an error). The genuine open nuance is when the unknown-name check runs — at install against a static tool catalog, or deferred — since the active registry is assembled dynamically from mixins and other skills, so the full tool universe may not be known at install time. Affects #1451 robustness.

  2. Skill→skill dependencies. The prior draft allowed a skill to depend on other skills (requirements.skills). Keep it under metadata.gaia.requirements, or defer dependency resolution to the marketplace (#647)?

  3. /api/skills install sources. Registry-only, or also local path / URL? The latter widens the install-time security surface.

  4. Body-injection scanning. Resolved by #2468: every skill's body is scanned, and the tier decides the consequence. The "required for community+, advisory for experimental" split was the right instinct, but it does not need to be its own policy — body findings feed the same tier gate as code findings, so the split falls out for free: at experimental a body finding is surfaced and does not gate, at community it forces REVIEW, at verified a medium is already enough to hold it. See Pre-publish security audit for the scan itself.

    Two decisions inside it are worth recording, because both trade precision against evasion:

    • Fenced and quoted text is downgraded one step, never ignored. A skill that documents injection patterns so the model can refuse them is the good case, so blocking it would punish the careful author. But fenced text still reaches the model — models do not reliably treat it as inert data — so a code block buys a severity step, not a pass. Otherwise "put the directive in a fence" would be the standing bypass.
    • Prohibitions are not directives. "NEVER dump the environment" is the security instruction, not a request for one, so a prohibition immediately before a match suppresses it. Rules whose own pattern opens with a negation (concealment's "do not tell the user") are unaffected, because the check looks only at text before the match.
  5. docs/spec/ relocation. Settled with Phase 1: this document stays in docs/plans/. Agent Skills is the spec surface — it already lives in docs/spec/ and satisfies #691's "documented in docs/spec/" acceptance. Moving this doc too would split the field grammar from the architecture across two directories for no reader benefit, and would break every inbound /plans/skill-format link, including the ones the runtime's own error messages emit (FORMAT_DOCS_URL in skills/errors.py points at https://amd-gaia.ai/docs/plans/skill-format). Revisit only if the two documents ever merge.


Current state of the code

The format is grounded on real primitives, and the Phase 1 runtime now exists. Only the sandbox/run-time-enforcement and REST/UI layers are greenfield; the marketplace shipped in #2467.

Area Symbol / fact file:line State
Tool registry _TOOL_REGISTRY: dict[str, dict] agents/base/tools.py:16 Exists
Tool decorator @tool(func, *, atomic, display_label, timeout, …) agents/base/tools.py:19 Exists
Mixin map KNOWN_TOOLS (rag, browser, file_io, …) agents/registry.py:38 Exists — source for the 3 example skills
Agent base Agent, _compose_system_prompt, _get_system_prompt agents/base/agent.py:245, :591, :669 Exists
Connector grant ConnectorRequirement(connector_id, scopes, reason) connectors/providers/base.py:23 Exists — permission bridge
Required connectors REQUIRED_CONNECTORS: ClassVar[...] agents/base/agent.py:295 Exists
Governance DecisionType = Literal["ALLOW","REVIEW","BLOCK"] governance/schemas.py:17 Exists — run-time, not tiers
Per-agent identity namespaced_agent_id (builtin:/custom:sha256:/installed:) agents/registry.py:392 Exists — scoping precedent
Default model DEFAULT_MODEL_NAME = "Gemma-4-E4B-it-GGUF" llm/lemonade_client.py:119 Exists
Model tiers / device build_model_tiers(), DeviceConfig(ctx_size=…) agents/registry.py:335, :227 Exists — requirements references these
Agents UI router /api/agents list/detail/export/import ui/routers/agents.py:130, :174, :184, :222 Exists — /api/skills mirrors it
Bundle format .zip + bundle.json, BUNDLE_FORMAT_VERSION = 1 installer/export_import.py:40 Exists
Builder template scaffolds agent.py, emits no SKILL.md agents/builder/template.py Exists
Skill parser / validator Skill, parse_skill, parse_skill_file, parse_skill_metadata, validate_skill skills/format.py Exists (#888)
Discovery + precedence SkillManager, SkillRoot, ROOT_AGENT_BUNDLED/ROOT_USER/ROOT_CLAUDE_IMPORT, user_skills_dir() skills/manager.py Exists (#888) — 3 roots
Tool registration register_skill_tools, unregister_skill_tools (<skill>/<tool>, no partial load) skills/loader.py Exists (#888)
Permission grammar + bridge Permission, parse_permissions, connector_requirements, refuse_unbridged_permissions, CONNECTOR_BRIDGED_DOMAINS, LOCAL_CAPABILITY_DOMAINS skills/permissions.py Exists (#888) — bridge only
Agent integration load_skill, unload_skill, SKILL_DIRS, skill_manager, loaded_skills, get_skills_system_prompt agents/base/agent.py Exists (#888)
Skill-set grammar SkillRef, SkillSets, SkillSetResolution, parse_skill_sets skills/sets.py Exists (#2466) — skills: / skill_sets: / default_skill_set:
Manifest wiring AgentManifest.skill_sets hub/manifest.py Exists (#2466) — malformed blocks raise ManifestError
Skill-set selection SKILL_MANIFEST, skill_sets, select_skill_set, resolve_skill_set, load_skill_set, active_skill_set agents/base/agent.py Exists (#2466) — one set per launch
CLI gaia skill list|info|create|import|export skills/cli.py Exists (#888)
Migration migrate_text, migrate_skill_dir, install_migrated, detect_vendor; gaia skill migrate skills/migrate.py Exists (#692) — OpenClaw/Hermes → GAIA
Trust reset reset_security_tier() — one path shared by import and migrate skills/format.py Exists (#692)
Errors SkillError, SkillValidationError, SkillNotFoundError, SkillPermissionError, SkillSetError skills/errors.py Exists (#888, #2466)
Local-capability permission sandbox — NOT FOUND Greenfield — PROPOSED (#1019)
Install-time tier ceiling enforce_tier_ceiling(), effective_tier() skills/tiers.py Exists (#2467)
Bundle signing / trust store sign_bundle(), verify_bundle(), TrustStore skills/signing.py Exists (#2467)
/api/skills router + UI panel — NOT FOUND Greenfield — PROPOSED
gaia skill install|remove|search|publish _handle_install, _handle_publish, … skills/cli.py Exists (#2467)
gaia skill update — NOT FOUND Greenfield — PROPOSED (marketplace)
Hub catalog lane (server side) skill catalog type, POST /publish/skill, skills/ R2 prefix, SKILL.md publish validation, audit gate workers/agent-hub/src/ Exists (#2467)
Project-local ./.gaia/skills/ root — NOT FOUND Deferred — PROPOSED
Registry lock (skill-lock.json) SkillLock, LockEntry skills/lock.py Exists (#2467)
Lock drift detection + relock check_drift(), assert_no_fatal_drift(), relock(), gaia skill lock skills/drift.py Exists (#2467)

Dependencies

Blocking: none. This is a foundational format contract; its design inputs are external standards plus the in-repo drafts.

Design inputs: Agent Skills (base), Hermes (metadata.hermes), OpenClaw (metadata.openclaw), Claude Code (reference impl); in-repo Agent Skills (architecture / compatibility surface) and tool-loader.mdx Part 3 (the tools_required contract).

Downstream consumers. The features whose contract this format must satisfy. A closed tracking issue means that design is settled and already references this format — not that the dependency is gone.

Consumer What it needs Tracking issue
#887 skill auto-synthesis Emits SKILL.md — the exact frontmatter, esp. tools_required. open
#553 self-improving agent Skill extraction emits conformant SKILL.md — same producer contract as #887. open
#1451 tool-loader Part 3 Reads tools_required to union into per-turn tool selection. open (gated on #887)
#648 OEM bundling A definition of "a skill" to pre-bundle SKU defaults. open
#647 marketplace The format + the three tiers to publish/promote. closed (design settled)
#462 Agent Manifest A skill-level complement to gaia-agent.yaml. closed (design settled)

Acceptance-criteria traceability

Every acceptance criterion from #691, verbatim, mapped to its section and what it guarantees a consumer.

#691 acceptance criterion Section What it guarantees
☑ SKILL.md spec documented in docs/spec/ Agent Skills is the docs/spec/ surface; this doc stays in docs/plans/ as the field grammar — settled, see Open question 5 A single authoritative field grammar.
☑ Permission model defined with granular domain:level syntax Permission model <domain>:<level>:scope grammar + the ConnectorRequirement bridge — no parallel grant ledger. Grammar and bridge implemented; enforcement of local-capability domains deferred (they refuse).
☑ Security tiers defined + enforced with promotion path Security tiers Three install-time tiers + experimental→community→verified, distinct from run-time governance. Field implemented; ceilings deferred to Phase 2.
☐ Agent UI configuration dashboard has skills management panel Agent UI dashboard support A /api/skills shape mirroring the live /api/agents panel (PROPOSED — Phase 3). The CLI half (install/remove) it would call now ships.
☑ CLI: gaia skill list, install, remove CLI All three shipped, alongside info/create/import/export/migrate/search/publish/keygen/trust (#2467, #692).
☑ Example skills written for 3 existing agents Example skills 4a–4c rag-search, web-research, file-operations — each using only real registry tool names.