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 editWhat happens downstreamRule 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-required
jdg-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-exist
ref-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-contradictions
jdg-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.

1

Trigger on path

The workflow runs on pull_request, filtered to skill-check/skills/**/SKILL.md. A PR that touches nothing else skips entirely.

2

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.

3

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.

4

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.

OutcomeCheckPR commentLabelMerge
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
FlagWhy
--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-mcpMCP servers have nothing to contribute here and would attempt connections on the runner that hang or fail.
--no-historyNothing to resume. Keeps the reviewed content out of persisted session history.
--bypassNon-interactive. There is no human present to answer a permission prompt, and the allowlist is the real control.
--max-turns 40A safety ceiling, set generously. Too low a value trips the ceiling on healthy runs.
--output-last-messageWrites only the final message. This file, not the exit code, is the verdict.
--format jsonKept for the diagnostic artifact only. It is never parsed by the gate.
|| trueA 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.

SeverityMeaning
criticalThe skill is broken or will misfire at runtime.
warningThe skill works but violates a practice that causes problems as it grows.
suggestionWorth improving. Never affects the tier.

Tier derivation

Applied in order, stopping at the first match:

  1. One or more critical findings, tier is BLOCK.
  2. Three or more warning findings, tier is BLOCK.
  3. One or two warning findings, tier is WARN.
  4. Only suggestion findings, or none, tier is PASS.

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.

RuleCheckSeverity
fm-presentFile opens with a --- delimited YAML block.critical
fm-valid-yamlFrontmatter parses as YAML.critical
fm-description-requiredA non-empty description is present. This is the trigger mechanism; without it the skill can never fire.critical
fm-description-substantiveAt least 40 characters, stating both what it does and when to use it.warning
fm-name-presentA name field is present.warning
fm-name-kebab-caseMatches ^[a-z0-9]+(-[a-z0-9]+)*$.warning
fm-name-matches-dirMatches the containing directory name.warning
fm-no-unknown-fieldsOnly name, description, required_skills, parent_skill. Unknown fields are ignored by the loader, so they read as working while doing nothing.suggestion
RuleCheckSeverity
size-body-capBody is at most 500 lines excluding frontmatter.warning
size-router-capA skill that only routes to sub-skills is at most 200 lines.warning
struct-has-workflowContains a section describing the workflow or procedure.warning
struct-numbered-stepsMulti-step procedures use numbered steps, not prose paragraphs.warning
struct-no-human-docsNo 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.

RuleCheckSeverity
ref-paths-existEvery relative path the file references exists on disk. A dangling reference means the agent reaches a dead end mid-task.critical
ref-steps-resolveEvery 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-closedAll 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.

RuleCheckSeverity
sec-no-hardcoded-credsNo literal passwords, tokens, private keys, or account identifiers. Placeholders such as <YOUR_ACCOUNT> are fine.critical
sec-no-secret-echoNo 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.

RuleWhat it looks forSeverity
jdg-no-contradictionsTwo instructions requiring opposite behaviour. The most damaging defect, because it makes behaviour unpredictable rather than consistently wrong.critical
jdg-no-fabricated-commandsInvented commands, flags, or API shapes. These fail at runtime with a confusing error.critical
jdg-terminatesEvery path reaches an end. No unbounded loops.critical
jdg-description-triggersThe description uses the words a user would actually say, not internal vocabulary.warning
jdg-explains-whyProhibitions 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-granularityNo single step hiding more than about five sub-rules. Oversized steps get partially executed.warning
jdg-reachableEvery step is reachable from the entry point.warning
jdg-explicit-transitionsHandoffs name their destination. "Continue to references/auth.md" is actionable; "return to the setup workflow when done" is not.warning
jdg-stopping-pointsIrreversible actions have an explicit stop for confirmation. Deletes, pushes, deploys, and production writes.warning
jdg-not-overfittedInstructions generalise beyond the author's examples. A skill hardcoded to one table name works only for its author.warning
jdg-description-scopeThe 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.

1

Copy two things

The skill-check/ folder, and .github/workflows/skill-check.yml to your repository root.

2

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.

3

Set secrets and auth

Account and user at minimum. Role and warehouse recommended. Then choose OIDC or the PAT fallback.

4

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:

FixturePlanted defectsTierExit
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:

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 changesWhere
What counts as a defectreferences/rubric.md, rewritten for the new domain
What the agent reads and reportsprompts/evaluate.md
Which files trigger a reviewThe 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.

Learn more

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.