Authoring Skill Bundles#
This guide shows how to write and package a bundle for a skill registry —
the canonical public one being
open-robot-skills. Bundles use the
Anthropic Agent Skills directory
format (SKILL.md + resources) and are discovered by path — never by
pip entry-points. For how registries are resolved and layered, see
Skill Registries; for testing your bundle, see
Testing Skill Bundles.
Note
What is gap_core? As of the gap-core / gap-runtime workspace
split, bundle authors depend only on graph-as-policy-core (~150 MB)
— a slim package with no fastapi, JAX, or MuJoCo. The stable authoring
surface (@tool, types, errors, Skill/SkillMeta/Param/Serving)
lives under gap_core.*; the heavy runtime (gap.execute,
gap.connector, gap.agent) stays on the full graph-as-policy
distribution. Your bundle’s pyproject.toml should depend on
graph-as-policy-core only.
Tools vs skills — the two bundle kinds#
The folder a bundle lives in conveys its kind (tools/ vs skills/ —
kind is never declared in frontmatter); both use the same SKILL.md
format.
|
|
|
|---|---|---|
What it is |
what the robot can compute: model-backed callables with no task strategy |
what the robot can do: a manipulation strategy that owns subgraphs in generated graphs |
Naming |
named after the model ( |
named after the capability ( |
Exposes |
typed functions via |
LLM guidance (SKILL.md body) + canonical scripts under |
Appears in graphs as |
|
the |
LLM context |
flat tool catalog (name + summary + typed schema) |
the coordinator sees name + description; the subgraph agent gets the full SKILL.md plus the schemas of its |
Tool names come from exactly two sources: connector tools
(robot.* / sim.* — shipped by GaP core; these prefixes are reserved
and @tool rejects them — see Connector Tools)
and bundle tools (<bundle>.<func>). The loader rejects name
collisions. All skills are flat — there is no atomic/composite
distinction. Browse what already exists in the
skill catalog and tool catalog.
Repo layout#
open-robot-skills/
├── pyproject.toml # ONE distribution; each bundle = one extra
├── .claude-plugin/marketplace.json
├── tests/ # repo-level + per-bundle tests (CPU by default)
├── tools/
│ └── <bundle>/
│ ├── SKILL.md # frontmatter + body (required)
│ ├── tools.py # @tool functions (lazy model loading)
│ └── _impl.py … # private helpers (underscore = not discovered)
└── skills/
└── <bundle>/
├── SKILL.md
├── scripts/ # canonical scripts (type: script states)
├── prompts/ # templates loaded via load_prompt at runtime
├── references/ # long-form rationale, lazy-loaded by the agent
└── examples/ # example subgraphs / invocations
One bundle = one directory = one pyproject extra = one PR. Directories
without a SKILL.md and underscore-prefixed directories are skipped by
discovery.
SKILL.md format#
Frontmatter is YAML between --- delimiters; everything after the closing
--- is the body, which is handed verbatim to the subgraph agent (for
skills) or shown as tool-bundle documentation. The frontmatter follows the
Agent Skills spec at the top level, with all GaP extensions nested under
one gap: key so spec fields are never overloaded.
Spec-core fields (top level)#
Field |
Required |
Constraint (enforced by the loader) |
|---|---|---|
|
yes |
must equal the bundle directory name; ≤64 chars; lowercase letters/digits with single hyphens ( |
|
yes |
≤1024 chars; third-person, with a “Use when …” sentence — this is the coordinator’s entire view of the bundle. A missing usage cue is a validation warning engine-side (the bundle reports WARN in |
|
no |
SPDX-ish string |
|
no |
e.g. |
|
no |
free-form mapping; the loader reads |
Legacy keys are hard errors with migration hints: runtime, shape,
composes, category, tags, and contract at the top level are
removed (the tools-vs-skills folder split replaces shape;
category/tags move under metadata:), and any GaP-extension key
found at the top level instead of under gap: is rejected.
The gap: extension block — every field the loader consumes#
Field |
Used by |
Meaning |
|---|---|---|
|
prompt assembler |
list of flat tool names this skill’s scripts may call; the subgraph agent receives exactly these schemas |
|
coordinator + validator |
|
|
coordinator |
|
|
coordinator |
|
|
subgraph agent + registry |
list of |
|
|
|
|
codegen agent |
list of |
|
codegen agent |
list of |
|
prompt assembler |
list of error strings the skill can raise (e.g. |
|
prompt assembler |
free-form authoring tips |
|
prompt assembler |
inline rules or anchored refs into the bundle’s own |
|
validator |
|
|
catalog docs |
tool bundles: list of |
|
|
operational requirements: |
params/outputs are not frontmatter — they come from Python
introspection of the bundle’s callables and scripts.
Important
In open-robot-skills, gap.requires is mandatory on every tool
bundle — declare requires: {} when the bundle genuinely needs
nothing. A tool bundle without the block has meta.requires is None and
silently reports as unconditionally ready in gap check; the registry’s
test suite rejects that, and
also requires gpu-tagged bundles to declare requires: {gpu: true}.
Example (skill bundle)#
Abridged from the real skills/perceiving-objects:
---
name: perceiving-objects
description: >
Fast single-path 3D object perception. Runs Grounding-DINO broad
detection, a pairwise VLM crop tournament to identify the target box,
SAM3 box segmentation, and depth back-projection to a world-frame point
cloud ... Use when a manipulation workflow needs to localize one named
object quickly.
license: MIT
compatibility: requires gap>=0.1
metadata:
category: perception
tags: [perception, dino, vlm, sam3, single-method]
gap:
allowed_tools:
- robot.get_observation
- grounding-dino.detect
- vlm.query
- sam3.segment_box
- geometry.mask_to_world_points
- geometry.filter_and_compute_obb
exit_conditions:
found: Target detected; OBB and mask bound in subgraph outputs.
not_found: Target not visible in any view.
produces_outputs:
"<name>_obb": OrientedBoundingBox
"<name>_mask": Mask
canonical_scripts:
- perceive_dino_vlm: scripts/perceive_dino_vlm.py
prompts:
vlm_pairwise: prompts/vlm_pairwise.md
---
Tool bundles use the same shape with gap.tools: instead of
exit_conditions/canonical_scripts.
Authoring a policy bundle#
A third bundle kind lives under <registry>/policies/<name>/ and wraps
a learned policy server (VLA, diffusion, IL) behind the connector. Pick
this kind when the deliverable is weights + a serving entrypoint
rather than scripts or @tool callables. The SKILL.md uses the usual
shape with a gap.serving: block declaring how to launch the server:
gap:
serving:
command: "uv run python -m my_policy.serve --port {port}"
protocol: websocket # or stdio-msgpack
env: [HF_TOKEN, GAP_DEVICE]
requires_gpu: true
weights_uri: "hf://my-org/my-policy-libero@v0.3"
gap.execute spawns the command, dials the protocol, and routes
observations/actions through the connector. See
policies/pi05-libero and
policies/molmoact-libero in
open-robot-skills for working examples.
The authoring contract (stable import surface)#
Bundle code imports only from these modules; everything else in GaP is internal and may change without notice (see the API reference):
from gap import NodeContext, CancelToken
from gap_core.types import (Se3Pose, OrientedBoundingBox, Mask, PointCloud,
Observation, CameraFrame, Trajectory)
from gap_core.errors import (PipelineError, PerceptionFailed, PlanningFailed,
GraspFailed, ValidationFailed, ToolError)
from gap_core.tools import tool
from gap_core.skills import Skill, SkillMeta, load_prompt
from gap.testing import FakeContext, make_test_observation # tests only
Scripts define
run(ctx: NodeContext, ...) -> Outputwith full type annotations (Outputa TypedDict). They call tools exclusively throughctx.tool(name, **kwargs)and raisePipelineErrorsubclasses on failure.Tool functions are plain typed functions decorated with
@tool(name="<bundle>.<func>", summary="...", tags=(...)). Tags drive the call-count guard limits: aperception/planning/sim_steptag puts the tool under the correspondingGAP_MAX_PERCEPTION_CALLS/GAP_MAX_PLANNING_CALLS/GAP_MAX_SIM_STEPSbudget (see Environment Variables).Stateful callable skills subclass
gap_core.skills.Skilland definerun(self, ctx, ...). The runtime keeps one instance per skill per workflow execution — instance attributes persist across visits to the state and are discarded when the execution ends. Function-stylerun(ctx, ...)callables work unchanged; subclass only for genuine state needs (replan caches, trackers accumulating evidence). At most oneSkillsubclass per module — two is aValueError.Prompts:
load_prompt(__package__, "vlm_pairwise", n=3, labels=...)rendersprompts/vlm_pairwise.mdwith{{ var }}substitutions and non-nested{% if var %}…{% endif %}blocks (plain regex substitution; no jinja dependency — an undefined{{ var }}raisesKeyError). This works because the registry loads canonical scripts under a synthetic package rooted at the bundle dir (the loader walks up toSKILL.md). Ad-hoc LLM-emitted scripts have no bundle and cannot use it — by design.Streaming skills declare
gap.streaming: true, loop withctx.publish(snapshot)per iteration, and checkctx.cancel_token.raise_if_set()so teardown drains promptly.
Lazy model loading (mandatory for tool bundles)#
Importing a bundle’s tools.py must never pull torch / transformers /
CUDA — discovery imports every bundle, and gap check relies on imports
being cheap. The open-robot-skills suite
asserts that after loading every
tools.py, none of torch, transformers, curobo, sam3 is in
sys.modules. The pattern, used by every shipped bundle:
_load_lock = threading.Lock()
_model = None
def _get_model():
global _model
with _load_lock:
if _model is None:
from transformers import AutoModel # import INSIDE the loader
_model = AutoModel.from_pretrained(os.environ.get("GAP_X_MODEL", DEFAULT))
_model = _model.to(os.environ.get("GAP_X_DEVICE", "cuda")).eval()
return _model
@tool(name="my-model.detect", summary="...", tags=("perception",))
def detect(rgb: np.ndarray, text: str) -> DetectResult:
model = _get_model() # weights load on FIRST CALL
...
Per-bundle device/model knobs are environment variables with a GAP_
prefix, documented in the SKILL.md body.
Dependencies: one pip extra per bundle#
open-robot-skills is a single distribution whose wheel ships no code —
bundles are read from the checkout by path. The pyproject exists for the
dependency mechanism: each bundle declares its deps as an extra whose
name equals the bundle name (an empty list [] when it has none), so one
resolver run surfaces cross-bundle conflicts:
[project.optional-dependencies]
my-model = ["torch>=2.7", "some-model @ git+https://github.com/...@<sha>"]
uv sync --extra my-model # the install line your SKILL.md shows
# (pip: pip install -e "open-robot-skills[my-model]")
Run uv lock after editing the extras so the committed lockfile stays in
sync. Meta-extras compose bundles: [quickstart] (sam3 + grounding-dino +
geometry), [grocery] (quickstart + curobo), [all]. A missing extra is
a warning in gap skills check and a hard test failure in
open-robot-skills.
uv/pip own code installs; gap skills check only verifies. Nothing
ever installs behind the user’s back.
Weights: weights_cached() and prefetch() hooks#
Bundles that download model weights declare requires: {weights: true}
and may define two optional module-level hooks in tools.py (or
skill.py):
weights_cached() -> bool | None— a filesystem-only probegap checkcalls to report cache state without downloading anything and without importing torch. ReturnTrue/False, orNonefor “unknown”; absent hook → “unknown”. See tools/grounding-dino/tools.py for a real one (checks the Hugging Face cache for the model’sconfig.json).prefetch()— an explicit download step.gap skills check --downloadruns each bundle’sprefetch()after the format checks; bundles without the hook are reported asdeclares no weights (no prefetch()), and a hook that raises marks the run failed.
Note
No bundle currently shipped in open-robot-skills defines prefetch(),
so today weights download lazily at the first model call rather than
during --download. Missing weights never fail gap check — the cache
state is an informational line and the bundle stays READY. Set
HF_TOKEN before the first call for gated Hugging Face repos.
Scaffolding a new bundle#
gap skills new my-model --kind tool # targets the highest-precedence registry
gap skills new doing-things --kind skill --registry my-lab-skills
This creates the bundle directory with a TODO-annotated SKILL.md and
either a tools.py stub (tool) or scripts/example.py + prompts/ +
references/ (skill), plus a unit-test skeleton
tests/test_<name_with_underscores>.py in the owning registry (a
tests/conftest.py with the standard session fixtures is created when
missing; an existing test file is never overwritten). It refuses to
overwrite an existing bundle directory, and prints the next steps:
declare the pip extra, then gap skills check, then
gap skills test <name>.
No registry configured? gap skills new exits with a recipe — create one
first with gap registry init <path> --add, or pass a target with
--skills <path> (see Skill Registries).
Verifying#
gap skills check # per-bundle PASS/WARN/FAIL; non-zero exit on FAIL
gap skills test my-model # the bundle's unit tests, run from its registry
gap check # capability report: can each bundle run HERE?
gap skills table --format markdown # catalog table, paste-ready for READMEs
gap skills check runs two layers per bundle: format validation (the
rules live engine-side in gap/skills/validate.py,
so third-party registries get the same checker the open-robot-skills test
suite enforces — frontmatter shape per kind, every referenced
canonical_scripts/prompts/references/examples path exists,
gap.allowed_tools resolve against connector tools plus all declared
bundle tools, produces_outputs/required_inputs type names, the
one-pip-extra-per-bundle convention, and gap.requires consistency) and
an import probe (each bundle registered individually so one broken
bundle doesn’t mask the rest; ImportErrors map to the owning registry’s
install line — uv sync --extra <bundle> or
pip install "<dist>[<bundle>]").
gap check answers the operational question — deps importable,
declared gap.requires met (GPU via nvidia-smi, env vars), weights
cached — and rolls it up per skill (a skill is blocked by exactly the
not-ready bundles owning its allowed_tools; robot.*/sim.* are
connector-satisfied). In short: gap skills check = “is the bundle
well-formed”; gap check = “can it run here”. See the
CLI reference for all flags, and
Testing Skill Bundles for gap skills test.
Contribution checklist#
Before opening the PR (one bundle = one directory = one PR):
[ ]
SKILL.mdfrontmatter passes the loader:name== directory name, description ≤1024 chars with a “Use when …” sentence, GaP extensions undergap:.[ ] Skills declare
gap.exit_conditions(+produces_outputs/required_inputsas applicable); tool bundles declaregap.toolswith one line per exposed function.[ ]
gap.requiresdeclared ({}when the bundle needs nothing); gpu-tagged bundles saygpu: true; weight-downloading bundles consider aweights_cached()hook.gap checkshows the bundle READY (or honestly NOT READY with the right hints).[ ] Every path referenced in frontmatter (
canonical_scripts/prompts/references/examples) exists.[ ]
tools.pyimports clean without torch/transformers insys.modules(lazy loading); model/device knobs areGAP_*env vars documented in the body.[ ] Dependencies are one extra in
pyproject.toml(extra name == bundle name); the SKILL.md body shows theuv sync --extra <bundle>install line;uv lockis updated and[all]still resolves.[ ] Tool names are
<bundle>.<func>— therobot.*/sim.*prefixes are reserved and will be rejected.[ ] Unit tests on
FakeContext/make_test_observationcover the happy path and at least one failure exit; GPU/LLM paths are marked (see Testing Skill Bundles).[ ]
gap skills check,gap skills test <bundle>, and the registry’s fullpytest tests -qpass;ruff checkis clean.