Metadata
Every OCX package includes a metadata.json file that declares how OCX should extract and configure the package at install time. Publishers create this file alongside the package archive; OCX stores it in the object store after installation and reads it whenever the package environment is resolved.
A formal JSON Schema is available for editor autocompletion and validation. Add a $schema field to get instant feedback in VS Code, JetBrains, and other editors that support JSON Schema:
{
"$schema": "https://ocx.sh/schemas/metadata/v1.json",
"type": "bundle",
"version": 1
}Format
Top-Level Structure
The metadata file is a JSON object with a type discriminator. Currently only the bundle type is supported.
| Field | Type | Required | Description |
|---|---|---|---|
type | string | Yes | Discriminator tag. Must be "bundle". |
version | integer | Yes | Format version. Currently 1. |
strip_components | integer | No | Leading path components to strip during extraction. |
env | array | No | Environment variable declarations. |
dependencies | array | No | Package dependencies. |
entrypoints | object | No | Named entry points, keyed by command name. |
binaries | array | No | Declared executable names the package puts on PATH. |
integrations | object | No | Vendor-namespaced configuration blocks for tools OCX does not model. |
Why a type discriminator?
The type field allows future metadata formats (e.g. "manifest", "virtual") without breaking existing packages. Parsers that encounter an unknown type can reject the file with a clear error rather than silently misinterpreting fields.
Minimal Example
A package with no environment variables and no extraction options:
{
"$schema": "https://ocx.sh/schemas/metadata/v1.json",
"type": "bundle",
"version": 1
}Full Example
A language runtime with multiple environment variables, archive stripping, a dependency, and named entry points:
{
"$schema": "https://ocx.sh/schemas/metadata/v1.json",
"type": "bundle",
"version": 1,
"strip_components": 1,
"env": [
{
"key": "PATH",
"type": "path",
"required": true,
"value": "${installPath}/bin",
"visibility": "public"
},
{
"key": "JAVA_HOME",
"type": "constant",
"value": "${installPath}",
"visibility": "public"
},
{
"key": "LD_LIBRARY_PATH",
"type": "path",
"required": false,
"value": "${installPath}/lib"
}
],
"dependencies": [
{
"identifier": "ocx.sh/gcc:13@sha256:a1b2c3d4e5f6..."
}
],
"binaries": ["cmake", "cpack", "ctest"],
"entrypoints": {
"cmake": {},
"fmt": { "command": "cmake-format" }
}
}Here cmake dispatches to a binary named cmake (empty object, the common case), while the fmt launcher dispatches to a differently-named binary cmake-format. binaries separately declares that cmake, cpack, and ctest exist as executables on the interface PATH — see Executables.
Environment Variables
The env array declares environment variables that OCX exposes when running commands with the package (via ocx package exec or ocx env).
Each entry is an object with a key, a type (path, constant, or list), and a value template. value is resolved when OCX composes the environment — see Interpolation Tokens for the grammar available in it.
Interpolation Tokens
A value cannot hardcode an install path. Content-addressed storage means the same package lands at a different path on every machine, and a version bump moves it even on one machine that never changes. ${installPath} is how a value names "wherever OCX put me" without knowing, at authoring time, where that will be.
Build tools face the same problem and defer the same way: CMake generator expressions like $<TARGET_FILE:foo> and Terraform string templates like ${var.region} both resolve a path or value at build/apply time instead of writing it out. OCX's tokens serve the same purpose for one field in one config file.
${…} is a closed namespace: every occurrence in a value (or in an entry-point args element) must parse as one of four recognised bodies, three of which take an optional :native / :posix render modifier. A ${…} that matches none of them is refused, not passed through — see Unrecognised Tokens below.
| Body | Resolves to |
|---|---|
${installPath} | This package's own content/ directory. |
${self.installPath} | Exact alias of ${installPath} — see Aliases. |
${self.env.KEY} | The resolved value of this package's own KEY var, declared earlier in the same env array. Env values only — not legal in entry-point args. See self.env. |
${deps.NAME.installPath} | A declared dependency's content/ directory, where NAME is the dependency's repository basename or its explicit name field. |
{
"key": "PATH",
"type": "path",
"value": "${self.installPath}/bin:${deps.cmake.installPath}/bin"
}A token may appear more than once, and different bodies combine freely in one value.
Render Modifiers
The three install-path bodies accept an optional :native or :posix suffix — ${installPath:posix} — controlling how path separators render. This is independent of the type field, which governs how the value as a whole composes: prepend, replace, or append (see Path, Constant, List). :native (the default when the suffix is omitted) renders Windows paths with \ and POSIX paths with /. :posix always renders /, for values read by tools that expect forward slashes regardless of host — an MSYS2/Git Bash binary, or a value piped into a shell script.
${self.env.KEY} takes no modifier, and one written there is refused at exit 65. The suffix rewrites every \ in the resolved value, and OCX cannot know what a referenced variable holds — a regex, a compiler flag, or a list would lose backslashes it meant to keep. Render at the point the value is built instead, and the reference inherits it:
[
{ "key": "SDK_ROOT", "type": "constant", "value": "${installPath:posix}/sdk" },
{ "key": "SDK_TOOL", "type": "constant", "value": "${self.env.SDK_ROOT}/bin/tool" }
]A modifier on an interface PATH value can fail ocx package create
Any render modifier — :native written out, or :posix — takes that token out of binaries-scan scope: the scan only matches the bare, modifier-free form. On Linux and any, ocx package create's libc lint (see Checking the Declared libc) treats a modifier-bearing interface-visible PATH value as a refusal, exit 65, naming the variable — exactly the value shape :posix looks most tempting on. On darwin and Windows the same exclusion is silent instead (see Interface Surface, Own Package Only). Reach for :posix on a PATH value only when a real MSYS2/Git-Bash-style consumer needs forward slashes, and expect the Linux lint to name it.
Escaping
Write $${ to emit a literal ${ — $${workspaceFolder} publishes as the literal text ${workspaceFolder}. This is the only escape; a bare $ not immediately followed by ${ is ordinary text.
Passing another tool's own ${…} syntax through untouched
devcontainer.json variables use the same ${…} syntax for their own substitutions (${workspaceFolder}, ${localEnv:VAR}). A package whose value ships a devcontainer.json snippet, or any config destined for a tool with its own ${…} vocabulary, needs $${workspaceFolder} so OCX leaves it untouched for the downstream tool to resolve.
Aliases
${self.installPath} is an exact alias of ${installPath} — same referent, same resolution, interchangeable. Prefer ${self.installPath} in new values: it reads unambiguously next to ${self.env.KEY} and ${deps.NAME.installPath}, all three of which name whose path or value is meant. The bare ${installPath} form is not deprecated and will not be removed — it remains legitimate and continues to appear throughout this page's examples; the preference above is guidance for new values, not a rewrite of existing ones.
self.env — Referencing Your Own Variables
${self.env.KEY} resolves to the resolved value of this package's own KEY env var — not its template text, and not a value folded together with a consumer's or a dependency's contribution. KEY must be declared strictly earlier in the same package's env array; a forward reference — to a var declared later, or to itself — is refused at publish time. Declaring the same key twice and then referencing it is refused as ambiguous: OCX does not guess which declaration was meant.
${self.env.KEY} is legal only inside env values, never inside entry-point args.
Generators must emit env in a stable order
Declaration order becomes part of a package's grammar once ${self.env.KEY} is used. A generator built on an unordered map type (Go map, Java HashMap) can emit env entries in a different order on every run, which makes publish succeed or fail nondeterministically depending on whether the referenced key happened to land earlier that particular run. Emit env from an ordered structure — a slice, a LinkedHashMap, a sorted map — when generating metadata that uses self.env.
Unrecognised Tokens
OCX claims the entire ${…} namespace in a value — there is no fifth body and no pass-through for a token this ocx does not recognise, unlike devcontainer.json's own open vocabulary. A refusal names what is wrong:
- A near-miss (
${slef.installPath}) suggests the root it is closest to. - An unknown root (
${workspaceFolder}) explains the$${escape. - A recognised root with an illegal body (
${self.env.A B}) lists the supported bodies for that root — no escape hint, since escaping is not what the publisher needs there. - A recognised namespace with an unknown leaf (
${deps.cmake.version}—depsis real,versionis not) is a distinct case: it names the field and lists the leaves that actually exist for it (installPath), rather than suggesting an escape or a different root.
Refusal is scoped to resolving the value, not to reading the package. ocx package pull, ocx package install, ocx package inspect, ocx package description pull, ocx package which, and ocx package deps on a document with an unrecognised token all succeed — none of them resolve env values. Only ocx package inspect actually echoes one, and only the declared template text, verbatim, never a resolved value (add --resolve when the reference is a multi-platform image index); ocx package description pull never even reads env — it reads only the package's __ocx.desc tag.
ocx env / ocx package exec / ocx exec, any environment composition, and ocx package create / ocx package push refuse, exit 65, naming the token. So does a generated entrypoint launcher: a refused token in a baked args element aborts the launcher at run time, after install — a package can always be inspected; only using it, at any point, requires every token to resolve.
Path Variables
Path variables are prepended to any existing value of the environment variable, separated by the platform path delimiter.
| Field | Type | Required | Description |
|---|---|---|---|
key | string | Yes | Environment variable name. |
type | string | Yes | Must be "path". |
required | boolean | No | If true, the resolved path must exist on disk. Defaults to false. |
value | string | Yes | Value template — see Interpolation Tokens. |
visibility | string | No | Entry visibility. See Entry Visibility. Default: "private". |
{
"key": "PATH",
"type": "path",
"required": true,
"value": "${self.installPath}/bin"
}When required is true and the resolved path does not exist, the operation fails with an error. Set required to false for optional paths like lib/ directories that may not be present on all platforms.
Constant Variables
Constant variables replace any existing value of the environment variable.
| Field | Type | Required | Description |
|---|---|---|---|
key | string | Yes | Environment variable name. |
type | string | Yes | Must be "constant". |
value | string | Yes | Value template — see Interpolation Tokens. |
visibility | string | No | Entry visibility. See Entry Visibility. Default: "private". |
{
"key": "JAVA_HOME",
"type": "constant",
"value": "${self.installPath}"
}Constants are useful for home directory variables (JAVA_HOME, CARGO_HOME) and fixed values that do not depend on the install path (e.g. a version string).
List Variables
List variables are appended to any existing value of the environment variable, joined by separator, with any earlier occurrence of the same contribution removed first — so re-declaring the same value moves it to the back instead of duplicating it. Use list for option-list variables that accumulate flags across packages — JDK_JAVA_OPTIONS, JAVA_TOOL_OPTIONS, GODEBUG, NODE_OPTIONS — where path would join with the platform path separator instead of an author-chosen one, and constant would erase every other package's contribution instead of composing with it.
| Field | Type | Required | Description |
|---|---|---|---|
key | string | Yes | Environment variable name. |
type | string | Yes | Must be "list". |
separator | string | Yes | The string this contribution joins to the variable's existing value (e.g. " " for JDK_JAVA_OPTIONS, "," for GODEBUG). Required in package metadata — see Separator Is Required below. Must be non-empty and must not contain =, a newline, or a carriage return. |
value | string | Yes | Value template — see Interpolation Tokens. Must not start or end with separator (see below). |
visibility | string | No | Entry visibility. See Entry Visibility. Default: "private". |
{
"key": "GODEBUG",
"type": "list",
"separator": ",",
"value": "gctrace=1"
}A contribution is opaque: OCX never splits it into elements, so a value carrying its own separator ("gctrace=1,madvdontneed=1" joined with ",") is still one contribution and is removed and re-appended as a unit, never partially matched.
Separator Is Required
Unlike path and constant, separator has no default in package metadata — a publisher must spell it out explicitly. The [env] table in ocx.toml and the --env flag may still omit it: on those human-facing surfaces an omitted separator inherits whatever another contributor to the same key already declared, or defaults to a single space if nothing did. See Env Composition for the full per-key agreement rule across a composition.
A separator that is empty, contains =, contains a newline or carriage return, or edges the value (a value starting or ending with its own separator) is rejected with exit 65, naming the variable — enforced wherever metadata.json is validated: ocx package create / push, and every later read. The newline and carriage-return exclusion exists because every export surface downstream is line-oriented — a CI env file, a shell snippet, a JSON-lines record — and a separator that ends a line is an injection primitive, not a delimiter any real consumer asks for.
A silent wrong separator is worse than a loud missing one
Go's GODEBUG scans its setting list backward and ignores anything it cannot parse — a list entry joined with the wrong separator does not error, it silently produces a value the consumer ignores. Requiring separator on the wire is the fail-closed answer: an author omitting it on ocx.toml/--env is choosing to inherit an already-established separator, never guessing at one from scratch.
Entry Visibility
Each env entry carries a visibility field that controls which surface the entry contributes to when composing the runtime environment. The field is distinct from the dependency-edge visibility, which controls how a dependency's env propagates to its dependents.
| Value | Interface surface (--self off) | Private surface (--self on) | Use case |
|---|---|---|---|
private (default) | No | Yes | Internal paths the package's own launchers need; not part of the public contract. |
public | Yes | Yes | Variables consumers should see — PATH, JAVA_HOME, tool-specific prefix paths. Both surfaces. |
interface | Yes | No | Values forwarded to consumers but not used by the package's own runtime — PKG_CONFIG_PATH, library include hints. |
"sealed" is rejected at parse time on env entries — a declared entry that is invisible on both surfaces is dead configuration.
See Env Composition for how these entry values interact with dependency-edge visibility during the full composition walk.
CMake vocabulary — a memory aid, not a contract
OCX entry visibility shares vocabulary with CMake's target_compile_definitions (PRIVATE, PUBLIC, INTERFACE), but the two govern different axes. CMake's keyword on a declaration controls what that target publishes to its build consumers at compile time. OCX's visibility field on an env entry controls which of the package's own two runtime surfaces that entry contributes to: private = self-only (the package's internal runtime), public = both surfaces, interface = consumer-only.
Use the CMake vocabulary as a memory aid — the terms carry the same directional intuition — but do not rely on behavioral parity. In OCX, entry visibility partitions a publisher's own declared env entries across two runtime surfaces. Dep reachability is a separate concern governed by dependency-edge visibility.
See the Authoring Guide migration section if you are updating packages that predate the entry visibility field.
Dependencies
The dependencies array declares packages that must be present for this package to function. Every dependency identifier is pinned by OCI digest, ensuring the same dependency graph is reproduced on every machine regardless of the current registry state.
A package declares at most 256 dependencies; a longer array is rejected when the file is read (too many dependencies, exit 65).
The metadata.json you author and the metadata.json OCX publishes are not always the same bytes. See Authoring vs Published below for the two shapes, and Manifest Pins, Never Index Pins for what the digest is allowed to point at.
The platform a bundle targets is not part of this file. On the wire it lives in the OCI Image Index the registry serves; between ocx package create and ocx package push or ocx package test it lives in a build receipt written beside the bundle — a build artifact with no schema, never pushed to a registry.
Authoring vs Published
The sidecar you hand to ocx package create is a superset of the metadata.json OCX publishes. In the authoring sidecar, a dependency identifier needs only an explicit registry — the digest is optional:
{ "identifier": "ocx.sh/java:21" }An identifier with no digest tells ocx package create --platform <PLATFORM> to resolve it against the selected index and rewrite the sidecar in place, pinning the resolved manifest digest directly on the identifier. --platform any resolves the same way: an any-targeted package can only depend on dependencies that themselves offer an any manifest, and the winning manifest's digest is pinned bare on the identifier — the same single-pin shape a concrete platform gets. The rewritten sidecar — not the one you hand-wrote — is what you commit alongside the archive and hand to ocx package push. push reads that file, verifies every dependency is pinned, and refuses to publish (exit 65) anything still tag-only.
The published form is what the registry stores and what ocx package install reads: every dependency identifier carries a manifest digest, and nothing else changes between the two shapes — the sole authoring-time relaxation is the optional digest.
{ "identifier": "ocx.sh/java:21@sha256:a1b2c3d4e5f6...", "visibility": "public" }A published metadata.json is a valid sidecar
Because the published form is a strict subset of the authoring form, an already-published metadata.json — fully pinned, no sidecar fields — parses as authoring metadata unchanged. ocx package create and ocx package push both accept it as-is; there is nothing to migrate.
Manifest Pins, Never Index Pins
The digest in a published dependency identifier must reference a platform manifest, never an OCI Image Index. Pinning a dependency's index digest looks attractive at first — a single identifier could then resolve to whichever platform an installing host needs, the same way an ordinary package reference does at install time — but it does not survive the dependency publisher's next release.
An index digest identifies one version of a tag's index. When the dependency publisher pushes a new platform, or re-pushes an existing one, ocx package push rewrites the tag's index to include the new platform descriptor — the old index digest is no longer referenced by any tag and becomes eligible for the registry's garbage collector on its next sweep. A dependency pinned to that now-untagged index digest starts 404ing the moment GC runs. The child platform manifests have no such problem: every successor index still references them, so they survive indefinitely.
ocx package push enforces this at publish time: it resolves each dependency's pin against its registry and rejects the push (exit 65) if the resolved digest is an image index rather than a manifest, naming the offending dependency.
The same rule governs the project lock
ocx.lock pins each tool to a per-platform leaf manifest digest, never the index digest — see Lock format. Package dependencies follow the same rule, for the same reason: the index digest is a moving target across a publisher's release history; the leaf manifest digest is not.
Dependency Entry
| Field | Type | Required | Description |
|---|---|---|---|
identifier | string | Yes | OCX identifier with an explicit registry. In the authoring sidecar the digest is optional — a tag-only identifier tells ocx package create to resolve it. In the published form the digest is mandatory and must reference a platform manifest, never an OCI Image Index (see above). The tag is always advisory. e.g. ocx.sh/java:21@sha256:a1b2c3d4e5f6..., ghcr.io/myorg/tool@sha256:.... |
name | string | No | Short name used to reference this dependency in ${deps.NAME.installPath} templates. When set, this name is used instead of the repository basename. Must match ^[a-z0-9][a-z0-9_-]*$ and be at most 64 characters. Useful when two dependencies share the same basename (e.g. myorg/cmake and upstream/cmake) or when the basename is long. |
visibility | string | No | Controls how the dependency's environment variables propagate. Default: sealed. See Visibility. |
{
"$schema": "https://ocx.sh/schemas/metadata/v1.json",
"type": "bundle",
"version": 1,
"env": [
{ "key": "PATH", "type": "path", "required": true, "value": "${installPath}/bin", "visibility": "public" },
{ "key": "JDK_HOME", "type": "constant", "value": "${deps.java.installPath}", "visibility": "public" }
],
"dependencies": [
{
"identifier": "ocx.sh/java:21@sha256:a1b2c3d4e5f6...",
"visibility": "private"
},
{
"identifier": "ocx.sh/kitware/cmake:3.28@sha256:f6e5d4c3b2a1...",
"name": "cmake"
}
]
}Visibility
Each dependency's visibility field controls how its environment variables propagate through the dependency chain. The model is inspired by CMake's target_link_libraries visibility (PUBLIC/PRIVATE/INTERFACE).
The struct has two boolean axes — private (self-axis: visible to the package's own runtime) and interface (consumer-axis: propagated to consumers). The four named constants map to the four (private, interface) combinations.
| Value | Private surface (--self) | Interface surface (default) | Use case |
|---|---|---|---|
sealed (default) | No | No | Structural dependency — content accessed by path, not env. Most deps. |
private | Yes | No | Package's own shims need the dep's env; consumers don't. |
public | Yes | Yes | Both the package and consumers need the dep's env. |
interface | No | Yes | Meta-packages that forward env to consumers without using it. |
Surface gating: has_private() returns true for private and public; has_interface() returns true for public and interface. The composer uses these accessors to gate TC entry emission per surface at exec time.
Transitive Propagation via through_edge
When dependencies form a chain (Root → Dep → Transitive), visibility propagates using Visibility::through_edge(child_eff): if the child's effective visibility does not export to consumers (child_eff.has_interface() == false), the result is sealed; otherwise the edge passes through unchanged.
| Edge | Child effective | Result (from root) |
|---|---|---|
public | public / interface | public |
public | private / sealed | sealed |
private | public / interface | private |
private | private / sealed | sealed |
interface | public / interface | interface |
interface | private / sealed | sealed |
sealed | any | sealed |
When two paths reach the same dependency (diamond), the most open visibility wins — each axis is OR-merged independently via Visibility::merge. This is computed at install time and stored in resolve.json. See Env Composition — Edge Filter for how the pre-computed effective visibilities are used at exec time.
Compare with Nix and Guix
Functional package managers describe the same idea as propagated dependencies. In Nix, propagatedBuildInputs is the propagated counterpart to buildInputs — dependencies of a package whose own dependencies cascade to indirect dependents without each consumer having to relist them. In Guix, propagated-inputs are "similar to inputs, but the specified packages will be automatically installed to profiles alongside the package they belong to."
OCX's public and interface visibilities are the same shape: they mark a dependency as contributing its environment to consumers transitively. private is the OCX equivalent of plain buildInputs / inputs — the package itself sees the env, consumers do not. sealed deliberately contributes nothing to either side.
Ordering
Array position defines the canonical order for environment composition. Dependencies are processed in array order — the first entry's environment is applied first. This ordering is preserved through transitive resolution: the full dependency graph is topologically sorted, deduplicated, and applied in that deterministic sequence.
Registry Requirement
Every dependency identifier must include an explicit registry (ocx.sh/java:21@sha256:a1b2c3d4e5f6..., not just java:21@sha256:a1b2c3d4e5f6...). Default registry resolution is not applied because the consumer may have a different default registry than the publisher. Identifiers without an explicit registry are rejected at deserialization.
No Version Ranges
In the published form, the digest is the complete truth — there is nothing to resolve. The tag portion of the identifier is purely informational: it records what the publisher pinned against and enables future update tooling, but is never used for resolution. Writing the digest by hand is never required — the authoring sidecar accepts a tag-only identifier and ocx package create computes the pin for you.
See Dependencies in the user guide for how dependencies affect installation, environment composition, and garbage collection from a user's perspective.
Entry Points
The entrypoints object declares named launchers that ocx package install generates at install time. Each launcher is a small .sh shell script on Unix, or a native <name>.exe shim plus a one-line <name>.shim sidecar on Windows, placed in an entrypoints/ directory inside the package directory. When the package is selected with --select, the per-repo current symlink is flipped to the package root and consumers traverse current/entrypoints from the same anchor to add the launchers to PATH.
Each launcher re-enters via ocx launcher exec with the package root baked at install time, preserving clean-environment execution semantics on every invocation. The launcher resolves a dispatch command against the composed PATH from the package's env block. By default the dispatch command is the entry point's own name — the publisher declares the binary's location once via env and the launcher exec resolver picks it up from there. A package that needs the invocable name to differ from the binary it runs sets the optional command field (see below).
Wire Shape
entrypoints is a JSON object keyed by the invocable name. The map shape mirrors the Cargo [dependencies.X], Compose services:, and GitHub Actions jobs: idioms — uniqueness within a package follows from JSON object key semantics, and per-entry fields land inside each value object.
| Position | Type | Required | Description |
|---|---|---|---|
| Key | string | Yes | The invocable name. Must match ^[a-z0-9][a-z0-9_-]*$ and be at most 64 bytes. Used as the launcher script filename and the command users invoke. |
| Value | object | Yes | Per-entry fields. {} is the common case: the invocable name is the dispatched command. |
command | string | No | Dispatch target resolved on the composed PATH when it differs from the invocable name. Same ^[a-z0-9][a-z0-9_-]*$ / 64-byte rule as the key. Omit it (the common case) and the invocable name is dispatched directly. Example: expose hello while running a binary named hello-bin. Not interpolated — must be a plain slug, not a path. |
args | array of strings | No | Fixed leading arguments prepended before user-supplied arguments when the launcher dispatches. Each element is one argv token (no shell word-splitting). ${installPath} / ${self.installPath} are interpolated in each element; ${deps.*} and ${self.env.*} tokens are rejected at publish time. Omit or supply an empty array — both are wire-identical; the field is absent in the serialized form when empty. See Baked Arguments. |
Baked Arguments
The args field embeds fixed leading arguments into a generated launcher. On every invocation the launcher prepends these arguments before the user's arguments, then passes the full list to the dispatched command.
{ "command": "python", "args": ["${self.installPath}/app/main.py"] }Invoking mytool a b with this entrypoint runs python <content>/app/main.py a b — baked args first, user args appended in left-to-right array order. Each element is one argv token; there is no shell word-splitting, so paths with spaces work without escaping.
${installPath} interpolation. Each element of args supports the ${installPath} token and its exact alias ${self.installPath} (see Aliases), optionally suffixed :native / :posix (see Render Modifiers). Both resolve to the package's content directory — the same path that env values reference with the same tokens. A token may appear more than once within a single element. $${ escapes a literal ${, exactly as in env values.
Token restrictions. ${deps.*} and ${self.env.*} tokens are both rejected at publish time with a dedicated error (DisallowedToken) — args permits only the install-path forms. Dependency paths and a package's own composed env values belong in the env block where the visibility contract applies; consumers read them at runtime from the composed environment. command is a plain slug resolved on the composed PATH; it accepts no interpolation and cannot be a filesystem path. To reach a dependency's binary in command, expose it through the dependency's interface or public env entries.
Disk Layout
Generated launchers land in entrypoints/ inside the package directory (a sibling of content/). When the package is selected with ocx package install --select or ocx package select, the per-repo current symlink is flipped to that package root, and consumers reach the launchers via {registry}/{repo}/current/entrypoints. Packages with no entrypoints produce no entrypoints/ directory, so the same current/entrypoints path simply does not exist for them.
Uniqueness
The map shape gives intra-package uniqueness via JSON object key semantics. Duplicate keys in the on-wire JSON are rejected at deserialization with a descriptive error rather than silently last-wins (the serde_json default). Name collisions across different currently-selected packages are detected at select time.
Example
{
"entrypoints": {
"cmake": {},
"ctest": {},
"hello": { "command": "hello-bin" },
"mytool": { "command": "python", "args": ["${installPath}/app/main.py"] }
}
}cmake and ctest dispatch the binaries of the same name. hello dispatches hello-bin resolved on the composed PATH. mytool uses args to bake the script path: invoking mytool x y runs python <content>/app/main.py x y.
Executables
CI matrices, Bazel toolchain rules, and devcontainer features often need one narrow answer before they trust a package: does cmake actually resolve on PATH once this package is installed? The composed environment (env) tells a caller how PATH gets built, not what resolves on it — answering that today means installing the package and probing PATH directly.
The binaries array closes that gap. It is an optional, publisher-declared array of bare executable names the package exposes on its interface PATH surface — a name a consumer can look up without a round trip through installation.
{
"$schema": "https://ocx.sh/schemas/metadata/v1.json",
"type": "bundle",
"version": 1,
"env": [
{ "key": "PATH", "type": "path", "value": "${installPath}/bin", "visibility": "public" }
],
"binaries": ["cmake", "cpack", "ctest"]
}A Claim, Not a Guarantee
binaries is unverified by default. ocx package push streams archives straight to the registry — it never decompresses a layer — and a package can compose several layers, including ones reused by digest from an earlier push. No point in the pipeline (create, push, or install) ever has every layer's content tree materialized at once, so full validation of the claim against disk is structurally impossible without breaking the streaming-push, lazy-pull model the rest of OCX relies on.
Same trust model as package.json
npm's bin field, Cargo's [[bin]] target name, and nixpkgs' meta.mainProgram all make the identical trade: the field documents what a consumer will find, and none of them are verified against the built artifact at publish time. binaries follows the same convention on purpose — every packaging ecosystem surveyed converges on a bare, unverified, documentation-grade name claim.
On Windows, the claim reflects the default executable-resolution set (.exe, .com, .bat, .cmd) — the fixed allowlist the create-time scan uses. A hardened child environment with a customized PATHEXT may resolve fewer of these, so a claimed name backed only by a .bat/.cmd/.com file is not guaranteed to resolve in every composed environment.
ocx package create's --bin-scan/--no-bin-scan flags can fill or verify the claim against the local content tree at publish time, closing the gap for content create can actually see. A foreign or reused layer added later at push time was never part of that scan — declaring its binaries is the publisher's job, by hand. See package create for the scan modes.
Interface Surface, Own Package Only
Only names reachable through a ${installPath}- or ${self.installPath}-rooted (the two are exact aliases) path variable with interface visibility are eligible. Private, libexec-style directories are never candidates — a name only a package's own launchers can see was never part of the public contract binaries describes.
A render modifier on that PATH value (${self.installPath:posix}/bin) takes it out of scan scope entirely — the scan only matches the bare, modifier-free token, so a modifier-bearing segment is silently excluded from the auto-filled claim rather than scanned. On Linux and any, ocx package create's libc lint (see Checking the Declared libc) independently refuses a modifier-bearing interface PATH value at publish time, so the exclusion surfaces there as an error naming the variable. On darwin and Windows the libc lint never runs, so the same exclusion produces a silently shorter binaries array with no diagnostic at all — the only defenses are --bin-scan in verify mode against a hand-authored claim, or hand-authoring binaries outright.
The claim is own-package only, never transitive: a package never lists a dependency's executables. What is reachable through a dependency chain is a composition question — answered by ocx env / ocx package env's binaries array, attributed per admitted package, not by a per-package metadata fact.
None vs an Empty Array
The two wire states carry different meaning, kept deliberately distinct:
// undeclared — omitted entirely; predates this field, or the publisher never set it
{ "type": "bundle", "version": 1 }
// declared: the publisher asserts zero executables on PATH (e.g. an env-only package)
{ "type": "bundle", "version": 1, "binaries": [] }A consumer reading metadata.json directly — an SBOM scanner, say — can tell "nobody has declared this yet" apart from "the publisher looked, and there genuinely are none."
Executables vs Entry Points
binaries and entrypoints describe two different things and are never confused with each other. binaries names files the publisher shipped in the content tree; entrypoints names launchers OCX generates at install time. A generated launcher name never appears inside binaries — it is not a file the publisher's archive contains, it is something ocx package install writes.
The same name can legitimately appear in both, without contradiction: binaries: ["cmake"] declares that the underlying cmake binary exists, and entrypoints: {"cmake": {}} declares that a generated launcher wraps it. At runtime the generated entrypoints/ directory is prepended ahead of bin/ on the composed PATH (see Disk Layout), so the launcher — not the raw binary — is what actually resolves; binaries still documents that the wrapped binary is there.
Writing the Field
binaries is always written as a plain array of bare strings, sorted and de-duplicated:
{ "binaries": ["cmake", "cpack", "ctest"] }There is no per-entry object form in the published schema — a binaries entry is a name, nothing else. ocx package create --bin-scan can generate this array for you from the content tree; a hand-authored metadata.json writes it exactly the same way.
Each name is validated against a grammar looser than an entry point key — it admits names like python3.13, c++, and MSBuild that the entry-point slug pattern would reject:
- ASCII printable characters only, no whitespace anywhere.
- Forbidden characters:
/ \ < > : " | ? *— the Windows-reserved filename characters, since OCX materializes binary names as real files on Windows. - No leading
-(shell flag-lookalike hazard). - No leading or trailing
.(Unix hidden-file ambiguity; a trailing dot that Windows silently strips would otherwise collide on disk). - Non-empty, at most 64 bytes.
- Reserved Windows device names (
CON,PRN,AUX,NUL,COM0–COM9,LPT0–LPT9) are rejected case-insensitively, checked against the basename before the first.— so a suffixed alias likeCON.txtis rejected too, matching how Windows reserves the device name regardless of extension. - Two names in the same
binariesarray that differ only by case (Cmakeandcmake) are rejected — a case-insensitive target filesystem would collide on them even though the two strings are distinct. - Bare names only — never
.exeor any other extension. Extension handling belongs to the platform-specific resolve step (see A Claim, Not a Guarantee), never to the metadata.
Integrations
Some tools have configuration OCX has no model for at all — a list of editor extensions, a JetBrains plugin set, a devcontainer fragment, a language-server setting block. Before this field, a publisher's only options were to fork the metadata.json format or ship a side-channel file consumers had to know to look for. integrations gives every publisher one reserved place to write vendor-specific configuration, keyed by a namespace, without OCX ever needing to understand what is inside.
{
"$schema": "https://ocx.sh/schemas/metadata/v1.json",
"type": "bundle",
"version": 1,
"integrations": {
"com.microsoft.vscode": {
"extensions": ["rust-lang.rust-analyzer"],
"settings": { "rust-analyzer.server.path": "${installPath}/bin/rust-analyzer" }
},
"com.jetbrains": { "plugins": ["com.jetbrains.rust"] }
}
}Each key in the integrations object is a namespace a publisher owns; each value is arbitrary JSON — an object, an array, a string, a number, or null. OCX stores it, enforces the two constraints below (a well-formed key, a size cap), resolves its own ${...} tokens inside any string value, and carries the whole map through the dependency graph unread. It never looks inside a payload to interpret, validate, merge, or flag a conflict between two packages that declare the same namespace — that non-interpretation is the entire point of the field. What a com.microsoft.vscode block should contain, and what a consuming tool does when it finds one from two different packages, is that tool's business, not OCX's.
An absent integrations field and an explicit {} are the same state. Unlike binaries, nothing here distinguishes "declares none" from "never said."
No Merge, Ever
If two packages in a dependency graph both declare com.microsoft.vscode, both blocks exist, each attributed to its own package, and the consuming application decides what to do with two of them. OCX merges nothing — not their extensions lists, not their settings objects, not anything.
This mirrors Cargo's [package.metadata.<tool>] table: Cargo reserves the namespace, ignores the contents entirely — no unused-key warning, no schema — and leaves interpretation to whichever tool the key names. integrations makes the same trade for OCX packages.
Compare with devcontainer.json
The closest analogue is devcontainer.json's customizations property, and it takes the opposite approach: when a dev container composes several Features, each feature's customizations.vscode.extensions array is concatenated into one list and its customizations.vscode.settings object merged into one settings object, leaving a tool implementor to write that merge logic. OCX takes the reverse-DNS-namespace idea and not the merge.
Namespace Keys
A namespace key is validated far more loosely than it looks. The convention is reverse-DNS — com.microsoft.vscode, com.jetbrains, sh.ocx.completions — but OCX does not enforce reverse-DNS shape. It only refuses a key that could not function as a JSON object key or as plain-text terminal output at all:
| Rule | Rejects |
|---|---|
| Non-empty | "" |
| At most 128 bytes | a 129-byte key |
No control characters (C0, DEL, and C1: U+0000–U+001F, U+007F, U+0080–U+009F) | "a\nb", a key containing a raw newline or tab |
No invisible characters — general category Cf and the Default_Ignorable_Code_Point property, since neither contains the other | a key containing U+202E (right-to-left override), U+200B (zero-width space), U+FEFF (byte-order mark), or U+3164 (Hangul filler, the classic blank character) |
| No Unicode whitespace, not just ASCII spaces | "com.foo bar", a key containing U+00A0 (no-break space) |
Everything else is legal. vscode, VSCode, com.微软, a, x/y, and 123 all pass — case is preserved, and Foo and foo are two distinct namespaces. Non-ASCII namespaces are not restricted by this rule: com.微软 and com.café are refused by nothing here, since none of their characters is invisible.
The invisible-character rule exists because a namespace key is printed verbatim into plain-text output. Without it, a key could be crafted to display as a different namespace than the one a consumer actually matches against — the bidirectional-override corner of Cf is the same rendering trick documented by Trojan Source for source code. OCX refuses two whole properties rather than a codepoint list, because a hand-maintained list goes stale against every Unicode release and this grammar can only ever be loosened once a package publishes: a zero-width space, a byte-order mark, or a Hangul filler hidden inside a key is just as invisible to a human comparing two namespaces on screen. An invalid key is rejected wherever metadata.json is validated — ocx package create / push, and every later read — with exit 65, naming the offending key.
sh.ocx. is reserved by convention, not by code
OCX declares its own first-party namespaces under the sh.ocx. prefix (e.g. sh.ocx.completions). Nothing in the grammar above refuses a third-party key that starts with sh.ocx. — the same documented-not-validated stance the reverse-DNS convention itself takes. Third-party packages must not publish under sh.ocx.*; today nothing stops them from doing so.
Size Caps
| Cap | Limit |
|---|---|
| One namespace's payload | 8 KiB |
The whole integrations map | 32 KiB |
Both are measured as compact (whitespace-free) JSON: the per-namespace cap over that one payload, the per-package cap over the whole map including every key and its surrounding punctuation. A namespace over its own cap is rejected first; if every namespace individually fits but the map as a whole does not, the per-package error fires. Both are enforced everywhere metadata.json is validated, exit 65.
These caps are raise-only. metadata.json is a read-path format — an already-published package must keep resolving on every future ocx — so lowering either cap would un-resolve a package that published successfully under today's limit. Raising costs nothing: a package that could not publish under a lower cap simply was never published.
Interpolation
An integrations payload gets the same interpolation engine as env values, resolved inside every string in the payload — object values, array elements, nested at any depth — never inside object keys, and never touching numbers, booleans, or null. That engine is a closed namespace (see Interpolation Tokens): a ${…} it does not recognise is refused, not passed through.
${installPath}, and its exact alias${self.installPath}, resolve to the declaring package's own content directory — never the consuming root's, even when the payload propagates to a consumer through the dependency graph. See Aliases.${deps.NAME.installPath}resolves to a direct dependency's content directory, the same as inenv. A${deps.NAME}naming a dependency the package does not declare is invalid metadata and rejected at publish time, exactly like an unresolvable reference in anenvvalue.- All three install-path bodies accept the optional
:native/:posixrender modifier described in Render Modifiers —${self.installPath:posix}is how a payload destined for a Windows-hosted VS Codesettings.jsongets forward slashes instead of backslashes. $${installPath}(a doubled$) escapes to the literal text${installPath}— the only way to emit a literal${…}OCX would otherwise try to resolve.- Every other
${...}token is refused, not passed through.${workspaceFolder}, VS Code's${env:VAR}, and devcontainer's own${localEnv:VAR}/${containerEnv:VAR}are not in OCX's closed vocabulary. Writing one bare fails with exit 65, naming the token: refused byocx package create/pushat publish time, and again at composition (ocx env/ocx package exec/ocx exec). Read-only paths —pull,install,inspect— echo an unrecognised token verbatim instead of refusing it. A payload destined for one of these tools escapes it instead:$${workspaceFolder}publishes as the literal text${workspaceFolder}, left for the downstream tool to resolve on its own turn.
{
"integrations": {
"com.example": {
"toolPath": "${installPath}/bin/tool",
"literalToken": "$${installPath}",
"editorVariable": "$${workspaceFolder}/config"
}
}
}Resolved for a package installed at /home/u/.ocx/packages/.../content, that payload becomes "toolPath": "/home/u/.ocx/packages/.../content/bin/tool", "literalToken": "${installPath}", and "editorVariable": "${workspaceFolder}/config" — the doubled $ in the source collapses to a single literal ${…} in the resolved payload, unchanged from there on. Writing editorVariable as a bare "${workspaceFolder}/config" instead is refused at exit 65: workspaceFolder is not a body this engine recognises.
A payload that is not an object at all — a bare string, an array, a number, or null — is legal too. A bare string is a single value and is interpolated the same way: "integrations": { "com.example": "${installPath}/notes.txt" } is valid metadata.
The schemaVersion Convention
OCX places no schema-versioning requirement on a payload's contents. If a namespace's own format needs to evolve, the recommended — never enforced — convention is a top-level "schemaVersion" key inside that namespace's own payload:
{
"integrations": {
"com.example": { "schemaVersion": 2, "setting": "value" }
}
}OCX never reads this key; it exists purely as a hint the consuming tool can check before parsing the rest of the payload.
Who Can Declare Them
Any package a composition admits, including a patch companion. A companion is a package loaded into the environment, so it contributes integrations on exactly the terms every other package does — no separate rule, no separate opt-out.
That makes site policy expressible where it belongs. A proxy setting, an internal CA bundle, or an extension allowlist that applies to every project on a machine is published once as a companion instead of restated by every package author. Each row names the declaring package, so a companion's contribution is always distinguishable from the one belonging to the package you asked for. Details and the surface rules live in Env Composition.
Extraction
strip_components
Many upstream archives wrap their content in a single top-level directory (e.g. cmake-3.28/bin/cmake). Rather than repackaging, set strip_components to remove leading path components when the package is assembled — analogous to tar --strip-components.
| Value | Effect |
|---|---|
omitted / 0 | Extract as-is. |
1 | Remove one leading directory: cmake-3.28/bin → bin. |
2 | Remove two: a/b/bin → bin. |
strip_components is the package-wide default, applied to any layer that carries no layout of its own. A multi-layer package can override it per layer with a strip/prefix pair carried in the manifest layer descriptor's annotations, set via the <ref>:strip=N,prefix=P syntax on ocx package push — see layer layout for the grammar and the fallback chain. There is no separate layers field in this schema; per-layer layout lives entirely in the manifest, not in metadata.json.
JSON Schema
The schema is generated from the Rust source types and published at:
https://ocx.sh/schemas/metadata/v1.json
Editor Integration
Add $schema to the top of your metadata.json for instant validation and autocompletion:
{
"$schema": "https://ocx.sh/schemas/metadata/v1.json"
}Generating Locally
The schema is a build artifact generated from the OCX source. To regenerate:
task schema:generateThis writes the schema to website/src/public/schemas/metadata/v1.json.
Validation
Validate a metadata file against the schema using check-jsonschema:
uvx check-jsonschema --schemafile https://ocx.sh/schemas/metadata/v1.json metadata.jsonSchema
The metadata format carries an integer version field reserved for future schema evolution. Currently the only valid value is 1. Top-level fields:
type— discriminator ("bundle"only currently).version— integer schema version. Currently1.strip_components— optional leading path components to strip during extraction.env— optional declarations of environment variables.dependencies— optional package dependencies, digest-pinned in the published form. Each entry carries anidentifier, optionalnameoverride (used asNAMEin${deps.NAME.installPath}tokens), and optionalvisibilitycontrolling env propagation through the chain. See Dependencies.entrypoints— optional object keyed by the invocable name. Each value object carries two optional fields.command: the binary the generated launcher dispatches to when it differs from the invocable name (e.g. exposefmtwhile runningcargo-fmt); follows the same slug constraint as the key ([a-z0-9][a-z0-9_-]*, at most 64 bytes); not interpolated; omitted means the invocable name is the dispatch target.args: array of fixed leading arguments prepended before user-supplied arguments at dispatch time; each element is one argv token;${installPath}is interpolated per element;${deps.*}tokens are rejected at publish time; omitted or empty are wire-identical — the field is absent in the serialized form when the array is empty.binaries— optional array of bare executable-name strings, sorted and unique: a publisher-declared, unverified claim of executables the package puts onPATH. Absent means undeclared;[]means the publisher asserts zero. Never lists a dependency's executables, and never lists anentrypointslauncher name. See Executables.integrations— optional object mapping a namespace key to an arbitrary JSON payload OCX never interprets, merges, or validates the contents of. Absent and{}are the same state. Namespace keys reject only unusable shapes (empty, over 128 bytes, control characters, Unicode whitespace, invisible characters — general categoryCftogether with theDefault_Ignorable_Code_Pointproperty) — reverse-DNS is a documented convention, not an enforced grammar. Capped at 8 KiB per namespace and 32 KiB per package (compact JSON, raise-only). String values are interpolated with the same closed-vocabulary engine asenv—${installPath}/${self.installPath}/${deps.NAME.installPath}, each with an optional:native/:posixrender modifier, and a$${...}escape to a literal token; an unrecognised${...}is refused at exit 65, not passed through. See Integrations.
Visibility model:
Dependency.visibility— two-axis struct (private+interfacebooleans) with four named constants:sealed(default; neither axis set; content accessible by path only via${deps.NAME.installPath}),private(self-axis only; package's own runtime sees dep's env, consumers don't),public(both axes; package and consumers see dep's env),interface(consumer-axis only; dep's env forwarded to consumers without being used by the package itself). Algebra:mergefor diamond dedup (OR per axis),through_edgefor inductive TC composition. Accessorshas_interface()/has_private()gate surface emission. See Dependency Visibility and Env Composition.Var.visibility— three-value entry-axis marker:private(default; private surface only),public(both surfaces),interface(interface surface only)."sealed"is rejected at parse — a declared entry visible on neither surface is dead configuration. See Entry Visibility.
OCI Platform Fields
metadata.json describes runtime configuration for a single package build. The OCI platform descriptor — the platform object in an OCI Image Index entry — is separate and not part of metadata.json. It is declared by the publisher at push time (or generated by the mirror tool from the asset spec) and consumed by OCX at index resolution time. This section covers the JSON wire object; see Platforms for the human-readable string form (--platform, lock keys, the build receipt's platform field) and the compatibility relation OCX evaluates it against.
os.features and libc tagging
The OCI Image Index specification defines os.features as an optional array of strings encoding mandatory OS features the image requires. For non-Windows operating systems the specification leaves values implementation-defined.
OCX uses this field to encode the libc family requirement of a Linux binary. Two values are defined:
| Value | Meaning |
|---|---|
libc.glibc | Binary links GNU libc (glibc). Requires glibc on the installing host. |
libc.musl | Binary links musl libc. Requires musl on the installing host. |
A static binary (no runtime libc dependency) carries no os.features declaration — the absent or empty set matches every Linux host.
These values appear in the published OCI image index JSON:
{
"platform": {
"architecture": "amd64",
"os": "linux",
"os.features": ["libc.glibc"]
}
}Normalization: OCX sorts and deduplicates os_features before serialization. The order in the YAML spec or the push command does not affect the wire format. Duplicate values collapse to one.
RESERVED features field: The OCI v1.1.1 specification marks the top-level platform.features field (not os.features) as RESERVED. OCX never serializes it and drops any value found in a foreign manifest with a warning.
See libc Differentiation in the multi-platform authoring guide for the publisher workflow and YAML examples.
Schema Changelog
The integer version field is reserved for future schema evolution. Behavioral changes that do not require a version bump (because they are backwards-compatible additions or clarifications within version: 1) are recorded here.
Version 1 — Current
All packages must declare "version": 1. This is the only valid value; other values are rejected at parse time.
Behavioral changes made within version: 1 since the initial release:
| Change | Description |
|---|---|
| Visibility default flip | Var.visibility now defaults to "private" instead of "public". Packages that relied on the old default emit no interface-surface env entries for un-tagged vars. Publishers must explicitly set "visibility": "public" to restore prior behavior for consumer-visible vars. |
| Entry-axis addition | Var.visibility gained the "interface" value: env entries visible on the interface surface but not the private surface. Previously only "private" and "public" were recognized; "interface" entries in older parsers will be rejected at deserialization. |
| Baked entry-point arguments | Entry-point values now accept an optional args array of fixed leading arguments prepended before user-supplied arguments at dispatch time. ${installPath} is interpolated per element; ${deps.*} tokens are rejected at publish time. An absent or empty args array is wire-identical to prior behavior — packages without args are unaffected. |
| Dependency manifest pinning | The authoring sidecar accepts a digest-optional dependency identifier — ocx package create --platform resolves it against the selected index and pins the manifest digest directly on the identifier, the same shape for a concrete platform or any. The published digest must reference a platform manifest, never an OCI Image Index — see Manifest Pins, Never Index Pins. ocx package push rejects an index-pinned or unpinned dependency (exit 65). |
| Declared executables | New optional binaries field: a sorted, unique array of bare executable-name strings, publisher-declared and unverified. Additive — absent is wire-identical to every package published before this field existed. None and [] are deliberately distinct wire states (unlike entrypoints, which collapses absent and empty). See Executables. |
list env type | New list modifier type for option-list variables: appends instead of replacing or prepending, removing any earlier occurrence of the same contribution first. Carries a new separator field, required for list entries and rejected on path/constant. Additive — packages using only path/constant are unaffected. See List Variables. |
| Vendor integrations | New optional integrations field: a namespace-keyed map of opaque JSON blocks for tools OCX does not model, never interpreted, merged, or validated beyond a namespace-key grammar and a size cap. Additive — absent is wire-identical to every package published before this field existed, and absent/{} are the same state. See Integrations. |
These changes affect existing packages
If you published packages before the visibility-default flip, their untagged env entries will no longer appear on the consumer surface. Add "visibility": "public" explicitly to vars that consumers should see.