Platform and CLI
What owners do on the platform, the commands of the ginko CLI, and why schemas only grow.
What owners do on the platform
The platform is a hosted web app, run by Lupinum and open by invitation. It turns your bricks into forms and stores the content. The site fetches the content from it as a bundle. Without the platform, a site reads all content from files.
- Sign-in. People sign in with a code that arrives by email. An account exists only by invitation.
- Roles. An owner (
owner) may do everything on their site and invites staff. A staff member (staff) may only do the tasks that the owner grants. An admin may do everything on all sites. - Editing. Owners change entries in forms. Tasks from the bricks are shortcuts for frequent chores. A preview shows the real site with the changed content.
- Save and publish. Saving puts nothing online: a new entry is a draft, and changes to an entry or single that is online wait as unpublished changes. "Publish …" puts them online now or at a time, "Discard" goes back to the version on the website. An entry of a collection can go back to a draft (offline, without deleting it); a single is always online. A task on Today publishes with one press on "Publish". Moves in the navigation tree go online at once.
- Safety. If two people change the same field, the platform detects the conflict. A change can be undone. Deleted entries can be restored for 30 days.
- Approval. If you want, a task or an agent key needs a yes before a change goes live. The reviewer sees the change and approves it, approves it for a time, or rejects it with a sentence; the author sees the reason. Approval is off until someone turns it on.
- Schedule. A draft or unpublished changes go live at a set time. A release publishes several changes together.
- History. The history shows every change, with "Show changes" and "Back to this version".
- Translations. If someone changes a text in the main locale, the translation counts as outdated. The site keeps showing it until someone confirms or rewrites it; the Studio lists it.
- Refresh now. The site reloads content at most every 60 seconds. An owner can have a site reload at once, for example to take an entry off the site. The site needs a token for this (see Deploy).
You, the developer, define the bricks. The owners fill them.
The CLI
ginko is a command in the package @lupinum/ginko. It has three commands: check, release and export. The commands load your ginko.config.ts (default: ./ginko.config.ts). Use --config <file> to name another file. The messages of the CLI are in English. In a project, you start it with pnpm exec ginko <command> or from a script in package.json.
pnpm exec ginko checknpx ginko checkyarn ginko checkginko --help lists the commands, ginko --version prints the version.
The CLI loads ginko.config.ts with the type stripping of Node, not with the loader of Nuxt. So relative imports in the config need the .ts suffix (import { menu } from './bricks/menu.ts'), and aliases such as ~/ do not work. nuxt build accepts both, so a config that is split into files can build and still fail in ginko check.
Environment variables
These are all GINKO_* variables that the CLI reads. The CLI does not read .env files: set the variables in the shell or as CI secrets. (The Nuxt site reads .env, so a value only in .env reaches the site but not the CLI.)
| Variable | Commands | Value |
|---|---|---|
GINKO_PLATFORM | check (platform bricks), release, export | The address of the platform. If it is not set, the CLI reads NUXT_PUBLIC_GINKO_PLATFORM, so one CI secret serves the site and the CLI. |
GINKO_KEY | check (platform bricks), release, export | A push key of the site, in the form ginko_<site>_<32 characters>. It has scopes: release, check, export, and pull. The platform stores only its hash. Owners and admins create it in the Studio under Team: “Publish and back up” gives all four scopes, “Back up only” gives only export. |
GINKO_COMMIT | release | The commit that the release builds. If it is not set, the CLI reads GITHUB_SHA (set by GitHub Actions). |
GINKO_BRANCH | release | The branch whose newest commit may release. Default: main. |
GINKO_REMOTE | release | The git remote of that branch. Default: origin. |
A site with only file bricks needs none of them for ginko check. release and export always need GINKO_PLATFORM and GINKO_KEY. Two more GINKO_* names exist, but not for sites: GINKO_LOCAL belongs to the local platform of the Ginko monorepo, and GINKO_REGISTRY_URL to the build of the Ginko component registry (see Deploy).
Output and exit codes
Every run prints exactly one JSON line on stdout. Messages for people go to stderr. Only --help and --version print plain text. --json is accepted for symmetry with ginko-i18n.
On success, the line starts with "ok":true, followed by the fields of the command:
| Command | Fields after "ok":true |
|---|---|
check | compared: true if the schema was compared with production |
release | deploy; with true also schemaRev and contentRev, with false also reason ("superseded") |
export | dir, entries, files |
On an error, the line is {"ok":false,"error":{"code":"…","message":"…"}}, and the exit code says what to do:
| Exit code | Meaning | What to do |
|---|---|---|
| 0 | Success. A superseded release also ends with 0. | Read deploy before you deploy. |
| 1 | Invalid or rejected: configuration, schema, content, or links. | Fix the code. Running again does not help. |
| 2 | Wrong call: unknown command or option, a missing or invalid environment variable, for example a GINKO_KEY in the wrong format or a GINKO_PLATFORM where no Ginko platform answers. | Fix the call or the CI settings. |
| 3 | The platform is not reachable, does not answer in time, sends an invalid answer, or the outcome is unknown. | Run the same commit again. This is safe. |
| 4 | The platform refuses the key: unknown, revoked, another site's, or missing the scope. Or something in front of the platform (deployment protection, a proxy) refuses the request: error.code is then HTTP_401 or HTTP_403. | Fix GINKO_KEY, or check GINKO_PLATFORM and the protection in front of it. |
error.code is USAGE, INVALID, UNAVAILABLE, UNEXPECTED (an error of the CLI itself, for example a folder it may not write), or the code of the platform's answer, for example SCHEMA_REJECTED, CHANGED_SINCE, UNAUTHORIZED or FORBIDDEN. Decide by the exit code and use error.code for logs: new codes can appear.
ginko check
ginko check
ginko check --offlineginko check writes nothing. It checks three things:
- Your
ginko.config.ts, with the same check asnuxt build. If the configuration is invalid, it lists the problems and stops. - The content files in
content/, their links, and thatseo.image(a path) exists inpublic/. - For a site with platform bricks: your schema against production. It shows the allowed changes, new migrations, and the size of the content. It reports every rejection with a hint on how to solve it. This step needs
GINKO_PLATFORMandGINKO_KEY.
With --offline, it runs steps 1 and 2 only and prints Skipped the comparison with production (--offline). Use this where the key is missing, for example for a pull request from a fork: GitHub does not pass secrets to it.
A site with only file bricks prints OK: schema and links are fine. when all checks pass. On a problem, ginko check exits with code 1. Use it in CI on every pull request.
ginko check asks external links (http://… and https://…) only if you set linkCheck: { external: 'error' } (or 'warn'). It requests each address once and reports only sure cases: 404, 410, an unknown host, or two timeouts. Answers such as 403 or 429 do not count, because many sites block bots. Ginko remembers good results for seven days in node_modules/.cache/ginko. The same check runs on nuxt build and nuxt generate, never in dev mode. Without the option, Ginko makes no network request.
ginko release
ginko releaseginko release brings schema and content to production. You run it in CI on main. It needs git and access to the remote. It does the following:
- It checks whether the release commit (
GINKO_COMMITorGITHUB_SHA) is still the newest commit ofmainonorigin(GINKO_BRANCHandGINKO_REMOTEchange both). If not, it prints{"ok":true,"deploy":false,"reason":"superseded"}and exits with code 0. - It loads the state of production and checks your schema (see below).
- It runs migrations and checks all entries.
- It writes everything in one step. If production changed in the meantime, it retries up to five times.
On success, it prints {"ok":true,"deploy":true,"schemaRev":…,"contentRev":…}. Repeating the same release with the same commit is safe.
A superseded release is not an error, so it exits with code 0. Deploy the site only when deploy is true:
# GitHub Actions runs this with bash -e: the exit codes 1 to 4 stop the job.
result=$(pnpm exec ginko release)
if [ "$(echo "$result" | jq -r .deploy)" = "true" ]; then
pnpm exec vercel deploy --prod # or the deploy command of your host
figinko export
ginko export ginko-export-my-siteginko export [folder] saves the schema, all entries (deleted ones too), and all files of production into a folder. The folder must be new or empty. Without a folder, it is called ginko-export-<site>-<yyyy-mm-dd>. It contains export.json and the folder files/. export.json is written last: a folder with this file is complete. The command prints {"ok":true,"dir":…,"entries":…,"files":…}. It needs a key with the scope export or pull. Run it weekly in CI and upload the folder as an artifact. Without the CLI, owners and admins download the same folder as a ZIP file in the Studio under Team, “Download backup”; unzipped, local:import reads it like an export.
ginko prepare-import
Are you moving a site from an old website to Ginko? Then fill a folder in the export format by hand or with a script: export.json with schema and entries, plus the folder files/. This is the same layout as ginko export. Images and components still look the way the old site knows them: as an address (https://…), with old tag names. ginko prepare-import turns this into a folder that Lupinum can load unchanged with ops:import.
ginko prepare-import migration --map map.json --config ginko.config.tsThe map file is JSON. All keys are optional:
{ "images": { "folder": "imported" },
"components": { "ginko-layout": "layout", "ginko-column": { "tag": "column", "attributes": { "size": "width" } } },
"redirects": { "/old/about-us": "pages/about-us", "/contact.html": "/contact" } }- Images: Every address with
httporhttpsin an image field ({ "url": … }or a text) and in Markdown () is downloaded (20 seconds, at most 20 MB, only JPEG, PNG and WebP, as for uploads in the Studio). Each image is stored once infiles/, even when several addresses return the same bytes. The text then holds$file.<sha256>, the image field{ "file": "<sha256>", "alt": … }. The images go in the file library folderimages.folder(defaultimported). An address that fails stays as it is. The command still finishes everything else, lists it, and ends with exit code 1. - Components:
componentsrenames tags (::old,:::old,:old) and, if you want, attributes inside{…}. Tags that the site'sdefineComponentsdoes not know are listed as a warning incomponents.unknown. This is not an error. - Redirects:
redirectsgives each old path a target:<brick>/<id>is the entry's address according to the brick'sroute. When the old path starts with a language (/en/…), the address in that language is used. A target that starts with/is used as it is. The result isredirects.jsonin the folder. Copy the list intoredirectsinginko.config.ts. An unknown entry is listed inredirects.problems.
export.json is changed in place. The command first writes export.json.bak. A second run changes nothing and downloads nothing again. With --config (or a ginko.config.ts in the current folder) the command knows the site's routes and components. It prints {"ok":…,"images":{"downloaded":…,"reused":…,"failed":[…]},"components":{"renamed":…,"unknown":[…]},"redirects":{"count":…,"file":…,"problems":[…]}}.
Then send the folder to Lupinum. We load it into the site with ops:import.
Developing a platform site
Outside the Ginko monorepo, there is no local platform. Run your site against the hosted platform instead. Write its address into ginko.config.ts, so npm run dev works without an .env file, in every clone and worktree:
export default defineSite({
site: 'linde',
url: 'https://linde.example',
platform: 'https://platform.example.com',
// …
})NUXT_PUBLIC_GINKO_PLATFORM overrides platform, for example for one run against another platform. pnpm local:dev in the monorepo sets it to the local platform, so a local test never reads the hosted content. Without platform and the variable, dev reads the local platform on http://localhost:3100.
The site shows the published content. Your local ginko.config.ts decides what the pages get:
- A new brick is empty.
- A new field with a
defaulthas its default value. - A new optional field has no value.
Owners can fill new fields only after ginko release. Run ginko check to see whether production accepts your schema changes.
Tools of the Ginko monorepo
The Ginko repository has four more commands. They need the local platform (apps/platform and its backend in packages/ginko-server), so they are not part of the published ginko command. Run them from the root of the repository. local:dev needs the site's config with --config:
pnpm local:dev --config sites/restaurant/ginko.config.ts
pnpm local:pull
pnpm local:import ginko-export-my-siteThey print one JSON line and use the exit codes of ginko.
local:dev starts everything locally: a local Convex backend, the platform, and your site with nuxt dev. It sends your schema to the local platform, right away and after every change of ginko.config.ts. Rejected changes appear as a warning but go through. So you can experiment freely locally. If a file that the configuration imports changes, you must restart local:dev. Ctrl-C stops all processes. For a site with only file bricks, nuxt dev is enough.
Once you have signed in to the local platform, local:dev writes .mcp.json into the site folder (server ginko-local). An agent such as Claude Code running in that folder then works in the local CMS as an admin: it reads the schema, writes content, imports and uploads files. Other servers in the file stay, the key is the same on every start and works only locally. Add .mcp.json to .gitignore.
local:pull copies the content of production into the local platform. It runs export, then import. So you test with real content. The command needs GINKO_PLATFORM and GINKO_KEY with the scope pull or export. It replaces the content of the site locally.
local:import <folder> loads an export into the local platform. It replaces the entries of the site there. People are kept. Use it to check that a backup can be restored.
local:files-export --config <file> [folder] turns the Markdown and YAML files of a site into an export folder (default ginko-export-<site>-files). It reads content/<brick>/ next to the config for every brick, whatever its source is, with the same reader as the site build, and keeps translations (*.en.md). Then local:import <folder> loads the folder, and the Studio shows the file content. Use it to see a site's files in the platform before you move its bricks there. Images stay plain paths; no files are uploaded.
pnpm ops:import <folder> --site <slug> --prod is for Lupinum operators. It loads an export folder into an empty site of the hosted platform and needs --prod and Convex access to production. Run ginko release for the site first: the command does not push a schema, and it stops if the released schema lacks a brick of the export. It refuses a site that already has entries, changes or edits, and it never deletes content.
local:pull and local:import run only against the local platform and stop before they write anything if the target is not local.
Schemas only grow
A schema in production may only grow. Content belongs to people, and older versions of your site still read the old schema. So ginko check and ginko release reject changes that could break data or old code. The rule: everything that is not explicitly allowed is rejected.
Allowed:
- new bricks; in a new single, every required field needs a
default.f.markdown,f.imageandf.filecannot have one, so setoptional: trueon them - new optional fields
- new required fields with a
default, or with a migration that fills them - new options in
choiceandchoices - a higher
maxand a lowermin - new texts:
label,hint,describe,itemLabel, option labels - new locales at the end of
locales - changes to
pages,tasks,order,visible, anduntranslated - changes to
components: the release checks every stored text again, so a component, prop or option that a text still uses cannot go
Rejected:
- a field or brick that is missing (do not rename, do not delete)
- a different type
- an optional field that becomes required
- a removed option
- a lower
max, a highermin - a changed translatability,
step,currency,route,timeZone, or main locale - a removed locale
- a changed
default
So there is no rename and no type change. To replace a field, you add a new one, fill it with a migration, and retire the old one. For each rejected change, ginko check prints these config lines with your brick and field names filled in: copy them, then set the migration ID (YYYY-MM) and convert the value where the type changes.
export default defineSite({
// …
bricks: {
menu: collection({
label: 'Menu',
fields: {
headline: f.text('Headline'), // stays unchanged in fields
title: f.text('Title'), // new required field
},
max: 150,
order: 'manual',
}),
},
// The old field keeps showing the current value of title in the bundle.
retired: { 'menu.headline': { from: 'title' } },
migrations: {
'2026-11-title-from-headline': {
brick: 'menu',
fills: 'title',
compute: (entry) => entry.headline,
},
},
})retiredretires a field. It disappears from the editor. It stays in the bundle, so older versions of the site keep working.frommirrors the value of another field; it works only when every value of that field fits the old definition (same type, translatability, format and currency, limits and options within the old ones). Withvalue, you set a fixed value. An optional field can also go without a value:{ drop: true }. A whole brick is retired withretired: { gallery: { drop: true } }.- A migration is a pure function. It has no access to the network, the clock, or randomness. It runs in
ginko releaseon your machine or in CI, never on the platform. Its ID looks like2026-11-name. You must not change a known migration.
ginko check first. If you really want to replace a field, plan the three steps (new field, migration, retire the old field) into one release.Bricks with source: 'files' are not part of the schema of the platform. This rule does not apply to them.
Internal, do not use
Ginko uses these names inside the module. They are not part of the API and can change in any release, also in a patch. Read content and site data through the composables and components in Reading content instead.
useStatekeys that start withginko:, such asginko:bundle,ginko:todayandginko:preview.- The
providekeysginko:componentsandginko:toc. - The virtual modules
#ginko/*, such as#ginko/schemaand#ginko/files. - The
{ ref }form of a Markdown value. Ginko keeps long texts out of the page payload, for file and platform bricks, and<GinkoMarkdown>loads them. Pass the value on as it is; never read or build a{ ref }.wordsOf(value)gives you its number of words. - The
GinkoCardcomponent, the template of the generated share card. Set the card throughuseGinkoSeo. - The values in
runtimeConfig.public.ginkoandruntimeConfig.ginkoRevalidateToken. Read site data withuseGinkoSite(), and set only the environment variables in Publishing. - The headers
x-ginko-rev,x-ginko-min-revandx-ginko-site-token, and the messages between the preview and the platform (ginko:preview,ginko:ready,ginko:navigate). - Addresses under
/__ginko/other than the stable ones in Addresses and headers. - The Nuxt hook
ginko:files.@lupinum/ginko-docsuses it to keep each entry's source file for its edit links.