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.

View as .md

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.

ItemValue
Base URLhttps://<shipyard>/openapi/v1/integration (also reachable as /integration, /integrations and the site root; use the first)
AuthAuthorization: Bearer ship_int_<64 hex> (see Credentials)
Content typeapplication/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:

  1. PUT /orgs/{org} when Nomi creates an organization.
  2. PUT /orgs/{org}/apps/{app} with "dev_database": true when a project is created. Keep the two *_url_ref values.
  3. Server-side only: reveal app_url_ref, set it as DATABASE_URL in the dev container.
  4. On publish: zip the source, build the manifest, POST .../envs/{env}/publish. You get 202 and a publish_id. The app is not live yet.
  5. Wait for the publish.succeeded webhook (or poll GET .../publishes/{publish_id} every 3 s). Only then show the user urls[0].
  6. If it failed, GET .../deployments/{deployment_id} returns failure with the step, phase, message and log tail. Fix the code, publish again. See the fix loop.
  7. Nomi picks each environment's full hostname (e.g. todo.nomi.app) and sends it as domain.hostname in the manifest. User wants another address: GET /orgs/{org}/domains/check?hostname=... as they type, then PUT .../envs/{env}/domain. See Choosing a domain.
  8. POST .../duplicate for 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.

CredentialReachesCan do
Integration key ship_int_...Orgs created through it (PUT /orgs/{org}), plus orgs a superadmin linked to the integrationEverything in this page, within its scopes (manage, secrets:reveal)
Org API key ship_... (created in the dashboard)Only its own orgSame 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 memberSame 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:

bash
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.

  1. 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.
  2. Use two keys. The key available to agent tools has only the manage scope. Only Nomi's server-side code that injects env vars into dev containers, or shows a user their own database login, holds a key with secrets:reveal. Reveal calls are audit-logged.
  3. Creating orgs, apps, dev databases and secrets is an idempotent PUT keyed by Nomi's own ids: retry freely on network errors and 5xx. Publish and duplicate are POSTs; before retrying one, check the environment's last publish (GET .../envs/{env}).
  4. Verify every webhook signature and treat delivery as at-least-once and unordered: dedupe on the event id, order by created_at.
  5. Prefer webhooks to polling. If you must poll, poll the publish (GET .../publishes/{id}) no faster than every 3 seconds.
  6. Don't send node_modules, .git, build output or .env files in the source zip. Shipyard builds from source.
  7. Accepted is not live. 202 from publish or duplicate means the build started. Never show the user a link, or tell them the app is up, before publish.succeeded (or status: "succeeded" and live: true on the environment). Before then the URL returns a Shipyard 502/404 page.
  8. Never invent a URL. Use urls from a succeeded publish/deployment. Failed and running deployments have none.
  9. 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

NomiShipyardIdentifier you use
Organization (or personal workspace)OrganizationYour org id, as {org} (external_id)
ProjectAppA slug you choose, as {app} (a-z0-9-, max 63)
DevelopmentDev database on a shared Postgres server(per app)
Published appEnvironment: one Shipyard project per environment{env}: production, staging, preview-123, ...
Secrets the user entersSecret store, app-wide or per environmentEnv 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:

bash
# 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=true

Then a Shipyard superadmin creates the integration and its keys once, in the dashboard API (session auth):

bash
# 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:

bash
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 below

Then 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}

json
// 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}

json
// 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):

  1. Reveal app_url_ref with the reveal key: POST /secrets/{app_url_ref}/reveal returns { "value": "postgres://..." }.
  2. Set it as DATABASE_URL in the container's environment.
  3. Tell the agent only that DATABASE_URL is set. Drizzle reads process.env.DATABASE_URL; drizzle-kit push or migrate work 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:

PartContent
manifestJSON, below
sourceZip of the project (optional: omit it to re-apply a changed manifest to the last upload)
bash
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:

json
{ "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

json
{
  "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" }
}
FieldMeaning
services[].namea-z0-9-, unique, not db. Keep it stable: same name = same service, updated in place
services[].kindweb (default; serves HTTP on port, gets the URL) or worker
services[].pathDirectory in the zip for this service (monorepos). Default .
services[].dockerfileDockerfile relative to path. Omit to use Dockerfile, or auto-detection (SvelteKit, Next.js, Nuxt, Angular, TanStack Start) when there is none
services[].portPort the app listens on. Required for web
services[].replicas1–20, capped by the plan
services[].cpu, memory_mbOptional limits (free plan caps at 0.5 CPU / 512 MB)
services[].envThe service's complete env. Keys not listed are removed
services[].volumesPaths whose data survives deploys
database{ "engine": "postgres" } gives the environment its own Postgres with a persistent volume
domain.hostnameRecommended. 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.subdomainLegacy, 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.serviceWhich web service gets the URL. Default: the first web service
labelsLabels for the environment's project. See Labels and versions
services[].labelsLabels for that service

Env values:

ValueResult
"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.0 and the manifest port. Read PORT if your framework does.
  • SvelteKit: use @sveltejs/adapter-node; set ORIGIN to the URL. Build output runs with node build.
  • Next.js: output: "standalone" in next.config; run node 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" }) from drizzle-orm/node-postgres/migrator in the server entry, and commit the generated drizzle/ folder. Don't use drizzle-kit push in production.
  • Health: respond 200 on / or a cheap route within a few seconds of starting.

A Dockerfile that works for SvelteKit (adapter-node):

dockerfile
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}

json
{ "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:

PartDescriptionRequiredDefault
artifactZip archive containing built static files (index.html, assets, etc.)Yes-
project_slugURL-safe slug for the project (e.g. todo-client or your app key)Yes-
nameHuman-readable name for the static projectNoSame as project_slug
aliasAlias to point to this deployment (e.g. production, staging, preview-1)No"production"
domainCustom domain to bind to this project/alias (e.g. app.example.com)No-
spa_fallbackWhether unknown routes fall back to /index.html (for client-side routing)Notrue
entry_filePrimary entry file to serve and verifyNo"index.html"
metadataJSON object of arbitrary metadata (agent ID, prompt, commit)No{"source": "nomi"}
bash
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:

json
{
  "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 READY immediately upon response. There is no container start or build delay.
  • Custom Domain & Auto-SSL: When domain is passed, Shipyard writes a Traefik router binding Host(domain) with automatic Let's Encrypt TLS. If the organization has a connected Cloudflare account, the DNS A record pointing to Shipyard is created automatically without manual steps.
  • Auto-Unwrap: If the zip wraps everything inside a single top-level folder like dist/ or build/, 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 with Cache-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

json
// 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.

json
{ "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.

KeyOnValue
shipyard.io/integrationorg, project, serviceThe integration that manages it, e.g. nomi
shipyard.io/appproject, serviceThe {app} key
shipyard.io/environmentproject, serviceThe {env} name
shipyard.io/versionserviceThe 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.

json
{ "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 null value removes that key.
  • Keys: 1–63 characters of a-z 0-9 . / _ -. Prefix them with nomi.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).

json
// 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

json
// 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 200 with "changed": false.
  • If the name is taken or invalid, nothing changes and you get 409, with the same suggestions under data:
json
{ "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 409 until the first publish.
  • The manifest's domain.hostname wins 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 without domain.hostname keeps 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.attached for the new name and domain.removed for 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

json
// 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_id like any deployment. Static sites don't restart. A service with no successful deployment is listed in skipped.
  • Same app key. If the destination already has an app with this key you get 409; send app_key to 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 409 while a publish or deployment of the app is running (try again when it finishes). 403 when the destination's plan can't hold the app's environments, or its billing doesn't allow deploys. 400 if to_org is the app's current org, 404 if 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.transferred is emitted in both orgs (data: app, from_org, to_org, environments). After this call GET /orgs/{old}/apps/{app} returns 404 and 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:

json
{ "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:

json
{ "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}

json
// 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

json
// 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:

json
{
  "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):

ts
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

TypeWhendata
org.created, org.deletedOrg provisioned / deletedname, plan / apps_terminated
app.created, app.terminatedApp created / terminatedapp, environments
project.created, project.duplicated, project.deletedEnvironment created / copied / removedapp, environment, from_environment
publish.started, publish.succeeded, publish.failedPublish lifecyclepublish_id, urls, error, deployments
deployment.started, deployment.succeeded, deployment.failedPer-service deploydeployment_id, urls, image, error
deployment.step_started, deployment.step_succeeded, deployment.step_failedBuild and deploy progressdeployment_id, step, error
service.created, service.deleted, service.env_changedService changesslug, type
service.crashedA container exited unexpectedlycontainer_id, replica, exit_code
service.resource_alertCPU or memory over thresholdmetric, value, threshold
service.error_rate_alert5xx error rate over thresholderror_rate, threshold, requests, errors_5xx
domain.attached, domain.removedHostname added / removedhostname, tls
volume.created, volume.attached, volume.deletedVolume changesvolume, mount_path
network.created, network.attached, network.detached, network.deletedNetwork changesnetwork_id
dev_database.created, dev_database.deletedDev databasedatabase, app_url_ref, owner_url_ref
secret.created, secret.updated, secret.deletedSecret store (never the value)ref, name, environment
database.provisionedAn environment's Postgres was createdservice, 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).

json
{ "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 with status, running/replicas, urls, live and the last deployment. live is true only 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 returns 400 if 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).

json
{
  "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.

ts
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.phaseUsual causeAgent action
validateManifest rejected, secret not set, zip missing pathFix the manifest or set the secret (ask the user for values)
buildnpm ci/build failed, no Dockerfile detected, wrong Node versionFix source; read tail
configurePlan limit, invalid resources, domain takenAsk the user or lower the request
deployContainer exits at start, port mismatch, migration error, health check failsRead 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.

StackManifest serviceSource 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-publishoutput: "export"; upload prebuilt out.zip directly
Vite React/Vue (prebuilt static)Use static-publishnpm 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 + workertwo services, one web, one workerWorker 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:

bash
SHIPYARD_URL=https://<shipyard>/openapi/v1/integration \
SHIPYARD_KEY=ship_int_... \
python3 scripts/check-nomi-integration.py --reveal-key ship_int_...   # reveal key optional

It 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

SymptomCauseFix
404 NOT_FOUND on an org that existsOrg was created in the dashboard, not via this integrationLink it with the claim endpoint (Credentials)
403 on PUT /orgs/{org} with an org keyOnly integration keys create orgsUse the integration key
403 on revealKey lacks secrets:revealUse the reveal key, server-side only
Publish 403Org billing is canceled or past dueFix the plan; PUT /orgs/{org} with plan if you own billing
URL shows a Shipyard 502 after publishYou opened url before publish.succeededWait for the webhook
publish.failed at validate with "missing secret"Manifest references { "secret": ... } that was never setPUT .../secrets/{name} first
Container starts then exitsNot listening on 0.0.0.0:$PORT, or migration errorRead service logs
Webhook signature mismatchBody was parsed before verifyingVerify 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.

FeatureStatusWorkaround
Promote a built image from staging to production without rebuildingNot builtduplicate re-deploys from the same source (rebuilds); rollback only moves within one service
Preview environments that expire automaticallyNot builtDelete the environment yourself (DELETE .../envs/{env}) when the preview closes
Sleep/wake of idle (free-plan) environmentsNot builtEnvironments stay up; scale replicas to 0 via the dashboard if needed
Server-sent-event log followNot builtPoll logs?since= or the deployment after cursor
Members and login links (Nomi users opening the Shipyard dashboard)Not builtNomi is the interface; the superadmin can use the dashboard
Org suspendNot builtDelete the org, or cancel its plan
Release command as a separate stepNot builtRun migrations on start

#Errors and limits

Statuserror.codeTypical causeWhat to do
400BAD_REQUESTInvalid manifest, missing secret, bad nameFix the request; the message says what
401UNAUTHORIZEDMissing, wrong or revoked keyCheck SHIPYARD_KEY
403FORBIDDENMissing scope, plan limitUse the reveal key / upgrade the plan
404NOT_FOUNDUnknown org, app, env, or one another integration ownsCreate it first (PUT)
409CONFLICTEnvironment exists (duplicate), secret in usePick another name / unbind first
5xxINTERNAL_ERROR, DATABASE_ERROR, DOCKER_ERRORShipyard or Docker problemRetry 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, reply 2xx fast, 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} with dev_database when the stack uses Postgres.
  • Dev container start → reveal app_url_ref server-side → DATABASE_URL in the container env. Agent sees the name only.
  • "Database credentials" button → reveal owner_url_ref for 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 from deployment.step_*; on publish.succeeded show urls[0]; on publish.failed show error and 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}/domain with { "hostname" } on confirm, then store the new hostname; on 409 show data.suggestions.
  • Project moved to another org → POST /orgs/{org}/apps/{app}/transfer with to_org; store the returned app key if you passed app_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_alert and service.error_rate_alert webhooks.
  • After publish: never show url until publish.succeeded; on failure read failure from the deployment and run the fix loop.
  • Run scripts/check-nomi-integration.py against the target Shipyard before go-live.
  • Catch-up job: on start, page GET /orgs/{org}/events?after=<last seen> for orgs with activity.