Skip to content

Build apps on OpenCloud ​

Use this skill as the execution policy and https://docs.opencloud.ai as the interface reference. When a usable terminal is available, use the public, versioned OpenCloud CLI (source: https://github.com/opencloud-ai/cli) even if hosted OpenCloud MCP tools are also exposed. Reserve MCP for surfaces that cannot run the CLI or call the API, such as mobile-only ChatGPT or Claude sessions. MCP and the CLI use the same control-plane contract, server-side drafts, validator, deployment executor, and verification operation. Do not inspect platform internals to infer undocumented behavior.

Quick start in a terminal ​

The usual first build with the CLI, in order. Each step is explained below. In an exact-app owner Agent session, the app already exists: skip steps 2 and 3 and use that session.

  1. Install the pinned CLI with curl -fsSL https://docs.opencloud.ai/install.sh | bash, apply any export PATH=… line it prints, and check it with opencloud --version. "Connect with the CLI" has the details.
  2. Run opencloud login and let the person approve it in their browser.
  3. Create the app once, with a key you reuse if you retry, so a retry can't make a second app: opencloud app create --name "$PROJECT_NAME" --visibility private --idempotency-key "$CREATE_KEY". Keys are 8 to 128 letters, digits, dots, underscores, colons or hyphens, such as create-2026-10-27-reading-log.
  4. In a new, empty app folder, run opencloud app connect "$APP_ID" and opencloud init "$APP_DIR" --app-id "$APP_ID", which starts a schema-3 app. Run every later app command from this folder: the connection lives there, and in another folder the CLI asks you to log in again.
  5. Build in small steps: opencloud validate "$APP_DIR", then opencloud app dev start "$APP_DIR" once and opencloud app dev sync "$APP_DIR" after each change. Show the person the returned browserPreviewUrl.
  6. Write tests/opencloud.e2e.js, run opencloud app dev verify, and once the person agrees, opencloud app dev promote, each with its own --idempotency-key.
  7. Check the live app signed out with opencloud app request "$APP_ID" /.
  8. To let other people in, give the person the app's Sharing page: sharingUrl from opencloud app get. The CLI can't invite anyone.

Keep the person's own files, such as spreadsheets and exports, outside the app folder, or list them in .gitignore. Everything else in the folder is uploaded with the app, and its admins can download it with the app's source.

Test in the preview. A CLI login can't run owner-only actions, such as deleting rows in the live app or running a schedule now; the owner does those in the app or its dashboard. So give the owner a way to remove anything the app stores before you add a test entry to the live app.

Select CLI, MCP, or offline mode ​

Inspect the execution surface first. When a terminal is available:

  1. Install and verify the pinned CLI release below.
  2. If OpenCloud explicitly supplied an exact-app owner Agent session and its assigned app ID, use that session directly. Run opencloud doctor and opencloud app get "$APP_ID"; do not replace it with account login, create another app, or connect a different source directory.
  3. Otherwise run opencloud auth status. Reuse a healthy account login or run opencloud login and let the user approve it in their browser. Both name the signed-in person in user: tell the user which account you are using, and run opencloud login --force when they want a different one. Run opencloud app list, select or create the requested app, and from its source directory run opencloud app connect "$APP_ID".
  4. Use the CLI for drafts, isolated development, deployment, verification, and operations. Do not switch to MCP merely because both interfaces are available.

When no usable terminal or direct API path exists and OpenCloud MCP tools are present:

  1. Use list_apps, or get_app when the app ID is already known. Protected calls trigger normal OAuth sign-in when needed; no session-status tool is required. An empty app list is valid.
  2. Use connect_opencloud only to explicitly reconnect an existing account. For first-project registration, continue with start_onboarding.
  3. For first registration, ask once for the email if necessary and call start_onboarding with the agreed project name and visibility.
  4. A new email receives a retained request with awaiting_email_verification and no app or credential. Building needs an organisation: after confirming their email, an owner without one creates or joins one through launchUrl, and complete_onboarding returns organisation_setup_required until then. An account can belong to at most one company. Never infer membership from an email domain or create a replacement company for a removed or suspended account.
  5. Give the owner the returned launchUrl as the primary link while email confirmation or deployment is pending. The confirmation message names the project and asks the owner to confirm the request. The retained request opens the project after deployment.
  6. Existing emails also approve the emailed request. After confirmation, call complete_onboarding with the same onboarding ID. Its private completion token remains inside the MCP session. While confirmation is pending, wait for the owner instead of starting another request.
    • If verification.emailSent is false, the email could not be sent yet: tell the owner and call start_onboarding again shortly with the same details.
    • onboarding_rate_limited means the address already received several confirmation emails; use the latest one, or connect_opencloud for an existing account, instead of retrying.
    • If the MCP session ends, or complete_onboarding returns ready with a null credential, after the owner confirmed, call connect_opencloud instead of starting another request.
    • A plan_limit_reached error names the limit and, where the installation bills, an upgradeUrl. Give both to the owner and continue only after they act. Inspect account limits in the owner's dashboard when needed.
  7. Resolve the assigned app from the onboarding result or call get_app. Before selecting capabilities, make one monitoring decision: determine whether the product has an important failure that OpenCloud's built-in app, deployment, operation, cron, background-job, HTTP-error-rate, and telemetry-freshness signals cannot express. If it does, include telemetry; otherwise do not add custom monitoring merely because it is available. This is an agent decision, not a user approval step. Call get_app_starter with every capability implied by the request: auth, data, files, functions, jobs, ai, email, notifications, realtime, telemetry, cron, and/or integrations. Implement every returned resolvedCapabilities and capabilityChecklist entry. Use its current minimal manifest, SDK pattern, external E2E specification, and build invariants before authoring the first draft; call it again if the product scope grows.
  8. Use source-draft, dev-session, verification, and promotion tools for all live work. Do not require the CLI as a second preflight.

Understand how hosted MCP works ​

OpenCloud MCP is a stateful Streamable HTTP protocol, not a remote shell:

  • Use /build on the canonical mcp.<domain> host for a native app-building agent. It exposes only onboarding, assigned-app inspection, draft, isolated dev, promotion, deployment evidence, and verification tools. Use the host root / for the full lifecycle on supported no-terminal connector surfaces. Terminal-enabled Codex, Claude Code, and similar agents use the CLI instead.
  • /mcp and /mcp/build remain compatibility aliases; configure new clients with the canonical host root or /build endpoint.
  • Initialization returns an opaque mcp-session-id. Reuse it on subsequent requests and deletion. A session belongs to the surface that created it; do not move one session between the full root and /build surfaces.
  • The server retains only bounded onboarding state and a provisional grant created in that session. OAuth bearer credentials remain request-scoped. Neither credential is tool output, source content, or something to request from the user.
  • Tools are authoritative actions. opencloud-skill is a supplemental MCP resource; some clients do not read resources automatically, so server instructions and get_app_starter carry the essential workflow too.
  • Draft files live on OpenCloud. expectedRevision prevents overwriting a newer draft, and each baseSha256 prevents overwriting a file that changed since it was read. On conflict, call get_draft plus list_files or read_files and rebase the intended change; never guess a revision/hash.
  • A draft cloned from the live release (the create_draft default) holds that release's complete source and may replace only that release. If someone releases in between, start_dev_session and deploy_draft fail with ACTIVE_DEPLOYMENT_CHANGED; never deploy over a release you have not seen.
  • validate_draft builds one immutable artifact from one source revision. Read sourceManifest and sourceFiles as author inputs, artifactFiles as packaged output, and every diagnostic path/suggestedFix before editing.
  • A dev session materializes only a validated revision in an isolated database. Each later edit requires validation and apply_dev_revision. Synthetic data and explicit Function invocations produce evidence without touching production.
  • verify_dev_session runs strict Chromium TLS/runtime checks plus the immutable tests/opencloud.e2e.js specification. Its receipt binds the exact revision, artifact, migration digest, and E2E source hash; any later edit invalidates the receipt. External tests run in independent child sandboxes with fresh migration-replayed data, Storage, Functions, and users. Up to five tests run concurrently by default; callers may request 1 through 10 without changing the test artifact.
  • Promotion and production verification return durable operations. Poll the matching operation.id with get_operation to a terminal state, then poll the matching verification. A queued operation or preview is not done.

Recover from failures by preserving evidence and changing the smallest boundary:

FailureCorrect recovery
draft revision or file hash conflictRe-read draft/files and reapply only the intended change.
ACTIVE_DEPLOYMENT_CHANGEDSomeone released after your copy; start from the latest release and reapply only your change.
manifest diagnosticFix the reported source path; use get_app_starter when the manifest or SDK pattern is missing.
development migration failureFix the named migration file/SQLSTATE, validate a new revision, and apply it.
Chromium certificate failureTreat it as an environment TLS failure; never disable certificate validation.
expired/stale receiptReapply the exact current revision, repeat dev checks, and obtain a new receipt.
durable operation failureReport the terminal failure and diagnostic; do not promote or claim completion.

Pasting a prompt cannot install an MCP connector. If tools are absent, state that OpenCloud must be enabled on a supported agent surface. Claude Code and Cowork can install the OpenCloud plugin; Claude.ai, Claude Desktop, and Claude mobile can use the hosted connector. ChatGPT plugins and arbitrary MCP tools are not currently available directly in ChatGPT mobile chats; mobile can steer an already configured desktop task through ChatGPT Remote.

For every terminal workflow, establish CLI execution capability before credentials.

The pinned CLI release for this skill is v3.18.0 and requires Node.js 22 and npm 10 or newer. The installer downloads that exact release, verifies its published checksum, and installs the opencloud command for the current user:

bash
curl -fsSL https://docs.opencloud.ai/install.sh | bash
test "$(opencloud --cli-version)" = "3.18.0"

CLI 3.18.0 requires explicit idempotency keys for app-owner Agent mutations and supports schema-3 manifest routes, SDK 2.3.0 Function routes, and app domain get|add|check|remove. Keep custom domains outside the manifest, reuse an idempotency key only for the same intended mutation, and follow the returned DNS and readiness observations before treating a domain as connected.

CLI 3.18.0 uploads each app's whole source tree (honouring .gitignore) so every release keeps its source, which the app's admins can download. It names the uploaded files that aren't part of the app (sourceOnlyFiles). Keep the person's own data files outside the app folder, or in .gitignore, and read them from there. The CLI also records the release a copy is based on in .opencloud/base.json. deploy and app dev start send it, so the platform refuses with ACTIVE_DEPLOYMENT_CHANGED when someone has released since. Then run opencloud pull <directory> to bring that release in. When it would overwrite your edits, pull it into a separate directory with --app and --deployment, merge it, and record the merge with opencloud pull <directory> --deployment <id> --merged. Use --force only when the person asks to replace the newer release. Pulling into a new directory with --app <id> also starts a copy of an existing app.

If the installer reports that its user-level binary directory is not on PATH, apply the export command it prints before running the version check.

Do this CLI preflight before starting CLI login. If the environment cannot execute a shell, Node.js 22/npm, HTTPS downloads, or the verified CLI, it cannot deploy from the public CLI. If MCP is also unavailable, work honestly offline.

Work offline only when both MCP and the verified CLI are unusable. Build and locally validate a complete source tree, but do not claim live deployment or verification.

Connect with the CLI ​

An exact-app owner Agent session is already connected. OpenCloud supplies it to AI SDK Codex harness v3 through OPENCLOUD_SESSION_FILE together with the assigned app identity. Never open or print that file, run browser login, create an app, or exchange the session for another credential. The session delegates human-owner-equivalent authority for the exact assigned app: the Agent may use every public CLI app operation, including deployment, production mutation, rollback, restore, secret and access changes, archive, and deletion. It does not need an additional confirmation merely because an operation is destructive or affects production. Honor explicit user constraints such as “do not deploy” and use ordinary product judgment, validation, and recovery.

That session cannot create another app, enumerate or operate another app, change account settings, authorize or reveal account-owned provider credentials, administer the platform, or access the host or shared infrastructure. Do not infer this authority from merely running as an Agent. Legacy AI SDK harness v1/v2 sessions and ordinary human-directed CLI sessions retain their existing authority and approval rules.

Every nested deployment, backup, operation, credential, File, binding, grant, or other resource is resolved back to its parent app before authorization. A foreign ID and a nonexistent ID produce the same non-enumerating denial. Do not probe either and never retry a boundary denial with account or platform credentials.

For an exact-app owner Agent, “every app operation” includes:

  • app inspection and supported lifecycle/configuration changes, including visibility, restart, archive, unarchive, and final deletion;
  • draft and source-file management, isolated development, captured effects, validation, verification, deployment, active-artifact inspection, rollback, and inactive-deployment cleanup;
  • production table and managed-File administration, including destructive row and File mutations;
  • secret metadata, generation, secure set/replace, entry links, and deletion;
  • backup creation, schedule, inspection, and restore;
  • production Function invocation plus cron, job, queue, and app-email operations through their supported typed surfaces;
  • logs, metrics, usage, visitors, alerts, Agent Feed, verification, and durable-operation evidence;
  • app members, access grants, app credentials, and app-scoped access tokens; and
  • listing, binding, configuring, using, and unbinding app integrations through typed app APIs.

Some app behavior intentionally remains reachable only through app code. For example, enqueue through a declared Function rather than inventing an owner queue bypass, and send email or execute a provider integration through its typed Function client. Account connection creation/consent, calling_user bindings, raw provider credentials, and unsupported platform features such as ownership transfer are not app-owner CLI operations.

The complete owner-parity command tree is documented in the CLI reference for the pinned public CLI v3.18.0. Confirm the exact version and command help before using owner operations. Do not invent a missing command or bypass the public CLI with a raw control-plane request; report an absent surface as a product limitation.

For ordinary CLI use, account login is per OS user and app connection is per source workspace, the local directory bound to one app:

bash
opencloud auth status
opencloud login
opencloud app list

# Only when the requested app does not already exist. Reuse the same key if
# you retry, so a retry can't create a second app:
CREATE_KEY="create-$(date +%Y%m%d)-first-app"
opencloud app create --name "$PROJECT_NAME" --visibility private \
  --idempotency-key "$CREATE_KEY"

opencloud app connect "$APP_ID"
opencloud doctor
  • opencloud login creates a short-lived browser approval request, prints and opens its HTTPS URL, and polls while the user signs in with a one-time email link or configured password and explicitly approves the CLI. It does not start a localhost callback or ask the user to paste a code, email link, cookie, or token. Use --no-browser when the terminal cannot open a browser; give the user the printed approval URL.
  • opencloud whoami names the person the CLI acts as (user), how its credential was obtained (credential.kind), and where the CLI found it (credential.source). An app credential acts for the person who created it, and an exact-app owner Agent session for the person who delegated it.
  • Approval returns a 15-minute account access token and a rotating 30-day refresh token. The account token may list, read, and create apps and connect a workspace; it cannot deploy, configure an app, read secrets, or perform owner-only actions. The one exception is the app's custom domain, which the person who created the app may add, check and remove with their own account token while they can still administer it.
  • Secrets are stored under the OS credential service ai.opencloud.cli when a keyring is available. Headless environments fall back to a per-user, mode-0600 file under $XDG_CONFIG_HOME/opencloud/credentials (normally ~/.config/opencloud/credentials) on Linux, ~/Library/Application Support/OpenCloud/credentials on macOS, or %APPDATA%\OpenCloud\credentials on Windows. Never open, print, copy, upload, summarize, or commit these credentials.
  • opencloud app connect writes only a non-secret, ignored .opencloud/app.json binding in the source tree. It stores a separate 24-hour app-scoped credential in the same protected credential backend. The CLI renews that credential from the account login when it nears expiry.
  • opencloud logout (or opencloud auth logout) revokes the refresh-token family and all workspace credentials issued from it, clears the local account credential, current workspace credential, and resolved onboarding session, and retains non-secret workspace bindings for later reconnection. opencloud login --force replaces an unusable stored login.
  • .opencloud/session.json and opencloud onboard implement passwordless first-project onboarding. OPENCLOUD_API_URL selects a non-default installation, and OPENCLOUD_TOKEN supplies explicitly delegated authority. Never inspect an onboarding session file or ask for a copied credential.
  • OpenCloud chooses the DNS-safe title-based slug and random suffix for new apps. Never ask the user to find an available domain.
  • Treat OPENCLOUD_EDGE_URL only as an optional CLI transport adapter. Preserve and report the canonical HTTPS app URL.
  • Never invent IDs, URLs, credentials, secrets, operations, or results.
  • Never print or commit tokens, passwords, cookies, .env contents, secret values, or brokered access tokens.

Run the CLI from any directory:

bash
opencloud <command>

Workspace and legacy-session discovery search parent directories, so commands work from nested source folders. opencloud doctor prints a redacted view of the CLI version, credential backend, app identity, endpoint reachability, and deployed platform version. Pass absolute app-directory paths when more than one app is present.

Use structured results and recover durable operations ​

The public CLI emits one stable machine-readable JSON result for every successful command. Read fields from that document instead of scraping prose. The owner-parity release also makes every failure structured, redacted JSON with a non-zero exit status. Retain the error code, request ID, retryability, operation ID, and bounded diagnostic; never retain credentials or secret values.

When OpenCloud itself fails rather than the request (an internal error, a 5xx answer, a tool that answers wrongly), the platform records it for the OpenCloud team. Through MCP, call report_problem when the user asks to report a problem or agrees to send one: describe what they were doing and what happened, include the error text and request IDs, and give the user the returned reference.

After the owner-parity release is pinned, app credential-create requires a new --token-file path, writes the one-time token there at mode 0600, and returns only redacted metadata plus the path in JSON. Never open, echo, trace, or report that file. Its idempotency key is required. If an ambiguous response leaves no file, repeat the exact request and key to replay the same credential; never overwrite an existing token file. The exact-app owner session normally needs no second app credential.

For every mutation, use one stable idempotency key for one intended effect and reuse it after a timeout, disconnect, or lost response. An exact-app owner Agent must pass it with every mutating command's --idempotency-key option; the CLI rejects a missing key before creating a journal entry or request. A new attempt with materially changed input is a new intended effect and gets a new key. An artifact digest may identify input but is not sufficient by itself to identify the intent. Never create a new key merely because the first response was ambiguous. On MUTATION_JOURNAL_AUTHORITY_MISMATCH, do not delete journal state or change keys to bypass recovery; inspect the exact operation and current app state first.

Operation-starting commands follow their durable operation by default with a bounded timeout. Use --no-follow only when deliberately detaching, and retain the returned operation ID before doing other work. Recover without repeating the mutation:

bash
opencloud operation get "$OPERATION_ID" --follow
opencloud operation list "$APP_ID"

operation get accepts bounded interval and timeout options. A local wait timeout says only that the terminal result was not observed; it does not mean the mutation failed. Re-read the operation. Terminal failure or cancellation exits non-zero and preserves typed diagnostics. After repairing input, start a new intended effect with a new idempotency key. App deletion is special only in lifecycle: the successful terminal result is returned before the app session is revoked, and that session may read only its exact deletion receipt for a bounded time afterward.

Read only the docs you need ​

Read these first:

  1. https://docs.opencloud.ai/getting-started/
  2. https://docs.opencloud.ai/getting-started/agents
  3. https://docs.opencloud.ai/sdk/javascript/
  4. https://docs.opencloud.ai/reference/manifest
  5. https://docs.opencloud.ai/guides/development
  6. https://docs.opencloud.ai/reference/verification
  7. https://docs.opencloud.ai/guides/functions-cron

Then read the capability page before implementing Auth, database, managed Files, Web Push, Realtime, Functions/background jobs/cron, telemetry, or verification. Use https://docs.opencloud.ai/llms.txt as the compact documentation index.

Discover the assigned app ​

With MCP, use list_apps and get_app. Treat get_app as authoritative for appUrl, authUrl, and apiUrl. Then call get_app_starter with the assigned app ID and complete capability list. Treat its resolved checklist as required. Use its files as a current scaffold, not permission to overwrite intentional existing source.

With the CLI:

bash
opencloud app list
opencloud app get "$APP_ID"
opencloud app origin "$APP_ID"

Treat app get as authoritative for appUrl, authUrl, and apiUrl. An exact-app owner Agent can inspect and manage credentials, restore backups, and delete its assigned app because its delegated role explicitly grants full owner authority for that app. It cannot create another app or use any supplied ID to cross the assigned-app boundary. Narrow app-scoped and legacy Agent credentials retain their existing scope and action restrictions. Provisional account grants can create multiple new apps during their 24-hour verification window:

With MCP, call create_app. With the CLI:

bash
opencloud app create \
  --name "Another project" \
  --visibility private \
  --idempotency-key "create-another-project"

Use a private app when its UI or data requires a user. Let the edge redirect to the central OpenCloud access page; do not build a second sign-in form. Use --visibility public (MCP visibility: "public") for an app anyone can open, including one that runs its own sign-in. The created app's visibility is the stored value. Use app configure --visibility to change it later.

People get into an app from its Sharing page in the dashboard. get_app and opencloud app get return its address as sharingUrl: give the owner that link. On this installation the owner shares it with people in their organisation. Data policies still decide what each person sees: use auth.uid() for a person's own rows and auth.is_app_admin() for the app's admins. A policy that lets every signed-in user read also lets everyone read once the app is public. When some people should change some of the data but not the app itself, such as coaches adding results while captains only read, invite them to use the app and give them a role inside it: keep the role in a table and check it in your policies. App Admin would also let them change and publish the app.

An app for people without an account, such as customers, parents, guests or event visitors, must be public. Keep its owner and staff screens for app admins with auth.is_app_admin() policies. Signed-out visitors can read data that a policy deliberately makes public, but they cannot write. Accept their submissions, such as sign-ups, orders and bookings, in an access: public Function that validates the input and calls jobs.enqueue, and save them in an access: system queue consumer under auth.is_system() policies for both insert and select. An anonymous insert policy does not work: anonymous writes are refused, and create must read the new row back. Confirm a submission as soon as the Function accepts it; the queue saves it moments later, so never wait for the saved row or report a failure because it is not there yet. Before you publish, test the visitor's journey in tests/opencloud.e2e.js with anonymousPage, which opens the development app signed out. Once it is live, check the app as a signed-out visitor with opencloud app request <app-id> /, which must return the page rather than a redirect to sign-in.

Change an existing app safely ​

For a change request, inspect and validate the existing bundle before editing. Do not run init, replace the app ID, recreate the app, or rewrite an applied migration. Preserve current behavior outside the request, append ordered migrations for schema changes, update product tests and the required external E2E specification, then deploy. OpenCloud assigns the new release name during promotion; do not edit a legacy schema-2 top-level version merely to make an update unique. Confirm the active deployment before and after the change and leave the previous release available for an authorized human owner or exact-app owner Agent to roll back. Preserve runtime.sdk.version unless the request explicitly includes an SDK upgrade; an upgrade is a new release and must pass the complete product and browser gates.

Make an immediate artifact checkpoint ​

Create a real manifest and non-empty frontend in the first coherent file batch:

With MCP, call capability-aware get_app_starter, then create_draft and inspect its files. Use apply_file_changes with the current draft revision and per-file hashes. Include opencloud.yaml plus a non-empty configured frontend in the first coherent batch, and call validate_draft. Read source/artifact file lists and diagnostic fixes from the result before continuing. Grow the app in small coherent revisions; use apply_dev_revision after each later validation.

With the CLI:

bash
opencloud init "$APP_DIR" \
  --app-id "$APP_ID"

opencloud artifact-check "$APP_DIR" \
  --expect-app-id "$APP_ID" \
  --max-files 4

opencloud validate "$APP_DIR"

Grow the product in small coherent batches. Re-run the checker after changing manifest-reachable paths and validate after each runtime boundary.

Author the deterministic bundle ​

Use this layout:

text
app/
├── opencloud.yaml
├── frontend/
├── migrations/
├── functions/
└── AGENT_REPORT.md

Hosted MCP and schema-3-capable clients use this manifest pattern:

yaml
schemaVersion: 3
appId: 6f9619ff-8b86-4e6e-a62a-889950f42d3e
frontend:
  directory: frontend
  spa: true
runtime:
  sdk:
    version: 2.2.0
files:
  access: user
  maxUploadBytes: 52428800
migrations:
  - id: 0001_create_items
    file: migrations/0001_create_items.sql
functions:
  - name: summarize
    entrypoint: functions/summarize/index.ts
    access: user
  - name: hourly-summary
    entrypoint: functions/hourly-summary/index.ts
    access: system
  - name: receive-support
    entrypoint: functions/receive-support/index.ts
    access: system
  - name: process-report
    entrypoint: functions/process-report/index.ts
    access: system
queues:
  - name: reports
    function: process-report
    concurrency: 2
    maxAttempts: 3
    retryDelaySeconds: 5
    retryBackoff: true
    timeoutSeconds: 120
email:
  addresses:
    - name: support
      displayName: Example Support
      function: receive-support
notifications:
  webPush: true
  icon: /icons/notification.png
cron:
  - name: hourly-summary
    schedule: "0 * * * *"
    function: hourly-summary
    enabled: true
health:
  path: /
secrets:
  INTERNAL_SIGNING_KEY: generated
  AI_API_KEY: required
  ORGANIZATION_LABEL: optional
integrations:
  calendar:
    provider: google-calendar
    account: calling_user
    cardinality: one
    capabilities:
      - calendar.events.read
  bank:
    provider: gocardless-bank-account-data
    account: calling_user
    cardinality: many
    capabilities:
      - bank.accounts.read
      - bank.balances.read
      - bank.transactions.read
observability:
  metrics:
    - name: items_created
      type: counter
      unit: items
      dimensions:
        actor_type:
          values: [member, admin]
    - name: overdue_items
      type: gauge
      unit: items
    - name: item_summarization_failures
      type: counter
      unit: failures
  alertRules:
    - id: overdue-items
      name: Overdue items detected
      metric: overdue_items
      aggregation: latest
      operator: gte
      threshold: 1
      window: 5m
      minimumSamples: 1
      severity: warning
    - id: item-summarization-failure
      name: Item summarization failure
      metric: item_summarization_failures
      aggregation: sum
      operator: gte
      threshold: 1
      window: 5m
      minimumSamples: 1
      severity: critical

Choose files.access: app only when authenticated members should share files; keep the safer user default for per-user files. files.maxUploadBytes is an integer from 1 byte through 100 MiB. Do not add a top-level release version to schema 3; the publisher records it separately in the deployment database. Pin the exact installed SDK version—never latest or a range. Keep migration IDs ordered and append-only. Never write migration checksums; the CLI computes them.

The pinned public CLI 3.18.0 defaults new versionless sources to schema 3 and omits the top-level release version. Use --version only when intentionally creating a legacy schema-2 source tree. Leave an existing schema-2 embedded version stable across updates: the publisher ignores it for release naming and assigns the database version. Immutable schema-2 artifacts remain readable.

Only the canonical manifest, configured frontend tree, declared migrations, and declared Function source trees enter the archive. Inspect the exact file list printed by validate.

A frontend that needs a build step (for example Vite) can let OpenCloud build it: declare frontend.build.script, keep package-lock.json in the app root, and upload source without the output directory. During validation OpenCloud runs npm ci --ignore-scripts and npm run <script> in a sandbox; that script must write frontend.directory. A failure is the FRONTEND_BUILD_FAILED diagnostic with the build's last output, and FRONTEND_BUILD_BUSY means validate again shortly. This works with MCP drafts and with CLI 3.13.1 or later.

yaml
frontend:
  directory: frontend/dist
  build:
    script: build

Define app routes and custom domains ​

Use schema 3 for routes. Assets are regular files relative to frontend.directory; the two aliases below serve the same file. Function routes require an explicit upgrade to the paired SDK 2.3.0, a declared user or public Function, and explicit methods. Preserve older SDK pins when no route-driven upgrade is needed.

yaml
runtime:
  sdk:
    version: 2.3.0
functions:
  - name: pixel
    entrypoint: functions/pixel/index.ts
    access: public
routes:
  - id: favicon
    path: /favicon.png
    asset: icons/app.png
    access: public
  - id: apple-icon
    path: /apple-icon.png
    asset: icons/app.png
    access: public
  - id: pixel
    path: /pixel.jpg
    function: pixel
    methods: [GET, HEAD]

Only exact literal asset aliases may use access: public in a private app. The exception does not expose the source file URL, directory, HTML, config, data, or Functions. Other routes inherit app admission. Asset methods are GET/HEAD; Function methods may also include POST, PUT, PATCH, and DELETE. Routes cannot replace /, health.path with a Function, or reserved service namespaces. Unmatched paths retain the existing static/SPA behavior.

Use whole-segment :id, optional {/literal} or {/:id}, a final optional extension as /report{.:ext} or /files/:id{.:ext}, and a terminal named wildcard such as /downloads/*parts. Omitted optional params have no key; wildcards are arrays of one or more segments. Do not use :id?, regex, unnamed wildcards, nested optionals, or required mixed segments. Paths are case-sensitive and trailing slashes matter. Specificity chooses the path before method checking; ambiguous overlaps fail validation and a disallowed method returns 405 without falling through. See https://docs.opencloud.ai/guides/routing for limits and complete examples.

For /pixel.jpg?param1=test123&tag=one&tag=two, use the SDK 2.3.0 handler's http.query.get("param1") and http.query.getAll("tag"); has and ordered entries are also available. http.params supplies decoded path values. Neither query nor params merges into schema-validated input. GET/HEAD input is {}; other methods retain JSON input. http is null for ordinary Function calls. Do not invent raw Request or cookie access; SDK 2.7.0 adds read-only headers (below). HEAD runs the handler and suppresses response bytes; keep GET/HEAD handlers free of write side effects. Exercise the actual preview route, not only a named invocation.

Custom domains are owner app settings outside the manifest. One exact apex or subdomain consumes the app's slot; www is separate. Use public CLI 3.18.0 app domain get|add|check|remove, App Settings → Domains, or full MCP get_app_domain, add_app_domain, check_app_domain, and remove_app_domain. CLI mutations use the exact-app journal and accept an optional --idempotency-key; MCP mutations require an explicit idempotencyKey of 8–200 characters. Reuse the key only with the same input after an uncertain result. A confirmed owner is required, and /build does not expose these tools. An app credential from app connect never changes a domain, and an account login does so only for the person who created the app; other app admins use App Settings or full MCP. CLI 3.18.0 sends the account login for these commands when one exists; without it, it sends the directory's app credential, which is refused. Copy the provided TXT ownership and traffic records; use apex ALIAS/ANAME/flattening or published A/AAAA alternatives. Preserve MX/TXT and unrelated records. Cloudflare proxying can remain enabled from the start; report actual check failures, not speculative proxy warnings. Add optional ACME delegation only from the provided record. Never ask for the owner's DNS credentials.

Only a connected domain becomes primary; pending setup keeps the canonical address. Private apps still sign in through central Auth; public apps retain optional signed-in features through the SDK. On a custom hostname, the same-origin sign-in bridge automatically continues in the same tab through auth.<base-domain> and returns through a one-time, claim-bound callback. The resulting host-only app-session cookie avoids another Auth round trip on later app loads; the central Auth cookie is never shared with or readable by the custom hostname. Email-link approval happens in the email window and session completion resumes in the initiating Auth tab. Website domains do not change email addresses or transfer browser storage and Web Push permissions. Removal disconnects immediately; a different hostname can be added during cleanup, while reuse of the retired hostname must wait. See https://docs.opencloud.ai/guides/custom-domains.

Do not invent opencloud.auth.signOut(). A visible custom-domain Sign out action sends a same-origin POST to /_opencloud/sign-out, then calls location.reload() on success to clear in-memory SDK state and connections. It revokes the current browser's parent session and aliases, retaining other devices. Handle a failed response before reloading.

Use the stable JavaScript SDK ​

Every frontend imports the deployment-pinned singleton from one stable path:

js
import {
  opencloud,
  OpenCloudError,
  OPEN_CLOUD_SDK_VERSION,
} from "/_opencloud/sdk.js";

Do not fetch /_opencloud/config, import an immutable version path, construct a client, or read runtime credentials in application code. The edge maps the stable module and /_opencloud/sdk.d.ts declarations to the exact artifact pinned by runtime.sdk.version. Inspect the active pin operationally without printing runtime credentials:

bash
opencloud app sdk-inspect "$APP_ID"

Use only these public methods:

InterfaceMethods
VersionOPEN_CLOUD_SDK_VERSION
Appapp.info()
Authauth.currentUser(), auth.requireUser(), auth.signInUrl()
Datadata.table(name).list/getById/create/createMany/updateById/deleteById
Filesfiles.upload/info/download/save/replace/remove/attach
Functionsfunctions.call(name, input?), functions.stream(name, input?) for user/public Functions
Notificationsnotifications.status(), notifications.subscribe(), notifications.unsubscribe()
Realtimerealtime.subscribe(topic, handler), realtime.publish(topic, event, payload)
Telemetrytelemetry.summary/increment/gauge
Clientdispose()

The SDK returns parsed values, managed file metadata, or typed streams—not raw HTTP responses. It owns config discovery, app identity, Function access mode, bearer tokens, cookie forwarding, refresh, file routing, and Realtime protocol state. Never decode JWTs, persist token material, call raw REST/Storage/Function endpoints, or construct buckets and object paths.

Handle expected failures through the typed error contract:

js
try {
  await opencloud.data.table("items").create({ title });
} catch (error) {
  if (error instanceof OpenCloudError) {
    renderProblem(error.code, error.requestId, error.retryable);
  } else {
    throw error;
  }
}

An OpenCloudError has code, surface, status, requestId, retryable, and optional details. Show a safe product message and retain the request ID for diagnostics. Do not parse response text or expose raw platform details.

The browser SDK deliberately exposes high-level data and managed Files operations instead of request paths. Use opencloud.data.table(...) and opencloud.files with opaque file IDs. Never construct REST paths, Storage buckets, object names, owner prefixes, URLs, or authorization headers.

Build data with RLS ​

Write unqualified DDL; OpenCloud selects the app schema.

For owner-isolated records:

sql
create table items (
  id uuid primary key default gen_random_uuid(),
  owner_id uuid not null default auth.uid(),
  title text not null check (length(title) between 1 and 200),
  created_at timestamptz not null default now()
);

create policy items_owner_access
  on items for all
  using (owner_id = auth.uid())
  with check (owner_id = auth.uid());

For records shared by admitted app members, add a permissive business policy:

sql
create policy items_member_access
  on items for all
  using (true)
  with check (true);

OpenCloud forces RLS and combines business policies with a restrictive app boundary. Deployments execute the complete history in a disposable constrained schema on the pinned PostgreSQL runtime before touching live app data.

Read https://docs.opencloud.ai/reference/sql before using nontrivial SQL.

Use the bounded data API. It validates identifiers, owns authentication and parses the result:

js
const items = opencloud.data.table("items");
const rows = await items.list({
  select: ["id", "title", "created_at"],
  orderBy: { column: "created_at", direction: "desc" },
  limit: 50,
});
const created = await items.create({ title: "Review evidence" });
await items.updateById(created.id, { title: "Reviewed evidence" });

list returns at most 1,000 rows (limit 1 to 1000); page through more with offset. The exact results are list -> Row[], getById -> Row | null, create -> Row, createMany -> Row[], updateById -> Row | null, and deleteById -> boolean. There is no .rows wrapper and no insert, upsert, or broad mutation alias.

Reads use the current user when present and can follow deliberately public RLS. Writes require a user. Mutate only through updateById and deleteById; never build filters that can accidentally update or delete multiple rows.

For platform-managed App Admin permissions, use the existing Data SDK: const [access] = await opencloud.data.table('opencloud_app_access').list();access.is_app_admin reflects the current app Edit permission, including access to its normal Agent conversation. Functions use the same read through data. This platform-managed, read-only view needs no migration or SDK upgrade. Enforce protected reads and writes with auth.is_app_admin() in SQL policies; the browser value only controls presentation. No second role table or first-visitor bootstrap is needed. Installation-wide review access does not grant this permission. Existing RLS and Files policies still apply.

Development identities are temporary and change in each fresh sandbox. Never copy a user ID from development inspection into application source or RLS. Use supported authenticated context for user-owned records. System ingestion has no user identity; model its app-owned records and explicit read policy without assuming that the system caller is the owner. In a development sandbox, synthetic user A is App Admin; B and C are ordinary users. For app-owned system-ingested records, give auth.is_system() one policy for all commands (using (auth.is_system()) with check (auth.is_system())) and let auth.is_app_admin() read/manage them instead of assigning a fake owner. create and update return the written row, so a system write also needs read access; an insert-only system policy fails with a row-level security error.

Rows added in a development session stay there. When the app can't be used until it is set up, such as a rota that needs its members or a shop that needs its menu, put the details the person gave, in the chat or in the preview, into a migration so the live app starts set up, and ask for any that are missing before you deploy. Show a setup form only to the app's admins (access.is_app_admin); everyone else sees who will set the app up. Let admins remove any entry the app stores, such as a wrong booking, from the app's own screen.

Apps that manage their own sign-in ​

Prefer OpenCloud accounts. Only when the user asks for an app that signs in its own users, such as a mobile app's API, pin SDK 2.7.0, keep the app public, and use access: public Functions. Read the app's own credential with headers?.get("authorization") (present only when it is not one of OpenCloud's credentials) or a custom header, verify it in the Function with a key from secrets or the identity provider's published keys, then call data.asAppUser(userId). Write owner policies with auth.app_user_id(), which is text, never with auth.uid() or an anonymous policy:

sql
create policy notes_owner on notes for all
  using (owner_id = auth.app_user_id())
  with check (owner_id = auth.app_user_id());

The app's own Authorization reaches the Function from browsers signed in to OpenCloud too: the development preview, verification browsers, and an owner testing their own app. Such a call never uses the browser session, so user stays null. A Function that also needs the OpenCloud user must read the app's credential from a custom header instead; an access: user Function rejects the call with OPENCLOUD_USER_REQUIRED. This applies to public apps only. On a private app and its development hosts, a signed-in browser's session replaces Authorization. Private apps also reject asAppUser with APP_USER_IDENTITY_REQUIRES_PUBLIC_APP. App-managed users get no per-user Files, Realtime, Web Push, or calling-user integrations. Never issue tokens that claim one of OpenCloud's issuers. See https://docs.opencloud.ai/guides/auth#apps-that-manage-their-own-sign-in.

Query declared PostgreSQL searches ​

The unpublished SDK 2.4/platform candidate supports data.search inside Functions. Pin runtime.sdk.version: 2.4.0 and declare data.search entries with a logical name, app table, selected columns including id, optional equality filterColumns, and fullText and/or vector configuration. App migrations create a tsvector column and/or vector(n) column and indexes; the platform owns pgvector installation. See https://docs.opencloud.ai/reference/manifest#postgresql-search-declarations.

ts
const { hits } = await data.search("document-chunks", {
  mode: "hybrid", query: input.question, vector: queryEmbedding,
  model: "potion-128-v1", where: { document_id: input.documentId }, limit: 10,
});

Use fullText with query only, vector with vector/model, or hybrid with both. Enforce the declared model/dimensions; re-embed stored content when the model changes. Query text is bounded to 2,048 characters, results to 50 hits, vectors to 2,000 finite float32-compatible dimensions with nonzero norm, and filters to declared scalar equality columns. Search keeps caller Authorization and RLS; private-file delegation adds no database authority. The browser SDK has no search method; invoke a declared Function.

Show returned passages and separate full-text score/rank, cosine distance/similarity/rank and hybrid fusionScore. Hybrid uses reciprocal-rank fusion with k=60, not addition of unlike component scores. Vector retrieval is exact; HNSW DDL is supported but approximate acceleration is not used. Never present similarity as confidence or word highlighting as an embedding explanation. Keep staged/obsolete chunks invisible through document-revision RLS and invalidate app-derived results when the document workflow replaces or deletes a source. Direct Files changes do not do that automatically. Read https://docs.opencloud.ai/guides/functions-cron#postgresql-search for limits, errors and indexing lifecycle before implementing this capability.

Independent app Agent tasks ​

For SDK 2.5.0 agentTasks, read the scheduled agent task guide before authoring the manifest. Do not set a task token limit. New app agents, independent tasks, and their children have no per-run token cap; OpenCloud-funded usage shares the owner's weekly account AI allowance across apps and Functions. Keep tasks focused, choose appropriate timeouts and batches, and verify durable results. Account allowance exhaustion requires renewed funding or the weekly reset; changing prompts or spawning another agent does not create more allowance.

For new interactive screenshot tasks on the v3 Agent runtime, omit capture and tell the task agent to use the installed Playwright CLI and opencloud-browser skill to open the supplied URL, dismiss cookie banners, wait for the content, take a PNG, upload it, and submit a result. URL field names follow the app schema. The legacy capture: website_screenshot option still automatically captures before the Agent runs and requires input.url; do not use it for new tasks that need interaction.

For either screenshot workflow, the result Function's strict input schema must declare both submissionKey and screenshotFileId, alongside your app fields. Use schema.uuid() for screenshotFileId; the worker uploads the captured PNG and passes the returned managed File ID. Validate it with files.info({ id: input.screenshotFileId }), persist the reference with the observation, and return the same screenshotFileId in every successful receipt, including unchanged and idempotent-replay receipts. For new tasks return exactly {schemaVersion:2,submissionKey,resultId,committed:true,screenshotFileId} after saving, where resultId identifies the saved row. Use the outer assignment's platform-issued submission key, not an app key nested inside its input. Legacy version-1 campaign receipts remain supported. Result Functions that inspect user-scoped Files must use access: "user" and normal caller/RLS checks: the owner's task CLI supplies authenticated user authority, whereas a system Function has no user identity. Never remove the field to work around input validation. Follow the complete receipt contract in the scheduled task guide. Before release, test the actual result Function with an uploaded PNG and the full worker payload, then assert the saved row and receipt; a hand-written test that omits the screenshot field does not test this contract.

Independent app tasks share the app's workspace queue with the builder. A task submitted by the active builder cannot execute until that builder ends its turn. After verifying the result Function in development and admitting a live task, finish the builder turn promptly and report verification as pending. Do not poll or sleep waiting for that task inside the builder run, and do not claim that admission proves successful saving. Check the task's reconciled receipt and retained data after the builder has released the workspace.

Use Files, Realtime, Functions, background jobs, and telemetry ​

Upload a browser Blob or File directly. OpenCloud returns an opaque ID and owns paths, authorization, size enforcement, names, content types, and idempotency:

js
const uploaded = await opencloud.files.upload({
  data: file,
  name: file.name,
  contentType: file.type || "application/octet-stream",
  onProgress: ({ percent }) => renderUploadProgress(percent),
});

upload and replace automatically retry one transient failure with the same private idempotency key. Do not invent or manage a retry key in app code.

When a file belongs to a database record, prefer the compound helper. Create the table with file_id, file_name, file_type, and file_size columns (or provide an explicit columns mapping):

js
const { file: storedFile, record } = await opencloud.files.attach({
  data: file,
  name: file.name,
  table: "item_attachments",
  values: { item_id: itemId },
});

attach reconciles an ambiguous metadata write and cleans up a definite failure. If it throws FILE_ATTACHMENT_INCOMPLETE, retain the returned file details for an explicit cleanup retry. Store the opaque ID; never store or construct a bucket/object path, send Storage headers, or request S3 credentials. Use download, save, replace, and remove with the returned file object or ID.

Subscribe to private Realtime topics with a logical purpose:

js
const unsubscribe = await opencloud.realtime.subscribe(
  "items",
  ({ event, payload }) => {
    if (event === "changed") void reloadItems(payload);
  },
);
void opencloud.realtime
  .publish("items", "changed", {
    reason: "item-created",
  })
  .catch((error) => console.warn("Realtime notification skipped", error));

Call unsubscribe() when the view unmounts and opencloud.dispose() when the app tears down. Realtime is best-effort invalidation, not durable truth. Check app.info().capabilities.realtime before subscribing, never await publication from a primary CRUD action or verifier cleanup, persist through opencloud.data first, and let recipients reload durable truth. Send identifiers, not secrets or full records. Query current controls again after rendering replaces nodes.

Write Deno-compatible Functions with the first-party server boundary. There is no global Buffer or require: use web APIs such as TextEncoder and crypto.subtle, or import Node modules explicitly (node:crypto). Each Function is deployed on its own with only its entrypoint's directory: keep helpers inside it, import exact file names with extensions, and copy a shared helper into each Function that uses it.

ts
import { defineFunction, errors, schema } from "@opencloud/server";

export default defineFunction({
  input: schema.object({ itemId: schema.uuid() }),
  handler: async ({
    input,
    user,
    data,
    secrets,
    log,
    requestId,
    environment,
  }) => {
    if (!user) {
      throw errors.unauthorized("SIGN_IN_REQUIRED", "Please sign in");
    }
    const item = await data.table("items").getById(input.itemId, {
      select: ["id", "title"],
    });
    if (!item) throw errors.notFound("ITEM_NOT_FOUND", "Item not found");
    log.info("item loaded", { itemId: input.itemId });
    return {
      item,
      requestId,
      environment,
      secretPresent: Boolean(secrets.get("AI_API_KEY")),
    };
  },
});

defineFunction accepts exactly { input, handler }; input is already parsed. Handler-only definitions and input.json/parse/text do not exist. Use ai.generateText for completed text, ai.streamText for incremental UTF-8 text bytes, ai.generateObject with a schema for a validated object, or ai.generateImage({ prompt }) for PNG bytes. Return an ai.streamText result in a no-store plain-text Response with X-Accel-Buffering: no, and consume it with browser functions.stream. Text, stream, and object calls accept managed PNG, JPEG, WEBP, non-animated GIF, or PDF Files as attachments: [{ type: "file", file: fileRef, detail? }]; prefer this opaque reference over downloading and copying bytes through the Function. PDFs are limited to one managed File, 20 pages, and 20 MiB. Use { type: "image", data: Uint8Array, contentType, detail? } only for generated or otherwise in-memory bytes. Remote URLs are not accepted. Image inputs are limited to four and 15 MiB each; all attachments share a 20 MiB total. Model selection, provider envelopes, idempotency, retries, and timeouts remain private platform behavior. await ai.generateText(...) returns a plain string: use text.trim(), never result.text, result.output, or a provider envelope.

AI calls run only in the live app: development previews and verification refuse them with AI_UNAVAILABLE_IN_DEVELOPMENT. Keep the page working without the AI result, for example "The summary appears once the app is live", and don't make tests depend on it. Tell the person in those words, and once the app is live, check the AI feature with one real item and say what it produced.

When a Function reports its pinned SDK version, import OPEN_CLOUD_SDK_VERSION from @opencloud/server; never hard-code the version string.

The Function context contains input, user, job, data, files, ai, email, notifications, jobs, integrations, secrets, log, requestId, and environment. SDK 2.3.0 and later also provide http, the read-only route context or null for an ordinary invocation. SDK 2.6.0 and later also provide integration: in an integration provider's operation, sync, webhook, or OAuth Function it names the connection (connectionId, trigger, operation, consumerAppId) and offers emit; otherwise it is null. SDK 2.7.0 and later also provide headers: read-only client request headers (get, has, entries, lowercase names) for browser, app, and routed calls, or null for platform invocations. Cookies, OpenCloud credentials and internal headers, forwarding and client-address claims are removed; header values are client input, never identity. These are high-level, invocation-scoped capabilities; raw database clients, buckets, provider keys, and OpenCloud bearer tokens are not exposed. The outer platform gateway allocates a request ID before module loading and catches imports, rejected promises, timeouts, invalid responses, and platform-call failures. Unknown production errors are generic; dev diagnostics are bounded and redacted.

Declare browser access as user or public in the manifest. Declare every cron, queue, inbound-email, or platform-only Function as system; the browser SDK rejects it with FUNCTION_SYSTEM_ONLY. Call a browser Function with opencloud.functions.call(name, input) for parsed output or opencloud.functions.stream(name, input) for a byte stream; the SDK selects the correct auth mode from the manifest.

Cron Functions receive { source: "opencloud.cron", name, scheduledAt } as their JSON input. Declare those three fields in the strict input schema; schema.object({}) rejects a real invocation. Use the scheduled occurrence in idempotency keys, and preserve the user's named timezone (for example, 09:00 UK time is 0 9 * * * with timezone: Europe/London). Test a full cron payload and inspect a real invocation before claiming automatic work succeeds. Development runs no schedules: with the CLI, run the Function once with opencloud app dev invoke and that payload. First add the rows it reads, such as a renewal due next week, with opencloud app dev data "$APP_DIR" <table> create --values '<json>' or create_dev_data_row, so the run has something to send. MCP has no tool that runs a Function directly, so put the scheduled work, such as a reminder email, in a module that the cron Function and an admin-only user Function both call, and give admins a "Send a test now" button. Press it in the preview, then read the kept email with list_dev_email_captures. Once the app is live, the owner can run a schedule once with Run now on the app's Scheduled jobs page in the dashboard. Only an owner Agent session can run it, with opencloud cron invoke <app-id> <cron-name>. Read past runs with opencloud cron history <app-id>.

Cron occurrences survive app deployments and worker restarts. Recovery runs the latest eligible missed occurrence within 24 hours, coalescing earlier ticks. Waiting for the app does not spend an attempt; proven failures allow up to four execution attempts, and unknown outcomes are not blindly retried. Changed or disabled schedules invalidate their obsolete unstarted work.

For change monitoring, retain structured source facts and compare a canonical representation. Generated prose can paraphrase unchanged facts; never use its exact string as the change detector. Verify an unchanged capture produces no change event or email, and a changed source produces one idempotent event.

Declare Postgres-backed background queues under queues; each targets a declared system Function. Producers call jobs.enqueue(queue, input, { idempotencyKey, delaySeconds? }), and every enqueue requires a stable idempotency key: up to 128 letters, digits, dots, underscores, colons or hyphens, such as signup:<id>. email.send needs at least 8 characters. Consumers receive the JSON object as input and { id, queue, attempt } as job. Delivery is at-least-once: make side effects idempotent and throw errors.unavailable only for retryable failures. jobs.get(id) reports queued, running, retry_wait, succeeded, or dead_lettered. Queue payloads are limited to 64 KiB, delays to seven days, and active work to 10,000 jobs per app/environment/Function namespace. Terminal job status is retained for 14 days. Queue concurrency, attempts, delay, backoff, and timeout come from the manifest and are pinned at enqueue. A suspended, archived, or transiently non-active production app defers work without consuming an attempt. Removing a queue declaration dead-letters its pending work with QUEUE_NOT_ACTIVE; restoring the queue does not redrive it. There is no strict FIFO, priority, manual acknowledgement, cancellation, or dead-letter redrive API in the first release.

For private file processing, pin the paired SDK to 2.4.0 and enqueue from an authenticated producer Function with explicit file inputs:

ts
await jobs.enqueue("index-document", { fileId }, {
  idempotencyKey: `index:${fileId}:${revisionId}`,
  files: { read: [fileId] },
});
// In the declared system consumer:
const pdf = await files.download(fileId);

Keep files.access: user; delegation does not require sharing the library. Pass one to sixteen UUIDs, at most 128 MiB aggregate; existing per-file limits still apply. Selection is normalized for idempotency. The platform pins the actual content generation: replacement, even with identical bytes, produces FILE_INPUT_CHANGED; enqueue a new revision with a new key. Grants last at most 14 days, with separate bounded access for each attempt. Logout alone does not cancel them, but current file, principal, app and retained domain/token access must remain valid. Delegation permits only downloading selected content, not metadata, listing, mutation, further delegation or user impersonation. Omit files for ordinary jobs and preserve their existing SDK pin.

System invocations keep user and auth.uid() null; an app-table RLS policy must explicitly use auth.is_system() for rows that a queue, cron, inbound email, Slack, Telegram, Asana, or integration event consumer may access, including the rows it creates. Never use auth.uid() is null for this because it also grants ordinary anonymous access in a public app. The platform separately binds the signed system identity to the current app or development namespace.

Declare app-owned identities under email.addresses. A receive-capable alias references a Function with access: system; that same allocated address is used for sending and receiving. Function code sends only from a declared alias with email.send(message, { idempotencyKey }). Provider credentials and SMTP are never exposed. For example, a queue consumer that confirms a booking:

ts
import { defineFunction, schema } from "@opencloud/server";

export default defineFunction({
  input: schema.object({ bookingId: schema.string(), email: schema.string() }),
  handler: async ({ input, email }) => {
    await email.send(
      {
        from: "bookings", // a name declared under email.addresses
        to: input.email,
        subject: "You're booked: Saturday taster class",
        text: "See you on Saturday at 10:00 at the studio.",
      },
      { idempotencyKey: `booking-confirmation:${input.bookingId}` },
    );
  },
});

Development Functions capture every outbound message and support synthetic .test inbound injection through the email CLI commands or MCP operations.

Development Functions remain dormant until an app dev invoke CLI command, deliberate preview interaction, or declared development event calls them. Dev Functions get the isolated dev schema, isolated values for secrets declared as generated, no owner-provided production values, and no cron triggers. A deliberate enqueue automatically wakes its consumer in the same isolated namespace. Before verification, exercise every Function through its intended path: direct invocation or browser action for ordinary Functions, enqueue for queue consumers, and synthetic injection for inbound email. Inspect list_dev_invocations or app dev requests; the latest invocation of each must succeed. Repeat these checks after any sync.

Captured delivery is a real development SDK result. Do not catch an email, notification, or integration error only in development and return success, or add an app-side injection bypass to satisfy verification. Persist and display the actual pending or failed state, and verify the intended path again in a fresh session. Only a successful SDK result justifies a completed-effect claim.

Declare native Web Push with notifications.webPush: true and SDK 2.1.0 or later. Optionally set a same-origin notifications.icon as the app-wide default; if it is omitted, OpenCloud supplies its logo. Browser code inspects notifications.status() without prompting and calls notifications.subscribe() only from a visible user gesture; never prompt on page load. A Function sends to an authenticated app user with notifications.send({ userId, title, body?, path?, icon? }, { idempotencyKey }). Keep path and icon same-origin. A per-message icon overrides the manifest default. Development captures visible notification payloads for list_dev_notification_captures and never contacts a browser push provider. For iOS acceptance, use Safari 16.4 or later and launch the web app after adding it to the Home Screen. iOS and iPadOS currently ignore the notification payload icon and display the installed app icon; provide that icon in the web app manifest or an apple-touch-icon, which takes precedence when both exist.

For every brokered integration, creating or authorizing an account connection and completing provider consent remain human account actions. With account: app, an exact-app owner Agent may list the app's integration declarations and current bindings plus bounded eligible-connection metadata and opaque IDs for the exact app's declared slots, then bind, configure, or unbind an already authorized connection through the owner-parity integration commands after that CLI release is pinned. This app-context projection includes only the delegated owner's connections usable by declared account: app slots. It cannot enumerate the general account catalog, manage account connections, expose raw credentials, reveal undeclared providers, include calling_user connections, or cross into another owner's connections. A calling_user binding always remains that calling user's action and authority. Ordinary human-directed flows may continue to use App Integrations in the browser.

Built-in providers work only where the installation has configured them. If connecting reports that a provider is not configured on this installation, tell the owner; do not switch providers or call the external system directly.

For Google Workspace, declare a named google-calendar, google-drive, google-sheets, google-docs, or google-slides integration slot with account: app | calling_user, cardinality: one | many, and only its provider-scoped read/write capabilities. Functions use the bounded typed integrations.calendar/drive/sheets/docs/slides(slot) clients; raw Google requests and batchUpdate bodies are not public APIs. A human connects one reusable Google account in account Integrations and authorizes the available resources; Calendar binds an exact calendar under the general binding rule above. Never ask for, receive, store, log, or return an OAuth token, Google subject, or raw provider response. Obtain an opaque connection ID only from the exact-app projection above, never from conversation or the general account catalog. Development uses deterministic synthetic Workspace data; production needs an active release and active binding.

For read-only Google reporting on SDK 2.2.0 or later, declare google-analytics with analytics.reports.read, google-search-console with search.performance.read, or google-ads with ads.reporting.read. These slots use account: app and bind one exact GA4 property, verified Search Console site, or directly accessible non-manager Ads customer. Functions use the provider-independent integrations.use(slot) client and call the bounded analytics.reports.run, searchConsole.performance.query, or googleAds.campaigns.report operation. The broker validates the provider, capability, binding, and input, so adding a future integration operation does not require a core SDK release. Never accept a raw property, site, customer, GAQL query, provider request, or token. Report ranges are bounded to 366 days, and development returns deterministic synthetic data without contacting Google unless the slot is switched to live.

The typed methods are Calendar bindings/listEvents/createEvent; Drive bindings/listFiles/getFile/createFile/createFolder; Sheets bindings/getSpreadsheet/createSpreadsheet/getValues/updateValues/appendValues; Docs bindings/getDocument/createDocument/appendText; and Slides bindings/getPresentation/createPresentation/addSlide. Pass an opaque bindingId only to select among cardinality: many bindings.

For Asana, declare provider: asana and only the needed asana.* capabilities for tasks, assignees, sections, custom fields, attachments, stories/comments, and events. Every binding selects one exact project. Functions use integrations.asana(slot) with bounded bindings/listTasks/getTask/createTask/updateTask, listAssignees/assignTask, listSections/moveTask, listCustomFields/updateCustomFields, listAttachments/createExternalAttachment/uploadAttachment/downloadAttachment, and listStories/createComment methods. A section is a project grouping: a list heading or board column. Never accept arbitrary Asana API calls, tokens, workspace/project IDs from conversation, or tasks outside the bound project. Task mutations that return current task state also require asana.tasks.read. Task results expose only the bound-project membership and independently gate assignee, section, and project custom-field details behind their read capabilities. Development data is deterministic and synthetic.

Incoming Asana changes require asana.events.receive, asana.tasks.read, account: app, and events.function naming a declared system Function. The handler receives an OpenCloudAsanaEvent with normalized events, up to 20 current contained task snapshots, and a tasksTruncated marker. Use its id idempotently; app writes can be observed again as incoming changes. Account connection and exact-project authorization are human actions in OpenCloud; the app binding follows the general rule above. Installation setup requires an Asana OAuth app with Full Permissions because not every endpoint has a granular scope; OpenCloud still enforces every manifest capability and project boundary.

For HubSpot CRM, declare provider: hubspot-crm, account: app, and only the needed crm.contacts.read|write, crm.companies.read|write, crm.deals.read|write, crm.owners.read, crm.pipelines.read, crm.notes.write, and crm.associations.write capabilities. Functions use integrations.crm(slot): contacts, companies, and deals expose get/search/create/update/upsert; owners expose list; pipelines expose listDeals; notes expose create; associations expose create. Association writes also require the write capability for both source and target record types. A human authorizes a reusable portal in account Integrations; its app binding follows the general rule above. Never ask for a HubSpot token, accept raw requests or a connection ID outside the exact-app projection, add delete operations, invent webhook events, or use account: calling_user. Development uses deterministic synthetic CRM data and does not contact HubSpot unless the slot is switched to live.

For open banking, declare provider: gocardless-bank-account-data with only bank.accounts.read, bank.balances.read, and/or bank.transactions.read. Functions use integrations.bankAccountData(slot) with bounded bindings/account/balances/listTransactions methods. A human selects their institution, completes bank-hosted consent, and authorizes exact accounts; the app binding follows the general rule above. Never ask for bank login details, GoCardless credentials, requisition IDs, account IDs, or raw provider access. Development data is synthetic and no bank is contacted.

For Wise transactions, declare the technical provider provider: wise-balance-webhook, account: app, and only payments.received.reconcile. Functions use integrations.receivedPayments(slot).bindings/reconcile for expected incoming credits. The owner creates exactly one Account deposit events webhook in Wise using the notification URL shown under account Integrations and tests it; the app binding follows the general rule above. Do not ask them to create Transfer update or Transfer issue events. Never request a Wise login, API token, OAuth grant, certificate, balance, raw transfer ID, or raw transaction feed. Reconcile only an exact expected amount/currency/time window; a result of ambiguous always requires human review and must never trigger fulfillment.

For Slack, declare provider: slack, account: app, and only slack.messages.send and/or slack.messages.receive. Receiving also declares events.message.function targeting a declared system Function. Functions use integrations.slack(slot).bindings/sendMessage; inbound handlers receive OpenCloudSlackMessageEvent. A human connects the reusable workspace and invites the managed @OpenCloud identity to an exact channel. The app binding selects that channel and mention or all_messages under the general rule above. Every outbound message is visibly labelled with the OpenCloud app name. Inbound delivery is at least once; use the stable event id to make side effects idempotent. Never ask for a Slack token, handle raw provider events, choose arbitrary channels in code, implement slash commands, or treat a Slack mention as authorization for a sensitive action.

For Telegram, declare provider: telegram, account: app, and only telegram.messages.send and/or telegram.messages.receive. Receiving also declares events.message.function targeting a declared system Function. Functions use integrations.telegram(slot).bindings/sendMessage; inbound handlers receive OpenCloudTelegramMessageEvent. A human uses the app's one-time link to add an exact private chat or group. The app binding selects an owned paired chat and directed or all_messages under the general rule above. cardinality: one selects one chat at a time; many can bind several. Private chats need no bot administrator access. Directed groups keep the bot as a member; all-message groups grant it minimal administrator access in that exact group while Telegram privacy mode stays enabled. Every outbound message is labelled with the OpenCloud app name. Inbound delivery is at least once; use the stable event id to make side effects idempotent. Never request a bot token or raw chat ID, handle raw Telegram updates, choose a chat in code, disable privacy mode, or treat a Telegram sender ID, mention, command, or reply as authorization for a sensitive action.

Development integration slots return deterministic synthetic data by default. To confirm real behaviour before deploying, switch one app-account slot in one development session to live with MCP set_dev_integration_mode; it uses the app's production binding, reaches the real system, and is audited. Prefer reads, avoid live writes unless the user asked for that behaviour, and switch the slot back to fake afterwards. calling_user slots and verification sandboxes always stay synthetic, and development email is always captured.

Apps can use integrations that another app of the same organisation or account publishes. Call MCP list_custom_integrations for the published contracts, then declare a slot with provider: custom, integration: <name>, account: app, and only the needed lowercase dotted capabilities on SDK 2.2.0 or later. Functions call integrations.use(slot).call(operation, input); typed provider errors keep their codes. Entering credential values or signing in is a human action in OpenCloud; never ask for credentials or tokens. Binding an existing connection follows the general app binding rule above. Use account: calling_user when each person must use their own account: a call without that person's connection fails with INTEGRATION_CONNECTION_REQUIRED and a details.connectUrl to show them, and only the person can bind it. To react to provider changes, give an account: app slot events: { function: <system Function>, types?: [...] }; delivery is at least once, so handle the event id idempotently, and test the handler with MCP inject_dev_integration_event.

To publish an integration, add provides.integration to a schema-3 manifest: a kebab-case name unique within the organisation or account, title, description, credential fields (secret and required unless secret: false or optional: true), capabilities, and operations. Each operation has a dotted camelCase name, a description, a declared capability, a declared system Function, optional JSON Schema input/output documents, and a static fake output that consumers receive in development. The operation Function receives the consumer's input and reads credential values with secrets.get(NAME); never log, store, or return them. For OAuth systems, declare authorization: { type: oauth2, authorizationUrl, clientId, scopes, exchange, refresh }: the exchange and refresh system Functions redeem codes and refresh tokens with the app's own client secret, and operation Functions read the access token with secrets.get("OAUTH_ACCESS_TOKEN"). On SDK 2.6.0, optional sync (a scheduled system Function run per bound connection), webhook (a system Function receiving each connection's webhook URL requests), and declared events let the provider keep per-connection data and publish changes with integration.emit({ type, id, data }). Partition that data by integration.connectionId and auth.connection_id() in RLS policies. Test it in the provider's development session with a test connection selected through set_dev_integration_test_connection, preferring a sandbox or read-only connection. The hosted Agent's organisation-integrations skill and https://docs.opencloud.ai/getting-started/agent-integrations give the full workflow, commands, and Function contracts.

Declare each Function secret in the manifest with one mode:

  • generated: OpenCloud creates and retains a strong production value automatically and supplies an isolated synthetic value in development;
  • required: the owner must enter a value before production; or
  • optional: the owner may enter a value, but its absence never blocks a release and secrets.get(name) returns undefined.

Do not call a setup command for generated secrets. In an ordinary human-directed or legacy Agent session, rotate one only when the user authorized that change. An exact-app owner Agent may rotate an assigned-app secret when its work requires it without seeking another operation-specific approval. With MCP use generate_secret; with the CLI use:

bash
opencloud secret rotate "$APP_ID" INTERNAL_SIGNING_KEY

For a required or optional owner-supplied value, call MCP create_secret_entry_link or use:

bash
opencloud secret configure "$APP_ID" AI_API_KEY

Give the returned URL to the user. Do not ask them to paste the value into the agent conversation. Return only a presence flag, version marker, or one-way digest for a secret.

Once the owner-parity CLI release is pinned, an exact-app owner Agent that already has a value in a protected source may replace it with:

bash
opencloud secret set "$APP_ID" AI_API_KEY \
  --idempotency-key "$IDEMPOTENCY_KEY" < "$PROTECTED_SECRET_FILE"

secret set reads the value only from standard input. Never place it in an argument, JSON file, command history, tool output, or report. Reuse the same idempotency key after an ambiguous response, then confirm only secret metadata. If the human owner is the only source of the value, use secret configure instead of asking them to reveal it to the Agent.

An AI Function runs on the AI account the app's owner chooses on the app's AI page (/apps/<appId>/ai in the dashboard); never ask for, substitute, or copy a provider key or the coding agent's credential. Some installations accept only OpenAI or Anthropic API keys for app AI. If an invocation reports app_ai_credential_not_selected, app_ai_own_use_required, app_ai_api_key_required, or ai_capacity_unavailable, give the owner its message and that page once, and resume only after they act. User-provided AI instead requires every authenticated caller to connect a default AI connection and explicitly allow the exact app in OpenCloud's trusted AI permission window before its first AI request. Open /_opencloud/ai-consent?return_to=<encoded same-origin path> only as a top-level popup navigation initiated by the user's AI action; never fetch, submit, frame, imitate, or auto-approve that Auth-origin form. Wait for the opencloud:ai-consent result from that exact popup before invoking the Function. The inference boundary rejects user-token use without the stored app-specific grant. Cron, inbound-email, anonymous, and other system calls cannot use AI in that mode. Keys and provider tokens remain inside the encrypted integration vault and short-lived inference runtime; never declare them as app secrets or ask for them in conversation. The owner also selects the app's text model and default reasoning effort on the app's AI page.

Use exact telemetry fields:

js
const summary = await opencloud.telemetry.summary();
const rest = summary.activity.surfaces.rest;
const freshness = summary.activity.telemetry;

All six surfaces are always present. usage can be null. Treat unavailable, missing, or truncated activity honestly; never label absence as healthy. Read https://docs.opencloud.ai/sdk/javascript/telemetry for the exact response.

Define custom metrics only when they express a product or workflow signal the platform cannot derive. Keep the catalog small and bounded. Never use user IDs, emails, URLs, object keys, or arbitrary strings as dimensions.

Connect every custom repair signal end to end: declare the counter or gauge, declare its matching observability.alertRules threshold in the same manifest, and emit the measurement through the supported browser SDK at the real product failure boundary. Use counters with sum for occurrences and gauges with latest or max for stuck state or backlog. Do not invent a telemetry member in the Function context, and do not add a fake failure solely to exercise an alert example.

js
await opencloud.telemetry.increment("items_created", 1, {
  dimensions: {
    actor_type: "member",
  },
});
await opencloud.telemetry.gauge("overdue_items", overdueCount);

Record a real browser-observed failure without replacing the original error:

js
async function summarizeItem(itemId) {
  try {
    return await opencloud.functions.call("summarize", { itemId });
  } catch (error) {
    try {
      await opencloud.telemetry.increment("item_summarization_failures");
    } catch {
      // Keep the product failure as the primary error.
    }
    throw error;
  }
}

The SDK creates the private idempotency key and safely retries one transient write. Application code supplies only the metric value and bounded declared dimensions. Browser measurements are product signals, not trusted security evidence.

For retained production notification history, use opencloud app notifications list <app-id> and opencloud app notifications get <app-id> <message-id>. Use opencloud operation list <app-id> --page for paginated durable operation history; continue with --cursor and the same filters and limit. Read command help for supported filters. These reads use the existing exact-app session.

CLI v3.18.0 provides alert-rule and agent-feed for operationally configuring and reading these signals. For important app-owned failure modes, define the bounded custom metric and its matching threshold under observability.alertRules in the same manifest revision. Manifest rules are validated before promotion and become active atomically with the deployment; do not require a post-deployment rule-creation step for intended app behavior. Use alert-rule put for an immediate operational rule or override against a metric in the active deployment. If that rule becomes durable product intent, move it into the next manifest revision and remove the operational override. Do not bypass the protected credential store. Prefer the Agent Feed over raw logs or metrics. Background queues contribute jobs.created, jobs.retried, jobs.succeeded, jobs.failed, and jobs.active signals, safe failure events, and a built-in failure alert. On full MCP, use list_background_jobs and get_background_job for retained per-queue and individual metadata. list_background_jobs accepts inclusive ISO 8601 from and to creation-time bounds and a returned nextCursor for pagination; time bounds apply to totals, queue rollups, and history. Payloads, idempotency keys, cancellation, and redrive are intentionally unavailable. Alerts inform the agent. An Agent may create an app-scoped custom-metric rule; each threshold occurrence creates one deduplicated fire in the same conversation, which stays unresolved until an exact-run repair deployment is verified. An alert never widens a credential. An exact-app owner Agent already has authority to choose rollback, deletion, or another owner operation for its assigned app without another approval; narrower and legacy principals keep their existing restrictions. Shared-platform repair always remains outside the app boundary. See the telemetry reference for the exact contract.

Validate, deploy, and verify the real UI ​

Test in the development preview, not on the live app. If you must add something to the live app to check it, give it an obvious test name, remove it through the app afterwards, and tell the owner about anything you could not remove.

Installations may enable online app updates when SQL, SDK and runtime declarations are unchanged and deployment health is static. The old release serves during preparation; started Functions retain their captured release within their deadline, subject to current access and suspension. Inspect the deployment's deploymentMode and preparationPhase. SQL or capability changes, initial deployment and Function-backed health use maintenance mode. Keep lazy assets content-hashed and include .vite/manifest.json (build.manifest: true) for retention across compatible deployments; copied public files are not retained. Handle expired assets by reloading. See the deployment guide.

With MCP, call validate_draft, then:

  1. start_dev_session for the exact validated revision; when the user asks to review the app before deployment, give them the returned browserPreviewUrl rather than the raw previewUrl. The browser entry requires an owner or builder login, then opens a clearly marked Not live development-preview window with Full size, Tablet, Mobile, and Reload tools. Its framed app uses isolated synthetic user A without replacing the user's normal OpenCloud session. An explicit no-deploy request stops at this review point and leaves the dev session active; it does not authorize promotion;
  2. apply_dev_revision after every later coherent validated change;
  3. request_dev_app to inspect the preview and REST reads;
  4. Use the separate fixture tools only for isolated dummy data: create_dev_data_row takes table and one values object; create_dev_data_rows takes table and 1–100 values objects; update_dev_data_row takes table, id, and values; delete_dev_data_row takes table and id. Each also requires the exact appId and sessionId, authenticates as synthetic user A, and preserves that identity through browser verification. Never pass a REST path, action selector, or user credential;
  5. exercise each Function through its intended path: visible app UI for ordinary Functions, enqueue through a producer for queue consumers, and synthetic injection for inbound-email handlers; then use list_dev_invocations for correlated diagnostics. Direct Function and cron invocation are CLI/API operations, outside the MCP catalog. Development runs no schedules, so verification only starts a Function that only a schedule calls. Never add another trigger, such as a public address, just to run it;
  6. for app email, inspect list_dev_email_captures and get_dev_email_capture, then exercise every receive-capable alias with inject_dev_email and a reserved .test sender;
  7. for Web Push, invoke its sending Function and inspect list_dev_notification_captures for the expected synthetic user, title, body, resolved icon, and same-origin click path; remember that iOS/iPadOS presents the installed app icon rather than the captured payload icon;
  8. verify_dev_session for the exact active revision;
  9. promote_dev_revision for that receipt;
  10. get_operation until the durable deployment is terminal;
  11. verify_app, its operation, and get_verification_run until every required production gate is terminal; and
  12. get_app to confirm the active deployment and canonical HTTPS URL.

Make at most ten verify_dev_session attempts for one build. Use validation, preview reads, and explicit Function evidence between attempts; after a tenth failure, stop and report that structured result instead of looping.

With the CLI, run app-local syntax checks/tests/build plus:

bash
opencloud validate "$APP_DIR"

Start the isolated development loop before changing production:

bash
opencloud app dev start "$APP_DIR"
# after each coherent edit batch
opencloud app dev sync "$APP_DIR"
opencloud app dev request "$APP_DIR" /

The start result includes session.browserPreviewUrl, and status prints the same value as top-level browserPreviewUrl. Give that URL to a human reviewer, never the raw previewUrl. It opens the marked development-preview window and its Full size, Tablet, Mobile, and Reload tools. If the user asked not to deploy, leave the dev session running for review and do not run app dev promote.

The capability URL has a separate migration-replayed schema. Auth, data, managed Files, Functions, and background jobs are isolated from production; generated secrets receive synthetic values. Owner-configured secrets, Realtime, runtime telemetry, and cron are unavailable. For external E2E verification, each REQ-### test gets a child sandbox with a fresh schema, managed Files namespace, Function namespace, and three short-lived synthetic browser sessions: owner A, admitted member B, and unrelated user C. The platform drops those resources after the test even when the app's own UI cleanup fails. Functions execute only when the CLI or a deliberate preview interaction calls them. Frontend-only syncs preserve dev data. A migration definition change resets the dev schema and replays the complete ordered history. Treat every listed unavailable capability as unavailable; never fall back to production.

Create isolated dummy fixtures without touching production:

bash
opencloud app dev data "$APP_DIR" items create \
  --values '{"title":"Preview item"}'

For an email-capable app, exercise its dev-only mailbox without contacting the production provider:

bash
opencloud app dev email inject "$APP_DIR" \
  --to support --from customer@example.test \
  --subject "Test request" --text "Please acknowledge this message."
opencloud app dev email list "$APP_DIR"
opencloud app dev email get "$APP_DIR" "$MESSAGE_ID"

For a Web Push app, invoke its sending Function and inspect the captured payload without contacting a browser push provider:

bash
opencloud app dev notifications list "$APP_DIR"

Run the exact-revision verification and promote its receipt:

bash
opencloud app dev verify "$APP_DIR" \
  --idempotency-key "$VERIFY_IDEMPOTENCY_KEY"
opencloud app dev promote "$APP_DIR" \
  --idempotency-key "$PROMOTE_IDEMPOTENCY_KEY"

Any source, migration, or production-base change invalidates promotion. Direct deploy remains a lower-level automation path but is not the default agent workflow because it has no dev verification receipt.

promote_dev_revision (or CLI app dev promote) is the default completion path. Follow the durable deployment, run authoritative feature-aware production verification, report the live URL, and stop dev only after success. The copied user prompt authorizes promotion of the exact verified receipt for the general builder flow, while an exact-app owner Agent session independently carries standing authority for every same-app deployment and operation. Neither flow adds another confirmation. Dev is an iteration environment, not a finished result.

MCP verify_app (or CLI app verify) remains available as a standalone durable release gate. It checks that the recorded deployment is still active, re-hashes its immutable artifact and manifest, matches the pinned OpenCloud SDK, and sends only a canonical HEAD. It does not load the production document, run Chromium or tests/opencloud.e2e.js, create users, or access application data; it writes only durable control-plane verification metadata. If a newer deployment became active, verification refuses instead of following the moving pointer. The full product gate is the exact-revision development receipt; CLI v3 provides no partial local substitute for that.

Every new app must replace the starter's failing external spec with tests whose titles begin with stable REQ-### IDs. Tests import exactly test and expect from @opencloud/test, drive visible accessible controls, assert exact durable create/reload/update/filter/navigation/delete outcomes where applicable, and clean unique fixtures through the UI. Each test needs at least one trusted action, such as a click, fill, select or reload, and one trusted assertion, or it fails as E2E_TEST_TOO_SHALLOW; a test that only checks what a page shows can reload it first. The bounded runner supplies owner, admitted member, second-owner-tab, unrelated, and signed-out visitor pages plus deterministic file fixtures; it rejects skip/only, direct network/backend access, evaluate, routing, direct navigation, and script injection. Test code runs in a blank network-isolated controller and cannot read app globals, cookies, SDK clients, or response bodies. The supplied marker is short and unique to the current REQ test rather than shared with the other tests in the run. Use uniqueValue(prefix, maximumLength) for bounded fields so the unique suffix cannot be truncated (maximumLength must be at least the marker's length, about 30 characters), clickIfVisible(locator) only for genuinely optional cleanup controls, and reloadUntilVisible(locator) for a result that a queue saves after another page's confirmation. Upload files.tinyPng, files.tinyPdf or files.tinyText with setInputFiles, or write a small text file yourself with files.text(name, contents), such as files.text("sales.csv", "date,total\n2026-10-01,12.50\n"), up to 64 KiB. Test code doesn't run in Node, so Buffer, fs and process don't exist. memberPage, unrelatedPage and anonymousPage may see expected 401 and 403 responses, such as when a test checks that an owner-only screen stays closed to them. A locator that matches several elements fails, for example a product name that appears in both a table and a chart: narrow it with a role and name, filter({ hasText }), or the region that holds it.

The owner, member, and unrelated pages can all load only the isolated development app. unrelatedPage is a distinct authenticated identity with no implicit app-authored ownership or team membership; use it to challenge owner- or team-scoped business RLS. Establish any app-domain membership through visible UI. Do not expect unrelatedPage to be denied when the app intentionally shares records with every admitted user through a permissive using (true) policy. Private production verification separately requires the anonymous canonical HEAD to redirect to Auth.

anonymousPage is a signed-out visitor: its own browser context with no session, as someone without an account sees a public app. Use it for every journey that such visitors take, such as a public sign-up, order or booking: act as the visitor, assert what the visitor sees, then check the result on ownerPage. A queue saves the submission moments after the visitor sees the confirmation, so wait for it before asserting it:

js
test("REQ-002 a visitor books a place", async ({ anonymousPage, ownerPage, uniqueValue, reloadUntilVisible }) => {
  const name = uniqueValue("Visitor", 60);
  await anonymousPage.getByLabel("Your name").fill(name);
  await anonymousPage.getByRole("button", { name: "Book my place" }).click();
  await expect(anonymousPage.getByRole("status")).toContainText("You're booked");
  await reloadUntilVisible(ownerPage.getByText(name));
  await expect(ownerPage.getByText(name)).toBeVisible();
});

reloadUntilVisible(locator) reloads that locator's page about once a second, for up to about 15 seconds, and returns whether it appeared. A reload keeps only what the page's address holds, so wait for a result the reloaded page shows without another click. Development hosts admit signed-out visitors even to a private app, so the receipt's signed-out-visitor phase says whether the app was public when the tests ran. A live private app sends signed-out visitors to sign in: make it public before you publish. For the same reason, don't write a test that expects a private app to turn a signed-out visitor away; verification of the live app checks that itself. When a submission takes a place or a slot, assert that the page shows it taken straight away, without a reload.

Each REQ test starts with fresh browser contexts and an independent runtime sandbox. The scheduler runs up to five tests concurrently by default; the verification request may set parallelism from 1 through 10. Role names accept bounded regular expressions for accessible names, label/text/test-ID/placeholder queries, and filter({ hasText }); bounded scalar matchers may supplement query checks, but scalar checks do not replace the required page or locator assertion. Scope duplicate labels through their dialog, form, or card. In finally, close open overlays and wait for cleanup state to converge before interacting with a page that another user changed. If cleanup also fails, the verifier preserves the first primary command error and adds bounded browser/network context. A failed step names its page and locator, such as memberPage.getByText("1", { exact: true }), says how many elements matched it, and quotes that page's own alert and status messages, so check the locator before assuming the data is missing. When two or more elements match, scope the locator to the card or row that holds the test's own entry, found by its uniqueValue. selectOption accepts a raw value and the bounded Playwright { value }, { label }, and { index } forms.

For controls created only after an interaction, assert the rendered phone target with expect(locator).toHaveMinimumSize({ width: 44, height: 44 }). This catches native checkbox and similar sizing defects that cannot be proven by static source inspection alone.

Verification success receipts and structured failures include an attempt report with revision/artifact/test hashes, requested parallelism, outcome, failure classification, and phase timings. Per-test evidence separates sandbox setup, browser execution, cleanup, and total time. If another request is still verifying the same dev session, wait after DEV_VERIFICATION_IN_PROGRESS; the platform preserves the active attempt instead of starting overlapping sandbox cleanup.

Development verification independently fails primary-flow HTTP 404/405, mobile horizontal overflow, unnamed visible controls, primary touch targets below 44 by 44 pixels, an element marked hidden that page styling still shows, and an urgent error message (role="alert" with aria-live="assertive") on the first screen; use role="status" for notices that are not errors. A test also fails when it ends with a page still showing an element marked hidden. Start every stylesheet with [hidden] { display: none !important; }, because a class that sets display otherwise keeps showing what the code hid, such as a loading placeholder over a photo. Wrap an action whose request should fail with a 4xx, such as a rejected sign-in, in expectResponseStatus(page, path, status, action); verification then accepts the browser's error for exactly that response. Its private-app run uses short-lived admitted synthetic users and removes their sessions, grants, and identities. Production retains only the anonymous Auth HEAD redirect check.

Run every UI assertion in development. Managed Files, Functions, and background jobs are available inside each disposable E2E sandbox and use isolated namespaces on the ordinary capability URL. Realtime and cron remain unavailable in dev and must never fall back to production. There is no production E2E run. Realtime remains best-effort and cannot block CRUD or cleanup; opencloud.data is the durable source of truth.

Inspect deployments, error logs, usage, and cron history after normal traffic.

Run manifest-derived release verification ​

The schema 2 or 3 manifest and immutable tests/opencloud.e2e.js artifact are the development-verification inputs. Use the existing @opencloud/test API rather than creating another test DSL or a local partial-verification configuration.

bash
opencloud app verify "$APP_ID"

The durable production operation checks the recorded active release, exact artifact digest and manifest, SDK metadata, and canonical HEAD. It does not execute the external E2E specification. Use the isolated dev tools to exercise Data, Files, Auth, and every declared Function before promotion; inspect cron history, logs, and usage after normal production traffic.

Development verification counts a Function as exercised once its own handler answers with a status below 500. A deliberate 4xx counts, such as a webhook rejecting a missing signature, since development never receives owner secrets; never weaken an auth check to produce a 2xx. A Function still fails DEV_FUNCTION_COVERAGE_FAILED if it only returned server errors, got no response, was rejected by OpenCloud before its handler ran (admission, access checks or SDK input validation), or was never invoked.

Apply the current authority boundary to destructive operations ​

Create and inspect backups before risky work when appropriate. Do not roll back or restore merely to test a deployment. Code rollback replaces runtime code/config but does not reverse migrations; database restore can discard newer writes.

An exact-app owner Agent may delete, archive, stop, roll back, restore, mutate production data or Files, rotate or delete secrets, and change access for its assigned app without requesting another per-operation approval. Prefer idempotent, recoverable commands, inspect the terminal operation, and verify the resulting state. The app is the security boundary, not a reason to stop.

For ordinary human-directed sessions and legacy or narrower Agent credentials, keep the prior rule: do not perform a destructive action unless the user clearly authorized that exact action. No credential permits cross-app, account, platform, host, or shared-infrastructure actions, and an explicit user instruction not to deploy or mutate remains binding.

Report completion honestly ​

For online work, record canonical URLs, IDs, exact validation/UI/verifier/log/ usage/cron outcomes, deployment state, observed friction, product limitations, and confirmation that the app remains active—or the exact terminal deletion receipt when deletion was the intended result.

For offline work, report the artifact digest and exact remaining online steps. An offline-valid bundle is useful progress, not a deployed app.

Self-hosted infrastructure for agent-built applications.