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.mdat the package root is required, and its frontmatternamemust equal the Format's slug. It cannot be deleted.- Files live at the root or one directory deep under
references/oragents/. No.., no absolute paths. - File names must match
^[A-Za-z0-9][A-Za-z0-9._-]*$— no leading_or.. .md,.json,.yaml,.yml,.txtonly.- 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
| Status | error.code | What happened |
|---|---|---|
403 | insufficient_scope | The 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. |
409 | skill_slug_taken | Your workspace already has a Format with that slug. Creating never overwrites; pick another. |
409 | skill_slug_reserved | A Format by Sume holds that slug globally. Pick another. |
404 | format_not_found | Unknown Format, or one outside the calling key's workspace. A team Format needs a key created in that workspace. |
404 | format_content_not_found | The Format exists but holds nothing at that path. |
409 | format_content_sha_required | The path already exists and you sent no sha. Read it, then retry. |
409 | format_content_sha_mismatch | Your sha is stale — someone else committed first. Re-read and retry. |
409 | format_package_sha_mismatch | Your If-Match package sha is stale. error.details.package_sha holds the current one. |
400 | skill_path_invalid, skill_frontmatter_invalid, skill_limit_exceeded, … | The resulting package broke a rule above. Nothing was committed. |
503 | format_git_unavailable | Package 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.