Writing Custom Skills
Create script-backed skills with tools, guardrails, and the compilation pipeline.
Skill Registry
Forge ships with a built-in skill registry. Add skills to your project with a single command:
# Add a skill from the registry
forge skills add tavily-research
# Validate skill requirements
forge skills validate
# Audit skill security
forge skills audit --embeddedforge skills add copies the skill's SKILL.md and any associated scripts into your project's skills/ directory. It validates binary and environment requirements, checks for existing values in your environment, .env file, and encrypted secrets, and prompts only for truly missing values with a suggestion to use forge secrets set for sensitive keys. If the skill declares egress_domains, they are automatically merged into the forge.yaml egress.allowed_domains list (deduplicated and sorted).
Skills as First-Class Tools
Script-backed skills are automatically registered as first-class LLM tools at runtime. When a skill has scripts in skills/scripts/, Forge:
- Parses the skill's SKILL.md for tool definitions, descriptions, and input schemas
- Creates a named tool for each
## Tool:entry (e.g.,tavily_researchbecomes a tool the LLM can call directly) - Executes the backing script with JSON input when the LLM invokes it, under the interpreter matching its extension —
scripts/<name>.sh(bash),.py(python3), or.js(node). A## Tool: foo_barbinds to eitherscripts/foo_bar.<ext>orscripts/foo-bar.<ext>(the underscore and hyphen forms both work; the hyphenated form wins only if both files exist). Shell wins if several extensions exist. Ensure the interpreter is provisioned (python3/nodeinrequires.bins; bash is built in).
Silent-failure guard. If the backing script is missing, or a
**Input:**parameter yields a JSON-schema property key outside^[a-zA-Z0-9_.-]{1,64}$, the runtime drops that tool at startup with only a log line. Runforge skills validateto catch both — plus orphan scripts with no## Tool:heading — before you ship; it exits non-zero on any error, so you can gate CI on it.
This means the LLM sees skill tools alongside builtins like web_search and http_request — no generic cli_execute indirection needed.
For skills without scripts (binary-backed skills like k8s-incident-triage), Forge injects the full skill instructions into the system prompt. The complete SKILL.md body — including triage steps, detection heuristics, output structure, and safety constraints — is included inline so the LLM follows the skill protocol without needing an extra tool call. Skills are invoked via cli_execute with the declared binary dependencies.
┌─────────────────────────────────────────────────┐
│ LLM Tool Registry │
├─────────────────┬───────────────────────────────┤
│ Builtins │ web_search, http_request │
│ Skill Tools │ tavily_research, codegen_* │ ← auto-registered from scripts
│ read_skill │ load any SKILL.md on demand │
│ cli_execute │ run approved binaries │
├─────────────────┴───────────────────────────────┤
│ System Prompt: full skill instructions inline │ ← binary-backed skills
└─────────────────────────────────────────────────┘Skill-relative files and scripts
A skill's SKILL.md can reference other files it ships — reference docs,
templates, or helper scripts — by a path relative to the skill's own
directory (skills/<skill>/…). The whole directory reaches the running
agent (COPY . . at build time), and two builtins resolve those references
against the skill dir (path-confined — no .. or absolute escapes):
-
Read a bundled file — the agent calls
read_skillwith itsfileargument. Write instructions like "readreference/runbook.md" and the agent loadsskills/<skill>/reference/runbook.md. -
Run a bundled script — the agent calls
run_skill_script. Write "runscripts/check.py" and the agent executesskills/<skill>/scripts/check.pywith the skill directory as the working directory (so the script's own relative reads resolve), picking the interpreter by extension:Extension Interpreter requires.bins.sh/.bashbash(built in) .pypython3add python3.jsnodeadd nodeJSON supplied in the tool's
argsis passed to the script as its first positional argument ($1). TypeScript must be shipped as compiled.js.
This is distinct from a ## Tool: entry backed by scripts/<name>.{sh,py,js},
which is registered as a first-class callable tool the model invokes by name
(see above) — all three languages get first-class registration. Skill-relative
scripts that DON'T correspond to a ## Tool: heading are still reachable by
path via run_skill_script.
Skill Execution Security
Skill scripts run in a restricted environment via SkillCommandExecutor:
- Isolated environment: Only
PATH,HOME, and the env vars the skill declared inmetadata.forge.requires.envare passed through. Values may live in the shell, a.envfile, or the encrypted secrets store — the runtime overlays each declared key from the provider chain at startup (see Secret Management — Skill-Declared Secrets) - OAuth token resolution: When
OPENAI_API_KEYis set to__oauth__, the executor resolves OAuth credentials and injects the access token,OPENAI_BASE_URL, and the configured model asREVIEW_MODEL - Configurable timeout: Each skill declares a
timeout_hintin its YAML frontmatter (e.g., 300s for research) - No shell string execution: a
## Tool:script runs as<interpreter> <script> <json-input>— interpreter chosen by extension (.sh/.bash→bash,.py→python3,.js→node; #405 D2), NOT via a shell string, so the JSON argument is a single opaqueargv[1]. A## Tool:whose interpreter (python3/node) is absent from PATH is skipped with a warning rather than failing at call time. - Egress proxy enforcement: When egress mode is
allowlistordeny-all, a local HTTP/HTTPS proxy is started andHTTP_PROXY/HTTPS_PROXYenv vars are injected into subprocess environments, ensuringcurl,wget, Pythonrequests, and other HTTP clients route through the same domain allowlist used by in-process tools (see Egress Security)
Symlink Escape Detection
The skill scanner validates symlinks when a filesystem root path is available. Symlinks that resolve outside the root directory are skipped with a warning log. This prevents malicious symlinks in skill directories from escaping the project boundary. The scanner exposes ScanWithRoot(fsys, rootPath) for callers that need symlink validation, while the original Scan(fsys) remains backward-compatible.
Trust Policy Defaults
The default trust policy requires checksum verification (RequireChecksum: true). Skills loaded without a signature emit a warning log at scan time. Signature verification remains opt-in (RequireSignature: false).
Skill Guardrails
Skills can declare domain-specific guardrails in their SKILL.md frontmatter to enforce security policies at runtime. These guardrails operate at four interception points in the agent loop, preventing unauthorized commands, data exfiltration, capability enumeration, and binary name disclosure.
Configuration
Add a guardrails block under metadata.forge in SKILL.md:
metadata:
forge:
guardrails:
deny_commands:
- pattern: '\bget\s+secrets?\b'
message: "Listing Kubernetes secrets is not permitted"
deny_output:
- pattern: 'kind:\s*Secret'
action: block
- pattern: 'token:\s*[A-Za-z0-9+/=]{40,}'
action: redact
deny_prompts:
- pattern: '\b(approved|allowed|available)\b.{0,40}\b(tools?|binaries)\b'
message: "I help with K8s cost analysis. Ask about cluster costs."
deny_responses:
- pattern: '\b(kubectl|jq|awk|bc|curl)\b.*\b(kubectl|jq|awk|bc|curl)\b.*\b(kubectl|jq|awk|bc|curl)\b'
message: "I can analyze cluster costs. What would you like to know?"Guardrail Types
| Type | Direction | Purpose |
|---|---|---|
deny_commands | Input | Block cli_execute commands matching patterns (e.g., kubectl get secrets) |
deny_output | Output | Block or redact tool output matching patterns (e.g., Secret manifests, tokens) |
deny_prompts | Input | Block user messages probing agent capabilities (e.g., "what tools can you run") |
deny_responses | Output | Replace LLM responses that enumerate internal binary names |
Capability Enumeration Prevention
The deny_prompts and deny_responses guardrails form a layered defense against capability enumeration attacks:
- Input-side (
deny_prompts) — Intercepts user messages that probe for available tools, binaries, or commands and redirects to the skill's functional description - Output-side (
deny_responses) — Catches LLM responses that list 3+ binary names and replaces the entire response with a functional capability description
Additionally, skill Description() methods and system prompt catalog entries use generic descriptions instead of listing binary names.
For full details on guardrail types, pattern syntax, and runtime behavior, see Content Guardrails — Skill Guardrails.
Iterating from the Skill Builder UI
Custom skills can be authored AND iterated on from the dashboard's Skill Builder. After a skill is saved and attached, real-world bugs surface only when the LLM actually calls the tool inside the agent loop — wrong input schema, brittle error handling, undeclared egress. The builder's edit mode closes the loop:
- Open the Skill Builder for the agent.
- The Skills attached to this agent panel lists your custom skills.
- Click Edit on a skill — its current SKILL.md and helper scripts load into the Monaco editor and the chat is primed with the existing content so the LLM can patch it intelligently.
- Describe the change in chat. The LLM is instructed to preserve
existing
## Tool: <name>headings (renaming breaks any agent already wired to that tool) and emit a**Changed:**summary. - Click Preview changes for a side-by-side diff before saving.
- Confirm save overwrites the existing skill directory in place. Helper scripts dropped from the new SKILL.md are removed from disk so the runtime stops discovering them.
- Restart agent when prompted so the running agent picks up the changes — the live tool registry is captured at startup and the watcher refreshes the agent card, not the registry.
Hand-editing skills/<name>/SKILL.md on disk still works for power
users and remains supported. The builder's edit mode is a strict
addition — no migration required for existing skills.
See Web Dashboard › Editing an Attached Skill for the full UX walkthrough and API endpoints.
Skill Instructions in System Prompt
Forge injects the full body of each skill's SKILL.md into the LLM system prompt. This means all detailed operational instructions — triage steps, detection heuristics, output structure, safety constraints — are directly available in the LLM's context without requiring an extra read_skill tool call.
For skills with extensive instructions (like k8s-incident-triage with ~150 lines of triage procedures), this ensures the LLM follows the complete skill protocol from the first interaction.
Compilation Pipeline
The skill compilation pipeline has three stages:
-
Parse — Reads
SKILL.mdand extractsSkillEntryvalues with name, description, input spec, and output spec. When YAML frontmatter is present,ParseWithMetadata()additionally extractsSkillMetadataandSkillRequirements(binary deps, env vars). -
Compile — Converts entries into
CompiledSkillswith:- A JSON-serializable skill list
- A human-readable prompt catalog
- Version identifier (
agentskills-v1)
-
Write Artifacts — Outputs to the build directory:
compiled/skills/skills.json— Machine-readable skill definitionscompiled/prompt.txt— LLM-readable skill catalog
Build Stage Integration
The SkillsStage runs as part of the build pipeline:
- Scans the
skills/subdirectory forSKILL.mdfiles in each subdirectory - Parses, compiles, and writes artifacts
- Updates the
AgentSpecwithskills_spec_versionandforge_skills_ext_version - Records generated files in the build manifest