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.

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.
| Rule | Documented value | Lint check |
|---|---|---|
| Entry file | SKILL.md at the package root, cannot be deleted | File exists at root |
| Frontmatter name | Must equal the Format slug | Parse the name: line |
| Depth | Root, or one directory under references/ or agents/; no .., no absolute paths | Count path parts |
| File name | ^[A-Za-z0-9][A-Za-z0-9._-]*$, never starting with _ or . | Regex |
| Extensions | .md, .json, .yaml, .yml, .txt | Suffix set |
| Size | 100 MiB per file and per package | Sum of file sizes |
| Batch | At most 1000 paths per batch | Count 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
- Lip-sync a 2-minute monologue: split one TTS wav into H3 Max clips
MiniMax H3 Max lip-sync takes audio of 5 to 14.8 seconds. For a longer speech, render one TTS wav and cut it at sentence ends; the cost is worked below.
- Is there a lipsync-1.0 endpoint on Sume? Old paths 404; use Fabric
Sume's old /v1/lipsync-1.0 paths return 404 and its model ids return model_not_found. Send the same still and audio to veed/fabric-1.0 or H3 Max lip-sync.
- List Sume's music and TTS router engines in Python before deploy
A short Python script reads the music and TTS router model lists so a deploy check can confirm your pinned engine ids and fixed prices still exist.
- Does Sume accept lowercase 4k as a video resolution? Yes
Since fab7d59af, lowercase 4k is an alias on every Sume video row that lists 4K, on both /v1/video-router/generate and /v1/videos.
Written by Sume