A skill file is production config. It is also prose.
A Cortex Agent's behaviour is shaped by the skill files it loads. Those files are markdown, which is exactly why the right people to edit them are often not engineers: the person who knows the domain vocabulary knows what users actually type. The problem is that prose has no compiler. There is no type check, no failing test, and no error at edit time. A bad change does not break loudly. It changes what the agent does, quietly, and the symptom shows up later as a worse answer rather than as a stack trace.
This gate exists to give that class of change a review step. When a pull request touches a
SKILL.md, Cortex Code reads the changed file, judges it against a rubric
committed alongside it, and returns a verdict that decides whether the change can merge.
What goes wrong without it
Each row below is a realistic edit, the failure it causes downstream, and the rubric rule that catches it. The pattern worth noticing is that none of these produce an error message.
| The edit | What happens downstream | Rule that catches it |
|---|---|---|
Someone shortens or removes description |
The skill stops firing. The description is the trigger, so the agent never loads the file and simply answers worse using general knowledge. Nothing errors, no log line appears, and the regression is invisible until someone notices the answers got vaguer. | fm-description-requiredjdg-description-triggers |
| Two instructions are added that conflict | Agent behaviour becomes nondeterministic rather than wrong in a fixed way. It may follow either instruction depending on which it weights, so the change passes manual review and then fails intermittently in production. | jdg-no-contradictions |
| A referenced file is renamed or removed | The agent reaches a dead end mid-task. It will usually improvise past the missing guidance rather than stop, which means the task completes while skipping whatever the reference was there to enforce. | ref-paths-existref-steps-resolve |
| A data-handling rule is loosened or contradicted | Actual data damage. The contradiction in this template's own block fixture, which
says ALWAYS TRUNCATE FIRST and then NEVER TRUNCATE, either
wipes history or double counts totals depending on which sentence the agent weighted
that run. |
jdg-no-contradictionsjdg-stopping-points |
The last row is the one to take seriously
The first three degrade answer quality. The fourth is a prose edit that becomes a data-quality incident, because the skill told the agent how to handle a table and the instruction was ambiguous. Reviewing agent instructions is a data-quality control, not just a documentation exercise.
Why the reviewer is an agent
A script handles part of this well. Checking that frontmatter parses, that a description exists, that referenced paths resolve, and that code fences are closed is cheap and exact, and a script should do it.
But the defects that cause incidents are not shaped that way. Two contradictory instructions are each individually valid. A description that is present but written in internal vocabulary no user would type is syntactically perfect. A workflow branch that never terminates parses cleanly. Catching those means reading the file the way the agent consuming it will read it, and forming a judgement. That is the part delegated to Cortex Code.
What this gate does not do
It reviews the instructions, not the behaviour. It cannot tell you whether the agent actually answers better after a change, only whether the instructions it was given are coherent, complete, and safe. Measuring behaviour is a different gate that runs an eval set and compares pass rates against the base branch.
It also reviews only what the pull request changed. It is not an audit of every skill in the repository, and adopting it does not retroactively clean up skills already on the main branch.
Who this is for
The developer setting the gate up and maintaining it. If you are the person editing skill files rather than running the pipeline, the part that matters to you is the pull request comment: it names the rule, quotes the offending line, and states the fix.
The flow
Four steps. Only step three involves an agent, which keeps the surface where behaviour can vary as small as possible.
Trigger on path
The workflow runs on pull_request, filtered to
skill-check/skills/**/SKILL.md. A PR that touches nothing else skips entirely.
Compute the diff
A three-dot diff between base and head lists the SKILL.md files added or
modified. Deletions are excluded: there is nothing to review in a file being removed.
Review
Cortex Code runs headlessly with a read-only tool allowlist. It reads the rubric, reads each changed file in full, and writes a verdict as JSON.
Decide and report
A script extracts the verdict, validates it against the schema, and maps the tier to an exit code. An unparseable verdict is its own failure, not a pass.
The workflow then upserts one PR comment, syncs the label, writes a job summary, and uploads the raw agent output as an artifact.
The four outcomes
Three come from the agent's judgement. The fourth comes from the agent failing to produce a judgement at all, which is treated as a distinct condition on purpose.
| Outcome | Check | PR comment | Label | Merge |
|---|---|---|---|---|
| PASS | Green | Updated to say it passed | Removed if present | Allowed |
| WARN | Green | Findings posted | skill-check:needs-revision |
Allowed |
| BLOCK | Red | Findings posted | skill-check:needs-revision |
Blocked when the check is required |
| Gate error | Red | Says the check itself could not complete | Unchanged | Blocked |
Why gate error is separate from BLOCK
They fail the same way but mean opposite things. BLOCK means the agent read the file and judged it unsafe. Gate error means the agent never delivered a judgement, so nothing is known about the file. Collapsing them sends the author to inspect a skill file when the real problem is a timeout or an expired credential. Keeping them distinct also guarantees the safe direction: a reviewer that fails to answer must not default to yes.
What a developer sees
One check named Review changed skills, and one comment. The comment is upserted against a hidden marker, so pushing a fix edits the existing comment rather than stacking a new one underneath. Findings are grouped by severity, and each carries the rule ID, the file and line, the offending text quoted, what it causes, and the fix.
The label tracks current state rather than history. It is added on WARN or BLOCK and removed
on PASS, so a fixed pull request does not sit there wearing a stale
needs-revision tag.
Every run uploads verdict.txt, the raw NDJSON stream, the rendered comment, and
the computed file list as an artifact, retained for fourteen days. When the gate errors,
that artifact is the only place the agent's actual output survives.
Fork pull requests skip the gate
The workflow uses pull_request, not pull_request_target. The
latter would run with repository secrets against code the fork author controls, which for
a gate that reads files and holds Snowflake credentials is not a trade worth making. Fork
PRs get an explicit notice in the checks list and need manual review.
The files
Nine files, with a deliberate division of labour: the prompt orchestrates, the rubric judges, and the scripts only extract and map. No script contains an opinion about skill quality, and the rubric contains no code.
skill-check/
README.md setup and adoption
skills/ WATCHED. Business-authored agent skills.
weekly-revenue-summary/
SKILL.md example: a skill a business team owns
references/ its business definitions
evaluator/ the reviewer, deliberately outside skills/
SKILL.md review workflow and output contract
references/rubric.md every rule, severity, tier derivation
prompts/evaluate.md the prompt passed to cortex exec
scripts/
changed-skills.sh PR diff to changed SKILL.md paths
parse-verdict.sh verdict to exit code
parse_verdict.py parsing, validation, comment rendering
auth-pat-fallback.sh connections.toml from a PAT, fallback only
fixtures/{pass,warn,block}/ sample skills producing each tier
.github/workflows/
skill-check.yml the workflow, must be at repo root
The reviewer lives outside the directory it reviews
The gate watches skills/, and the evaluator sits in
evaluator/ rather than inside it. If the evaluator lived in the watched
directory it would review itself, and a rubric change could fail its own gate. Separating
the two directories makes that structurally impossible instead of relying on a prompt
instruction to prevent it.
The watched directory is for skills a Cortex Agent uses in automated workflows: the ones a business team authors and owns. That is the population whose changes need a review step.
Why the workflow lives at the repository root
GitHub only executes workflows from .github/workflows/ at the root of the
repository. A copy inside skill-check/ would be inert. Everything else stays
in the folder so it can be copied into a project as one unit.
How the agent is invoked
The action installs and pins the CLI. It is not used to run the prompt, because its
prompt-args input is split on whitespace, which makes any argument containing a
space unreachable, and it hardcodes flags this gate needs to control. Calling
cortex exec directly in a run: step is also what the action's own
documentation demonstrates.
cortex exec \
--file skill-check/prompts/evaluate.md \
--connection "$CONNECTION_NAME" \
--allowed "read,grep,glob" \
--no-mcp \
--no-history \
--bypass \
--max-turns 40 \
--output-last-message verdict.txt \
--format json > cortex-stream.ndjson 2>&1 || true
| Flag | Why |
|---|---|
--allowed "read,grep,glob" | Least privilege. No write, no edit, no bash. The agent judging the pull request cannot modify it, and the comment and label are posted by the workflow rather than by the agent. |
--no-mcp | MCP servers have nothing to contribute here and would attempt connections on the runner that hang or fail. |
--no-history | Nothing to resume. Keeps the reviewed content out of persisted session history. |
--bypass | Non-interactive. There is no human present to answer a permission prompt, and the allowlist is the real control. |
--max-turns 40 | A safety ceiling, set generously. Too low a value trips the ceiling on healthy runs. |
--output-last-message | Writes only the final message. This file, not the exit code, is the verdict. |
--format json | Kept for the diagnostic artifact only. It is never parsed by the gate. |
|| true | A BLOCK verdict is a successful run of the gate. The verdict file decides the outcome, so this step must not decide it first. |
The verdict contract
This is the entire interface between the agent and the workflow. The agent ends its response with one fenced JSON block, and the gate reads the last such block in the file.
{
"tier": "BLOCK",
"summary": "one sentence naming the most serious problem",
"files_reviewed": ["skill-check/skills/weekly-revenue-summary/SKILL.md"],
"findings": [
{
"severity": "critical",
"rule": "fm-description-required",
"file": "skill-check/skills/weekly-revenue-summary/SKILL.md",
"line": 2,
"quote": "name: weekly-revenue-summary",
"issue": "Frontmatter has no description, so the skill can never be triggered.",
"fix": "Add a description stating what it does and when to use it, including the words a user would say: revenue, weekly report, week over week."
}
]
}
Two fields carry most of the weight. quote makes a finding locatable, and a
finding the author cannot locate is not actionable. fix has to be specific
enough to apply without further thought, which is why the rubric rejects
"Improve the description" as a fix and requires the actual words to add.
The gate checks the agent's arithmetic
The tier is derived from severity counts, so it can be recomputed. parse_verdict.py
does exactly that and escalates when the agent under-reported:
# findings say BLOCK, the agent labelled it PASS
WARNING: agent reported tier PASS but its findings derive BLOCK
(1 critical, 0 warning); escalating to BLOCK
An agent that lists a critical finding and then labels the verdict PASS has
contradicted itself. The findings are evidence and the tier is a claim about that evidence,
so the evidence wins. Escalation only ever runs in the strict direction: the gate will
raise a tier the agent under-called, never lower one it over-called.
Where the seams are
Changing what counts as a defect is a rubric edit. Changing what the workflow does about a verdict is a workflow edit. Changing how a verdict is parsed is a script edit. Those three concerns do not overlap, which is what makes the gate safe to tune.
One file owns all the judgement
evaluator/references/rubric.md defines every rule, every severity, and
the tier derivation. The scripts only extract and map, so making the gate stricter or looser
is a documentation change that a reviewer can read in a diff.
Severity
Severity is a property of the rule, not a judgement about how bad a particular instance feels. That distinction is what keeps verdicts consistent between runs.
| Severity | Meaning |
|---|---|
| critical | The skill is broken or will misfire at runtime. |
| warning | The skill works but violates a practice that causes problems as it grows. |
| suggestion | Worth improving. Never affects the tier. |
Tier derivation
Applied in order, stopping at the first match:
- One or more
criticalfindings, tier isBLOCK. - Three or more
warningfindings, tier isBLOCK. - One or two
warningfindings, tier isWARN. - Only
suggestionfindings, or none, tier isPASS.
Rule two is the opinionated one. Volume of unaddressed warnings is treated as a defect in its own right, on the grounds that a file with five warnings is not a working file with minor notes, it is a file nobody has maintained. Raise the threshold if that is too aggressive for your team.
Mechanical rules
These have single defensible answers. Two reviewers looking at the same file should agree.
| Rule | Check | Severity |
|---|---|---|
fm-present | File opens with a --- delimited YAML block. | critical |
fm-valid-yaml | Frontmatter parses as YAML. | critical |
fm-description-required | A non-empty description is present. This is the trigger mechanism; without it the skill can never fire. | critical |
fm-description-substantive | At least 40 characters, stating both what it does and when to use it. | warning |
fm-name-present | A name field is present. | warning |
fm-name-kebab-case | Matches ^[a-z0-9]+(-[a-z0-9]+)*$. | warning |
fm-name-matches-dir | Matches the containing directory name. | warning |
fm-no-unknown-fields | Only name, description, required_skills, parent_skill. Unknown fields are ignored by the loader, so they read as working while doing nothing. | suggestion |
| Rule | Check | Severity |
|---|---|---|
size-body-cap | Body is at most 500 lines excluding frontmatter. | warning |
size-router-cap | A skill that only routes to sub-skills is at most 200 lines. | warning |
struct-has-workflow | Contains a section describing the workflow or procedure. | warning |
struct-numbered-steps | Multi-step procedures use numbered steps, not prose paragraphs. | warning |
struct-no-human-docs | No README.md or CHANGELOG.md beside SKILL.md. Skills are read by agents, not browsed by humans. | suggestion |
The 500-line cap is a decision, not a quotation
Snowflake's own skill-authoring tooling is inconsistent here. Its best-practices document states 500 lines, while its validator script warns at 250. Rather than inherit the ambiguity, this rubric picks 500 and states it once. If your team prefers 250, change the number in one place.
| Rule | Check | Severity |
|---|---|---|
ref-paths-exist | Every relative path the file references exists on disk. A dangling reference means the agent reaches a dead end mid-task. | critical |
ref-steps-resolve | Every internal step reference points at a step that exists. A file saying "return to Step 4" with three steps is a loop with no exit. | critical |
ref-fences-closed | All fenced code blocks are closed. An unclosed fence swallows the rest of the file. | critical |
These are the most common real defects, and all three are the kind of thing a human
reviewer skims past. ref-paths-exist in particular fails only at runtime,
inside someone else's task, long after the pull request merged.
| Rule | Check | Severity |
|---|---|---|
sec-no-hardcoded-creds | No literal passwords, tokens, private keys, or account identifiers. Placeholders such as <YOUR_ACCOUNT> are fine. | critical |
sec-no-secret-echo | No instruction to print, log, or echo a credential value. | critical |
The second rule matters more than it looks. A skill that tells an agent to echo a credential turns every future run into a leak, into terminal output, CI logs, and session transcripts at once.
Judgement rules
These require reading the file end to end, and they are why the reviewer is an agent. The rubric constrains them in one important way: a finding is only reportable if the reviewer can quote the specific text that violates the rule. No quote, no finding.
| Rule | What it looks for | Severity |
|---|---|---|
jdg-no-contradictions | Two instructions requiring opposite behaviour. The most damaging defect, because it makes behaviour unpredictable rather than consistently wrong. | critical |
jdg-no-fabricated-commands | Invented commands, flags, or API shapes. These fail at runtime with a confusing error. | critical |
jdg-terminates | Every path reaches an end. No unbounded loops. | critical |
jdg-description-triggers | The description uses the words a user would actually say, not internal vocabulary. | warning |
jdg-explains-why | Prohibitions state their reason. An unexplained rule cannot be applied to a case it does not literally cover, so the agent over-applies it or ignores it. Stacked MUSTs and ALL CAPS are the signal. | warning |
jdg-step-granularity | No single step hiding more than about five sub-rules. Oversized steps get partially executed. | warning |
jdg-reachable | Every step is reachable from the entry point. | warning |
jdg-explicit-transitions | Handoffs name their destination. "Continue to references/auth.md" is actionable; "return to the setup workflow when done" is not. | warning |
jdg-stopping-points | Irreversible actions have an explicit stop for confirmation. Deletes, pushes, deploys, and production writes. | warning |
jdg-not-overfitted | Instructions generalise beyond the author's examples. A skill hardcoded to one table name works only for its author. | warning |
jdg-description-scope | The description says when not to use the skill, where a sibling skill covers adjacent ground. | suggestion |
Tuning it
Add a rule by adding a row with an ID and a severity. Soften one by changing its severity. Retire one by deleting it. The prompt tells the agent to use the rubric as written rather than any skill-authoring guidance it already knows, so a local decision that contradicts published guidance is respected rather than silently overridden.
Adopting it
The template
repository carries the exact commands, in
skill-check/README.md. This is the shape of the work and the places it usually
goes wrong.
Copy two things
The skill-check/ folder, and
.github/workflows/skill-check.yml to your repository root.
Align the path filter
The gate watches skill-check/skills/**/SKILL.md. To watch somewhere else,
change it in both places: the workflow paths: filter and
SKILL_GLOB in changed-skills.sh.
Set secrets and auth
Account and user at minimum. Role and warehouse recommended. Then choose OIDC or the PAT fallback.
Prove it, then require it
Open a pull request with the block fixture and watch it fail. Only then mark the check required in branch protection.
The path filter has to agree in both places
Change only the workflow filter and the gate triggers but reviews nothing, reporting a clean pass on a pull request it never looked at. That failure is silent and looks exactly like success, which makes it the most dangerous misconfiguration in the whole setup.
Authentication
OIDC workload identity federation is the default and the better option, because it mints a short-lived token per run and leaves no long-lived secret in the repository to leak or rotate.
CREATE USER skill_check_ci
WORKLOAD_IDENTITY = (
TYPE = OIDC
ISSUER = 'https://token.actions.githubusercontent.com'
SUBJECT = 'repo:YOUR_ORG/YOUR_REPO:pull_request'
)
TYPE = SERVICE
DEFAULT_ROLE = SKILL_CHECK_CI;
If OIDC cannot be enabled yet, set the repository variable
SKILL_CHECK_AUTH_MODE to pat and add a SNOWFLAKE_PAT
secret. The fallback writes the token to its own file at mode 600 and references it by
token_file_path rather than inlining it into the TOML, and it refuses to
overwrite an existing connection so it cannot silently downgrade OIDC credentials to a
long-lived secret. Treat it as a starting point rather than a destination.
Grant the role as little as possible
This gate reads files in the repository. It does not query your data. The Snowflake role it runs as needs only enough privilege to run Cortex Code, so there is no reason to give it access to anything else.
Running it locally
Three fixtures ship with the template, one per tier, so the gate can be exercised without a pull request or a customer repository:
# point the file list at a fixture, then run the gate
echo "skill-check/fixtures/block/data-loader/SKILL.md" > skill-check/.changed-skills.txt
cortex exec --file skill-check/prompts/evaluate.md \
--allowed "read,grep,glob" --no-mcp --no-history --bypass \
--max-turns 40 --output-last-message verdict.txt
VERDICT_FILE=verdict.txt bash skill-check/scripts/parse-verdict.sh
Verified results from the committed fixtures:
| Fixture | Planted defects | Tier | Exit |
|---|---|---|---|
block/data-loader |
Missing description, hardcoded password, echo-the-password instruction, dangling
reference, a "return to Step 6" in a four-step workflow, and
ALWAYS TRUNCATE next to NEVER TRUNCATE. All six found. |
BLOCK | 1 |
warn/log-summarizer |
Description of "Summarizes logs." Two warnings and one suggestion. | WARN | 0 |
pass/invoice-parser |
A clean skill. One suggestion only. The shipped example skill,
skills/weekly-revenue-summary, also passes. |
PASS | 0 |
Test the failure paths, not just the happy one
A gate that always passes is indistinguishable from a gate that works, so the checks worth running are the ones that prove it can say no:
- Feed
parse-verdict.sha verdict file containing malformed JSON and confirm it reports a gate error rather than a pass. A silent pass on a parse failure makes the entire gate decorative. - Prompt the reviewer to write a file and confirm the tool call is denied. That proves the allowlist is doing something.
- Run
changed-skills.shwith a base commit that is not present and confirm it fails loudly. On a shallow clone the diff silently returns nothing, which would pass a pull request that changed every skill in the repository. This is why the workflow setsfetch-depth: 0.
Portability note
The shell scripts avoid mapfile, which is bash 4 and later, because macOS
still ships bash 3.2 and contributors run these locally before pushing.
Exit status is not a verdict
This is the single most important design decision, and the reasoning generalises to any agent running in CI. There are two independent questions, and they are easy to conflate:
Did the run finish cleanly?
Answered by the process exit code and the is_error field in the stream. This
is a question about infrastructure.
Is this skill file any good?
Answered only by the verdict the agent wrote. This is a question about content.
They come apart in practice. A run that answers correctly can still report an error, because a turn ceiling is a property of the run rather than of the answer:
# the agent answered correctly, and still reported an error
{"type":"assistant","message":{"content":[{"type":"text","text":"PONG"}]}}
{"type":"result","subtype":"error_max_turns","is_error":true}
Reading exit status as the verdict breaks in both directions. A healthy pull request gets blocked because a ceiling was hit or a network call blipped, and the author has no idea why since nothing is wrong with their file. Worse, a run that legitimately concluded BLOCK but exited cleanly reads as a pass, and the gate merges a file the agent just called broken. That second failure is silent, and a gate that always passes looks exactly like a gate that works.
So the verdict comes from a file the agent writes, and the absence of a verdict is its own outcome rather than an approval.
Why the JSON stream is not parsed
--format json emits NDJSON with human-readable status lines interleaved:
✓ Auto-apply [_CORTEX_CODE_DEFAULT]: 5 skill repos
History redaction mode is enabled. Session history will not be persisted.
{"type":"system","subtype":"init", ...}
{"type":"assistant","message": ...}
Max tool call iterations reached
{"type":"result","subtype":"error_max_turns","is_error":true, ...}
Piping that into jq fails on the first line. You can filter to the lines that
parse, but then the gate depends on which status messages this CLI version happens to print
and on the shape of the envelope, both of which change between versions.
--output-last-message writes only the final message, so the gate is coupled to
the verdict contract rather than to the CLI's output format. The stream is still captured as
a diagnostic artifact, which is the right role for it.
Why the CLI version is not pinned
A stale CLI refuses to run headlessly at all:
$ cortex exec "..." --format json
This version is no longer supported please run `cortex update`
The instinct is to pin a version so runs are reproducible. That instinct is wrong here, and
this gate learned it the hard way on its own first pull request. A pin does not prevent that
failure, it schedules it: the pinned build eventually becomes the build the CLI refuses to
run, and then every pull request fails at once for a reason unrelated to any skill file. The
workflow uses latest, which cannot go stale.
If you do need strict reproducibility, pin the full build-qualified version rather than a bare semver. A bare version is rejected:
$ cortex update 1.1.53
Invalid version format: 1.1.53
# the accepted form is what `cortex versions` prints
$ cortex update 1.1.53+181504.3a965a854064
The reproducibility a pin buys is smaller than it looks. Bundled skills load on every run regardless of configuration and there is no flag to suppress them, so a pin freezes that set, but the rubric this gate judges against is read from the repository by explicit path. Verdicts are anchored to a file you version, not to whatever shipped with the CLI.
This section used to argue the opposite
The first version of this gate pinned 1.1.53 and this page explained why
pinning was correct. The pin failed on the very first run, for two reasons at once: the
format was wrong, and the reasoning was wrong. Both are documented here rather than quietly
corrected, because the failure is instructive and someone else will reach for a pin for the
same plausible reason.
A flag that was removed
An earlier version passed --skills at a committed skills.json,
intending to load only the evaluator skill. It does not work: with the flag set, the agent
reported the skill as not installed and fell back to reading the files directly. The flag is
also placement-sensitive. Passed as --skills value after exec the
value is consumed as a positional message and collides with --file; placed
before exec it prints help; only --skills=value parses at all.
Rather than depend on that, the prompt reads SKILL.md and
references/rubric.md by explicit path. That is deterministic, needs no
registration step, and removes a dependency on undocumented behaviour. The evaluator is still
authored as a skill, because that is the right shape for the content and it can be loaded
interactively when iterating on the rubric.
The general lesson
When a convenience mechanism in a fast-moving CLI is load-bearing for a gate, prefer the boring explicit path. Reading a file by path will keep working. A flag whose semantics you inferred may not.
Adapting the pattern
Most of this template is not about skills. To gate a different kind of change, four things move and the rest stays:
| What changes | Where |
|---|---|
| What counts as a defect | references/rubric.md, rewritten for the new domain |
| What the agent reads and reports | prompts/evaluate.md |
| Which files trigger a review | The workflow paths: filter and SKILL_GLOB, kept in agreement |
| What the agent may do | --allowed. Widen it only with a reason; a reviewer rarely needs write access |
Unchanged: the verdict contract, the tier-to-exit-code mapping, the escalation cross-check, the comment upsert, the label sync, and the gate-error path. Those are domain-independent and are the parts that took the longest to get right.
Every flag, rule ID, and threshold on this page is taken from the committed template rather than paraphrased, so the doc and the implementation can be diffed against each other.