Project spec
The portable project file (kind "spunto/project") — every field, a complete example, legacy compatibility, and what deliberately does not travel.
Both products describe the same thing: a devcontainer environment, made of an image, some features,
some extensions, repositories to clone and lifecycle commands. Spunto Lite could already export that
to JSON and import it back, but Spunto Cloud could not. Lite's format also announced itself as
spunto-lite/project, which put the producer's name in the format's signature and made the file
local by construction.
One neutral format, spunto/project, is what turns "prototype it locally, then move it to the
cloud" into a feature. Module reference: spec.
Envelope
export const PROJECT_SPEC_KIND = "spunto/project"
export const LEGACY_PROJECT_SPEC_KINDS = ["spunto-lite/project"] as const
export const PROJECT_SPEC_VERSION = 1Prop
Type
project
The fields that every product understands come first. A field with a default may be omitted from a file, and parsing fills it in.
Prop
Type
Then come the product-specific fields. They are all optional. Absent is normal, and unsupported is not an error.
Prop
Type
SpecFeature
Prop
Type
SpecRepository
Prop
Type
A complete example
{
"kind": "spunto/project",
"version": 1,
"exportedAt": "2026-10-07T09:12:44.512Z",
"project": {
"name": "My App",
"description": "Next.js front-end and its API",
"image": "mcr.microsoft.com/devcontainers/typescript-node:20",
"features": [
{ "id": "docker-in-docker" },
{ "id": "my-tool", "ociRef": "ghcr.io/acme/features/my-tool:1", "options": { "version": "latest" } }
],
"vscodeExtensions": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint"],
"prewarmImages": ["postgres:16"],
"dind": true,
"postCreateCommand": "npm ci",
"postStartCommand": null,
"repositories": [
{
"provider": "github",
"project": "acme/my-app",
"workspacePath": "my-app",
"cloneUrl": "https://github.com/acme/my-app.git",
"branch": "main"
}
],
"forwardPorts": [3000, 8787],
"secretNames": ["DATABASE_URL", "STRIPE_SECRET_KEY"],
"taskAgentCommand": "claude -p --dangerously-skip-permissions",
"taskAgentProtocol": "claude-stream",
"taskReviewMode": "stop"
}
}The filename comes from projectSpecFilename(name). The name is lowercased, every run of characters
outside [a-z0-9] becomes -, leading and trailing dashes are trimmed, and .spunto-project.json
is appended. An empty slug becomes project.
projectSpecFilename("My App") // "my-app.spunto-project.json"
projectSpecFilename("!!!") // "project.spunto-project.json"Writing and reading
import { buildProjectSpec, parseProjectSpec } from "@spunto/build/spec"
// Export: wraps the project in a fresh envelope. Always the current kind, never a legacy one.
const file = JSON.stringify(buildProjectSpec(project), null, 2)
// Import: parse + validate. Throws an Error whose message is safe to show in a toast.
const spec = parseProjectSpec(await uploaded.text())parseProjectSpec runs on a file that a human picked, so "why can't I import this?" has to be
answerable without opening a console. It throws one of three messages:
| Input | Message |
|---|---|
| Not JSON | Not a valid JSON file |
JSON, but with no kind key (someone picked the wrong file) | Not a Spunto project spec |
Has a kind, but fails validation (wrong kind, wrong version, missing image, a port above 65535…) | Unsupported project spec: followed by the first line of z.prettifyError(...), i.e. the first issue. For a wrong kind, that issue's message is Expected "spunto/project". |
Legacy spunto-lite/project files
Every file that Spunto Lite exported before this package carries "kind": "spunto-lite/project".
Those files are on people's disks, and refusing them to tidy up a string would break the only half
of the feature that had already shipped. So the legacy kind is accepted on read, and
re-exporting such a file writes the neutral kind. A legacy file is upgraded the first time it
goes through a product, and no new file is ever written in the old format.
What travels, and what does not
The spec holds what describes the environment, never what identifies an instance:
- No instance identity. There is no project id, no version history, no deploy key and no favourite
flag. A repository's
idis optional, and the forge-appinstallationIdthat a product keeps on its own repositories is not part of the schema at all. - Secrets travel as names only. Values are write-only by design, and a spec is a file that people email each other. The names are there so that an import can lay out the rows left to fill in. Spunto Cloud's end-to-end test for this flow checks precisely that a secret's value appears nowhere in the exported file, and only its name does.
Shape here, policy there
These schemas check that a file is well-formed, not that it is acceptable. Whether a mount path is allowed, whether an extension id exists on the configured registry, and whether a branch name is valid git are all left to the product doing the import, which validates its own create payload anyway. A format that enforced one product's policy would stop being portable on the day the other product's policy differed.
In Spunto Cloud, for instance, the file is parsed in the browser with parseProjectSpec, and it
only pre-fills the new-project form. The API validates the creation as it would a hand-filled form,
so the import is not a trust boundary.
Ignoring what you cannot honour
A product ignores the fields it has no feature for. Shared volumes are a Lite concept and the task settings are a Cloud one. Importing a Cloud spec into Lite is supposed to drop the task settings, not fail. Losing a field that the target cannot honour is the correct outcome. Refusing the file is not.
The schema does carry both products' optional fields through a parse, so a product that can hand them back (Lite re-exporting a Cloud spec, for example) can keep them intact. Keys the schema does not declare at all are dropped silently on parse, because zod objects strip unknown keys. They are not an error.
The spec's feature shape is narrower than the in-app ProjectFeature type
(types). It has no localScript (the filename of an
install script the platform ships itself), so that key does not survive a parse.
Compatibility
- Adding an optional field is compatible in both directions. Old readers drop it, and new readers accept files without it.
- Making a field required, or narrowing an enum, turns files that already exist into import
errors. If it has to happen, bump
PROJECT_SPEC_VERSIONand keep reading version 1. - Changing
PROJECT_SPEC_KINDworks the way the Lite rename did: the old value moves intoLEGACY_PROJECT_SPEC_KINDSand is read forever.