Lint a Format package in CI before you PUT it

A short Python check for a Sume Format package: SKILL.md name equals the slug, allowed folders and extensions, file names, 100 MiB limits. Run it before PUT.

5 min readSume
All posts

You can lint a Sume Format package in CI by checking the same rules the Contents API enforces: a root SKILL.md whose frontmatter name equals the Format slug, files only at the root or one level under references/ or agents/, safe file names, five allowed extensions, and the 100 MiB limits. A failing check costs you a CI minute instead of a rejected write.

The rules come from the "What a package may contain" section of Format contents API, where the page says they match the dashboard editor. The script below is a client-side pre-check only. The API stays the final judge, so keep handling skill_path_invalid on the wire too.

The rules you can test offline

Everything in the table is checkable from a directory listing, with no network call. The docs say the API rejects a broken package before it commits anything, and answers a rejected path with skill_path_invalid plus the full allowlist.

Package rules from the Contents API page (read 2026-10-10)
RuleDocumented valueLint check
Entry fileSKILL.md at the package root, cannot be deletedFile exists at root
Frontmatter nameMust equal the Format slugParse the name: line
DepthRoot, or one directory under references/ or agents/; no .., no absolute pathsCount path parts
File name^[A-Za-z0-9][A-Za-z0-9._-]*$, never starting with _ or .Regex
Extensions.md, .json, .yaml, .yml, .txtSuffix set
Size100 MiB per file and per packageSum of file sizes
BatchAt most 1000 paths per batchCount files before a PUT

A checker you can paste into CI

The script takes the package directory and the slug, prints every problem and exits non-zero when there is one. It uses only the standard library, and it is a sketch of the documented rules rather than a copy of the server code. It does not check the 1000-path batch limit, so count that yourself before a bulk PUT.

import re
import sys
from pathlib import Path
NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
EXT = {".md", ".json", ".yaml", ".yml", ".txt"}
LIMIT = 100 * 1024 * 1024
def lint(root: Path, slug: str) -> list[str]:
    skill = root / "SKILL.md"
    if not skill.is_file():
        return ["SKILL.md is missing at the package root"]
    head = skill.read_text().split("---")
    m = re.search(r"^name:\s*(\S+)", head[1] if len(head) > 2 else "", re.M)
    errs = [] if m and m.group(1) == slug else ["frontmatter name must equal the slug"]
    total = 0
    for p in sorted(x for x in root.rglob("*") if x.is_file()):
        rel = p.relative_to(root).parts
        size = p.stat().st_size
        total += size
        if len(rel) > 2 or (len(rel) == 2 and rel[0] not in ("references", "agents")):
            errs.append(f"{'/'.join(rel)}: only root, references/ or agents/ allowed")
        if not NAME.match(rel[-1]) or p.suffix not in EXT:
            errs.append(f"{'/'.join(rel)}: bad file name or extension")
        if size > LIMIT:
            errs.append(f"{'/'.join(rel)}: over 100 MiB")
    return errs + (["package over 100 MiB"] if total > LIMIT else [])
problems = lint(Path(sys.argv[1]), sys.argv[2])
print("\n".join(problems) or "package looks valid")
sys.exit(1 if problems else 0)

What it will not catch

A linter cannot see server state. A slug that your workspace already holds returns 409 skill_slug_taken on create, and a slug reserved by a Format from Sume returns 409 skill_slug_reserved. A write to an existing path without its sha returns 409 format_content_sha_required, and a stale one returns format_content_sha_mismatch.

Scope problems also arrive only on the wire. A key without formats:write, or a service-account key, gets 403 insufficient_scope, and a team Format needs a key created in that team workspace. The same status for a missing scope is deliberate: the docs say a missing scope never shows up as format_not_found.

Where to put it

Run the lint on every pull request that touches the Format folder, and again just before the deploy step that calls the Contents API. Make the slug a pipeline variable, because the frontmatter rule compares against it.

After a clean lint, send the whole edit as one change set, covered in the Contents API post on If-Match. One commit then gives one version bump and one new package_sha, which is easier to review than a string of single-file commits.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume