# Spex Space agent guide

> Spex Space brings together your groups, project records and a registry of immutable, semver-versioned spec packages.
> Every page is also JSON (send `Accept: application/json` or append `.json`); errors are always `{"error": {"code", "message", "details"?}}`.
> This page is the complete guide to generating, publishing and consuming a spec package.

## Release layout

A spec package `<org>/<pkg>` is one spec with the sources, skills, playbooks and applets made from it; each of these, the spec included, is an artifact.
A release is the package's files at one version, published as one gzipped tarball — the archive — rooted at a single `<pkg>/` folder:

```text
<pkg>/meta.yaml                          the manifest, required
<pkg>/README.md                          optional description, shown on the package page
<pkg>/LICENSE                            optional license text
<pkg>/sources/<language>/<id>/SOURCE.md  source material, its attachments beside it
<pkg>/specs/<language>/<pkg>.md          the spec: one file, alone in its folder
<pkg>/skills/<language>/<id>/SKILL.md    an Agent Skills folder whose SKILL.md `name` is <id>
<pkg>/playbooks/<language>/<id>/...      a playbook in Playbook's native layout
<pkg>/applets/<id>/...                   a browser-server application in its native layout
```

- Every file is one of the three root files or lies under exactly one artifact folder.
- `<language>` is a BCP 47 tag of at most 64 characters, such as `en` or `zh-Hans`; a grandfathered tag such as `i-klingon` is refused. An artifact's language folders are its original text and its translations; an applet has none.
- A release has at most one spec, whose id is `<pkg>`. Its items are the level-3 and level-4 headings `<pkg>-<N>`, each `<N>` once per file, and a translation keeps the original's item ids.
- A skill's or a playbook's id is an Agent Skills name — lowercase kebab-case of at most 64 characters — and no skill and playbook share an id. Every id is one portable path component.
- Paths are portable: components of at most 255 bytes, no control characters, none of `: * ? " < > |`, no trailing dot or space, no Windows device name (`con`, `nul`, `com1`, …), and no two paths, or a path and another's folder, equal after NFC normalization and case folding. The archive holds regular files and folders only: no links, no special files, no backslashes, no `..`, and every entry path begins `<pkg>/` exactly, never `./<pkg>/`.
- Each file keeps its bytes and its owner-execute bit; no other mode bit.
- Limits: 5 MiB compressed, 20 MiB extracted, 900 files, 1,024 UTF-8 bytes per release path, 256 KiB per `README.md` or spec file and 1 MiB for them together, 256 ASCII bytes per version with every number in it at most 9007199254740991, and 1 MiB of stored manifest metadata.

The registry checks shape and references only: it never compiles, converts, translates or runs content, and checks nothing inside a playbook or applet folder.

## meta.yaml

```yaml
format: 2                     # required: the only format accepted
org: my-org                   # must equal <org> in the publish URL
name: my-pkg                  # must equal <pkg> in the publish URL
version: 1.0.0                # SemVer 2.0.0; a version number is never reused
description: One-line summary.
license: Apache-2.0
repository: https://github.com/my-org/my-pkg   # optional, http or https
dependencies:                 # optional: <org>/<pkg> to one version requirement
  other-org/base: "^1.2.0"    # exactly 1.2.3, ^1.2.3 or ~1.2.3
artifacts:                    # at least one entry: artifact id to its entry
  notes: { kind: source, language: en }
  my-pkg: { kind: spec, language: en, from: notes }
  guide: { kind: skill, language: en, from: my-pkg }
  build:
    kind: playbook
    language: en              # the original's language folder
    from: my-pkg              # optional: the artifact this one was made from
    requires: [guide]         # optional: artifacts installed with this one
    generated-by: { agent: Claude Code, model: claude-fable-5 }   # optional
  viewer: { kind: applet }    # an applet has no language
```

- `kind` is `source`, `spec`, `skill`, `playbook` or `applet`; `from` and `requires` name other artifacts of this release.
- The manifest is one YAML map: a repeated key is invalid, an unknown field is refused, and a field prefixed `x-` is ignored.
- A dependency's value is the requirement string itself; a `{version, mode}` entry is refused, as Spex DR-104 retired `mode`. Dependencies are checked by shape only: they may be published later, and which versions satisfy a requirement is the client's rule.

## Spec style (GEARS)

One requirement per `### <pkg>-<N>` heading (item ids are permanent once published):

```text
[Where <static precondition>] [While <runtime state>] [When <trigger>] The <subject> shall <observable outcome>.
```

Test items cite their own package's behavior inline at each verifying assertion (for example, `[[demo-1](#demo-1)]`), mapping Given to Where/While, When to When, and Then to shall.
A spec file holds the sections Intent, External Behavior, optional Internal Behavior, and Verification.

## Publish

One-time human bootstrap: sign in at `/login` (GitHub, GitLab or JiHu GitLab OAuth; connect the other git hosts from `/account`), claim `<org>` at `/account/orgs` (requires owner/admin of the same-named GitHub organization, or of the same-named group at the root of the instance whose git host the sign-in's provider is, the org's name being the group's own path), then mint an API token at `/account/tokens` (`spex_` + 43 chars). The Spex app publishes with its own app token instead. One account can hold all three git hosts and publish to namespaces bound to any of them; connecting a login that belongs to another Spex Space account merges that account's namespaces, packages, and tokens into the signed-in one after a confirmation page.

A namespace is `public` (default) or `private`, chosen at claim time and changeable by an owner/admin at `/account/orgs` (`PATCH /api/v1/namespaces/<org>` with `{"visibility": "private"}`). Everything under a private namespace is readable only by live-verified members of the bound org or group: the web UI needs a signed-in member, and every read below needs `Authorization: Bearer` with a token scoped to that namespace. Anyone else receives `404 not_found`, and private packages never appear on the home page or in search. Publishing to a private namespace is unchanged.

```sh
COPYFILE_DISABLE=1 tar -czf my-pkg-1.0.0.tgz my-pkg   # the archive, rooted at my-pkg/
curl -X PUT https://spex.sublang.ai/api/v1/packages/<org>/<pkg>/<version> \
     -H "Authorization: Bearer $SPEX_TOKEN" \
     --data-binary @my-pkg-1.0.0.tgz
```

A version with build metadata goes in the URL with its `+` percent-encoded, as `1.0.0%2Bbuild.1`.

| Status | `error.code` | Agent action |
| --- | --- | --- |
| 201 | — | published and immutable; the body is the version resource |
| 401 | `unauthenticated`, `invalid_token`, `token_revoked` | supply a valid bearer token |
| 403 | `token_scope`, `forbidden`, `reauth_required` | use a token scoped to `<org>`, held by a member of the bound git-host org; `reauth_required` needs a human to sign in again |
| 404 | `not_found` | namespace unclaimed — a human must claim it first |
| 409 | `version_exists` | version number already used, ever — bump it |
| 413 | `payload_too_large` | archive over the size cap |
| 422 | `invalid_artifact` | `error.details[]` lists every issue `{code, message, path?}` — fix all, retry the same version |
| 500 | `internal_error` | storage or registry failure — retry; verify before choosing a new version |
| 429 | `rate_limited` | the git host is rate limiting — wait the `Retry-After` seconds, then retry |
| 502 | `provider_unavailable` | git-host role verification failed — retry later |

The issues of a 422, each with the release path or manifest field it concerns as `path`:

| `details[].code` | Meaning |
| --- | --- |
| `invalid_org`, `invalid_package`, `invalid_version` | the publish URL's org, package or version breaks its naming rule |
| `artifact_unreadable` | the body is no readable gzipped tarball, exceeds an extraction limit, or holds a link, a special file, a sparse file, a backslash, a path escape, a duplicate path, a pax global header setting a path or a size, or anything but zero padding after its two-zero-block end marker — `path` names the entry extraction stopped at |
| `artifact_bad_root`, `artifact_invalid_path` | a file or folder entry outside `<pkg>/`, `./<pkg>/` included, or a file under no artifact folder and no root file |
| `artifact_path_too_long`, `artifact_path_not_portable` | a release path over 1,024 bytes, or one that is not portable |
| `manifest_missing`, `manifest_invalid_yaml`, `manifest_unsupported_format` | no `<pkg>/meta.yaml`, one that is no YAML map with unique keys, or one without `format: 2` |
| `manifest_missing_field`, `manifest_invalid_field`, `manifest_unknown_field` | the manifest's closed schema, the dependency grammar included |
| `manifest_mismatch`, `manifest_metadata_too_large` | `org`, `name` or `version` differ from the URL; stored metadata over 1 MiB |
| `invalid_artifact_id`, `invalid_language_dir` | an id or a language folder name breaks its rule |
| `language_not_in_tree`, `artifact_not_in_tree`, `artifact_not_in_manifest` | the manifest and the folders do not name the same artifacts and languages |
| `artifact_id_shared`, `artifact_ref_not_in_manifest` | a skill and a playbook share an id; a `from` or `requires` names no other artifact of this release |
| `skill_md_missing`, `skill_name_mismatch`, `source_md_missing` | a skill folder without `SKILL.md` naming its id, a source folder without `SOURCE.md` |
| `spec_invalid_item_id`, `spec_translation_mismatch` | an item id repeated in a spec file, or a translation whose item ids differ from the original's |
| `spec_file_too_large`, `spec_content_too_large` | `README.md` or a spec file over 256 KiB, or all of them over 1 MiB |

Yank (reversible; hides a version from `latest` only): `POST /api/v1/packages/<org>/<pkg>/<version>/yank` with optional `{"reason": "..."}`; `DELETE` the same path to unyank.

## Consume

```text
GET /api/v1/packages/<org>/<pkg>                       latest + the version index
GET /api/v1/packages/<org>/<pkg>/<version>             the version resource: artifacts, dependencies, files[] with sha256
GET /api/v1/packages/<org>/<pkg>/<version>/download    the archive; verify its SHA-256 equals dist.sha256
GET /<org>/<pkg>/<version>/<release-path>              one file, byte for byte, at a stable URL
GET /api/v1/search?q=<query>                           {"results": [{org, name, description}]}   public packages only
GET /api/v1/namespaces/<org>                           {name, provider, canonical_slug, visibility, packages}
GET /api/v1/whoami                                     check credentials
```

- The version index, `versions[]` of the package resource, carries every version's `version`, `format`, `published_at`, `checksum`, `yanked`, `suppressed`, `dependencies` — `<org>/<pkg>` to its requirement string — and `artifacts` — id to `{kind, language, languages}`, `languages` the artifact's language folders. Resolve a whole dependency graph from the indexes alone, and read a version resource only for each version picked.
- `latest` is the highest active release, else the highest active prerelease. A yanked version stays installable by exact version; a suppressed one keeps its metadata and withholds its content with `410 suppressed`.
- The version resource adds `description`, `license`, `repository` (when declared), `languages`, the artifacts in full (`from`, `requires`, `generated-by`), `size`, and `files[]`: `{path, size, sha256, executable}` per file, its release path, its lowercase-hex SHA-256 and its owner-execute bit.
- The archive and the raw files deliver the same files: fetch the archive, or any subset — one artifact, one language, one file — by raw URL, and verify each file against its `sha256`. Each release-path segment is percent-encoded as needed, with `/` between segments.
- A `format` other than 2 is one this guide does not describe: refuse it rather than guess.

Private namespaces answer every read above with `404 not_found` unless the request carries a scoped token whose account still holds a live member role; a scoped token without that role gets `403 forbidden`, and a malformed or revoked token gets `401` on every namespace, package, download, or raw-file read, public or private.

Canonical rules: <https://github.com/sublang-ai/spex.space/tree/main/specs>
