diff --git a/codex-rs/core/src/skills/assets/samples/skill-creator/agents/openai.yaml b/codex-rs/core/src/skills/assets/samples/skill-creator/agents/openai.yaml
new file mode 100644
index 000000000..3095c600c
--- /dev/null
+++ b/codex-rs/core/src/skills/assets/samples/skill-creator/agents/openai.yaml
@@ -0,0 +1,5 @@
+interface:
+ display_name: "Skill Creator"
+ short_description: "Create or update a skill"
+ icon_small: "./assets/skill-creator-small.svg"
+ icon_large: "./assets/skill-creator.png"
diff --git a/codex-rs/core/src/skills/assets/samples/skill-creator/assets/skill-creator-small.svg b/codex-rs/core/src/skills/assets/samples/skill-creator/assets/skill-creator-small.svg
new file mode 100644
index 000000000..c6e4f67c6
--- /dev/null
+++ b/codex-rs/core/src/skills/assets/samples/skill-creator/assets/skill-creator-small.svg
@@ -0,0 +1,3 @@
+
diff --git a/codex-rs/core/src/skills/assets/samples/skill-creator/assets/skill-creator.png b/codex-rs/core/src/skills/assets/samples/skill-creator/assets/skill-creator.png
new file mode 100644
index 000000000..4f3d6d82f
Binary files /dev/null and b/codex-rs/core/src/skills/assets/samples/skill-creator/assets/skill-creator.png differ
diff --git a/codex-rs/core/src/skills/assets/samples/skill-creator/references/openai_yaml.md b/codex-rs/core/src/skills/assets/samples/skill-creator/references/openai_yaml.md
new file mode 100644
index 000000000..da5629f8d
--- /dev/null
+++ b/codex-rs/core/src/skills/assets/samples/skill-creator/references/openai_yaml.md
@@ -0,0 +1,43 @@
+# openai.yaml fields (full example + descriptions)
+
+`agents/openai.yaml` is an extended, product-specific config intended for the machine/harness to read, not the agent. Other product-specific config can also live in the `agents/` folder.
+
+## Full example
+
+```yaml
+interface:
+ display_name: "Optional user-facing name"
+ short_description: "Optional user-facing description"
+ icon_small: "./assets/small-400px.png"
+ icon_large: "./assets/large-logo.svg"
+ brand_color: "#3B82F6"
+ default_prompt: "Optional surrounding prompt to use the skill with"
+
+dependencies:
+ tools:
+ - type: "mcp"
+ value: "github"
+ description: "GitHub MCP server"
+ transport: "streamable_http"
+ url: "https://api.githubcopilot.com/mcp/"
+```
+
+## Field descriptions and constraints
+
+Top-level constraints:
+
+- Quote all string values.
+- Keep keys unquoted.
+- For `interface.default_prompt`: generate a helpful, short (typically 1 sentence) example starting prompt based on the skill. It must explicitly mention the skill as `$skill-name` (e.g., "Use $skill-name-here to draft a concise weekly status update.").
+
+- `interface.display_name`: Human-facing title shown in UI skill lists and chips.
+- `interface.short_description`: Human-facing short UI blurb (25–64 chars) for quick scanning.
+- `interface.icon_small`: Path to a small icon asset (relative to skill dir). Default to `./assets/` and place icons in the skill's `assets/` folder.
+- `interface.icon_large`: Path to a larger logo asset (relative to skill dir). Default to `./assets/` and place icons in the skill's `assets/` folder.
+- `interface.brand_color`: Hex color used for UI accents (e.g., badges).
+- `interface.default_prompt`: Default prompt snippet inserted when invoking the skill.
+- `dependencies.tools[].type`: Dependency category. Only `mcp` is supported for now.
+- `dependencies.tools[].value`: Identifier of the tool or dependency.
+- `dependencies.tools[].description`: Human-readable explanation of the dependency.
+- `dependencies.tools[].transport`: Connection type when `type` is `mcp`.
+- `dependencies.tools[].url`: MCP server URL when `type` is `mcp`.
diff --git a/codex-rs/core/src/skills/assets/samples/skill-creator/scripts/generate_openai_yaml.py b/codex-rs/core/src/skills/assets/samples/skill-creator/scripts/generate_openai_yaml.py
new file mode 100644
index 000000000..1a9d784f8
--- /dev/null
+++ b/codex-rs/core/src/skills/assets/samples/skill-creator/scripts/generate_openai_yaml.py
@@ -0,0 +1,225 @@
+#!/usr/bin/env python3
+"""
+OpenAI YAML Generator - Creates agents/openai.yaml for a skill folder.
+
+Usage:
+ generate_openai_yaml.py [--name ] [--interface key=value]
+"""
+
+import argparse
+import re
+import sys
+from pathlib import Path
+
+import yaml
+
+ACRONYMS = {
+ "GH",
+ "MCP",
+ "API",
+ "CI",
+ "CLI",
+ "LLM",
+ "PDF",
+ "PR",
+ "UI",
+ "URL",
+ "SQL",
+}
+
+BRANDS = {
+ "openai": "OpenAI",
+ "openapi": "OpenAPI",
+ "github": "GitHub",
+ "pagerduty": "PagerDuty",
+ "datadog": "DataDog",
+ "sqlite": "SQLite",
+ "fastapi": "FastAPI",
+}
+
+SMALL_WORDS = {"and", "or", "to", "up", "with"}
+
+ALLOWED_INTERFACE_KEYS = {
+ "display_name",
+ "short_description",
+ "icon_small",
+ "icon_large",
+ "brand_color",
+ "default_prompt",
+}
+
+
+def yaml_quote(value):
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
+ return f'"{escaped}"'
+
+
+def format_display_name(skill_name):
+ words = [word for word in skill_name.split("-") if word]
+ formatted = []
+ for index, word in enumerate(words):
+ lower = word.lower()
+ upper = word.upper()
+ if upper in ACRONYMS:
+ formatted.append(upper)
+ continue
+ if lower in BRANDS:
+ formatted.append(BRANDS[lower])
+ continue
+ if index > 0 and lower in SMALL_WORDS:
+ formatted.append(lower)
+ continue
+ formatted.append(word.capitalize())
+ return " ".join(formatted)
+
+
+def generate_short_description(display_name):
+ description = f"Help with {display_name} tasks"
+
+ if len(description) < 25:
+ description = f"Help with {display_name} tasks and workflows"
+ if len(description) < 25:
+ description = f"Help with {display_name} tasks with guidance"
+
+ if len(description) > 64:
+ description = f"Help with {display_name}"
+ if len(description) > 64:
+ description = f"{display_name} helper"
+ if len(description) > 64:
+ description = f"{display_name} tools"
+ if len(description) > 64:
+ suffix = " helper"
+ max_name_length = 64 - len(suffix)
+ trimmed = display_name[:max_name_length].rstrip()
+ description = f"{trimmed}{suffix}"
+ if len(description) > 64:
+ description = description[:64].rstrip()
+
+ if len(description) < 25:
+ description = f"{description} workflows"
+ if len(description) > 64:
+ description = description[:64].rstrip()
+
+ return description
+
+
+def read_frontmatter_name(skill_dir):
+ skill_md = Path(skill_dir) / "SKILL.md"
+ if not skill_md.exists():
+ print(f"[ERROR] SKILL.md not found in {skill_dir}")
+ return None
+ content = skill_md.read_text()
+ match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
+ if not match:
+ print("[ERROR] Invalid SKILL.md frontmatter format.")
+ return None
+ frontmatter_text = match.group(1)
+ try:
+ frontmatter = yaml.safe_load(frontmatter_text)
+ except yaml.YAMLError as exc:
+ print(f"[ERROR] Invalid YAML frontmatter: {exc}")
+ return None
+ if not isinstance(frontmatter, dict):
+ print("[ERROR] Frontmatter must be a YAML dictionary.")
+ return None
+ name = frontmatter.get("name", "")
+ if not isinstance(name, str) or not name.strip():
+ print("[ERROR] Frontmatter 'name' is missing or invalid.")
+ return None
+ return name.strip()
+
+
+def parse_interface_overrides(raw_overrides):
+ overrides = {}
+ optional_order = []
+ for item in raw_overrides:
+ if "=" not in item:
+ print(f"[ERROR] Invalid interface override '{item}'. Use key=value.")
+ return None, None
+ key, value = item.split("=", 1)
+ key = key.strip()
+ value = value.strip()
+ if not key:
+ print(f"[ERROR] Invalid interface override '{item}'. Key is empty.")
+ return None, None
+ if key not in ALLOWED_INTERFACE_KEYS:
+ allowed = ", ".join(sorted(ALLOWED_INTERFACE_KEYS))
+ print(f"[ERROR] Unknown interface field '{key}'. Allowed: {allowed}")
+ return None, None
+ overrides[key] = value
+ if key not in ("display_name", "short_description") and key not in optional_order:
+ optional_order.append(key)
+ return overrides, optional_order
+
+
+def write_openai_yaml(skill_dir, skill_name, raw_overrides):
+ overrides, optional_order = parse_interface_overrides(raw_overrides)
+ if overrides is None:
+ return None
+
+ display_name = overrides.get("display_name") or format_display_name(skill_name)
+ short_description = overrides.get("short_description") or generate_short_description(display_name)
+
+ if not (25 <= len(short_description) <= 64):
+ print(
+ "[ERROR] short_description must be 25-64 characters "
+ f"(got {len(short_description)})."
+ )
+ return None
+
+ interface_lines = [
+ "interface:",
+ f" display_name: {yaml_quote(display_name)}",
+ f" short_description: {yaml_quote(short_description)}",
+ ]
+
+ for key in optional_order:
+ value = overrides.get(key)
+ if value is not None:
+ interface_lines.append(f" {key}: {yaml_quote(value)}")
+
+ agents_dir = Path(skill_dir) / "agents"
+ agents_dir.mkdir(parents=True, exist_ok=True)
+ output_path = agents_dir / "openai.yaml"
+ output_path.write_text("\n".join(interface_lines) + "\n")
+ print(f"[OK] Created agents/openai.yaml")
+ return output_path
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="Create agents/openai.yaml for a skill directory.",
+ )
+ parser.add_argument("skill_dir", help="Path to the skill directory")
+ parser.add_argument(
+ "--name",
+ help="Skill name override (defaults to SKILL.md frontmatter)",
+ )
+ parser.add_argument(
+ "--interface",
+ action="append",
+ default=[],
+ help="Interface override in key=value format (repeatable)",
+ )
+ args = parser.parse_args()
+
+ skill_dir = Path(args.skill_dir).resolve()
+ if not skill_dir.exists():
+ print(f"[ERROR] Skill directory not found: {skill_dir}")
+ sys.exit(1)
+ if not skill_dir.is_dir():
+ print(f"[ERROR] Path is not a directory: {skill_dir}")
+ sys.exit(1)
+
+ skill_name = args.name or read_frontmatter_name(skill_dir)
+ if not skill_name:
+ sys.exit(1)
+
+ result = write_openai_yaml(skill_dir, skill_name, args.interface)
+ if result:
+ sys.exit(0)
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/codex-rs/core/src/skills/assets/samples/skill-installer/agents/openai.yaml b/codex-rs/core/src/skills/assets/samples/skill-installer/agents/openai.yaml
new file mode 100644
index 000000000..88d40cd94
--- /dev/null
+++ b/codex-rs/core/src/skills/assets/samples/skill-installer/agents/openai.yaml
@@ -0,0 +1,5 @@
+interface:
+ display_name: "Skill Installer"
+ short_description: "Install curated skills from openai/skills or other repos"
+ icon_small: "./assets/skill-installer-small.svg"
+ icon_large: "./assets/skill-installer.png"
diff --git a/codex-rs/core/src/skills/assets/samples/skill-installer/assets/skill-installer-small.svg b/codex-rs/core/src/skills/assets/samples/skill-installer/assets/skill-installer-small.svg
new file mode 100644
index 000000000..ccfc03424
--- /dev/null
+++ b/codex-rs/core/src/skills/assets/samples/skill-installer/assets/skill-installer-small.svg
@@ -0,0 +1,3 @@
+
diff --git a/codex-rs/core/src/skills/assets/samples/skill-installer/assets/skill-installer.png b/codex-rs/core/src/skills/assets/samples/skill-installer/assets/skill-installer.png
new file mode 100644
index 000000000..2977cd5bb
Binary files /dev/null and b/codex-rs/core/src/skills/assets/samples/skill-installer/assets/skill-installer.png differ
diff --git a/codex-rs/core/src/skills/assets/samples/skill-installer/scripts/list-skills.py b/codex-rs/core/src/skills/assets/samples/skill-installer/scripts/list-skills.py
new file mode 100755
index 000000000..0977c296a
--- /dev/null
+++ b/codex-rs/core/src/skills/assets/samples/skill-installer/scripts/list-skills.py
@@ -0,0 +1,107 @@
+#!/usr/bin/env python3
+"""List skills from a GitHub repo path."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+import urllib.error
+
+from github_utils import github_api_contents_url, github_request
+
+DEFAULT_REPO = "openai/skills"
+DEFAULT_PATH = "skills/.curated"
+DEFAULT_REF = "main"
+
+
+class ListError(Exception):
+ pass
+
+
+class Args(argparse.Namespace):
+ repo: str
+ path: str
+ ref: str
+ format: str
+
+
+def _request(url: str) -> bytes:
+ return github_request(url, "codex-skill-list")
+
+
+def _codex_home() -> str:
+ return os.environ.get("CODEX_HOME", os.path.expanduser("~/.codex"))
+
+
+def _installed_skills() -> set[str]:
+ root = os.path.join(_codex_home(), "skills")
+ if not os.path.isdir(root):
+ return set()
+ entries = set()
+ for name in os.listdir(root):
+ path = os.path.join(root, name)
+ if os.path.isdir(path):
+ entries.add(name)
+ return entries
+
+
+def _list_skills(repo: str, path: str, ref: str) -> list[str]:
+ api_url = github_api_contents_url(repo, path, ref)
+ try:
+ payload = _request(api_url)
+ except urllib.error.HTTPError as exc:
+ if exc.code == 404:
+ raise ListError(
+ "Skills path not found: "
+ f"https://github.com/{repo}/tree/{ref}/{path}"
+ ) from exc
+ raise ListError(f"Failed to fetch skills: HTTP {exc.code}") from exc
+ data = json.loads(payload.decode("utf-8"))
+ if not isinstance(data, list):
+ raise ListError("Unexpected skills listing response.")
+ skills = [item["name"] for item in data if item.get("type") == "dir"]
+ return sorted(skills)
+
+
+def _parse_args(argv: list[str]) -> Args:
+ parser = argparse.ArgumentParser(description="List skills.")
+ parser.add_argument("--repo", default=DEFAULT_REPO)
+ parser.add_argument(
+ "--path",
+ default=DEFAULT_PATH,
+ help="Repo path to list (default: skills/.curated)",
+ )
+ parser.add_argument("--ref", default=DEFAULT_REF)
+ parser.add_argument(
+ "--format",
+ choices=["text", "json"],
+ default="text",
+ help="Output format",
+ )
+ return parser.parse_args(argv, namespace=Args())
+
+
+def main(argv: list[str]) -> int:
+ args = _parse_args(argv)
+ try:
+ skills = _list_skills(args.repo, args.path, args.ref)
+ installed = _installed_skills()
+ if args.format == "json":
+ payload = [
+ {"name": name, "installed": name in installed} for name in skills
+ ]
+ print(json.dumps(payload))
+ else:
+ for idx, name in enumerate(skills, start=1):
+ suffix = " (already installed)" if name in installed else ""
+ print(f"{idx}. {name}{suffix}")
+ return 0
+ except ListError as exc:
+ print(f"Error: {exc}", file=sys.stderr)
+ return 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))