Editing a Format package

Every custom Format is a small package of files — a SKILL.md entry file plus optional notes under references/ — and that package has real history behind it. The Contents API lets you read those files and commit changes to them with an API key, so a coding agent can edit a Format the way it already edits a repository.

The shape is GitHub's Contents API, on purpose. If you know gh api repos/{owner}/{repo}/contents/{path}, you know this:

{handle}/{slug} is the same address you invoke the Format at. Reads need formats:read, writes need formats:write, and the commit's author is always the key's owner — an author or committer in the body is ignored.

This is authoring, not execution. Editing a package never touches a run that is already in flight: each run reads the package it started with.

Create a Format

POST /v1/formats opens a Format and its package repository, the way POST /repos opens one on GitHub. It needs formats:write, and the Format is created in your key's workspace — there is no {handle} in the address, so there is no way to create one in somebody else's.

auto_init (default true) commits a minimal valid SKILL.md, so the Format is readable and writable through the endpoints below immediately — replacing that file is the intended next call. false is a 400: every Format package must contain SKILL.md, so there is no empty Format to create. The body does not take package files; that is what the Contents API is for, and a Format's files should follow one set of rules no matter who wrote them.

Keep package_sha and contents_url from the reply — the first is the If-Match precondition for your next write, the second is where to send it. A slug your workspace already uses answers 409, never an overwrite.

The repository is opened before the catalog row. If package history cannot be reached the call fails with 503 format_git_unavailable and no Format is created, rather than one whose commits nobody can resolve.

Read the package

One path returns the file itself, base64-encoded:

Keep the sha. It is the git blob sha of the file as stored, and it is the precondition every write takes. A path naming a directory (…/contents/references) returns that directory's entries instead.

Read the whole package at once

Listing the root and then fetching each path is one request per file. When what you want is the whole package — hydrating it into an agent's context, say — ask for it in one call:

Every row is a file with its body, sorted by path, and there are no dir rows — the directories are the paths. recursive=true works too. Anything else, including leaving it off, is the plain root listing above. The sha on each row is the same precondition a write takes, so one recursive read is enough to start editing.

This only applies to the listing. …/contents/{path} answers the same way with or without it.

Commit a change

PUT writes one whole file and produces one commit. It is a replace, not a patch — send the entire new body, base64-encoded.

commit.tree.sha is the Format's new package identity — the same value the Format's own record reports — and version is the display counter the dashboard shows.

Send sha when the path already exists; omit it to create a new file. Two agents editing the same file cannot silently overwrite each other, because the second one's sha no longer matches. For agents editing different files of one Format, see If-Match.

DELETE takes message and sha, and answers with content: null:

Commit several files at once

Editing a folder one path at a time costs one commit and one round trip per file. PUT at the package root — no {path} — takes a files list and writes all of them as one commit, one version bump, one new package_sha:

Each entry follows the single-file rules: content is the whole file base64-encoded, sha is required when the path already exists and omitted to create it. content in the reply is the list of files this commit wrote.

files is a change set, not the package. Paths the Format holds but this body does not name are kept exactly as they are — editing two of twelve files never risks the other ten. That also means deletion is not expressible here: remove a file with DELETE …/contents/{path}, so that dropping a file is always something you asked for and never something you forgot to say.

If any entry's sha is stale, or the resulting package breaks a rule, nothing is committed — the batch lands whole or not at all.

If-Match

Per-file sha cannot express "the package has not moved since I read it". Two agents editing different files each hold a sha that is still current, so both writes land — and the second one planned its edit against a tree that no longer existed.

Send the package's own sha to close that gap. It is commit.tree.sha from your last write, and the same value the Format record reports as package_sha:

If the package has moved on, the write is refused with 409 format_package_sha_mismatch, and error.details.package_sha carries the current one so you can re-read and retry in a single round trip. The header works on PUT …/{path} and DELETE …/{path} too, and it is additional — per-file sha checks still apply.

If-Match here is not an opaque ETag; it is the package sha, as a bare 40-character hex string. Formats published before package_sha existed carry none, and those ignore the header rather than answering with a 409 you could never satisfy.

What a package may contain

The same rules the dashboard editor enforces:

  • SKILL.md at the package root is required, and its frontmatter name must equal the Format's slug. It cannot be deleted.
  • Files live at the root or one directory deep under references/ or agents/. No .., no absolute paths.
  • File names must match ^[A-Za-z0-9][A-Za-z0-9._-]*$ — no leading _ or ..
  • .md, .json, .yaml, .yml, .txt only.
  • At most 100 MiB per file and 100 MiB per package. A Contents batch may name at most 1000 paths.

A package that breaks any of these is rejected before anything is committed. A rejected path answers with skill_path_invalid and spells the whole allowlist back to you, so the first rejection is enough to fix the name.

Errors worth handling

Statuserror.codeWhat happened
403insufficient_scopeThe key lacks formats:read / formats:write, or it is a service-account key. Those cannot create or edit packages. next_action is authenticate. Scopes cannot be patched — mint a new key. Missing scopes are never format_not_found.
409skill_slug_takenYour workspace already has a Format with that slug. Creating never overwrites; pick another.
409skill_slug_reservedA Format by Sume holds that slug globally. Pick another.
404format_not_foundUnknown Format, or one outside the calling key's workspace. A team Format needs a key created in that workspace.
404format_content_not_foundThe Format exists but holds nothing at that path.
409format_content_sha_requiredThe path already exists and you sent no sha. Read it, then retry.
409format_content_sha_mismatchYour sha is stale — someone else committed first. Re-read and retry.
409format_package_sha_mismatchYour If-Match package sha is stale. error.details.package_sha holds the current one.
400skill_path_invalid, skill_frontmatter_invalid, skill_limit_exceeded, …The resulting package broke a rule above. Nothing was committed.
503format_git_unavailablePackage history could not take the commit, so nothing was saved. Retry.

That last one is deliberate: a write either commits or fails. There is no path where the Format changes but the commit does not exist.

What this is not

There is no public git endpoint, no clone URL, and no way to reach the history writer directly. Commits happen because this API makes them. Reverting, blame, and history browsing are not part of this surface yet.