Pages and sections
shipyard.trian.space/docs/nomi
Nomi integration
Instructions for the coding agent implementing Nomi's Shipyard integration: org provisioning, dev databases, secrets, publish, environments, webhooks and termination.
This page is written for the coding agent that implements Nomi's side of the Shipyard integration. Read all of it before writing code. Every request and response shape here matches the server; when this page and your assumptions disagree, this page wins.
Nomi is an agent orchestrator that builds web apps (SvelteKit, Next.js, React, Vue; Postgres with Drizzle). Shipyard is where those apps are deployed and published. Nomi does development in its own containers; Shipyard provides the development database, runs published environments, owns their web addresses, and reports everything back through webhooks.
| Item | Value |
|---|---|
| Base URL | https://<shipyard>/openapi/v1/integration (also reachable as /integration, /integrations and the site root; use the first) |
| Auth | Authorization: Bearer ship_int_<64 hex> (see Credentials) |
| Content type | application/json, except publish (multipart/form-data) |
| Response envelope | { "data": ..., "error": null } or { "data": null, "error": { "code", "message" } } |
#Quick start for the coding agent
If you read nothing else, this is the whole integration in order:
PUT /orgs/{org}when Nomi creates an organization.PUT /orgs/{org}/apps/{app}with"dev_database": truewhen a project is created. Keep the two*_url_refvalues.- Server-side only: reveal
app_url_ref, set it asDATABASE_URLin the dev container. - On publish: zip the source, build the manifest,
POST .../envs/{env}/publish. You get202and apublish_id. The app is not live yet. - Wait for the
publish.succeededwebhook (or pollGET .../publishes/{publish_id}every 3 s). Only then show the userurls[0]. - If it failed,
GET .../deployments/{deployment_id}returnsfailurewith the step, phase, message and log tail. Fix the code, publish again. See the fix loop. - Nomi picks each environment's full hostname (e.g.
todo.nomi.app) and sends it asdomain.hostnamein the manifest. User wants another address:GET /orgs/{org}/domains/check?hostname=...as they type, thenPUT .../envs/{env}/domain. See Choosing a domain. POST .../duplicatefor another environment,DELETE .../apps/{app}to terminate.
Before you ship, run the readiness checker against a real Shipyard (it creates and deletes a throwaway org): python3 scripts/check-nomi-integration.py in the Shipyard repo. See Verifying a Shipyard.
#Credentials
Three kinds of credential work on this API. Each only reaches the organizations it owns.
| Credential | Reaches | Can do |
|---|---|---|
Integration key ship_int_... | Orgs created through it (PUT /orgs/{org}), plus orgs a superadmin linked to the integration | Everything in this page, within its scopes (manage, secrets:reveal) |
Org API key ship_... (created in the dashboard) | Only its own org | Same routes by manage-equivalent scopes: needs write or admin. Cannot create orgs, change plans, manage webhooks or delete the org |
| User access token (JWT) | Orgs where the user is a member | Same as an org key by role. Cannot create orgs, change plans, manage webhooks or delete the org |
Use an integration key for everything Nomi's server does. Integration keys never see an org they did not create, so an org that already existed in Shipyard (made in the dashboard) is invisible until a superadmin links it:
curl -X PUT https://<shipyard>/api/admin/integrations/<integration_id>/orgs/<org_uuid> \
-H "Authorization: Bearer <admin session token>" -H "Content-Type: application/json" \
-d '{"external_id":"org_2Nf8sK"}' # your id for it; DELETE the same path to unlink#Rules
Follow these without exception.
- Secret values never enter a model's context. Not in prompts, tool results, logs shown to the agent, or files the agent can read. The agent works with secret refs (
sec_...) and env var names only. - Use two keys. The key available to agent tools has only the
managescope. Only Nomi's server-side code that injects env vars into dev containers, or shows a user their own database login, holds a key withsecrets:reveal. Reveal calls are audit-logged. - Creating orgs, apps, dev databases and secrets is an idempotent
PUTkeyed by Nomi's own ids: retry freely on network errors and 5xx. Publish and duplicate arePOSTs; before retrying one, check the environment's last publish (GET .../envs/{env}). - Verify every webhook signature and treat delivery as at-least-once and unordered: dedupe on the event
id, order bycreated_at. - Prefer webhooks to polling. If you must poll, poll the publish (
GET .../publishes/{id}) no faster than every 3 seconds. - Don't send
node_modules,.git, build output or.envfiles in the source zip. Shipyard builds from source. - Accepted is not live.
202from publish or duplicate means the build started. Never show the user a link, or tell them the app is up, beforepublish.succeeded(orstatus: "succeeded"andlive: trueon the environment). Before then the URL returns a Shipyard 502/404 page. - Never invent a URL. Use
urlsfrom a succeeded publish/deployment. Failed and running deployments have none. - A failed publish does not take the previous version down. The old release keeps serving until a new one succeeds, so a failure is safe to retry.
#How Nomi maps to Shipyard
| Nomi | Shipyard | Identifier you use |
|---|---|---|
| Organization (or personal workspace) | Organization | Your org id, as {org} (external_id) |
| Project | App | A slug you choose, as {app} (a-z0-9-, max 63) |
| Development | Dev database on a shared Postgres server | (per app) |
| Published app | Environment: one Shipyard project per environment | {env}: production, staging, preview-123, ... |
| Secrets the user enters | Secret store, app-wide or per environment | Env var name, e.g. STRIPE_KEY |
Nomi decides each environment's address and sends it as a full hostname (domain.hostname, e.g. todo.nomi.app or todo-staging.nomi.app). Because Shipyard stores the whole domain, not just a label under its own base domain, a Nomi admin can move apps to a different domain at any time (todo.nomi.app → todo.nomi.dev) without any Shipyard config change. The hostname's DNS must point at this Shipyard's Traefik (a wildcard record per domain you use); TLS is issued automatically.
If the manifest has no domain.hostname, Shipyard falls back to the legacy behavior: production gets https://<app>.<apps-domain>, other environments https://<app>-<env>.<apps-domain>, with a random suffix when taken. Either way, show the URL Shipyard returns. To let a user pick their own name, see Choosing a domain.
#Setup
The Shipyard operator configures, in /opt/shipyard/.env:
# Optional fallback: apps published without domain.hostname get <app>.<this> and <app>-<env>.<this>;
# needs a wildcard DNS record to Traefik. Not needed when Nomi always sends domain.hostname.
SHIPYARD__INTEGRATION__APPS_BASE_DOMAIN=apps.example.dev
# Postgres server for dev databases; the role needs only CREATEDB and CREATEROLE.
SHIPYARD__INTEGRATION__DEV_DB_ADMIN_URL=postgres://devdb_admin:[email protected]:5432/postgres
# How dev containers reach that server, if different from the admin URL.
SHIPYARD__INTEGRATION__DEV_DB_PUBLIC_HOST=devdb.example.dev
SHIPYARD__INTEGRATION__DEV_DB_PUBLIC_PORT=5432
SHIPYARD__INTEGRATION__DEV_DB_SSLMODE=require
# Tenants must not reach each other (see Guide → Tenant isolation).
SHIPYARD__DOCKER__TENANT_ISOLATION=trueThen a Shipyard superadmin creates the integration and its keys once, in the dashboard API (session auth):
# create the integration
curl -X POST https://<shipyard>/api/admin/integrations \
-H "Authorization: Bearer <admin session token>" -H "Content-Type: application/json" \
-d '{"name":"nomi"}'
# key for agent tools and normal operations
curl -X POST https://<shipyard>/api/admin/integrations/<integration_id>/keys \
-H "Authorization: Bearer <admin session token>" -H "Content-Type: application/json" \
-d '{"name":"nomi-agent","scopes":["manage"]}'
# key for the server code that injects secrets
curl -X POST https://<shipyard>/api/admin/integrations/<integration_id>/keys \
-H "Authorization: Bearer <admin session token>" -H "Content-Type: application/json" \
-d '{"name":"nomi-injector","scopes":["manage","secrets:reveal"]}'The full key is returned once. Store both in Nomi's server environment:
SHIPYARD_URL=https://<shipyard>/openapi/v1/integration
SHIPYARD_KEY=ship_int_... # manage
SHIPYARD_REVEAL_KEY=ship_int_... # manage + secrets:reveal, server-only
SHIPYARD_WEBHOOK_SECRET=whsec_... # from POST /webhooks, see belowThen register Nomi's webhook endpoint once (see Webhooks).
#1. A user creates an organization
When an organization (or a user's personal workspace) is created in Nomi, create it in Shipyard in the same request handler. Shipyard returns 201 the first time and 200 after, with the same org.
#PUT /openapi/v1/integration/orgs/{org}
// request
{ "name": "Ana's Workspace", "plan": "free", "labels": { "nomi.app/workspace": "personal" } }
// response 201
{ "data": {
"id": "01a1...", "external_id": "org_2Nf8sK", "name": "Ana's Workspace",
"slug": "ana-s-workspace-k3x9qa", "plan": "free", "created_at": "...",
"integration": "nomi",
"labels": { "shipyard.io/integration": "nomi", "nomi.app/workspace": "personal" },
"created": true
} }plan is optional (free on create). Send it again to change the plan. Renames: send the new name. labels is optional; see Labels and versions. integration is the integration that created the org, and the Shipyard dashboard shows it as a badge on the org.
#GET /openapi/v1/integration/orgs/{org}
Returns the org and its apps with their environments.
#2. A user creates a project and starts building
Create the app, with its dev database if the project needs Postgres:
#PUT /openapi/v1/integration/orgs/{org}/apps/{app}
// request
{ "name": "Todo", "dev_database": true }
// response 201
{ "data": {
"key": "todo", "name": "Todo", "created": true,
"dev_database": {
"database": "dev_01a1...", "host": "devdb.example.dev", "port": 5432,
"app_role": "dev_01a1..._app", "owner_role": "dev_01a1..._owner",
"app_url_ref": "sec_61490a48...", "owner_url_ref": "sec_b76e4154...",
"created": true
}
} }You can also create or fetch the dev database on its own with PUT / GET .../apps/{app}/dev-database, and drop it with DELETE.
Wiring the dev container. In the code that starts the dev container (not in agent tools):
- Reveal
app_url_refwith the reveal key:POST /secrets/{app_url_ref}/revealreturns{ "value": "postgres://..." }. - Set it as
DATABASE_URLin the container's environment. - Tell the agent only that
DATABASE_URLis set. Drizzle readsprocess.env.DATABASE_URL;drizzle-kit pushormigratework against it.
The user's own login. owner_url_ref is the user's credential for their database client. Reveal it only in response to the user's own request in Nomi's UI, and show it only to them. The owner role can read and change everything the app creates.
Each app's database is private: other apps' roles can't connect to it. The app role has a 20-connection limit; use a pool of 5 or fewer in dev.
#3. A user publishes
When the user clicks publish, send the source and a manifest. Shipyard builds it, provisions what the manifest asks for (database, volumes, domain, generated secrets) and deploys, database first.
#POST /openapi/v1/integration/orgs/{org}/apps/{app}/envs/{env}/publish
multipart/form-data with two parts:
| Part | Content |
|---|---|
manifest | JSON, below |
source | Zip of the project (optional: omit it to re-apply a changed manifest to the last upload) |
curl -X POST "$SHIPYARD_URL/orgs/org_2Nf8sK/apps/todo/envs/production/publish" \
-H "Authorization: Bearer $SHIPYARD_KEY" \
-F "manifest=<manifest.json" -F "[email protected]"Response 202:
{ "data": {
"publish_id": "01a1...", "app": "todo", "environment": "production",
"project_id": "01a1...", "status": "running",
"services": { "web": "01a1..." },
"url": "https://todo.apps.example.dev",
"warnings": []
} }The environment is created on first publish. url is the address reserved for it, and live is false: nothing answers there until publish.succeeded arrives. Treat url as a name, not a link, until then.
A publish is refused with 403 if the org's billing state doesn't allow deploys (canceled or past due), and with 400 for an invalid manifest, a missing secret, or a zip that doesn't contain the service path.
#The manifest
{
"services": [
{
"name": "web",
"kind": "web",
"path": ".",
"port": 3000,
"replicas": 1,
"env": {
"NODE_ENV": "production",
"ORIGIN": "https://todo.apps.example.dev",
"SESSION_SECRET": { "generate": "hex32" },
"STRIPE_KEY": { "secret": "STRIPE_KEY" },
"DATABASE_URL": { "database": "url" }
},
"volumes": [{ "path": "/app/uploads" }]
}
],
"database": { "engine": "postgres" },
"domain": { "hostname": "todo.apps.example.dev" },
"labels": { "nomi.app/template": "nextjs" }
}| Field | Meaning |
|---|---|
services[].name | a-z0-9-, unique, not db. Keep it stable: same name = same service, updated in place |
services[].kind | web (default; serves HTTP on port, gets the URL) or worker |
services[].path | Directory in the zip for this service (monorepos). Default . |
services[].dockerfile | Dockerfile relative to path. Omit to use Dockerfile, or auto-detection (SvelteKit, Next.js, Nuxt, Angular, TanStack Start) when there is none |
services[].port | Port the app listens on. Required for web |
services[].replicas | 1–20, capped by the plan |
services[].cpu, memory_mb | Optional limits (free plan caps at 0.5 CPU / 512 MB) |
services[].env | The service's complete env. Keys not listed are removed |
services[].volumes | Paths whose data survives deploys |
database | { "engine": "postgres" } gives the environment its own Postgres with a persistent volume |
domain.hostname | Recommended. The environment's full hostname, e.g. todo.nomi.app (lowercased, trailing dot dropped). Use a different one per environment (todo-staging.nomi.app). It is used exactly: no suffix is added, and if another app already uses it the publish fails with 409. Sending a different hostname on a later publish moves the environment to it (new name attached first, old one removed), so changing your domain is just publishing with the new one. Its DNS must point at Shipyard. Can't be combined with domain.subdomain |
domain.subdomain | Legacy, needs APPS_BASE_DOMAIN. First label under the apps base domain. Default <app> (production) or <app>-<env>. If the name is already used by another environment, a non-production environment gets <subdomain>-<env> instead (todo → todo-staging) |
domain.service | Which web service gets the URL. Default: the first web service |
labels | Labels for the environment's project. See Labels and versions |
services[].labels | Labels for that service |
Env values:
| Value | Result |
|---|---|
"text" | Plain value |
{ "generate": "hex32" } | Generated once per environment, then kept. Kinds: password, hex32, base64_32, uuid |
{ "secret": "STRIPE_KEY" } | A secret set with PUT .../secrets/STRIPE_KEY. Publish fails listing missing secrets |
{ "database": "url" } | From the environment's database: url, host, port, user, password, name |
Unknown fields are rejected (a typo fails loudly instead of being ignored).
#Preparing the source
- Listen on
0.0.0.0and the manifestport. ReadPORTif your framework does. - SvelteKit: use
@sveltejs/adapter-node; setORIGINto the URL. Build output runs withnode build. - Next.js:
output: "standalone"innext.config; runnode server.js. - Static SPA (React, Vue): a Dockerfile that builds and serves with a static server on the port.
- Migrations: run them on start, before listening. With Drizzle, call
migrate(db, { migrationsFolder: "./drizzle" })fromdrizzle-orm/node-postgres/migratorin the server entry, and commit the generateddrizzle/folder. Don't usedrizzle-kit pushin production. - Health: respond
200on/or a cheap route within a few seconds of starting.
A Dockerfile that works for SvelteKit (adapter-node):
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
FROM node:22-alpine
WORKDIR /app
COPY --from=build /app/build ./build
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./
COPY --from=build /app/drizzle ./drizzle
ENV PORT=3000
EXPOSE 3000
CMD ["node", "build"]#Following a publish
Webhooks tell you everything: publish.started, then for each service deployment.started, deployment.step_started / deployment.step_succeeded / deployment.step_failed, deployment.succeeded or deployment.failed, then publish.succeeded (with urls) or publish.failed (with error).
publish.succeeded means every service is running, not just deployed. Show the user the URL from it.
Status vocabulary (everywhere in this API): publish and deployment status is running, succeeded or failed. Shipyard's internal value is kept in raw_status for debugging; branch on status. Step status is succeeded, failed, running, pending or skipped (steps after a failed one are skipped). Each step has a phase: validate, build, configure, deploy or finalize.
#GET /openapi/v1/integration/orgs/{org}/apps/{app}/envs/{env}/publishes/{publish_id}
{ "data": {
"publish_id": "01a1...", "status": "succeeded",
"urls": ["https://todo.apps.example.dev"], "error": null,
"deployments": [
{ "service": "db", "deployment_id": "01a1...", "status": "succeeded", "raw_status": "success" },
{ "service": "web", "deployment_id": "01a1...", "status": "succeeded", "raw_status": "success" }
],
"created_at": "...", "finished_at": "..."
} }On failure, error names the service and the cause; fetch that deployment (below) for the step, phase and log tail. Other lookups:
GET /orgs/{org}/publishes/{publish_id}: same body, when you only have the id.GET /orgs/{org}/apps/{app}/publishes?limit=20: recent publishes, newest first.
#Fast static publish (zero Docker overhead)
When Nomi builds a purely static frontend (Vite React/Vue, SvelteKit static export, Next.js static export, Astro, HTML/JS), use the dedicated static publish endpoint. It runs with zero Docker overhead, serves instantly from Shipyard's serving layer, provides an immutable preview URL for every build, atomically updates the requested alias (e.g. production or an env name), and can optionally bind a custom domain with automated Let's Encrypt TLS and Cloudflare DNS management.
#POST /openapi/v1/integration/orgs/{org}/static-publish
multipart/form-data:
| Part | Description | Required | Default |
|---|---|---|---|
artifact | Zip archive containing built static files (index.html, assets, etc.) | Yes | - |
project_slug | URL-safe slug for the project (e.g. todo-client or your app key) | Yes | - |
name | Human-readable name for the static project | No | Same as project_slug |
alias | Alias to point to this deployment (e.g. production, staging, preview-1) | No | "production" |
domain | Custom domain to bind to this project/alias (e.g. app.example.com) | No | - |
spa_fallback | Whether unknown routes fall back to /index.html (for client-side routing) | No | true |
entry_file | Primary entry file to serve and verify | No | "index.html" |
metadata | JSON object of arbitrary metadata (agent ID, prompt, commit) | No | {"source": "nomi"} |
curl -X POST "$SHIPYARD_URL/orgs/org_2Nf8sK/static-publish" \
-H "Authorization: Bearer $SHIPYARD_KEY" \
-F "project_slug=todo-client" \
-F "alias=production" \
-F "domain=todo.example.com" \
-F "spa_fallback=true" \
-F "[email protected]"Response 200:
{
"success": true,
"data": {
"project_id": "0193309a-4f5b-7b00-8cb5-123456789abc",
"project_slug": "todo-client",
"deployment_id": "0193309a-5001-7c00-9abc-987654321def",
"status": "READY",
"preview_url": "/dpl/0193309a-5001-7c00-9abc-987654321def",
"alias": "production",
"alias_url": "/alias/0193309a-4f5b-7b00-8cb5-123456789abc/production",
"domain": "todo.example.com",
"domain_url": "https://todo.example.com",
"archive_size_bytes": 1048576,
"extracted_size_bytes": 3145728,
"file_count": 28,
"checksum_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}
}- Immediate Serving: The status is
READYimmediately upon response. There is no container start or build delay. - Custom Domain & Auto-SSL: When
domainis passed, Shipyard writes a Traefik router bindingHost(domain)with automatic Let's Encrypt TLS. If the organization has a connected Cloudflare account, the DNSArecord pointing to Shipyard is created automatically without manual steps. - Auto-Unwrap: If the zip wraps everything inside a single top-level folder like
dist/orbuild/, Shipyard automatically unwraps it. - Immutable Previews:
preview_url(/dpl/{deployment_id}) is immutable and forever pinned to this specific build. - Zero-Downtime Aliases:
alias_url(/alias/{project_id}/{alias}) atomically switches to the new deployment with zero downtime. - Cache Policy: HTML and aliases are served with
Cache-Control: no-cache, no-store, must-revalidate. Content-hashed assets (e.g.main-a1b2c3d4.js) are served withCache-Control: public, max-age=31536000, immutable.
#4. Environments
Each environment is independent: its own Shipyard project with its own services, database, generated secrets, volumes and URL. Publish to any {env} name (production, staging, dev, preview-123, ...) to create it. You can publish the same manifest and source to each one, and publishing one environment never touches another. To start one from an existing environment:
#POST /openapi/v1/integration/orgs/{org}/apps/{app}/envs/{env}/duplicate
// request: copy production into staging
{ "to": "staging", "copy_secrets": true }The copy uses the source environment's last manifest and source, gets an empty database, newly generated secrets and its own URL, and deploys. copy_secrets copies environment-specific user secrets; app-wide secrets are shared anyway. Response: same as publish (202).
#GET /openapi/v1/integration/orgs/{org}/apps/{app}/envs/{env}
Services with status, running replica count, URLs, last deployment, version and labels, plus the environment's labels and the last publish.
{ "data": {
"environment": "production", "project_id": "01b2...",
"labels": { "shipyard.io/integration": "nomi", "shipyard.io/app": "todo", "shipyard.io/environment": "production" },
"services": [{
"name": "web", "status": "running", "live": true, "urls": ["https://todo.apps.example.dev"],
"version": "3f2a91c",
"labels": { "shipyard.io/integration": "nomi", "shipyard.io/app": "todo",
"shipyard.io/environment": "production", "shipyard.io/version": "3f2a91c" },
"...": "..."
}],
"last_publish": { "...": "..." }
} }#DELETE /openapi/v1/integration/orgs/{org}/apps/{app}/envs/{env}
Removes the environment: services, database and its data, volumes, domains and DNS records.
#Labels and versions
Orgs, environments (projects) and services carry key/value labels. The Shipyard dashboard shows them as tags. The integration badge shows which platform manages an object, so support staff can tell a Nomi org from one created in the dashboard.
Labels Shipyard sets. Keys under shipyard.io/ are written by Shipyard and are read-only for you. If you send one, the request is refused with 400.
| Key | On | Value |
|---|---|---|
shipyard.io/integration | org, project, service | The integration that manages it, e.g. nomi |
shipyard.io/app | project, service | The {app} key |
shipyard.io/environment | project, service | The {env} name |
shipyard.io/version | service | The tag of the image the service runs, set after each successful deploy |
Version. Every git or publish build is tagged with the short commit sha of the source it built (the same shipyard/<service>:<sha> tag shown in the deploy log). When the deploy succeeds, that tag becomes the service's shipyard.io/version, and GET .../envs/{env} returns it as version. A failed deploy leaves the previous version in place, matching what is actually serving. Services that run a registry image get the image's tag (1.4.2, latest). Static sites get the commit sha.
Your own labels. Send labels on PUT /orgs/{org}, at the top level of the manifest (for the environment's project) and on each manifest service. Use them for anything Nomi wants to see next to the app in Shipyard, such as a template name, a Nomi build number or a release name.
{ "labels": { "nomi.app/template": "nextjs", "nomi.app/release": "spring-launch", "old-label": null } }- Labels are merged: keys you send are set, keys you leave out stay, and a
nullvalue removes that key. - Keys: 1–63 characters of
a-z 0-9 . / _ -. Prefix them withnomi.app/so they never clash with another integration's labels. - Values: up to 256 characters. At most 32 labels per request.
#Choosing a domain
Every environment gets its address on its first publish: domain.hostname from the manifest, or the legacy <name>.<apps-domain>. When a user wants a different name, use these endpoints. They take a full hostname, so the new name can be on any domain you point at Shipyard. Hostnames are shared across the whole platform, so one another app already uses is refused, and Shipyard suggests free ones nearby (same parent domain, different first label).
Each endpoint still accepts the legacy subdomain (a label under the apps base domain) instead of hostname; send one or the other, never both.
Typing flow in Nomi: call check as the user types (debounced), show the answer, and call change when they confirm.
#GET /openapi/v1/integration/orgs/{org}/domains/check?hostname=my-shop.nomi.app
Optional app and env: with them, the environment's own current name counts as available (so re-confirming the current name isn't an error).
// free
{ "data": { "hostname": "my-shop.nomi.app", "subdomain": null, "available": true, "suggestions": [] } }
// already used by someone else
{ "data": {
"hostname": "todo.nomi.app", "subdomain": null, "available": false,
"reason": "taken", "message": "'todo.nomi.app' is already in use",
"suggestions": [
{ "subdomain": "todo-app", "hostname": "todo-app.nomi.app" },
{ "subdomain": "my-todo", "hostname": "my-todo.nomi.app" },
{ "subdomain": "todo-hq", "hostname": "todo-hq.nomi.app" }
] } }
// not a valid hostname
{ "data": { "hostname": null, "input": "ab_c.nomi.app", "available": false,
"reason": "invalid", "message": "Use only letters a-z, digits 0-9, '-' and '.'", "suggestions": [ ... ] } }The answer is 200 in every case; read available. Input is lowercased and trimmed (a trailing dot is dropped). reason is taken or invalid. A valid hostname has at least two labels, each 1–63 characters of a-z 0-9 - (not starting or ending with -), at most 253 characters in all, and is not an IP address or a wildcard. Directly under the apps base domain the stricter label rules still apply (3+ characters, reserved names such as www, api, admin, mail, status or anything starting preview- are refused), and the base domain itself can't be used. subdomain in the answer is the first label when the hostname sits directly under the apps base domain, else null. Suggestions are checked against the platform at the time of the call, but a name can still be taken a moment later, so handle 409 on change.
#PUT /openapi/v1/integration/orgs/{org}/apps/{app}/envs/{env}/domain
// request
{ "hostname": "my-shop.nomi.app" }
// response 200
{ "data": { "hostname": "my-shop.nomi.app", "url": "https://my-shop.nomi.app",
"previous_hostname": "todo.nomi.app", "changed": true } }- The new name is attached before the old one is removed, so the app stays reachable. The old name becomes free for others.
- TLS and the DNS record for the new name are set up the same way as on publish.
- Asking for the name the environment already has returns
200with"changed": false. - If the name is taken or invalid, nothing changes and you get
409, with the samesuggestionsunderdata:
{ "data": { "available": false, "reason": "taken", "suggestions": [ { "subdomain": "todo-app", "hostname": "todo-app.nomi.app" } ] },
"error": { "code": "CONFLICT", "message": "'todo.nomi.app' is already in use" } }- An environment that hasn't been published has no address yet: you get
409until the first publish. - The manifest's
domain.hostnamewins on the next publish: if it differs from the name you set here, the publish moves the environment back to it. After a change, update the hostname Nomi stores for that environment so the next manifest carries it. A manifest withoutdomain.hostnamekeeps the current name. - Moving every app to a new domain: point the new domain's DNS at Shipyard, then
PUT .../domain(or republish) each environment with its new hostname. No Shipyard setting has to change. - Emits
domain.attachedfor the new name anddomain.removedfor the old one.
#GET /openapi/v1/integration/orgs/{org}/apps/{app}/envs/{env}/domain
Returns { "hostname", "subdomain", "url" } for the environment's current address, or nulls when there is none yet. subdomain is only set when the hostname sits directly under the apps base domain.
Only the one address Nomi manages per environment is handled here. Extra domains a user adds in the Shipyard dashboard are left alone.
#Moving an app to another organization
When a project moves to another Nomi organization, move its Shipyard app too, so the two stay in step. Both organizations must belong to your integration (both created through it, or linked by a superadmin).
#POST /openapi/v1/integration/orgs/{org}/apps/{app}/transfer
// request
{ "to_org": "org_9Zk2", "app_key": "todo-2" } // app_key is optional
// response 200
{ "data": {
"app": "todo", "from_org": "org_2Nf8sK", "to_org": "org_9Zk2",
"environments": [ { "environment": "production", "project_id": "01a1...", "slug": "todo-production" } ],
"redeploying": [ { "service": "web", "deployment_id": "01a1..." } ],
"skipped": []
} }What moves, in one step: the app, all its environments (production, staging, ...), their services and data, secrets (app-wide and per environment), the dev database, publish history and the image registry namespace. Nothing is copied, so web addresses, volumes and databases stay as they are and URLs keep working.
What you should know:
- Containers restart once. Each container service is redeployed from the image it already runs so it joins the new org's private network. Watch
redeploying[].deployment_idlike any deployment. Static sites don't restart. A service with no successful deployment is listed inskipped. - Same app key. If the destination already has an app with this key you get
409; sendapp_keyto rename it on the way. - Slugs. If a project slug is in use in the destination, the moved project gets a short suffix. The environment name does not change.
- Refused with
409while a publish or deployment of the app is running (try again when it finishes).403when the destination's plan can't hold the app's environments, or its billing doesn't allow deploys.400ifto_orgis the app's current org,404if either org is unknown to your key. - Access. Integration key only. Project-level access that was given to members of the old org is removed; the new org's members and roles apply.
- Events.
app.transferredis emitted in both orgs (data:app,from_org,to_org,environments). After this callGET /orgs/{old}/apps/{app}returns404and the app answers under the new org. - Cloudflare. DNS records created for the app's hostnames stay in place. If the new org uses a different Cloudflare connection, manage those hostnames there.
#5. A user deletes a project or organization
#DELETE /openapi/v1/integration/orgs/{org}/apps/{app}
Terminates the app everywhere: every environment (workloads, data, volumes, domains, DNS records, source), the dev database and its roles, and all secrets. Irreversible: ask the user to confirm in Nomi first.
Answers 202 Accepted right away and runs the termination in the background, because stopping every environment can take longer than a proxy waits for a response:
{ "data": { "app": "todo", "status": "terminating", "environments": ["preview", "production"] } }Shipyard emits app.terminated when the termination finishes. Until then, GET /orgs/{org}/apps/{app} still returns the app. Poll it until it returns 404, or wait for the webhook. Sending DELETE again while the termination runs returns 202 again. If the termination fails partway, the app stays and the next DELETE resumes it.
#DELETE /openapi/v1/integration/orgs/{org}
Terminates every app of the org, then deletes the org. Answers 202 Accepted right away and runs the teardown in the background, because stopping every stack of a large org can take minutes:
{ "data": { "deleted": "acme", "status": "deleting", "apps_terminated": ["todo", "blog"] } }Shipyard emits org.deleted when the teardown finishes. Until then, GET /orgs/{org} still returns the org. Poll it until it returns 404, or wait for the webhook. Sending DELETE again while the teardown runs returns 202 again. If the teardown fails partway, the org stays and the next DELETE resumes it.
#Secrets
Users enter API keys and other secrets in Nomi's UI; Nomi's server stores them in Shipyard. The agent may name the secrets an app needs (STRIPE_KEY) and reference them in the manifest; it never sees or sets values.
#PUT /openapi/v1/integration/orgs/{org}/apps/{app}/secrets/{name}
// set a value (from the user, server-side only)
{ "value": "sk_live_...", "environment": "production" }
// or let Shipyard generate one
{ "generate": "hex32" }
// response: a ref, never the value
{ "data": { "ref": "sec_07db...", "name": "STRIPE_KEY", "environment": "production", "bound_env_vars_updated": 1 } }Omit environment for an app-wide secret; an environment-specific one wins over it. Changing a secret updates every env var bound to it; services pick it up on their next publish or restart.
#GET /openapi/v1/integration/orgs/{org}/apps/{app}/secrets
Names, refs, kinds, environments and where each is used (production/web:STRIPE_KEY). No values.
#DELETE /openapi/v1/integration/orgs/{org}/apps/{app}/secrets/{name}
?environment= for an environment-specific one. Refused while an env var uses it.
#POST /openapi/v1/integration/secrets/{ref}/reveal
Needs secrets:reveal. Returns { "ref", "name", "value" }. Server-side only; see Rules.
#Webhooks
#POST /openapi/v1/integration/webhooks
// request
{ "url": "https://nomi.example.com/hooks/shipyard", "events": ["*"] }
// response: the secret is shown once
{ "data": { "id": "01a1...", "url": "...", "events": ["*"], "org": null, "secret": "whsec_..." } }events takes exact types (deployment.failed), prefixes (deployment.*) or *. Add "org": "<org>" to limit an endpoint to one org. Manage with GET /webhooks, PATCH /webhooks/{id} (url, events, enabled), DELETE /webhooks/{id}, POST /webhooks/{id}/rotate-secret, and POST /webhooks/{id}/test (sends a signed ping and returns the result).
Each request is a POST with this JSON body:
{
"id": "01a12df4-...",
"type": "publish.succeeded",
"created_at": "2026-10-12T02:32:49.966835Z",
"org": { "id": "01a1...", "external_id": "org_2Nf8sK" },
"app": "todo",
"environment": "production",
"project_id": "01a1...",
"service_id": null,
"service": null,
"data": { "publish_id": "01a1...", "urls": ["https://todo.apps.example.dev"], "error": null }
}Headers: Shipyard-Event-Id, Shipyard-Event-Type, Shipyard-Delivery-Id, and Shipyard-Signature: t=<unix seconds>,v1=<hex>, where v1 = HMAC-SHA256(secret, "<t>.<raw body>").
Verify before parsing (Node):
import crypto from "node:crypto";
export function verifyShipyard(rawBody: Buffer, header: string, secret: string, toleranceSec = 300): boolean {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex");
const given = Buffer.from(parts.v1 ?? "", "hex");
const want = Buffer.from(expected, "hex");
return given.length === want.length && crypto.timingSafeEqual(given, want);
}Reply 2xx within 10 seconds; do slow work after replying. Anything else is retried after 10 s, 30 s, 2 min, 10 min, 30 min, 1 h, 2 h, 4 h, 8 h and 12 h, then marked failed. GET /webhooks/{id}/deliveries?status=failed lists failures; POST /deliveries/{id}/redeliver retries one.
#Event types
| Type | When | data |
|---|---|---|
org.created, org.deleted | Org provisioned / deleted | name, plan / apps_terminated |
app.created, app.terminated | App created / terminated | app, environments |
project.created, project.duplicated, project.deleted | Environment created / copied / removed | app, environment, from_environment |
publish.started, publish.succeeded, publish.failed | Publish lifecycle | publish_id, urls, error, deployments |
deployment.started, deployment.succeeded, deployment.failed | Per-service deploy | deployment_id, urls, image, error |
deployment.step_started, deployment.step_succeeded, deployment.step_failed | Build and deploy progress | deployment_id, step, error |
service.created, service.deleted, service.env_changed | Service changes | slug, type |
service.crashed | A container exited unexpectedly | container_id, replica, exit_code |
service.resource_alert | CPU or memory over threshold | metric, value, threshold |
service.error_rate_alert | 5xx error rate over threshold | error_rate, threshold, requests, errors_5xx |
domain.attached, domain.removed | Hostname added / removed | hostname, tls |
volume.created, volume.attached, volume.deleted | Volume changes | volume, mount_path |
network.created, network.attached, network.detached, network.deleted | Network changes | network_id |
dev_database.created, dev_database.deleted | Dev database | database, app_url_ref, owner_url_ref |
secret.created, secret.updated, secret.deleted | Secret store (never the value) | ref, name, environment |
database.provisioned | An environment's Postgres was created | service, host |
GET /event-types lists them. The same envelopes are available as a feed: GET /orgs/{org}/events?after=<event id>&type=deployment.*&app=todo&limit=100 returns { "events": [...], "next_after": "<id>" }, oldest first. Use it to catch up after downtime. Events are kept 30 days.
#Logs, deployments, usage and metrics
Show users failures in their own words; give the agent the same logs so it can fix the app. Nomi's Monitor sheet (MonitorPanel.svelte) displays live usage, structured runtime logs, and 7-day traffic and container resource history. Secret values are masked as **** in everything below.
#Checking a deployment
The same body comes from any of:
GET /orgs/{org}/apps/{app}/envs/{env}/deployments/{deployment_id}GET /orgs/{org}/deployments/{deployment_id}(no app/env needed)GET /deployments/{deployment_id}(the id alone; the key's orgs are searched)
Query: logs=false returns status and steps only; after=<timestamp> returns log lines after the previous call's next_after; logs_limit (default 2000, max 5000).
{ "data": {
"deployment_id": "01a1...", "service": "web", "status": "failed", "raw_status": "failed",
"image": null, "urls": [],
"failure": {
"step": "build_image", "phase": "build",
"message": "npm run build exited with code 1",
"tail": ["src/routes/+page.svelte:12:5 'x' is not defined", "error during build"]
},
"steps": [
{ "name": "clone_repo", "phase": "build", "status": "succeeded", "started_at": "...", "finished_at": "..." },
{ "name": "build_image", "phase": "build", "status": "failed", "error": "..." },
{ "name": "deploy", "phase": "deploy", "status": "skipped" }
],
"logs": [ { "level": "error", "message": "...", "at": "..." } ],
"next_after": "2026-10-16T09:00:00Z"
} }urls is present only when status is succeeded. failure is null unless it failed. Poll with after=next_after to stream a running build without re-reading the log.
GET .../envs/{env}/deployments?service=web&limit=20: recent deployments, newest first.GET .../envs/{env}: services withstatus,running/replicas,urls,liveand the last deployment.liveistrueonly when at least one replica is running and the service has a URL.
#Rollback and restart
POST .../envs/{env}/services/{service}/rollback, body optional{ "deployment_id": "..." }. Redeploys the image of an earlier successful deployment (default: the one before the current), without a rebuild, so it takes seconds. Database migrations are not undone. Calling it twice flips between the two newest releases. It returns400if there is nothing to return to.POST .../envs/{env}/restart: restarts every service with its current env (use after changing a secret). Returns the services restarted.GET .../envs/{env}/services/{service}/env: the service's env vars as[{ name, secret, secret_ref }]. Values are never returned.
#GET /openapi/v1/integration/orgs/{org}/apps/{app}/envs/{env}/services/{service}/logs
Runtime output of all replicas. Query: tail (default 200, max 5000), since (10m, 1h or RFC 3339). Returns { "service", "lines": [{ "at": "<RFC 3339>", "level": "info|warn|error", "message": "...", "service": "web" }] }. Levels are extracted from structured JSON output where available and infer stream/severity otherwise. Secret values are masked.
#GET /openapi/v1/integration/orgs/{org}/apps/{app}/envs/{env}/usage
Per running container: cpu_percent, memory_usage_bytes, memory_limit_bytes, network and block I/O, and configured limits. Containers on other nodes show stats: null.
#GET /openapi/v1/integration/orgs/{org}/apps/{app}/envs/{env}/metrics
Bucketed traffic and container resource history. Query: range (1h, 24h, 7d; default 24h), step (1m, 5m, 1h, 1d; default matched to range).
{
"data": {
"range": "24h",
"step": "1h",
"buckets": [
{
"at": "2026-10-26T09:00:00Z",
"visitors": 34,
"requests": 812,
"errors_5xx": 2,
"errors_4xx": 11,
"p50_ms": 96,
"p95_ms": 288,
"containers": [
{ "service": "web", "container_id": "…", "cpu_percent": 21.4, "memory_percent": 58.1, "memory_bytes": 312475648, "memory_limit_bytes": 536870912 }
]
}
],
"totals": { "visitors": 412, "requests": 9811, "errors_5xx": 38, "errors_4xx": 120, "p50_ms": 120, "p95_ms": 310 },
"previous_totals": { "visitors": 349, "requests": 8702, "errors_5xx": 41, "errors_4xx": 98, "p50_ms": 131, "p95_ms": 342 }
},
"error": null
}Unmeasured metrics return null. Historical metrics are retained for at least 7 days.
#The fix loop for coding agents
When a publish fails the agent should read the cause, fix the source and publish again. Do not retry blindly.
async function publishUntilLive(ctx, maxAttempts = 3) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const pub = await shipyard.publish(ctx); // 202
const result = await waitForPublish(pub.publish_id); // webhook, or poll every 3 s
if (result.status === "succeeded") return result.urls[0];
const failed = result.deployments.find((d) => d.status === "failed");
const dep = await shipyard.getDeployment(failed.deployment_id);
const { phase, step, message, tail } = dep.failure;
if (phase === "validate") await agent.fixManifest(message); // bad manifest, missing secret
else if (phase === "build") await agent.fixCode(message, tail); // compile/type/install errors
else if (phase === "configure") await agent.askUser(message); // secrets, limits, plan
else /* deploy, finalize */ {
// The image built but the app didn't come up: read runtime logs.
const logs = await shipyard.serviceLogs(ctx, failed.service, { tail: 200 });
await agent.fixRuntime(message, logs.lines);
}
}
throw new Error("giving up after 3 failed publishes");
}failure.phase | Usual cause | Agent action |
|---|---|---|
validate | Manifest rejected, secret not set, zip missing path | Fix the manifest or set the secret (ask the user for values) |
build | npm ci/build failed, no Dockerfile detected, wrong Node version | Fix source; read tail |
configure | Plan limit, invalid resources, domain taken | Ask the user or lower the request |
deploy | Container exits at start, port mismatch, migration error, health check fails | Read service logs; listen on 0.0.0.0:$PORT, check migrations |
#Framework recipes
Shipyard detects SvelteKit, Next.js, Nuxt, Angular and TanStack Start when there is no Dockerfile, and runs Node 22. If detection fails or you need control, ship a Dockerfile.
| Stack | Manifest service | Source requirements |
|---|---|---|
| SvelteKit (SSR) | { "name":"web", "kind":"web", "port":3000 } | @sveltejs/adapter-node; set ORIGIN to the public URL |
| SvelteKit (Static) | Use static-publish | @sveltejs/adapter-static; upload prebuilt build.zip directly |
| Next.js (SSR) | { "name":"web", "kind":"web", "port":3000 } | output: "standalone" |
| Next.js (Static export) | Use static-publish | output: "export"; upload prebuilt out.zip directly |
| Vite React/Vue (prebuilt static) | Use static-publish | npm run build outputs dist/; upload dist.zip for instant zero-container serving |
| Vite React/Vue (build on deploy) | { "name":"site", "kind":"static" } | npm run build outputs dist/; served by nginx with SPA fallback |
| Node API + worker | two services, one web, one worker | Worker has no URL or port |
kind: "static" builds the site in Docker and serves the output with nginx; the port is detected, so omit port.
Alternatively, if your agent or CI already runs the build, call POST /orgs/{org}/static-publish to upload the prebuilt zip directly: it skips Docker entirely, deploys in milliseconds, and provides immutable previews. A static site has no server env at runtime: bake public config at build time.
#Verifying a Shipyard
Before relying on a Shipyard instance, run the checker from the Shipyard repo. It uses only the Python standard library, creates a throwaway org, and removes it afterwards:
SHIPYARD_URL=https://<shipyard>/openapi/v1/integration \
SHIPYARD_KEY=ship_int_... \
python3 scripts/check-nomi-integration.py --reveal-key ship_int_... # reveal key optionalIt exercises auth, org and app creation, dev database (refs only), secrets, webhooks (with a test ping), publishing a static site and a container, rollback, restart, a deliberately failing build (checks the failure object and that the old version keeps serving), duplicate, terminate and org delete. It prints ok/FAIL per check and exits non-zero on failures. Publish checks need the instance to be able to build and pull images, so run it against a real deployment, not a bare dev stack.
#Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
404 NOT_FOUND on an org that exists | Org was created in the dashboard, not via this integration | Link it with the claim endpoint (Credentials) |
403 on PUT /orgs/{org} with an org key | Only integration keys create orgs | Use the integration key |
403 on reveal | Key lacks secrets:reveal | Use the reveal key, server-side only |
Publish 403 | Org billing is canceled or past due | Fix the plan; PUT /orgs/{org} with plan if you own billing |
| URL shows a Shipyard 502 after publish | You opened url before publish.succeeded | Wait for the webhook |
publish.failed at validate with "missing secret" | Manifest references { "secret": ... } that was never set | PUT .../secrets/{name} first |
| Container starts then exits | Not listening on 0.0.0.0:$PORT, or migration error | Read service logs |
| Webhook signature mismatch | Body was parsed before verifying | Verify against the raw bytes |
#Not available yet
These came up in design and are not built. Don't call routes for them; use the workaround.
| Feature | Status | Workaround |
|---|---|---|
| Promote a built image from staging to production without rebuilding | Not built | duplicate re-deploys from the same source (rebuilds); rollback only moves within one service |
| Preview environments that expire automatically | Not built | Delete the environment yourself (DELETE .../envs/{env}) when the preview closes |
| Sleep/wake of idle (free-plan) environments | Not built | Environments stay up; scale replicas to 0 via the dashboard if needed |
| Server-sent-event log follow | Not built | Poll logs?since= or the deployment after cursor |
| Members and login links (Nomi users opening the Shipyard dashboard) | Not built | Nomi is the interface; the superadmin can use the dashboard |
| Org suspend | Not built | Delete the org, or cancel its plan |
| Release command as a separate step | Not built | Run migrations on start |
#Errors and limits
| Status | error.code | Typical cause | What to do |
|---|---|---|---|
| 400 | BAD_REQUEST | Invalid manifest, missing secret, bad name | Fix the request; the message says what |
| 401 | UNAUTHORIZED | Missing, wrong or revoked key | Check SHIPYARD_KEY |
| 403 | FORBIDDEN | Missing scope, plan limit | Use the reveal key / upgrade the plan |
| 404 | NOT_FOUND | Unknown org, app, env, or one another integration owns | Create it first (PUT) |
| 409 | CONFLICT | Environment exists (duplicate), secret in use | Pick another name / unbind first |
| 5xx | INTERNAL_ERROR, DATABASE_ERROR, DOCKER_ERROR | Shipyard or Docker problem | Retry with backoff |
Limits: source zip 200 MB (configurable), 20 services per environment, 1–20 replicas within the plan, projects per org per the plan (each environment is a project).
#Implementation checklist
- Server env:
SHIPYARD_URL,SHIPYARD_KEY(manage),SHIPYARD_REVEAL_KEY(manage + reveal, never exposed to agent tools),SHIPYARD_WEBHOOK_SECRET. - Webhook route: raw body, signature check, dedupe on event
id, reply2xxfast, process async. - Org created in Nomi →
PUT /orgs/{org}. Org renamed → same call. Org deleted →DELETE /orgs/{org}after user confirmation. - Project created →
PUT /orgs/{org}/apps/{app}withdev_databasewhen the stack uses Postgres. - Dev container start → reveal
app_url_refserver-side →DATABASE_URLin the container env. Agent sees the name only. - "Database credentials" button → reveal
owner_url_reffor that user only. - Secrets form →
PUT .../secrets/{name}from the server; agent tools can list names and refs, never values. - Publish button → zip source (no
node_modules,.git,.env, build output), build manifest,POST .../publish; show progress fromdeployment.step_*; onpublish.succeededshowurls[0]; onpublish.failedshowerrorand fetch the deployment log for the agent. - "New environment" →
POST .../envs/{env}/duplicate. - Every manifest carries
domain.hostname, the full hostname Nomi stores for that environment (one per environment); DNS for each domain you use points at Shipyard. - "Change address" →
GET /orgs/{org}/domains/check?hostname=...while typing,PUT .../envs/{env}/domainwith{ "hostname" }on confirm, then store the new hostname; on409showdata.suggestions. - Project moved to another org →
POST /orgs/{org}/apps/{app}/transferwithto_org; store the returned app key if you passedapp_key. - Project deleted → confirm →
DELETE /orgs/{org}/apps/{app}. - Logs and Monitor panel → runtime logs (structured), live usage (
/usage), and historical metrics (/metrics) for traffic and containers. - Monitor sheet alerts → handle
service.resource_alertandservice.error_rate_alertwebhooks. - After publish: never show
urluntilpublish.succeeded; on failure readfailurefrom the deployment and run the fix loop. - Run
scripts/check-nomi-integration.pyagainst the target Shipyard before go-live. - Catch-up job: on start, page
GET /orgs/{org}/events?after=<last seen>for orgs with activity.