Skillfile reference
A Skillfile derives one skill from another. It reads like a Dockerfile and builds like a pure function: nothing executes, and the same bases, Skillfile and context always produce the same artifact.
The commands that evaluate one are in the CLI reference, and the quick start runs the whole round trip end to end.
Syntax
A Skillfile is read top to bottom, one instruction per line. Instructions apply in file order, and when two of them touch the same bytes the later one wins.
A line ending in \ continues on the next. A line whose first non-blank character is # is a comment; a # anywhere else is ordinary text, so git+https://host/o/r#v1.2.0 and a # note inside a regex both survive.
Arguments are split on whitespace, and single or double quotes group an argument that contains spaces. Quoting means nothing to any instruction except SET, where it forces the value to be a string.
Flags are written --name=value. A bare --name value is rejected: it would make SET --file values.yaml model opus ambiguous with a positional argument.
APPEND, PATCH and AWK take their payload either inline as a heredoc — <<EOF, then the body, then a line holding only EOF — or as the path of a file in the build context. A heredoc body is taken verbatim: no comment stripping, no line joining, and no $NAME expansion, so an AWK script's $1 and a template's {{ }} both reach the artifact as written.
$NAME and ${NAME} in an argument expand to the value of an ARG of that name. A name no ARG declared is left exactly as written, which is what keeps REPLACE's $1 submatch references intact.
Nothing executes. There is no RUN, no ENTRYPOINT and no CMD, and a build is a pure function of its bases, its Skillfile and its context — the same three inputs always produce the same digest.
FROM sources
A FROM reference is one of four things. Only the OCI scheme touches a registry, so a Skillfile built on local or git bases is a complete, standalone workflow.
:, the subdirectory. The ref may be a branch, a tag, a full refs/… name or a commit SHA; omit the fragment for the default branch. An annotated tag is peeled to the commit it points at. git+http:// reaches a server without TLS.localhost.FROM … AS base bound. Checked before the filesystem, so a directory that happens to share the name cannot shadow the stage.ARG
ARG <name>[=<default>]
Declare a build argument, optionally with a default.
--build-arg <name>=<value> on epos build wins over the default. --build-arg <name>= sets it to the empty string, which is how a default is suppressed.
Only declared names expand. $1 in a REPLACE replacement, or a name nobody declared, is left as written rather than blanked — a typo stays visible in the output instead of vanishing.
Build arguments are the build-time substitution mechanism. {{ }} is the install-time one and is never touched here (see Values and templating).
Build context
base/SKILL.md
--- name: reviewer --- Reviews changes.
Skillfile
ARG language=Go FROM ./base SET language $language
Result
SKILL.md
--- name: reviewer language: Go --- Reviews changes.
FROM
FROM <ref> [AS <stage>]
Start a stage from a base skill.
The base enters the stage at its root: base/references/style.md in the context becomes references/style.md in the artifact, which is what every later instruction addresses.
A Skillfile needs at least one FROM, and the last one is the stage that becomes the artifact. Earlier stages are sources a COPY --from names.
See FROM sources for the four things <ref> can be, and Multi-stage composition for AS.
Build context
base/SKILL.md
--- name: reviewer --- Reviews changes.
base/references/style.md
Two spaces after a full stop.
Skillfile
FROM ./base
Result
references/style.md
Two spaces after a full stop.
COPY
COPY [--from=<stage>] <src>... <dest>
Copy files in from the build context, or from a finished stage.
--from=<stage>- Read the sources from the named stage's final tree instead of the build context. The stage must already have finished.
A <dest> ending in /, or written as ., is a directory: the source keeps its base name under it. Anything else is the destination path itself.
A <src> naming no file is taken as a directory prefix, and everything under it is copied with its layout kept. A source that matches neither a file nor a prefix fails the build.
Composition is explicit enumeration, not merge-by-default: what you take, you name.
Build context
base/SKILL.md
--- name: reviewer --- Reviews changes.
checklist.md
- Table-driven tests for every exported function.
Skillfile
FROM ./base COPY checklist.md references/checklist.md
Result
references/checklist.md
- Table-driven tests for every exported function.
RM
RM <path>...
Remove a file, or everything under a directory prefix.
An absent path is fatal. This is deliberately unlike the two instructions that warn: a zero-match REPLACE and an absent-key UNSET end in the state the author asked for — the pattern is gone, the key is gone — so they stay idempotent against a base that has already adopted the same change. RM has no such reading: a path that is not there is a path the author was wrong about, and continuing would ship an artifact built from a Skillfile that no longer describes its base.
A directory is not a thing the tree holds, only files are, so RM sections/ removes everything beneath it.
Build context
base/SKILL.md
--- name: reviewer --- Reviews changes.
base/sections/house-style.md
Oxford comma, always.
base/sections/checklist.md
- One assertion per test.
Skillfile
FROM ./base RM sections/house-style.md
Result
sections/checklist.md
- One assertion per test.
Removed
sections/house-style.md
APPEND
APPEND <path> (<<EOF … EOF | <file>)
Add text to the end of a file, creating it if it is not there.
If the file does not end in a newline, one is added before the payload, so an append never joins itself onto the last line of the base.
The payload is verbatim (SPEC.md 8.6): a {{ }} in it reaches the artifact untouched and renders at install.
Build context
base/SKILL.md
--- name: reviewer --- Reviews changes.
Skillfile
FROM ./base APPEND SKILL.md <<EOF See references/style.md for the house style. EOF
Result
SKILL.md
--- name: reviewer --- Reviews changes. See references/style.md for the house style.
REPLACE
REPLACE [--count=<n>] <path> <pattern> <replacement>
Rewrite a file with a regular expression.
--count=<n>- Apply to the first
nmatches only. Positional, so an upstream insertion of an earlier match silently retargets the edit.
The engine is Go's regexp, which is RE2: no backreferences, no lookahead or lookbehind, and a linear-time guarantee, so no pattern from a third-party base can hang a build.
The replacement uses Go's $1 and ${name} expansion.
Zero matches is not an error. A warning is emitted, the file is left alone, and the build continues — which is what makes a defensive edit survive a base that has already adopted the same change. epos build prints every no-op on stderr, so a Skillfile that has quietly stopped doing anything is still visible.
REPLACE is the instruction for edits that must survive line drift. PATCH is the one that fails on it.
Build context
base/SKILL.md
--- name: reviewer model: sonnet --- Reviews changes.
Skillfile
FROM ./base REPLACE SKILL.md "model: (sonnet|haiku)" "model: opus # was $1" REPLACE SKILL.md "model: gpt-4" "model: opus"
Result
SKILL.md
--- name: reviewer model: opus # was sonnet --- Reviews changes.
On stderr
warning: line 3: SKILL.md: "model: gpt-4" matched nothing
PATCH
PATCH <path> (<<EOF … EOF | <diff-file>)
Apply a unified diff to a file.
Strict, and stricter than git apply. Each hunk is applied at the line its header records. There is no offset search and no fuzz factor, so a pure line-number shift caused by an unrelated upstream insertion fails the build even when every context line still matches.
Failure is fatal. No .rej file, no warn-and-continue, no partial application: the artifact is content-addressed, so half a patch would silently produce a different digest from the same inputs.
The payload is authored with git diff. git show, format-patch, GNU unified diffs and Git binary patches are all accepted. It must describe exactly one file — a payload that patches several, or that is not a diff at all, is refused rather than quietly doing nothing.
Use REPLACE for an edit that has to survive the base moving under it.
Build context
base/SKILL.md
--- name: reviewer --- Reviews changes.
base/notes.md
alpha beta gamma
Skillfile
FROM ./base PATCH notes.md <<EOF --- a/notes.md +++ b/notes.md @@ -1,3 +1,3 @@ alpha -beta +BETA gamma EOF
Result
notes.md
alpha BETA gamma
AWK
AWK [--timeout=<duration>] <path> (<<EOF … EOF | <script-file>)
Filter a file through a sandboxed AWK program.
--timeout=<duration>- How long the program may run, as a Go duration such as
2s. Defaults to10s.
The file's current content is the program's stdin, and its stdout replaces the file. This is what REPLACE cannot do: multi-line, conditional and section-scoped edits.
The sandbox is mandatory and not configurable. NoExec, NoFileWrites, NoFileReads and an empty environment are all set, so the program is a pure stdin-to-stdout function — it cannot spawn a process, touch the filesystem, or read the environment. That is what keeps AWK compatible with the no-RUN rule.
rand(), srand() and systime() are rejected. They would make the output digest vary between builds of identical inputs. The check reads the compiled program, not the script text, so the same words inside a string literal or a regex are left alone.
AWK is Turing-complete, so execution is bound to a deadline. Exceeding it fails the build, as do parse errors, runtime errors and a non-zero exit. There is no partial application.
Output is LF-terminated whatever the input used, on every platform: a build must not produce a different digest depending on the machine that ran it. A CR the script asks for by name still survives.
Build context
base/SKILL.md
--- name: reviewer --- ## Checklist - One assertion per test. ## House style - Oxford comma, always.
Skillfile
FROM ./base
AWK SKILL.md <<EOF
BEGIN { keep = 1 }
/^## House style/ { keep = 0 }
keep { print }
EOF Result
SKILL.md
--- name: reviewer --- ## Checklist - One assertion per test.
SET
SET [--file=<path>] <key> <value>
Write a YAML key, structure-aware.
--file=<path>- Edit this YAML file instead of
SKILL.md's frontmatter.
The default target is the YAML frontmatter block of SKILL.md — the block that holds name, description and allowed-tools, the fields that decide whether an agent loads the skill at all. --file=<path> targets any YAML file in the tree.
Keys use dotted paths for nested mappings, and an intermediate key that is missing is written along with the leaf.
Quoting forces a string. SET version 1.2 writes the float 1.2; SET version "1.2" writes the string "1.2". An unquoted value is parsed as a YAML scalar, so it gets the type it looks like.
The edit goes through the document's syntax tree, so it cannot produce invalid YAML, and key order, comments and the quoting style of every key it did not name survive it. An existing key keeps its place and its trailing comment; a new key is appended. Files no instruction targeted are never re-serialised and stay byte-identical.
One known deviation: inline comment whitespace is normalised across the edited block, so - Read # note comes back as - Read # note.
Build context
base/SKILL.md
--- # The name an agent loads this skill by. name: reviewer description: 'Reviews changes' version: 1.0 # bumped by hand --- Reviews changes.
Skillfile
FROM ./base SET version "1.2" SET metadata.author acme
Result
SKILL.md
--- # The name an agent loads this skill by. name: reviewer description: 'Reviews changes' version: "1.2" # bumped by hand metadata: author: acme --- Reviews changes.
UNSET
UNSET [--file=<path>] <key>
Remove a YAML key.
--file=<path>- Edit this YAML file instead of
SKILL.md's frontmatter.
An absent key warns and continues, for the same reason a zero-match REPLACE does: the end state is the one the author asked for, so the edit stays idempotent against a base that has already dropped the key. epos build prints the warning on stderr.
Only the key's own entry goes. A comment on a line of its own belongs to the key it sits above and leaves with it; every other comment stays attached to the key it was written against.
Build context
base/SKILL.md
--- name: reviewer allowed-tools: Read, Grep --- Reviews changes.
Skillfile
FROM ./base UNSET allowed-tools UNSET license
Result
SKILL.md
--- name: reviewer --- Reviews changes.
On stderr
warning: line 3: SKILL.md: key "license" was already absent
Multi-stage composition
Multi-stage follows Docker's semantics. FROM <ref> AS <stage> binds a name to the stage it starts, COPY --from=<stage> takes named files out of a stage that has finished, and a bare FROM <stage> continues from one.
A stage finishes when the next FROM begins, so COPY --from reads the stage's final tree — including everything the stage's own instructions did to it after its FROM line. A stage that has not finished yet cannot be read: naming a stage declared further down, or the stage currently being built, is an error rather than a silent miss.
FROM <stage> takes a copy. Instructions in the derived stage cannot reach back and change the stage they descended from, so a later COPY --from naming it still sees what it was.
The last stage is the artifact. Earlier stages are sources, however much their own instructions edited them.
Stage names are also the values-scope keys at install time: a file a COPY --from=shared brought in renders against .Values scoped to shared, so two stages can both write {{ .Values.title }} and mean two different things.
Build context
pdf/SKILL.md
--- name: pdf --- Extracts tables from PDFs.
shared/reference.md
Two spaces after a full stop.
Skillfile
FROM ./shared AS shared FROM ./pdf AS base APPEND SKILL.md <<EOF See references/shared.md for the house style. EOF FROM base COPY --from=shared reference.md references/shared.md
Result
SKILL.md
--- name: pdf --- Extracts tables from PDFs. See references/shared.md for the house style.
Values and templating
A {{ … }} action is not a build-time construct. It passes through the build untouched and is rendered only when the skill is installed, by epos install. Build-time substitution is ARG and $NAME; the two never collide.
Every payload is carried verbatim, so a {{ }} inside an APPEND heredoc, a COPY'd file, a PATCH or an AWK script reaches the artifact as written.
Rendering is Go text/template with no custom functions, against .Values and nothing else. Values come from epos install -f values.yaml and --set k=v, with later files winning key by key and --set winning over every file. A file with no {{ in it is copied through byte for byte, which is also what keeps binary assets out of the parser. A value a template needs and the user did not supply is an error, not an empty string.
Scoping follows Helm. The top level of values.yaml is the skill's own scope; a key named after a Skillfile stage is that stage's scope, seen only by the files that stage contributed; and a global block is visible to all of them.
Quote a template in a frontmatter value. YAML reads a bare { as the start of a flow mapping, so model: {{ .Values.model }} is a YAML syntax error and the build fails when it reads the frontmatter. Write model: '{{ .Values.model }}'. In the Markdown body below the frontmatter, no quoting is needed.
Build context
base/SKILL.md
---
name: reviewer
model: '{{ .Values.model }}'
---
Reviews {{ .Values.language }} changes. Skillfile
FROM ./base
APPEND SKILL.md <<EOF
House style: {{ .Values.style }}
EOF Result
SKILL.md
---
name: reviewer
model: '{{ .Values.model }}'
---
Reviews {{ .Values.language }} changes.
House style: {{ .Values.style }}