# Connect an AI agent (/agents) opencloudport has an MCP server: the standard way AI agents discover and use tools. Once connected, your agent has [a tool for every job](/agents/tools): apps, services, code, data, email and sandboxes, and the same guidance a person would get. The server lives at: ```txt https://opencloudport.com/mcp ``` It takes your API key in the `Authorization` header, the same way as the [API](/api). ## 1. Make a key for the agent [#1-make-a-key-for-the-agent] Give every agent its own key, so you can see what it did and stop it on its own. * **Whole workspace:** on the **API keys** page, choose **Create an API key**. * **Just one app** (recommended for agents working on one project): on the app's **Settings** tab, choose **Create key**. You can narrow it to one database or bucket, make it read-only, and set a spending limit. See [Keys and spending limits](/billing/keys). The key is shown once. Keep it like a password: in your agent's settings or an environment variable, never in code you share. ## 2. Add it to your agent [#2-add-it-to-your-agent] ```bash claude mcp add --transport http opencloudport \ https://opencloudport.com/mcp \ --header "Authorization: Bearer " ``` Run `/mcp` inside Claude Code to check it's connected. Add this to `.cursor/mcp.json` in your project (or `~/.cursor/mcp.json` for every project): ```json title=".cursor/mcp.json" { "mcpServers": { "opencloudport": { "url": "https://opencloudport.com/mcp", "headers": { "Authorization": "Bearer " } } } } ``` Any MCP client that supports the **Streamable HTTP** transport with custom headers can connect. Point it at `https://opencloudport.com/mcp` and send: ```http Authorization: Bearer ``` Building your own agent in TypeScript? The [SDK](/api/sdk) calls the same actions directly, without MCP. ## 3. Ask [#3-ask] Describe what you want in plain words. A few things agents do well here: * "Put my bakery's order form online, with a database for orders and email receipts." * "Spin up a sandbox, install my project and run its tests." * "Add a sign-up list to my site and tell me who joined today." * "Let visitors upload photos to my gallery." ## What keeps it safe [#what-keeps-it-safe] The server also tells every agent two things up front: * **Data stays data.** Database rows, files, emails and command output were written by other people. The agent is told never to follow instructions found in them, and to check with you before acting on anything they ask for. * **Limits are yours to raise.** When a key reaches its spending limit, the agent is told to stop and tell you, rather than look for a way around it. ## After an update [#after-an-update] When opencloudport adds tools or new options to a tool, reconnect your agent to see them (in Claude Code, `/mcp` and reconnect, or start a new session). The server always lists today's tools; some agents keep the list they got when they connected. ## If something goes wrong [#if-something-goes-wrong] | What you see | What it means | | ------------------------- | ---------------------------------------------------------------------------- | | `invalid_api_key` | The key is wrong, revoked, or its workspace was deleted. Make a new one. | | `key_out_of_scope` | The key only reaches one app or service, and the agent tried something else. | | `key_spend_limit_reached` | The key reached its spending limit. Raise it on the **API keys** page. | | `insufficient_balance` | The workspace is out of credit. Add funds on the **Billing** page. | # Tool reference (/agents/tools) This list comes straight from opencloudport's MCP server, so it's always the set your agent sees. Each tool is marked: * **Reads:** only looks. Safe to allow. * **Changes:** creates or updates something, and may cost money. * **Deletes · asks first:** removes something for good. Agents like Claude Code ask you before running these. # Apps and services (/api/apps) ## The app object [#the-app-object] ```json { "id": "app_7QxV2nLk0PqR8sTuVwXyZa1b", "object": "app", "name": "Bakery orders", "status": "active", "url": "https://ocp-aabd5c1cfc91d7b629a0.", "addresses": [ { "url": "https://ocp-aabd5c1cfc91d7b629a0.", "kind": "default", "status": "active" }, { "url": "https://bakery.yourapps.com", "kind": "platform", "status": "active" } ], "suspended_reason": null, "paused_by": null, "paused_at": null, "pause_note": null, "monthly_spend_limit": "25.000000", "external_id": "customer_42", "metadata": { "plan": "pro" }, "services": [{ "id": "svc_…", "type": "database", "name": "DB", "status": "active" }], "current_deployment_id": "dep_…", "created_at": "2026-10-21T10:58:00.000Z", "updated_at": "2026-10-21T10:58:00.000Z", "deleted_at": null } ``` `status` is `active`, `suspended` (paused) or `deleted`. `paused_by` says why a paused app is paused: | `paused_by` | Why | It runs again when | | ----------- | --------------------------------------------------------------------------------------------- | -------------------------------------------------------- | | `owner` | You (or an agent) paused it, with an optional `pause_note` only you see. `paused_at` is when. | You resume it. | | `limit` | It reached its `monthly_spend_limit`. | The month turns (UTC), or you raise or remove the limit. | | `workspace` | The workspace ran out of credit. | Funds are added. | More than one can apply at once; the app serves only when none does, and `paused_by` shows the first of them in that order. (`suspended_reason` says the same with the older names: `owner`, `limit`, `balance`.) ## Create an app [#create-an-app] `POST /api/v1/apps` Returns `201` with the new app. It starts with no services. Creating an app is free and needs no balance; adding services does. Each workspace can have up to 1,000 apps. ```bash curl https://opencloudport.com/api/v1/apps \ -H "Authorization: Bearer $OCP_KEY" -H "Content-Type: application/json" \ -d '{"name": "Bakery orders", "external_id": "customer_42"}' ``` ## List apps [#list-apps] `GET /api/v1/apps`, newest first. ## Get, rename or delete an app [#get-rename-or-delete-an-app] | Endpoint | What it does | | -------------------------- | ------------------------------------------------------------------------------------------------- | | `GET /api/v1/apps/{id}` | One app, including deleted ones. | | `PATCH /api/v1/apps/{id}` | Send `name` and/or `metadata`. `metadata` replaces what was there. | | `DELETE /api/v1/apps/{id}` | Deletes the app, **all its services and their data**, and its sandboxes. Returns the deleted app. | ## Pause and resume an app [#pause-and-resume-an-app] | Endpoint | What it does | | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `POST /api/v1/apps/{id}/pause` | Pauses it. Optional body `{ "note": "Fixing checkout" }` (up to 500 characters, for you; visitors never see it). Returns the app. | | `POST /api/v1/apps/{id}/resume` | Undoes your pause. Returns the app, which stays paused if its limit or the workspace's balance still holds it. **Workspace keys only.** | While paused, visitors get "This app is paused." (503), its sandboxes stop, and new sandboxes, emails and builds are refused (`app_paused`; `app_limit_reached` for a limit). An app you paused takes no deploys either; one paused for money still does, so a fix is ready when it runs again. Its data is kept and can still be read through the API. An app's own key can pause it but not resume it, so an agent can stop an app but only a person starts it again. ## Monthly spending limit [#monthly-spending-limit] | Endpoint | What it does | | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | `GET /api/v1/apps/{id}/spend-limit` | The limit, what the app spent this month, and whether it reached the limit. | | `PUT /api/v1/apps/{id}/spend-limit` | `{ "monthly_limit": "25.00" }` sets it (a number or a decimal string, to the cent); `null` removes it. **Workspace keys only.** | ```json { "data": { "object": "app_spend", "month": "2026-10", "monthly_limit": "25.000000", "spent_this_month": "3.418200", "spent_this_month_micros": 3418200, "limit_reached": false } } ``` Months are calendar months in UTC. Spending is everything the app's usage costs (requests, database, files, email, sandboxes, builds), priced as on your bill and checked every 10 minutes, so an app can go a little over before it pauses. Setting a limit below what's already spent pauses the app straight away. For limits per customer rather than per app, see [end users](/api/billing#end-users). ## Scheduled runs [#scheduled-runs] `GET /api/v1/apps/{id}/schedules` lists the app's schedules; `PUT` with `{ "schedules": [{ "cron": "0 9 * * *", "enabled"?: true }] }` replaces them all (at most 5; `[]` removes them). Each calls the app's code's `scheduled()` handler; expressions are five-field cron in UTC. Each schedule comes back as `{ id, cron, description, enabled, next_run_at, last_run_at, last_result, last_detail }`, where `last_result` is `ok`, `error` or `skipped` and `last_detail` says why. A mistake in any expression is `400 invalid_schedule`, naming it, and changes nothing. See [Scheduled runs](/guides/schedules). ## The service object [#the-service-object] ```json { "id": "svc_…", "object": "service", "app_id": "app_…", "type": "database", "name": "DB", "status": "active", "created_at": "…", "deleted_at": null } ``` `status` goes from `pending` to `active` within seconds (or `failed`). `name` is how the app's code reaches it (`env.DB`); Compute and Sandboxes have no name. ## Add a service [#add-a-service] `POST /api/v1/apps/{id}/services` Returns `201` with the service, usually `pending`. Needs a positive balance. | Error | When | | ------------------------------------------ | ---------------------------------------------------- | | `409 compute_exists`, `409 sandbox_exists` | An app has at most one of each. | | `409 name_taken` | Another service or a variable already has that name. | | `409 service_limit_reached` | An app can have up to 20 named services. | ## List, get or remove services [#list-get-or-remove-services] | Endpoint | What it does | | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | `GET /api/v1/apps/{id}/services` | The app's services, oldest first. | | `GET /api/v1/apps/{id}/services/{serviceId}` | One service. | | `DELETE /api/v1/apps/{id}/services/{serviceId}` | Removes it **and deletes its data**. Removing Compute also removes the app's variables; removing Sandboxes deletes its sandboxes. | ## Domains [#domains] An app's own addresses: custom domains, and its platform address under the workspace's platform domain. See [Domains](/guides/domains) for the DNS side. | Endpoint | What it does | | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | `GET /api/v1/apps/{id}/domains` | The app's domains, oldest first. Pending ones are checked again first. | | `POST /api/v1/apps/{id}/domains` | `{ "hostname": "shop.example.com" }` adds a custom domain: 201, `pending`, with the `records` to add. Up to 20 per app. | | `GET /api/v1/apps/{id}/domains/{domainId}` | One domain, checked again if pending. | | `DELETE /api/v1/apps/{id}/domains/{domainId}` | Stops serving a custom domain. | | `PUT /api/v1/apps/{id}/slug` | `{ "slug": "bakery" }` gives the app `bakery.`, replacing its old one. | | `DELETE /api/v1/apps/{id}/slug` | Removes the app's platform address. | | `GET` / `PUT` / `DELETE /api/v1/apps-domain` | The workspace's platform domain: `{ "domain": "yourapps.com" }`. Workspace keys only. | The domain object: ```json { "id": "dom_…", "object": "domain", "app_id": "app_…", "hostname": "shop.example.com", "url": "https://shop.example.com", "kind": "custom", "slug": null, "status": "pending", "ssl_status": "pending", "message": "Waiting for the domain to point here. Add the records below at your DNS provider; …", "records": [{ "type": "CNAME", "name": "shop.example.com", "value": "", "purpose": "routing", "note": "Points the domain at your app. …" }], "checked_at": "2026-10-22T12:00:00.000Z", "created_at": "2026-10-22T12:00:00.000Z" } ``` Errors: `invalid_domain` (422), `domain_taken` (409: another app has it, or it's under someone's platform domain), `too_many_domains` (409), `slug_taken` (409), `no_platform_domain` (409), `platform_address` (409: remove a platform address with `DELETE /slug`), `domains_unavailable` (503: not set up on this server). # Workspace and billing (/api/billing) These need a workspace key (`ocp_…`). Amounts are US dollars as exact decimal strings, with the same amount in millionths of a dollar in the matching `_micros` field. ## Workspace [#workspace] `GET /api/v1/workspace` returns `{ "data": { "id", "name", "slug" } }`: the workspace the key belongs to. ## Balance [#balance] `GET /api/v1/balance` ```json { "data": { "currency": "USD", "balance": "18.420000", "balance_micros": 18420000, "low_balance_threshold": "5.000000", "low_balance_threshold_micros": 5000000, "out_of_credit_since": null, "pauses_at": null, "paused_at": null } } ``` When the balance reaches $0, `out_of_credit_since` and `pauses_at` are set: apps pause at `pauses_at` unless funds are added. `paused_at` is set once they have. See [How billing works](/billing). ## Usage [#usage] `GET /api/v1/usage` adds up what was used between two days (UTC, both included). ```json { "data": [ { "meter": "email.sent", "unit": "email", "quantity": 812, "cost": "0.284200", "cost_micros": 284200 } ], "total_cost": "0.284200", "total_cost_micros": 284200, "currency": "USD", "start": "2026-10-01", "end": "2026-10-21" } ``` Each row is named by what you grouped by (`"meter"`, `"app"`, `"end_user"` or `"day"`), most expensive first. `unpriced: true` marks a meter with no price (one that isn't offered yet). The meters are listed in [How billing works](/billing#whats-measured). `end_user` is the id you passed when starting sandboxes or sending email, so you can see usage per customer. `GET /api/v1/apps/{id}/usage` takes the same query for one app, and works with a key [scoped to that app](/api/keys). ### An exact window [#an-exact-window] With `since` (and optionally `until`), usage is added up over an exact window instead of whole days, from the recorded usage itself: The answer has `since`, `until` and `latest_recorded_at`: the newest usage recorded for the workspace, so you can tell how far the numbers reach. How soon usage is recorded depends on the meter: | Meter | Recorded | | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | | Emails sent | when each is sent | | Sandbox running time | at least every 10 minutes while running, and when it stops | | Requests, CPU time, database reads, writes and storage, file operations and storage | about every hour, from the provider's analytics, stamped with when they were collected | ## End users [#end-users] If you run a platform, your customers are your end users: tag their work with `end_user` (your id for them) when you send email or start sandboxes, and you can see and cap what each one spends. At their monthly limit, new work tagged with them is refused with `402 end_user_limit_reached`; your apps keep serving everyone. These need a **workspace key**. | Endpoint | What it does | | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET /api/v1/end-users` | Everyone with usage this month (UTC) or a limit of their own: what they spent and the limit that applies, biggest spenders first. Also `default_monthly_limit`. | | `PUT /api/v1/end-users/default-spend-limit` | `{ "monthly_limit": "5.00" }`: what each end user may spend in a month unless they have their own limit. `null` removes it. | | `PUT /api/v1/end-users/{end_user}/spend-limit` | Their own limit. `null` means no limit for them, even with a default. URL-encode the id. | | `DELETE /api/v1/end-users/{end_user}/spend-limit` | Back to the default. | ```json { "object": "list", "data": [ { "object": "end_user", "end_user": "customer_42", "spent_this_month": "4.120000", "spent_this_month_micros": 4120000, "monthly_limit": "5.000000", "limit_source": "default", "limit_reached": false } ], "has_more": false, "month": "2026-10", "default_monthly_limit": "5.000000" } ``` Spending is checked when new work starts, so work already running finishes. To cap a whole app instead, give it a [monthly limit](/api/apps#monthly-spending-limit). ## Prices [#prices] `GET /api/v1/prices` lists today's price for every meter, for this workspace: ```json { "data": [ { "meter": "email.sent", "provider": "Email", "description": "…", "unit": "email", "price_per_million_units": "350.000000", "price_micros_per_million_units": 350000000 } ], "currency": "USD", "effective": "2026-10-21" } ``` Prices are per million units, so tiny prices stay exact. They're the providers' own prices, nothing added, and follow the providers the same day they change. `null` means a meter isn't offered yet. A workspace with a negotiated price sees its own. # Code and variables (/api/code) The app needs [Compute](/services/compute) first, and it must be ready (`active`). Otherwise these calls get `409 no_compute` or `409 compute_not_ready`. ## Deploy [#deploy] `POST /api/v1/apps/{id}/deployments`, as `multipart/form-data`: * A `metadata` field with JSON: * One file field per module, named by its path (`index.js`, `lib/util.js`). Types follow the extension: | Extension | Becomes | | ----------------------- | --------------- | | `.js`, `.mjs` | ES module | | `.cjs` | CommonJS module | | `.wasm` | WebAssembly | | `.txt`, `.html`, `.sql` | Text | | `.bin` | Binary data | | `.map` | Source map | Up to 100 files and 10 MB. The request must say its `Content-Length` (curl and fetch do). * Static files (HTML, CSS, scripts for the browser, images, fonts) as fields named `assets/`: `assets/index.html` is served at `/`, `assets/css/site.css` at `/css/site.css`. Up to 20,000 files, 25 MB each and 50 MB in total per upload. Content types follow the extension. * Code whose own folder is called `assets` (Vite puts a server build's chunks in `dist/server/assets/`): name its fields `modules/`, which is always code. `modules/assets/chunk.js` is the module `assets/chunk.js`. ### Static files [#static-files] Files are served before your code: a request for a file gets the file, and anything else reaches your code, which can also fetch files itself through `env.ASSETS`. With files and no code, it's a static site. `assets_config` works like Cloudflare's [static assets settings](https://developers.cloudflare.com/workers/static-assets/routing/): ```bash curl https://opencloudport.com/api/v1/apps/$APP_ID/deployments \ -H "Authorization: Bearer $OCP_KEY" \ -F 'metadata={"assets_config": {"not_found_handling": "single-page-application"}}' \ -F 'assets/index.html=@dist/index.html' \ -F 'assets/assets/app.js=@dist/assets/app.js' ``` ```bash curl https://opencloudport.com/api/v1/apps/$APP_ID/deployments \ -H "Authorization: Bearer $OCP_KEY" \ -F 'metadata={"main_module": "index.js", "compatibility_flags": ["nodejs_compat"]}' \ -F 'index.js=@dist/index.js' \ -F 'lib/util.js=@dist/lib/util.js' ``` Answers `201` once the code is live, with the deployment: ```json { "data": { "id": "dep_…", "object": "deployment", "app_id": "app_…", "status": "active", "main_module": "index.js", "compatibility_date": "2026-10-01", "compatibility_flags": ["nodejs_compat"], "modules": [{ "name": "index.js", "type": "esm", "size": 1840, "sha256": "…" }], "assets": { "count": 0, "size_bytes": 0 }, "assets_config": null, "size_bytes": 1840, "source_deployment_id": null, "error": null, "created_at": "…", "finished_at": "…" } } ``` ### Sending only what changed [#sending-only-what-changed] A deploy with many static files (images, video, fonts) doesn't have to send them all again. Ask first which ones this workspace has already deployed: ```bash curl https://opencloudport.com/api/v1/apps/$APP_ID/deployments/upload-session \ -H "Authorization: Bearer $OCP_KEY" \ -H "Content-Type: application/json" \ -d '{ "files": [{ "path": "index.html", "sha256": "…" }, { "path": "video/intro.mp4", "sha256": "…" }] }' ``` It answers `{ "data": { "missing": ["index.html"], "known": ["video/intro.mp4"] } }`. Then deploy with only the missing files, and list the known ones by hash in `metadata.assets_manifest` (path → sha256 of the file's content). Only what you send counts toward the 50 MB per upload. Only files this workspace has deployed before count as known. The SDK's `deploy()` and `deploy_from_sandbox` do this for you. ### Files left out: `.assetsignore` [#files-left-out-assetsignore] As on Cloudflare, a `.assetsignore` file at the top of your static files lists files not to publish, one gitignore-style pattern per line (`wrangler.json`, `.dev.vars`, `*.map`, `drafts/`, `!keep.map`). The `.assetsignore` file itself is never published either. A deployment is `deploying`, `active`, `superseded` (a newer one replaced it) or `failed`. If the code is refused, you get `422 deployment_rejected` with the reason, and the failed deployment is kept so you can look at it. Code that fails to start (an error at the top of a file, a missing module, a `node:` import without `nodejs_compat`) is refused this way, with the error, and the app keeps running its previous code. ## Roll back [#roll-back] `POST /api/v1/apps/{id}/deployments/{deploymentId}/redeploy` deploys an earlier deployment's code and files again, as a new deployment whose `source_deployment_id` points at the original. ## List and get deployments [#list-and-get-deployments] | Endpoint | What it does | | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET /api/v1/apps/{id}/deployments` | Newest first. Paged with `limit` and `starting_after`. | | `GET /api/v1/apps/{id}/deployments/{deploymentId}` | One deployment, with `source`: `{ "type": "git", repo, branch, commit, commit_message, build_id }`, `{ "type": "sandbox", sandbox_id, folder, code_folder }`, or `null` for an upload. | To deploy what a sandbox built without uploading it yourself, see [Deploy what a sandbox built](/api/sandboxes#deploy-what-a-sandbox-built). ## Variables [#variables] Settings and secrets your code reads as `env.NAME`. Changes apply straight away, without a new deployment. | Endpoint | What it does | | ------------------------------------- | ------------------------------------------------------ | | `GET /api/v1/apps/{id}/env` | Every variable. Secrets come back with `value: null`. | | `PUT /api/v1/apps/{id}/env/{NAME}` | Sets one. `201` if it's new, `200` if it replaced one. | | `DELETE /api/v1/apps/{id}/env/{NAME}` | Removes one. | ```bash curl -X PUT https://opencloudport.com/api/v1/apps/$APP_ID/env/STRIPE_KEY \ -H "Authorization: Bearer $OCP_KEY" -H "Content-Type: application/json" \ -d '{"value": "sk_live_…"}' ``` Names are letters, digits and underscores, starting with a letter or underscore, and can't be a service's name. An app can have up to 100. # Data (/api/data) These calls work on one service: `{serviceId}` is its `svc_…` id. A key made for just that service can use them. ## Run SQL [#run-sql] `POST /api/v1/apps/{id}/services/{serviceId}/query` ```json { "data": [ { "columns": ["item", "qty"], "rows": [["Sourdough", 2]], "truncated": false, "rows_read": 1, "rows_written": 0 } ], "duration_ms": 3 } ``` One result per statement, each with up to 1,000 rows (`truncated` says if there were more). A SQL error is `400 query_failed`, with the database's own message. ### Reading counts as reading [#reading-counts-as-reading] SQL that only reads counts as a read, though it's a `POST`: every statement a `SELECT`, `WITH … SELECT`, `VALUES`, `EXPLAIN`, or a reading `PRAGMA` (`table_info`, `index_list`, `foreign_key_list`…), and no word that changes data (`INSERT`, `UPDATE`, `DELETE`, `CREATE`, `DROP`…) outside strings and comments. So: * **Read-only keys** can run it; anything else from them is `403 key_out_of_scope`. * **Spending limits** don't stop it: a key at its limit can still read. * **The activity log** leaves it out, as it does every read. The check errs on the side of "changes": if a read trips it, run it as a change. Agents get two tools for this: `query_database` reads, and `execute_sql` changes data (it asks the person first). ### Migrations [#migrations] Apply schema migrations in order, each once. Applied names are kept in a `d1_migrations` table, the same one `wrangler d1 migrations apply` uses, so you can switch between the two: ```bash curl https://opencloudport.com/api/v1/apps/$APP_ID/services/$DB_ID/migrations \ -H "Authorization: Bearer $OCP_KEY" \ -H "Content-Type: application/json" \ -d '{ "migrations": [ { "name": "0001_init.sql", "sql": "create table orders (id integer primary key, item text)" }, { "name": "0002_email.sql", "sql": "alter table orders add column email text" } ] }' ``` Send every migration each time: the ones already applied are skipped. It answers `{ "data": { "applied": [...], "skipped": [...], "migrations": [...] } }`. The first one that fails stops the run with `400 migration_failed` and the database's message; the ones before it stay applied. A database can't undo part of a migration, so keep each one small. `GET …/migrations` lists what's applied. Over MCP: `apply_migrations` (asks first) and `list_migrations`. ### Copies and loading SQL files [#copies-and-loading-sql-files] * `GET /api/v1/apps/{id}/services/{serviceId}/export` streams a copy of the database as a `.sql` file (`content-disposition: attachment`): tables, their rows as `INSERT`s, then indexes, triggers and views. Built with paged queries, so the database keeps answering while it runs. Read-only keys may export. * `GET …/export-link` answers `{ "data": { "url", "expires_at", "expires_in" } }`: a link to a fresh copy that works without a key for 15 minutes. * `POST …/import` runs a SQL file (the body, as `application/sql` or `text/plain`, or JSON `{ "sql": "…" }`), up to 10 MB, in batches. Answers `{ "data": { "statements", "rows_written" } }`. A failing statement stops it with `400 query_failed`, saying how far it got; what ran before stays. `BEGIN`, `COMMIT` and `PRAGMA` lines are skipped. ## Files [#files] ### List [#list] `GET /api/v1/apps/{id}/services/{serviceId}/files` ```json { "object": "list", "data": [{ "object": "file", "key": "uploads/cake.jpg", "size": 120000, "uploaded_at": "…" }], "next_cursor": null, "has_more": false } ``` ### Delete [#delete] `DELETE /api/v1/apps/{id}/services/{serviceId}/files?key=uploads/cake.jpg`. Deleting a file that isn't there succeeds. ### Signed links [#signed-links] `POST /api/v1/apps/{id}/services/{serviceId}/signed-url` returns a link that downloads, uploads or deletes one file until it expires. Hand it to a browser for direct uploads. ```json { "data": { "url": "https://…", "method": "PUT", "key": "uploads/photo.jpg", "expires_at": "…" } } ``` ### Temporary credentials [#temporary-credentials] `POST /api/v1/apps/{id}/services/{serviceId}/credentials` returns S3 credentials that reach only this bucket, for any S3 tool or library. ```json { "data": { "access_key_id": "…", "secret_access_key": "…", "session_token": "…", "endpoint": "https://…", "bucket": "…", "region": "auto", "expires_at": "…" } } ``` Where these can't be issued (local development), you get `501 not_supported`; use signed links instead. Changing files (delete, signed links, credentials) needs a key that isn't read-only. ## Send email [#send-email] `POST /api/v1/apps/{id}/services/{serviceId}/send` sends one email from the app's own address, or from the service's own domain once it's verified (see below). Answers `202` with `{ "data": { "id": "msg_…", "object": "email" } }`. Each email is billed as one `email.sent`, to the key that sent it. Not sent while the app is paused (`402 app_paused`); if the provider refuses it (`502 email_failed`), nothing is billed. ### Sender domain [#sender-domain] An email service can send from its own domain once it's verified. * `PUT /api/v1/apps/{id}/services/{serviceId}/sender-domain` with `{ "domain": "bakery.com", "default_from"?: "hello" }` sets it up (or changes `default_from`). It answers the domain with `status: "pending"` and the DNS `records` to add (`type`, `name`, `value`, `priority` for MX, `purpose`: `spf`, `dkim`, `dmarc` or `bounces`). Replacing it with another domain starts again. * `GET …/sender-domain` answers it (or `null`), checking again while it's pending: `pending`, `verified`, or `failed` with `problem` saying why. * `DELETE …/sender-domain` goes back to the app's own address. Each domain can be used by one email service across all workspaces (`409 domain_taken`). Addresses like URLs, IPs and our own domains are refused with `422 invalid_domain`. # API overview (/api) Everything you can do in the dashboard, you can do over HTTP. The API is plain JSON over HTTPS at: ```txt https://opencloudport.com/api/v1 ``` The [TypeScript SDK](/api/sdk) wraps it, and [AI agents](/agents) use the same actions over MCP. ## Keys [#keys] Send your key in the `Authorization` header: ```bash curl https://opencloudport.com/api/v1/workspace \ -H "Authorization: Bearer $OCP_KEY" ``` `GET /api/v1/workspace` is a handy first call: it answers with the workspace the key belongs to (`id`, `name`, `slug`). There are two kinds of key: | Key | Starts with | Reaches | Made on | | ------------- | ----------- | ---------------------------------------------------- | ------------------------------------------------------------------- | | Workspace key | `ocp_` | Everything in the workspace. | The **API keys** page. | | App key | `ocps_` | One app, or one service in it, optionally read-only. | The app's **Settings** tab, or [`POST /apps/{id}/keys`](/api/keys). | A read-only key can only make `GET` requests. A key used outside its reach gets `403 key_out_of_scope`. Any key can have a [spending limit](/billing/keys#spending-limits); once reached, changes get `402 key_spend_limit_reached` and reads still work. Every change a key makes (anything but `GET`) is written to its activity log, with what was done and whether it worked. Request bodies are never recorded. ## Requests [#requests] * Send JSON with `Content-Type: application/json`. Deployments are the one exception: they're `multipart/form-data`. * Bodies are checked strictly: an unknown field is an error, so typos don't pass silently. * Bodies are limited to 16 KB, except where an endpoint says otherwise (SQL 128 KB, email 1 MB, sandbox files 25 MB, deployments 10 MB of code plus 50 MB of static files). Every limit is on one page: [Limits](/limits). * Ids in paths are always ids (`app_…`, `svc_…`), never names. Variable names and sandbox process names are the only names in paths. ## Responses [#responses] One object comes back in `data`: ```json { "data": { "id": "app_7QxV…", "object": "app", "name": "Bakery orders", "status": "active" } } ``` Lists come back as: ```json { "object": "list", "data": [ … ], "has_more": false } ``` Apps and deployments are paged: pass `limit` (1 to 100, default 20) and `starting_after` with the last id you got. Files in a bucket use `cursor` and `next_cursor` instead. Money is in US dollars, as exact decimal strings (`"12.500000"`), with the same amount in millionths of a dollar alongside (`balance_micros`). Times are ISO 8601 in UTC. ## Errors [#errors] Every error has the same shape and a matching HTTP status: ```json { "error": { "code": "name_taken", "message": "This app already has a service named \"DB\". Pick another name." } } ``` Check `code` in your code; `message` is written for people and may change. When a field is wrong, its name starts the message (`"name: name is required"`). | Status | Common codes | What to do | | -------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | 400 | `invalid_request`, `invalid_json`, `query_failed` | Fix the request. The message says what's wrong. | | 401 | `missing_api_key`, `invalid_api_key` | Send a valid key. | | 402 | `insufficient_balance`, `app_paused`, `key_spend_limit_reached` | Add funds, or raise the key's limit. | | 403 | `key_out_of_scope` | Use a key that reaches this app or service. | | 403 | `limited_key` | This key has a spending limit, so it can only tighten limits, make read-only keys and pause. Do it in the dashboard, or with a key that has no limit. | | 404 | `not_found` | The id doesn't exist in this workspace. | | 409 | `name_taken`, `app_deleted`, `compute_not_ready`, `idempotency_in_progress`, … | The app isn't in a state for this yet, or the same request is still running. The message says why. | | 413, 415 | `body_too_large`, `unsupported_media_type` | Send less, or send JSON. | | 422 | `idempotency_key_reused` | This `Idempotency-Key` was used for a different request. Use a new key. | | 429 | `rate_limited` | Too many requests with this key. Wait for the seconds in `Retry-After`, then try again. | | 502, 503 | `deployment_failed`, `sandbox_unavailable`, … | Something upstream failed. Try again shortly. | ## Ids [#ids] | Thing | Looks like | | ---------- | --------------------------------------- | | App | `app_` + 24 characters | | Service | `svc_` + 24 characters | | Deployment | `dep_` + 24 characters | | App key | `key_` + 24 characters | | Sandbox | `sbx` + 20 lowercase letters and digits | | Email | `msg_` + 32 characters | ## Retrying safely [#retrying-safely] Send an `Idempotency-Key` header with any `POST` you might retry: sending an email, deploying, creating an app or a sandbox. Use a new random value (a UUID) for each new request, and the same one when you retry it. ```bash curl -X POST https://opencloudport.com/api/v1/apps/$APP_ID/services/$EMAIL_ID/send \ -H "Authorization: Bearer $OCP_KEY" \ -H "Idempotency-Key: 3f6c1a52-8d7e-4b1f-9c0a-5e2d7b9f4a11" \ -H "Content-Type: application/json" \ -d '{ "to": "ann@example.com", "subject": "Your order", "text": "Thanks!" }' ``` * A retry with the same key and the same request gets the first answer back, with the header `Idempotent-Replayed: true`, and nothing happens twice: one email, one deployment. * The same key with a different request is refused with `422 idempotency_key_reused`. * A retry that arrives while the first request is still running gets `409 idempotency_in_progress`; wait a moment and retry. * Keys are remembered for 24 hours, separately for each API key. Answers that were server errors (5xx) aren't remembered, so retrying those really tries again. These are also safe to repeat, with or without the header: * `POST /apps` with the same `external_id` returns the app you already made. * Deleting an app, service, sandbox or file that's already gone succeeds. * `PUT /apps/{id}/env/{NAME}` sets the same value again. ## Rate limits [#rate-limits] Each API key has its own limit, so one busy key never slows another. Calls made by an AI agent through MCP count towards its key the same way. | What | Average | Bursts of up to | | ------------------ | ----------------- | --------------- | | Every request | 20 a second | 60 | | Sending email | 2 a second | 20 | | Starting a sandbox | 1 every 5 seconds | 5 | Over the limit, you get `429 rate_limited` with a `Retry-After` header in seconds. Waiting that long and retrying always works. ## Money checks [#money-checks] Two calls need a positive balance, and get `402 insufficient_balance` without one: adding a service, and starting a sandbox. Creating an app is free. While a workspace is paused for lack of credit, sending email and using sandboxes get `402 app_paused`. See [How billing works](/billing). ## Endpoints [#endpoints] # App keys (/api/keys) App keys (`ocps_…`) are for agents, scripts and servers that should only touch one app. Only a **workspace** key can make, change or revoke them. See [Keys and spending limits](/billing/keys) for how limits work. ## The key object [#the-key-object] ```json { "id": "key_…", "object": "scoped_key", "name": "Report agent", "app_id": "app_…", "service_id": "svc_…", "access": "read", "start": "ocps_3fQ1x", "created_at": "…", "last_used_at": null, "spend_limit": "5.000000", "spent": "0.000000" } ``` ## Make a key [#make-a-key] `POST /api/v1/apps/{id}/keys` Returns `201` with the key object plus `key`: the secret itself, **shown only this once**. ### Keys made by keys [#keys-made-by-keys] An app key can make keys too, for the sub-agents it hands work to, but never broader than itself: * only for its own app (a workspace key can make keys for any app); * read-only if it's read-only itself; * within its spending limit, if it has one (see below). Anything else is `403 key_out_of_scope`, with the reason. A key that made other keys can change their limits or revoke them, and revoke itself, but no other keys. ### Keys with a spending limit only make things safer [#keys-with-a-spending-limit-only-make-things-safer] Any key with a spending limit of its own, workspace key or app key, can only tighten things. Otherwise an agent could give itself room you never gave it. Such a key: * can add a spending limit or lower one (on an app, an end user, the end-user default or another key), but not raise or remove one; * can make read-only keys freely, but a key that can change things only with a spending limit of its own, no higher than what it has left; * can pause an app, but not resume it; * can list, test, rename and turn off webhooks, but not add one or change where one sends or what it sends. A key's limit covers everything spent by the keys it made, and by the keys they made, revoked ones included. So handing a sub-agent a key never gets round a limit: when the family reaches it, every key in it is stopped. A key below with its own, smaller limit stops at that one first. Anything else gets `403 limited_key`, saying what the key may still do. You can always do these yourself in the dashboard, or with a key that has no limit. Over MCP, the tools that give an agent more room (`create_app_key`, `set_app_spend_limit`, `set_end_user_spend_limit`, `pause_app`, `resume_app`, `create_webhook`) are marked to ask you first. ## List, limit or revoke [#list-limit-or-revoke] | Endpoint | What it does | | --------------------------------------- | -------------------------------------------------------------------------------- | | `GET /api/v1/apps/{id}/keys` | The app's keys, newest first, with what each has spent. Secrets are never shown. | | `PATCH /api/v1/apps/{id}/keys/{keyId}` | Set `spend_limit` (dollars), or `null` to remove it. | | `DELETE /api/v1/apps/{id}/keys/{keyId}` | Revoke it, at once. | ## What a key can reach [#what-a-key-can-reach] | Key | Can call | | ----------------------- | ------------------------------------------------------------------------------------------------------------ | | Workspace key | Everything. | | App key | `/api/v1/apps/{its app}` and everything under it. | | App key for one service | `/api/v1/apps/{app}/services/{its service}` and everything under it. Look the service up by id, not by name. | | Read-only | Only `GET`, and SQL that only reads ([Data](/api/data#reading-counts-as-reading)), within the above. | Workspace-wide calls (`/workspace`, `/balance`, `/usage`, `/prices`, and listing or creating apps) need a workspace key. An app key sees its own app's usage at `GET /api/v1/apps/{id}/usage`. # Sandboxes (/api/sandboxes) The app needs the Sandboxes service first (`POST /apps/{id}/services` with `type: "sandbox"`). See [Sandboxes](/services/sandboxes) for sizes and how sleeping works. ## The sandbox object [#the-sandbox-object] ```json { "id": "sbxab12cd34ef56gh78ij", "object": "sandbox", "app_id": "app_…", "size": "standard-1", "status": "running", "idle_timeout_seconds": 900, "internet": true, "end_user": null, "label": "Preview build", "image": "default", "keep_files": true, "keep_dependencies": true, "saved_files": { "saved_at": "…", "size_bytes": 48211 }, "saved_files_note": null, "setup_command": "pnpm install", "setup_runs": "once", "setup": { "ran_at": "…", "exit_code": 0, "output": "Done in 9.4s\n", "note": null }, "public_preview": false, "preview_inject": null, "preview_auth": "cookie", "preview_embedders": [], "preview_url": "https://sbxab12cd34ef56gh78ij-{port}./?ocp_preview=…", "created_at": "…", "deleted_at": null } ``` `status` is `running`, `asleep`, `stopped` or `deleted`. Replace `{port}` in `preview_url` with the port your server listens on. * **Private previews.** Unless `public_preview` is `true`, `preview_url` carries a signed link that works for an hour. Opening it lets that browser in for the hour; anyone without it gets "This preview is private". Fetch the sandbox again for a fresh link. * **Files are kept.** With `keep_files` on (the default), everything in `/workspace` is saved when the sandbox stops or falls asleep, and is back when it starts again, before anything runs. Installed packages (`node_modules` and package caches) are kept too, so there's no reinstall; set `keep_dependencies: false` to save less and install again instead. Up to 2 GB is kept; `saved_files_note` says when a save was skipped and why. `saved_files` says when they were last saved. (Sandboxes made before 22 October 2026 left packages out.) * **Setup command.** `setup_command` runs in `/workspace` when it starts, before anything else: `pnpm install`, or tools it needs. With `setup_runs: "once"` (the default) it runs again only if the command changes or its saved files are gone, since what it wrote into `/workspace` comes back with them. Use `"every_start"` for things installed outside `/workspace`, like system packages (`apt-get install -y ffmpeg`), which aren't saved. The first command after a start waits for it (up to 10 minutes); `setup` says how it last went. ## Start one [#start-one] `POST /api/v1/apps/{id}/sandboxes` Returns `201`. Needs a positive balance. Up to 50 per app and 200 per workspace. | Endpoint | What it does | | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET /api/v1/apps/{id}/sandboxes` | The app's sandboxes. | | `GET /api/v1/apps/{id}/sandboxes/{sandboxId}` | One sandbox. | | `PATCH /api/v1/apps/{id}/sandboxes/{sandboxId}` | Change `setup_command` and `setup_runs` (from its next start), and `public_preview`, `preview_inject`, `preview_auth` and `preview_embedders` (at once, no restart). | | `GET /api/v1/apps/{id}/sandboxes/{sandboxId}/preview-link?port=5173` | A fresh signed link to a preview (an hour), with its `token` and `expires_at`. | | `POST /api/v1/apps/{id}/sandboxes/{sandboxId}/stop` | Stop it and stop paying. Its files are saved first if it keeps them. | | `POST /api/v1/apps/{id}/sandboxes/{sandboxId}/start` | Start a stopped or asleep one again: its saved files put back (or an empty workspace if it doesn't keep them), same size and variables. Needs a positive balance. A running one is left as it is. (Running a command also starts it.) | | `DELETE /api/v1/apps/{id}/sandboxes/{sandboxId}` | Delete it. | ## Run a command [#run-a-command] `POST /api/v1/apps/{id}/sandboxes/{sandboxId}/exec` runs a command and waits for it. ```json { "data": { "exit_code": 0, "stdout": "…", "stderr": "", "truncated": false } } ``` A stopped or sleeping sandbox starts again first (with its saved files, if it keeps them). ### Output as it happens [#output-as-it-happens] With `"stream": true` the answer is `text/event-stream`: an event for each piece of output, then one for how it ended. ```text event: stdout data: {"type":"stdout","data":"added 214 packages\n"} event: exit data: {"type":"exit","exitCode":0} ``` `stderr` events look the same, and `error` (`{ "type": "error", "message" }`) means the sandbox stopped answering. With the SDK: `for await (const chunk of box.execStream('pnpm build')) { … }`. MCP tools can't stream: for long jobs an agent starts a process and checks on it. ## Files [#files] | Endpoint | What it does | | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PUT /api/v1/apps/{id}/sandboxes/{sandboxId}/files` | Write files. Body: `{ "files": [{ "path", "content", "encoding"? }] }`, up to 500 files and 20 MB. `encoding` is `utf8` (default) or `base64`. Paths are relative to `/workspace`. | | `GET …/files?path=src/index.ts` | A file's raw contents (not JSON). | | `GET …/files?path=src&list=1` | What's in a folder: `name`, `type` (`file`, `directory`, `other`) and `size`. | | `DELETE …/files?path=dist` | Delete a file or folder. | ### Between a sandbox and a bucket [#between-a-sandbox-and-a-bucket] Files move straight between the sandbox and one of the app's buckets (`bucket` is the storage service's `svc_…` id), binary files included, without passing through you. At most 1,000 files and 50 MB per call. | Endpoint | What it does | | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `POST …/copy-from-bucket` | `{ "bucket", "prefix"?, "path"? }`: copy every file under `prefix` into the sandbox at `path` (default `/workspace`), keeping its path below the prefix. Returns `{ copied, size_bytes, path }`. | | `POST …/save-to-bucket` | `{ "path", "bucket", "prefix"? }`: save a file or folder of the sandbox under `prefix`, keeping paths below the folder, replacing files with the same keys. Returns `{ saved, size_bytes, prefix }`. | ## Deploy what a sandbox built [#deploy-what-a-sandbox-built] `POST /api/v1/apps/{id}/sandboxes/{sandboxId}/deploy` makes a sandbox's output live: it's packed in the sandbox and deployed like an upload, so nothing is read back through you and binary files are fine. Answers `201` with the [deployment](/api/code#deploy) once it's live; its `source` says which sandbox and folders it came from. For a TanStack Start app (or anything built with `@cloudflare/vite-plugin`), build it and give just `{ "wrangler_config": "dist/server/wrangler.json" }`: the server code, its entry, the client files in `dist/client` and `nodejs_compat` all come from that file. With a code folder that holds a `wrangler.json`, it's found by itself; with nothing at all, `.wrangler/deploy/config.json` (which the plugin writes) points to it. Otherwise give `assets_folder`, `code_folder` with `main`, or both; anything you give wins over the config. If the code fails to start, the deploy is refused with the error and the app keeps its previous code. It uses no time beyond the sandbox's own. ## Previews inside your own pages [#previews-inside-your-own-pages] For platforms that show a sandbox's preview inside their own app, in a frame. * **Who may frame it.** Set `preview_embedders` to your app's origins, like `["https://app.example.com"]`. Previews then send `Content-Security-Policy: frame-ancestors 'self' https://app.example.com`, so other sites can't show them. * **Access in a frame.** In the default `cookie` mode, opening the signed link leaves a cookie made for frames: `SameSite=None; Secure; Partitioned` (each site that frames it gets its own). Where a browser blocks even that, use `preview_auth: "url"`. The token then stays in the preview's address, and a small service worker adds it to the page's own requests, so scripts, styles and module imports load. The page reloads once while that worker starts. * **Renew without reloading.** A link works for an hour. Before then, get a new one with `GET …/preview-link?port=…` and pass its token to the frame: ```js frame.contentWindow.postMessage({ type: 'ocp-preview-token', token }, previewOrigin) ``` The preview takes it from its embedders only (from its parent if none are set), and keeps working. It posts `{ type: 'ocp-preview-ready' }` to its parent when it loads. * **Your own tools in the page.** `preview_inject` adds a script to every HTML page the preview serves, before ``: `{ "script_url": "https://…" }` or `{ "inline": "…" }` (up to 16 KB). Use it for click-to-edit, "view as" or your own checks, without a proxy of your own. It's only added to previews, never to deployed apps. It runs under the page's own Content-Security-Policy with a nonce we add, so a strict policy doesn't block it. * **Hot reload works through private previews** in both modes. Tell your dev server the address it's reached at. For Vite: `server: { allowedHosts: true, hmr: { clientPort: 443 } }`; locally (`pnpm sandbox:dev`) use `clientPort: 8788`. Change any of these later with `PATCH`, without restarting the sandbox. ## Long-running processes [#long-running-processes] For dev servers and anything else that keeps running. | Endpoint | What it does | | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `POST /api/v1/apps/{id}/sandboxes/{sandboxId}/processes` | Start one: `{ "name": "dev", "command": "pnpm dev --host 0.0.0.0 --port 5173" }`. Replaces a process with the same name. | | `GET …/processes/{name}` | Whether it's running, its exit code, and the end of its output (`log`). | | `DELETE …/processes/{name}` | Stop it and everything it started. | Process names are lowercase letters, digits and dashes, up to 41 characters. # TypeScript SDK (/api/sdk) `@opencloudport/sdk` covers everything in the API, with types. It has no dependencies and runs in Node 18+, Bun, Deno, Cloudflare Workers and browsers. The SDK isn't published yet. Until it is, everything it does is also a plain HTTP call in this API reference. ## Set up [#set-up] ```ts import { OpenCloudPort } from '@opencloudport/sdk' const ocp = new OpenCloudPort({ apiKey: process.env.OCP_API_KEY!, // a workspace key (ocp_…) or an app key (ocps_…) baseUrl: 'https://opencloudport.com', }) ``` Every call throws an `OcpError` when it fails, with the API's `status`, `code` and `message`: ```ts import { OcpError } from '@opencloudport/sdk' try { await ocp.apps.create({ name: 'Bakery site' }) } catch (err) { if (err instanceof OcpError && err.code === 'insufficient_balance') { // ask the person to add funds } } ``` ## An app, start to finish [#an-app-start-to-finish] ```ts const { id } = await ocp.apps.create({ name: 'Bakery site', external_id: 'customer_42' }) const app = ocp.app(id) await app.services.add({ type: 'compute' }) await app.services.add({ type: 'database' }) // env.DB await app.services.add({ type: 'storage', name: 'IMAGES' }) // env.IMAGES await app.services.add({ type: 'email' }) // env.EMAIL await app.services.waitUntilReady() await app.deploy({ main: 'index.js', files: { 'index.js': code } }) await app.env.set('STRIPE_KEY', 'sk_live_…') ``` ## Reference [#reference] ### `ocp` [#ocp] | Method | What it does | | -------------------------------------------------------------------------- | ----------------------------------------------------- | | `apps.create({ name, external_id?, metadata? })` | Make an app (or get the one with that `external_id`). | | `apps.list({ limit?, starting_after?, status?, external_id? })` | A page of apps. | | `apps.get(id)`, `apps.update(id, { name?, metadata? })`, `apps.delete(id)` | One app. | | `app(id)` | Everything about one app, below. | | `balance()` | The workspace's balance. | | `prices()` | Today's price for every meter. | ### `app.services` [#appservices] | Method | What it does | | ----------------------------------------------- | ------------------------------------------------------------------------- | | `add({ type, name? })` | Plug in a service. | | `list()`, `get(serviceId)`, `remove(serviceId)` | | | `waitUntilReady({ timeoutMs?, intervalMs? })` | Wait until every service is set up. Throws `service_failed` or `timeout`. | ### Code and variables [#code-and-variables] | Method | What it does | | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | | `app.deploy({ main, files, compatibilityDate?, compatibilityFlags? })` | Upload and deploy. `files` maps each path to a string, bytes or a Blob. Resolves once it's live. | | `app.deployments.list()`, `.get(id)`, `.redeploy(id)` | `redeploy` is a rollback. A deployment's `source` says where it came from. | | `app.usage({ start?, end?, group_by? })` | This app's usage and cost (`ocp.usage(…)` for the whole workspace). | | `app.env.list()`, `.set(name, value, { type? })`, `.remove(name)` | Variables. `type` is `'secret'` (default) or `'plain'`. | ### Databases: `app.database(nameOrId)` [#databases-appdatabasenameorid] | Method | What it does | | ------------------------------------ | --------------------------------------------------------------------------------------------------------- | | `query(sql, params?, { readOnly? })` | Run SQL. One result per statement. With `readOnly`, SQL that would change data is refused before it runs. | | `rows(sql, params?)` | The last statement's rows, as objects keyed by column. | ### Files: `app.storage(nameOrId)` [#files-appstoragenameorid] | Method | What it does | | ----------------------------------------- | -------------------------------------------------------------------------------------------- | | `put(key, body, { contentType? })` | Upload, straight to the bucket. | | `get(key)` | A `Response` (read it with `.text()`, `.arrayBuffer()`…), or `null` if there's no such file. | | `list({ prefix?, cursor?, limit? })` | A page of files. | | `delete(key)` | | | `signedUrl(key, { method?, expiresIn? })` | A link to hand a browser. | | `credentials()` | Temporary S3 credentials for this bucket, or `null` where they can't be issued. | ### Email: `app.email(nameOrId)` [#email-appemailnameorid] `send({ to, subject, text?, html?, from_name?, reply_to?, end_user? })`. Inside an app's own code, use `connectEmail(env.EMAIL).send(…)` instead: it needs no API key. ### Sandboxes [#sandboxes] | Method | What it does | | -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | `app.sandboxes.create({ size?, idle_timeout_seconds?, internet?, env?, label?, end_user? })` | Start one. | | `app.sandboxes.list()`, `.get(id)` | | | `app.sandbox(id).exec(command, { cwd?, env?, timeout_ms? })` | Run and wait. | | `.writeFiles(files)` | `{ path: text }`, or a list with base64 content for binary files. | | `.readFile(path)` | Text, or `null`. | | `.list(path?)`, `.remove(path)` | | | `.startProcess(name, command, { cwd?, env? })`, `.process(name)`, `.stopProcess(name)` | Long-running servers. | | `.previewUrl(port)` | The preview link, or `null` until previews have a domain. | | `.deploy({ folder?, codeFolder?, main?, assetsConfig? })` | Make what it built live: `folder` is static files (`dist`), `codeFolder` + `main` bundled code. Nothing is read back through you. | | `.copyFromBucket(bucket, { prefix?, path? })`, `.saveToBucket(path, bucket, { prefix? })` | Move files between the sandbox and a bucket (by name or id), binary included. | | `.start()`, `.stop()`, `.delete()` | Start again after stopping (empty workspace), stop to stop paying, or delete. | ### App keys: `app.keys` [#app-keys-appkeys] `list()`, `create({ name, service_id?, access?, spend_limit? })` (the secret `key` is returned this once), `setSpendLimit(keyId, dollarsOrNull)` and `revoke(keyId)`. An app key can make keys for its sub-agents too, [never broader than itself](/api/keys#keys-made-by-keys). ## Using an app key [#using-an-app-key] A key for one app works the same way, within its app. A key for just one service can't list the app's services, so name the service by id instead: ```ts const db = ocp.app(appId).database('svc_…') // not 'DB' ``` # How billing works (/billing) opencloudport is prepaid. Each workspace has one balance; every app and every agent in it draws from that balance. There are no plans and no seats. **Usage is charged at the providers' own prices, with nothing added.** Our fee is on adding funds instead: 5.5% of each top-up, and never less than $0.80. ## Add funds [#add-funds] On the **Billing** page, choose **Add funds** and pay by card. Owners and admins can add funds and see billing. The whole amount you choose goes on your balance. The fee is added on top and shown before you pay: 5.5% of the amount, or $0.80 if that's more (it is below about $14.55). Adding $100 costs $105.50; adding $10 costs $10.80. Creating apps is free. Two things need a positive balance before they start: adding a service, and starting a sandbox. Everything already running keeps running as usage is measured. ## What's measured [#whats-measured] Each service is counted by what it actually does. The Billing page shows each one in these words, per month, and the [usage API](/api/billing#usage) can add it up by app, by day, or by your own customers. Things that are asleep, stopped or idle cost nothing to run: Compute while nobody visits, sandboxes while asleep or stopped. What you keep (database size, stored files) is measured as an average over the month. ## Prices [#prices] Every price is the provider's own published price, converted to the units we measure in. When a provider changes its price, ours changes the same day; usage already charged keeps the price it was charged at. You pay from the first unit: providers' free allowances are shared across our whole account, so they aren't passed on. Sandboxes are priced as if their CPU is busy the whole time they run, because only running time can be measured; the provider bills CPU only while it's working, so this is the most a sandbox can cost. The same prices are in the [prices API](/api/billing#prices) and in the `get_prices` tool for agents. ## When the balance runs low [#when-the-balance-runs-low] | When | What happens | | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | The balance drops below your threshold (default $5, set on the **Billing** page) | Owners and admins get one "running low" email. It's sent again only after the balance has gone back above the threshold. | | The balance reaches $0 | An "out of credit" email. New apps, services and sandboxes are refused. A 24-hour countdown starts. | | Still at $0 after 24 hours | The workspace's apps pause, and a "paused" email goes out. | | Funds are added | Paused apps start again on their own, within minutes. | **Nothing is ever deleted for lack of credit.** Paused apps keep all their data. Storage is still measured while paused, so the balance can go below $0. The [balance API](/api/billing#balance) shows where a workspace is in this: `out_of_credit_since`, `pauses_at` and `paused_at`. ## Limits per app and per customer [#limits-per-app-and-per-customer] You don't have to wait for the balance to run out. On an app's **Settings** tab: * **Pause this app** stops it straight away: visitors see "This app is paused.", its sandboxes stop, and nothing new runs until you resume it. Nothing is deleted. * **Monthly spending limit**: when the app has spent that much in a calendar month, it pauses until the month turns or you raise the limit. Its **Usage** tab shows how close it is. If your apps serve your own customers, tag their usage with `end_user` and give each a monthly limit under **End users** on the **Billing** page (or one default for everyone). At their limit, new emails and sandboxes for that customer are refused; your apps keep serving. See the [API](/api/billing#end-users). # Keys and spending limits (/billing/keys) Agents work fast. Keys are how you stay in charge: one per agent or script, each with only the reach and budget it needs. ## Kinds of key [#kinds-of-key] | Key | Reaches | Make it on | | --------------------------- | -------------------------------------------------------- | ------------------------------------------- | | **Workspace key** (`ocp_…`) | Everything in the workspace. | **API keys** page → **Create an API key** | | **App key** (`ocps_…`) | One app, or one database, bucket or email service in it. | The app's **Settings** tab → **Create key** | An app key can also be **read-only**: it can look at everything it reaches, but change nothing. Good for an agent that writes reports. A key is shown once, when it's made. Treat it like a password: keep it in your agent's settings or an environment variable, never in shared code. Owners and admins manage keys. ## Spending limits [#spending-limits] Any key can have a spending limit in US dollars. What counts is what the key itself caused: sandboxes it started and email it sent. At the limit: * **Changes are refused** with `402 key_spend_limit_reached`, and the key's sandboxes are stopped. * **Reading still works**, so the agent can still tell you what happened. * Raise or remove the limit and it carries on. Set a limit when you make a key, or later with **Limit** on the **API keys** page. The page shows what each key has spent, like "$3.10 spent of $5.00". ## Activity log [#activity-log] Every change a key makes is written down in plain words: "Deployed code", "Started a sandbox", "Ran SQL on a database". Each entry shows when, which app, whether it worked, failed or was refused, and whether an AI agent did it over MCP. What was sent is never recorded. Open it with **Activity** next to a key on the **API keys** page, or next to an app key on the app's **Settings** tab. ## Revoking [#revoking] Revoke a key from the same places and it stops working at once. When you remove someone from the workspace, the **Members** page offers to revoke the keys they made too. ## For agents [#for-agents] Over MCP, deleting tools are marked as such, so agents like Claude Code ask you before running them. And the server tells every agent that when its key reaches a limit, it should stop and tell you. See [Connect an AI agent](/agents). # Changelog (/changelog) ## October 22, 2026 [#october-22-2026] * **Scheduled runs.** Run an app's code on a timetable: up to 5 schedules per app, picked in plain words or as cron expressions, each calling its code's `scheduled()` handler. See [Scheduled runs](/guides/schedules). * **Send from your own domain.** An email service can send from addresses like `orders@bakery.com` once the domain's DNS records are in place. See [Email](/services/email#your-own-domain). * **Take a copy of a database.** Download any database as a `.sql` file, or load one in, from the dashboard, the API, the SDK or an agent. See [Databases](/services/databases#take-a-copy). * **Webhooks.** Your server can hear about things as they happen: deploys, email deliveries and bounces, the balance running low, apps pausing, limits reached, sandboxes going to sleep and domains going live. Each delivery is signed and retried for about a day and a half. See [Webhooks](/guides/webhooks). * **Static files in deployments.** A deployment can carry HTML, CSS, images and fonts as well as code, or be a static site with no code at all, including single-page apps. See [Code and deployments](/api/code). * **Rate limits and `Idempotency-Key`.** Each API key has its own rate limit, and POST requests can be retried safely with an `Idempotency-Key` header. See [Retrying safely](/api#retrying-safely). * **More in the dashboard's viewers.** Files: rename and move, folders you can make, select and delete, folder uploads, small thumbnails and Markdown previews. Databases: search by column, views, any number of tables, and binary values. Email: the list keeps itself up to date, and shows Delivered or Bounced once delivery reports are switched on. * **New pages:** [Your app's address](/guides/address), [Building a platform](/guides/platforms), [Limits](/limits) and this changelog. {/* Paused for launch (src/lib/git/enabled.ts). Uncomment when deploying from GitHub is back. - **Deploy from GitHub.** Connect a repository to an app's compute and it builds and goes live on every push, with live build logs, "Redeploy this commit" and Cancel. Builds run in a sandbox and use sandbox time. Static sites, Vite, Astro, SvelteKit and projects with a Wrangler config are built for you. See [Compute](/services/compute). */} ## October 21, 2026 [#october-21-2026] * **See inside every service from the dashboard.** * Databases: browse tables like a spreadsheet, search, sort, edit and add rows, see each table's columns, or run SQL. * Files: folders, thumbnails, previews (pictures, video, PDFs, text, CSV), drag-and-drop uploads. * Email: every email the app sent in the last 30 days, with its content. * **Real apps locally.** The dev server runs apps' deployed code at `http://.localhost:3000`, with real sandboxes in Docker when you want them. # Your app's address (/guides/address) Every app gets its own address the moment it has [compute](/services/compute). Until you deploy code it shows a starter page; after that, it runs your code. ## The address you get [#the-address-you-get] Each app has a fixed **resource name**, made when the app is created, like `ocp-aabd5c1cfc91d7b629a0`. Its address is that name on opencloudport's apps domain: ```txt https://. ``` * The address never changes, even if you rename the app. * You'll find it on the app's page in the dashboard, under its name. * A paused app still answers at its address, with a short "This app is paused." page and status `503`, so visitors aren't shown errors. App objects from the API include it as `url`, and every address the app answers at as `addresses` (its own address first, then its platform address and custom domains, each with `status`): ```json { "id": "app_…", "url": "https://ocp-aabd5c1cfc91d7b629a0.", "addresses": [ { "url": "https://ocp-aabd5c1cfc91d7b629a0.", "kind": "default", "status": "active" }, { "url": "https://shop.example.com", "kind": "custom", "status": "pending" } ] } ``` ## Your own domain [#your-own-domain] You can serve an app at a domain you own, like `shop.example.com`, or, if you run many apps for your own customers, at `.`. Both get a certificate automatically once the domain points at opencloudport. See [Domains](/guides/domains). ## When you're developing locally [#when-youre-developing-locally] Running opencloudport's dev server yourself, apps answer at `http://.localhost:3000`. Most browsers send every `*.localhost` name to your own computer, so no setup is needed. # Domains (/guides/domains) An app always answers at [its own address](/guides/address). You can also give it addresses of your own: * **A custom domain** you own, like `shop.example.com`. Up to 20 per app. * **A platform address**, `.`, if your workspace runs many apps for your own customers (for example `bakery.yourapps.com`). One per app. Each one gets a certificate automatically once its DNS points at opencloudport. Until then it's **pending**. ## Add a custom domain [#add-a-custom-domain] In the dashboard, open the app, go to **Settings**, and add the domain under **Domains**. Or with the API: ```bash curl -X POST https://opencloudport.com/api/v1/apps/$APP_ID/domains \ -H "authorization: Bearer $OCP_KEY" \ -H "content-type: application/json" \ -d '{ "hostname": "shop.example.com" }' ``` ```ts const domain = await ocp.app(appId).domains.add('shop.example.com') console.log(domain.records) // what to add at your DNS provider ``` The answer lists the DNS records to add at your domain's DNS provider: | Record | What it's for | | ----------------------------------- | -------------------------------------------------------------------------------------- | | `CNAME shop.example.com → ` | Points the domain at your app. Required. | | `TXT …` (sometimes) | Proves you own the domain, so it can go live before you switch traffic over. Optional. | * For a **root domain** (`example.com` rather than `www.example.com`), most DNS providers don't allow a CNAME. Use their **CNAME flattening** or **ALIAS** record instead, or serve the app at `www`. * It usually goes live within a few minutes of the record being in place, sometimes up to a day while DNS catches up. The dashboard checks every few seconds while it's open; the API checks again each time you ask. * A domain can only belong to one app at a time. ## Statuses [#statuses] | Status | Meaning | | --------- | ------------------------------------------------------------------------------------------------------------------- | | `pending` | Waiting for the domain to point here, or for its certificate. Visitors don't reach the app at it yet. | | `active` | Live, with a certificate. | | `failed` | It couldn't be set up (for example, the certificate timed out). Check the records, then remove it and add it again. | `ssl_status` says the same for the certificate on its own, and `message` explains the status in plain words. ## Platform addresses [#platform-addresses] If you run many apps under your own name (each of your customers gets an app), set a **platform domain** for the workspace once, then give each app a short name: ```ts await ocp.appsDomain.set('yourapps.com') // once for the workspace await ocp.app(appId).setSlug('bakery') // bakery.yourapps.com ``` Then add **one** DNS record for the whole platform domain, from the `records` the first call returns: ```txt CNAME *.yourapps.com → ``` * Names are unique in the workspace. Giving an app a new name replaces its old address; `clearSlug()` removes it. * Changing the platform domain moves every app's address to the new domain. Removing it removes them all. * Nobody else can add a custom domain under your platform domain. ## API [#api] | Method | Path | | | ------------------------ | ------------------------------------ | ------------------------------------------------------------------------------------------ | | `GET` | `/api/v1/apps/:id/domains` | The app's domains, pending ones checked again first. | | `POST` | `/api/v1/apps/:id/domains` | `{ hostname }` → 201, `pending`, with `records`. | | `GET` | `/api/v1/apps/:id/domains/:domainId` | One domain, checked again if pending. | | `DELETE` | `/api/v1/apps/:id/domains/:domainId` | Stop serving a custom domain. | | `PUT` / `DELETE` | `/api/v1/apps/:id/slug` | `{ slug }`: set or remove the app's platform address. | | `GET` / `PUT` / `DELETE` | `/api/v1/apps-domain` | `{ domain }`: the workspace's platform domain, and the record to add. Workspace keys only. | AI agents get the same through the `add_domain`, `list_domains` and `remove_domain` tools. ## Cost [#cost] Adding domains costs nothing today. # Logs (/guides/logs) Every app with [compute](/services/compute) keeps logs of what its code did: * **A line per request:** the method, the path, the status it answered with, how long it took, and how the run ended (for example "Crashed" or "Ran out of processing time"). * **What the code printed:** `console.log`, `console.info`, `console.warn`, `console.error` and `console.debug`, each with its level. * **Crashes:** errors the code didn't catch, with their stack. * **Scheduled runs**, when your code has them. Lines are kept **7 days**. Logs cost nothing. ## In the dashboard [#in-the-dashboard] Open the app and choose **Logs**. Newest lines are at the top, and **Live** adds new ones as they happen. Search messages and stacks, show only warnings and errors, or only requests, printed lines or crashes. Click a crash to see its stack. ## Secrets stay out [#secrets-stay-out] Before a line is stored, opencloudport takes out what shouldn't be there: * Request headers are never kept, so `Authorization` and cookies never reach the logs. * Query values with names like `token`, `key`, `secret`, `password`, `signature` or `code` are blanked: `/orders?token=[redacted]&page=2`. * Inside printed messages, bearer tokens, cookies, passwords, opencloudport keys, Stripe secret keys and GitHub tokens are blanked. Still, don't print secrets on purpose: blanking catches common shapes, not everything. ## From the API [#from-the-api] ```bash curl "https://opencloudport.com/api/v1/apps/$APP_ID/logs?level=warn&limit=50" \ -H "authorization: Bearer $OCP_KEY" ``` | Query | | | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `since`, `until` | ISO times. Default: everything kept. | | `level` | The lowest level shown: `debug`, `info`, `warn` or `error`. `warn` shows warnings and errors. | | `kind` | `request` (request lines and scheduled runs), `console` (printed lines) or `exception` (crashes). | | `search` | Words to look for in messages and stacks. | | `limit` | 1 to 500, default 100. | | `cursor` | Older lines: pass the `next_cursor` from the page before. | | `after`, `wait` | Follow along: lines newer than this id, oldest first. `after=latest` starts from now. `wait` (up to 20 seconds) holds the request until there's something new. | Newest first, each line like: ```json { "id": 1042, "at": "2026-10-22T09:14:03.120Z", "run_id": "6f1c…", "kind": "request", "level": "error", "message": "GET /boom", "method": "GET", "path": "/boom", "status": null, "duration_ms": 3, "outcome": "exception" } ``` Lines from one run share a `run_id`, so you can find what a request printed. `exception` lines also have a `stack`. To follow along from a script, ask with `after=latest&wait=20`, then keep asking with the `after` each answer gives back: ```ts let after: number | 'latest' = 'latest' for (;;) { const page = await ocp.app(appId).logs.follow(after) for (const line of page.data) console.log(line.at, line.level, line.message) after = page.after } ``` `ocp.app(appId).logs.list({ level: 'error', since })` reads a page, newest first. ## For agents [#for-agents] The MCP tool `get_logs` reads an app's recent lines as short text, oldest first, with the same filters. It's read-only, so agents don't need to ask before using it. ## Limits [#limits] * At most 2,000 lines per app per hour are kept. Past that, one line says some were left out, until the hour has passed. * At most 100 printed lines per run; messages and stacks past 8,000 characters are cut short. * A key scoped to an app can read that app's logs. # Building a platform on opencloudport (/guides/platforms) If you run a product that builds or hosts apps for your own customers (an AI app builder, an agency's client sites, a course platform), you can run all of them on one opencloudport workspace. This page is the pattern: one app per customer, tagged so you can find it again, with usage, limits and keys per customer. ## One app per customer, found by your own id [#one-app-per-customer-found-by-your-own-id] Create each customer's app with your own id for it as `external_id`. Creating again with the same `external_id` returns the app that already exists instead of making a second one, so a retried request or a re-run job is safe. ```bash curl https://opencloudport.com/api/v1/apps \ -H "Authorization: Bearer $OCP_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Ann’s bakery", "external_id": "customer_123", "metadata": { "plan": "pro" } }' ``` Find it later with `GET /api/v1/apps?external_id=customer_123`. `metadata` holds up to 50 short values of your own (plan names, owners), returned with the app. ## Usage per customer: `end_user` [#usage-per-customer-end_user] Tag work with the customer it's for. Sending email and starting sandboxes take an `end_user`, and the usage they cause carries it: ```json { "to": "ann@example.com", "subject": "Your order", "text": "Thanks!", "end_user": "customer_123" } ``` Then ask what each customer used, this month or any range up to 92 days: ```bash curl "https://opencloudport.com/api/v1/usage?group_by=end_user" -H "Authorization: Bearer $OCP_KEY" ``` Group by `app` instead to see each app's usage, whoever it was for. ## Limits per customer [#limits-per-customer] So one customer can't run up your whole balance: * **A monthly limit per app.** `PUT /api/v1/apps/:id/spend-limit { "monthly_limit": "25.00" }`. At its limit the app pauses (visitors see "This app is paused."), and it runs again when the month turns or you raise the limit. Its data can still be read. * **A monthly limit per end user.** `PUT /api/v1/end-users/default-spend-limit { "monthly_limit": "5.00" }` for everyone, or `PUT /api/v1/end-users/:end_user/spend-limit` for one customer. At their limit, new work tagged with them (an email, a sandbox) is refused with `402 end_user_limit_reached`; their apps keep serving. `GET /api/v1/end-users` lists who spent what this month. * **Pause and resume one app yourself**, when a customer stops paying or asks you to: `POST /api/v1/apps/:id/pause` (with an optional `note` only you see) and `POST /api/v1/apps/:id/resume`. Nothing is deleted. Your workspace's own balance still pays for everything: if it stays at $0 for 24 hours, every app in it pauses until you top up (nothing is deleted). Keep an eye on it with `GET /api/v1/balance`. Months are calendar months in UTC. ## What paused apps show [#what-paused-apps-show] By default, visitors of a paused app see a plain "This app is paused." Your customers' visitors shouldn't see our name, so choose what they see, for every kind of pause (yours, a spending limit, an empty balance): ```bash curl -X PUT https://opencloudport.com/api/v1/workspace/paused-page \ -H "Authorization: Bearer $OCP_KEY" \ -H "Content-Type: application/json" \ -d '{ "kind": "redirect", "url": "https://yourplatform.com/paused" }' ``` `{ "kind": "message", "message": "…" }` shows your own plain-text message instead, and `{ "kind": "default" }` goes back to ours. It's also under Settings in the dashboard, and reaches every app within about 15 seconds. ## Limits for a platform [#limits-for-a-platform] The [workspace limits](/limits) suit a person or a team. A platform with an app per customer needs more: we raise apps, sandboxes, services per app, domains per app, webhooks and the request rate per key for your workspace on request. Tell us roughly how many customers you expect. ## Usage every few minutes [#usage-every-few-minutes] To refresh your own customers' allowances often, ask for usage over an exact window instead of whole days: ```bash curl "https://opencloudport.com/api/v1/usage?since=2026-10-22T14:00:00Z&group_by=end_user" -H "Authorization: Bearer $OCP_KEY" ``` `latest_recorded_at` says how far recorded usage reaches. Emails count the moment they're sent, sandbox time within 10 minutes, and requests, CPU and database or storage use within about an hour (they come from the provider's analytics). See [Usage](/api/billing#usage). ## A key per customer [#a-key-per-customer] If your customers (or their agents) call opencloudport themselves, give each one a key that reaches only their app: ```bash curl https://opencloudport.com/api/v1/apps/$APP_ID/keys \ -H "Authorization: Bearer $OCP_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "customer_123", "access": "full", "spend_limit": "10.00" }' ``` Add `service_id` to limit it to one database or bucket, and `"access": "read"` to make it read-only. Every change a key makes is in its activity log, so you can see what each customer's agent did. ## Retries and rate limits [#retries-and-rate-limits] * Send an `Idempotency-Key` header on POST requests you might retry (sending email, deploying, creating apps): a retry gets the first answer back instead of doing it twice. See [the API's errors and retries](/api#retrying-safely). * Each key has its own rate limit, so one busy customer's key doesn't slow the others. See [Limits](/limits). # Scheduled runs (/guides/schedules) An app with [compute](/services/compute) can run its code on a timetable, like a nightly report, a reminder email or tidying up old rows. Each schedule calls your code's `scheduled()` handler, the same as a Cloudflare Worker's Cron Triggers. * Up to **5 schedules** per app. * Times are **UTC**, and the smallest gap is a minute. * Each run costs what a request to your app costs. * A run is **skipped** (with the reason) while the app is paused, has no code deployed, or its compute is still being set up. Runs missed while skipped aren't made up later. * What a run prints, and any error it throws, shows in the app's [logs](/guides/logs). ## Your code [#your-code] Export a `scheduled` handler next to `fetch`. `controller.cron` says which schedule fired, so one handler can do different jobs: ```js export default { async fetch(request, env) { return new Response('Hello') }, async scheduled(controller, env, ctx) { if (controller.cron === '0 9 * * *') { const { results } = await env.DB.prepare("select count(*) as n from orders where created_at >= date('now', '-1 day')").all() console.log(`Orders yesterday: ${results[0].n}`) } }, } ``` If the handler throws, the run is marked as failed and the error is in the logs. ## In the dashboard [#in-the-dashboard] Open the app, choose its **Compute**, then **Schedules**. Pick one in plain words (every 15 minutes, every day at a time, every weekday, every Monday, the 1st of each month) or write your own cron expression. Each schedule shows when it runs next and how its last run went. Untick **On** to pause one without removing it. ## Cron expressions [#cron-expressions] Five parts, separated by spaces: minute, hour, day of the month, month, day of the week. | Expression | Runs | | ------------------------------------------ | ----------------------------------------- | | `0 9 * * *` | Every day at 9:00 UTC | | `*/15 * * * *` | Every 15 minutes | | `0 8 * * 1-5` | Every weekday at 8:00 UTC | | `30 6 * * mon` | Every Monday at 6:30 UTC | | `0 0 1 * *` | On the 1st of every month at midnight UTC | | `@daily`, `@hourly`, `@weekly`, `@monthly` | The usual shortcuts | Each part takes `*`, numbers, ranges (`1-5`), lists (`1,15`) and steps (`*/10`). Months and days of the week also take names (`jan`, `mon`). As in cron, when both the day of the month and the day of the week are set, a day matching either one counts. ## From the API [#from-the-api] ```bash # Replace all of the app's schedules (an empty list removes them). curl -X PUT https://opencloudport.com/api/v1/apps/$APP_ID/schedules \ -H "Authorization: Bearer $OCP_KEY" \ -H "content-type: application/json" \ -d '{ "schedules": [{ "cron": "0 9 * * *" }, { "cron": "*/15 * * * *", "enabled": false }] }' # See them, with when each runs next and how its last run went. curl https://opencloudport.com/api/v1/apps/$APP_ID/schedules -H "Authorization: Bearer $OCP_KEY" ``` Each schedule comes back with `description` (in plain words), `next_run_at`, `last_run_at`, `last_result` (`ok`, `error` or `skipped`) and `last_detail` (the reason, for errors and skips). A mistake in any expression changes nothing and says which one. With the SDK: `app.schedules.list()` and `app.schedules.set([{ cron: '0 9 * * *' }])`. Agents have `list_schedules` and `set_schedules`. # Webhooks (/guides/webhooks) A webhook is an address on your own server that we send events to. Add one under **Manage → Webhooks** in the dashboard, with the API, the SDK or your agent (`create_webhook`). A workspace can have up to 10. ## What gets sent [#what-gets-sent] Each event is a `POST` with a JSON body: ```json { "id": "evt_…", "type": "deployment.succeeded", "created_at": "2026-10-22T19:31:31.820Z", "workspace": { "id": "…" }, "data": { "app_id": "app_…", "deployment_id": "dep_…" } } ``` and these headers: | Header | What it is | | ------------------------- | ----------------------------------------------------------------------------------------------------- | | `opencloudport-signature` | `t=,v1=`: check it before trusting the body (below). | | `opencloudport-event` | The event type, so you can route without parsing. | | `opencloudport-delivery` | This delivery's id. A retried delivery keeps its id, so use it (or the event's `id`) to skip repeats. | Answer with any `2xx` within 10 seconds. Do slow work after answering. ## Events [#events] You can take every event (new kinds are added over time) or pick the ones you want. | Type | When | | ------------------------- | ----------------------------------------------------------------------------------------- | | `deployment.succeeded` | New code went live on an app. | | `deployment.failed` | A deployment didn’t go live. `data.error` says why. | | `email.delivered` | An app’s email reached the recipient’s mail server. | | `email.bounced` | An app’s email bounced: the address doesn’t exist or won’t take mail. | | `email.complained` | The recipient marked an app’s email as spam. | | `email.failed` | An app’s email couldn’t be sent or delivered. | | `balance.low` | The workspace balance went below its low-balance warning. | | `workspace.out_of_credit` | The balance reached $0. Apps pause at `data.pauses_at` unless funds are added. | | `workspace.paused` | The workspace’s apps were paused because it ran out of credit. | | `workspace.resumed` | Funds were added and the workspace’s apps run again. | | `app.paused` | An app was paused. `data.paused_by` is `owner`, `limit` or `workspace`. | | `app.resumed` | A paused app runs again. | | `app.limit_reached` | An app reached its monthly spending limit (it pauses until next month or a higher limit). | | `end_user.limit_reached` | An end user reached their monthly spending limit; their new work is refused. | | `sandbox.stopped` | A sandbox stopped. `data.reason` is `asleep` (idle) or `stopped`. | | `domain.active` | A custom domain points here and serves the app, with its certificate. | | `domain.failed` | A custom domain couldn’t be set up. `data.problem` says why. | {/* Paused for launch (src/lib/git/enabled.ts). Put back when deploying from GitHub is back: | `build.succeeded` | A build from GitHub finished and went live. | | `build.failed` | A build from GitHub failed. `data.error` says why. | */} There's also `webhook.test`, sent only when you press **Send test** (or call `POST /api/v1/webhooks/:id/test`). ## Checking a delivery [#checking-a-delivery] The signature is an HMAC-SHA256, keyed with the webhook's secret (`whsec_…`, shown once when you add the webhook or make a new one), of the timestamp, a dot, and the raw body: `.`. Check it against the body **exactly as received**, before parsing it, and refuse signatures more than 5 minutes old so an old delivery can't be replayed. In any runtime with Web Crypto (Workers, Deno, Bun, Node 20+): ```ts /** Whether a delivery really came from opencloudport, and is recent. */ async function verifyWebhook(body: string, header: string | null, secret: string) { const t = /t=(\d+)/.exec(header ?? '')?.[1] const v1 = /v1=([0-9a-f]+)/.exec(header ?? '')?.[1] ?? '' if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']) const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${t}.${body}`))) const expected = Array.from(mac, (b) => b.toString(16).padStart(2, '0')).join('') // Compare every character, so the time taken doesn't give the answer away. let diff = v1.length ^ expected.length for (let i = 0; i < expected.length; i++) diff |= (v1.charCodeAt(i) || 0) ^ expected.charCodeAt(i) return diff === 0 } export async function POST(request: Request) { const body = await request.text() if (!(await verifyWebhook(body, request.headers.get('opencloudport-signature'), process.env.OCP_WEBHOOK_SECRET!))) { return new Response('Bad signature', { status: 400 }) } const event = JSON.parse(body) // … handle event.type return new Response('ok') } ``` With Node's own crypto module: ```js import { createHmac, timingSafeEqual } from 'node:crypto' function verify(body, header, secret) { const t = /t=(\d+)/.exec(header ?? '')?.[1] const v1 = /v1=([0-9a-f]+)/.exec(header ?? '')?.[1] ?? '' if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false const expected = createHmac('sha256', secret).update(`${t}.${body}`).digest('hex') return v1.length === expected.length && timingSafeEqual(Buffer.from(v1), Buffer.from(expected)) } ``` `@opencloudport/sdk` will export the same check as `verifyWebhook(body, header, secret)`. It isn’t on npm yet, so copy the function above for now. ## When a delivery fails [#when-a-delivery-fails] Anything but a `2xx` within 10 seconds counts as failed. We try again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours (about a day and a half in all), then give up on that event. Each try is in the webhook's delivery log, with what your server answered; you can send any of them again. A webhook whose deliveries keep failing for 3 days in a row is turned off, and the dashboard says why. Fix the address or your server, then turn it back on. Events and deliveries are kept 30 days. ## The API [#the-api] All workspace keys; [app keys](/api/keys) can't reach webhooks. | Method | Path | | | -------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | `GET` | `/api/v1/webhooks` | List them. Secrets aren't shown again; `secret_hint` tells them apart. | | `POST` | `/api/v1/webhooks` | `{ url, description?, events? }`: `events` is `"*"` (default) or a list of types. The answer has `secret`, once. | | `GET` / `PATCH` / `DELETE` | `/api/v1/webhooks/:id` | One webhook. `PATCH { url?, description?, events?, enabled? }`; turning it back on clears a failing streak. | | `POST` | `/api/v1/webhooks/:id/rotate-secret` | A new secret (in the answer, once). The old one stops working at once. | | `POST` | `/api/v1/webhooks/:id/test` | Send a `webhook.test` now; the answer says how it went. | | `GET` | `/api/v1/webhooks/:id/deliveries?limit=50` | What was sent, newest first. | | `POST` | `/api/v1/webhooks/:id/deliveries/:delivery_id/redeliver` | Send that event again. | ```sh curl -X POST "https://opencloudport.com/api/v1/webhooks" \ -H "authorization: Bearer $OCP_KEY" -H "content-type: application/json" \ -d '{"url": "https://example.com/webhooks/opencloudport", "events": ["deployment.failed", "email.bounced"]}' ``` Once the SDK is published: `ocp.webhooks.create / list / get / update / delete / rotateSecret / test / deliveries / redeliver`, and `verifyWebhook`. Webhooks only go to `https` addresses on the public internet. # Introduction (/) opencloudport gives an app everything it needs to run on the internet, and lets an AI agent set it all up for you. You can work three ways, and they all do the same things: * **Ask your AI agent.** Connect Claude Code, Cursor or your own agent once, then ask in plain words: "put my bakery's order form online, with a database for orders". It uses opencloudport's tools to build it. * **Use the dashboard.** Make apps, plug in what they need, look inside your databases and files, and watch what every key did. * **Call the API.** The same actions over HTTP, or with the TypeScript SDK, from your own code. opencloudport runs for you: sign up at [`https://opencloudport.com`](https://opencloudport.com) and everything here works with your account. There's nothing to install or host, and you never need an account with the providers behind it. ## How it fits together [#how-it-fits-together] An **app** starts empty. You plug **services** into it, as many as it needs: | Service | What it does | Your code reaches it as | | -------------------------------------- | -------------------------------------------------------------------- | ----------------------- | | [Compute](/services/compute) | Runs your code and answers at the app's own address. | (it *is* your code) | | [Database](/services/databases) | Keeps data in SQL tables. | `env.DB` | | [File storage](/services/file-storage) | Holds uploads, images and downloads. | `env.FILES` | | [Email](/services/email) | Sends receipts and sign-ups from the app's own address. | `env.EMAIL` | | [Sandboxes](/services/sandboxes) | Private Linux machines where agents install, build and preview code. | (from the API or SDK) | Names like `DB` are defaults. You can add several databases or buckets and name each one (`env.ORDERS`, `env.PHOTOS`). ### Who runs each service [#who-runs-each-service] Each service runs on a named provider. Today that's: ### Paying and keys [#paying-and-keys] Every workspace has one **prepaid balance**. Each service is measured by what it actually does, and you can see every charge in plain words on the Billing page. Each agent or script gets its own **API key**, which you can limit to one app, make read-only, and give a spending limit. ## Where to go next [#where-to-go-next] Every page is also plain Markdown: add `.md` to its address, or ask for `text/markdown`. [`/llms.txt`](/llms.txt) lists every page, and [`/llms-full.txt`](/llms-full.txt) is all of them in one file. # Limits (/limits) These are opencloudport's own limits, read from the code that enforces them. Each comes back as a clear error with a message saying which limit you hit. If one gets in your way, tell us what you're building. The limits marked **(raisable)** can be raised for your workspace on request, for example when you run an app per customer: apps, sandboxes, sandboxes per app, services per app (up to 100), domains per app, webhooks, and the request rate per key. See [Building a platform](/guides/platforms#limits-for-a-platform). ## Requests [#requests] | What | Limit | | ----------------------------- | ----------------------------------------------------- | | Requests per API key | 20 a second on average, bursts of up to 60 (raisable) | | Emails sent per API key | 2 a second on average, bursts of up to 20 | | Sandboxes started per API key | 1 every 5 seconds, bursts of up to 5 | | `Idempotency-Key` | 1 to 255 visible characters, remembered for 24 hours | | Request body | 16 KB, unless listed below | Over a rate limit you get `429 rate_limited` with `Retry-After` in seconds. See [Rate limits](/api#rate-limits). ## Workspaces and apps [#workspaces-and-apps] | What | Limit | | -------------------------------------------- | ------------------------------------------------------ | | Apps per workspace | 1,000 (raisable) | | App name | 100 characters | | `external_id` | 200 printable characters, no spaces | | `metadata` | 50 values; names up to 40 characters, values up to 500 | | Databases, buckets and email senders per app | 20 in all (raisable, up to 100) | | Compute and sandboxes | one of each per app | | Variables and secrets per app | 100, each value up to 5 KB | | Domains per app | 20 (raisable) | ## Webhooks [#webhooks] | What | Limit | | -------------------------------------- | ------------------------------ | | Webhooks per workspace | 10 (raisable) | | Time to answer a delivery | 10 seconds | | Retries of a failed delivery | 6, over about a day and a half | | Failing before a webhook is turned off | 3 days in a row | | Events and deliveries kept | 30 days | ## Code [#code] | What | Limit | | ----------------------------------- | ------ | | Code files (modules) per deployment | 100 | | Code per deployment | 10 MB | | Static files per deployment | 20,000 | | One static file | 25 MB | | All static files in one upload | 50 MB | {/* Paused for launch (src/lib/git/enabled.ts). Put back when deploying from GitHub is back: | A build from GitHub | 15 minutes | */} ## Logs [#logs] | What | Limit | | ------------------------------ | ---------------- | | How long lines are kept | 7 days | | Lines kept per app | 2,000 an hour | | Printed lines kept per run | 100 | | One message or stack | 8,000 characters | | Lines per page from the API | 500 | | Waiting for new lines (`wait`) | 20 seconds | ## Databases [#databases] | What | Limit | | ----------------------------- | ------------------------------------------------------ | | SQL per request | 100 KB | | Values for `?` placeholders | 100 | | Rows returned per statement | 1,000 (the rest are left out, and `truncated` is true) | | A SQL file to load (`import`) | 10 MB | | A copy's download link | 15 minutes | ## File storage [#file-storage] | What | Limit | | ------------------------- | ---------------------------------------------- | | A file's path | 1,024 bytes | | Files listed per page | 1,000 | | Signed links | 1 minute to 7 days (15 minutes unless you say) | | Direct bucket credentials | 15 minutes to 12 hours (1 hour unless you say) | ## Email [#email] | What | Limit | | --------------------------------- | ------------------- | | Subject | 500 characters | | Text version | 200,000 characters | | HTML version | 500,000 characters | | Sent emails kept in the dashboard | 30 days | | Sender domains | 1 per email service | ## Scheduled runs [#scheduled-runs] | What | Limit | | ----------------- | ----------------------------- | | Schedules per app | 5 | | How often | at most once a minute, in UTC | ## Sandboxes [#sandboxes] | What | Limit | | ----------------------------- | ------------------------------------------------ | | Sandboxes per app | 50 (raisable) | | Sandboxes per workspace | 200 (raisable) | | Falls asleep after being idle | 1 minute to 24 hours (15 minutes unless you say) | | A command | up to 10 minutes (2 minutes unless you say) | | Files written at once | 500, up to 25 MB in all | | A variable's value | 4 KB | ## Money [#money] | What | Limit | | --------------------------------------- | ----------------------------- | | Adding funds | $5 to $10,000 at a time | | Spending limits (keys, apps, end users) | up to $1,000,000, to the cent | | Usage you can ask for at once | 92 days | # Quickstart (/quickstart) You'll need an opencloudport account. Signing up and creating apps are free; you add funds on the **Billing** page before plugging services in. ### Make a key for your agent [#make-a-key-for-your-agent] In the dashboard, open **API keys** (under Manage) and choose **Create an API key**. Name it after the agent, like "Claude agent". Copy the key: it's shown once. Treat it like a password. ### Connect the agent [#connect-the-agent] For Claude Code, run this in your terminal: ```bash claude mcp add --transport http opencloudport \ https://opencloudport.com/mcp \ --header "Authorization: Bearer " ``` Other agents are covered in [Connect an AI agent](/agents). ### Ask for what you want [#ask-for-what-you-want] > Put my bakery's order form online, with a database for orders and email receipts. The agent creates the app, adds Compute, a database and email, makes the orders table, and deploys the code. Each step shows up in the key's activity log on the **API keys** page. ### Create an app [#create-an-app] Open **Apps** and choose **Create app**. Give it a name people will recognise. The app starts empty. ### Add Compute [#add-compute] On the app's page, the **Get started** list offers **Add Compute**. It gives the app somewhere to run code, and a starter page answers at the app's address straight away. ### Deploy your code [#deploy-your-code] Open Compute and follow **Show me how**. It shows the exact command for your app, with its id filled in. You'll need an API key from the **API keys** page. ### Add a place for data (optional) [#add-a-place-for-data-optional] Choose **Add a service** for a database, file storage or email. Your code reaches each one by name, like `env.DB`. Make a key on the **API keys** page and keep it in an environment variable, then: ### Create an app [#create-an-app-1] ```bash curl https://opencloudport.com/api/v1/apps \ -H "Authorization: Bearer $OCP_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "Bakery site"}' ``` The answer includes the app's `id` (`app_…`). ### Add Compute [#add-compute-1] ```bash curl https://opencloudport.com/api/v1/apps/$APP_ID/services \ -H "Authorization: Bearer $OCP_KEY" \ -H "Content-Type: application/json" \ -d '{"type": "compute"}' ``` It starts as `pending` and becomes `active` within seconds. Check with `GET /api/v1/apps/$APP_ID/services`. ### Deploy [#deploy] Write `index.js`: ```js title="index.js" export default { fetch() { return new Response('Hello from opencloudport') }, } ``` Then upload it: ```bash curl https://opencloudport.com/api/v1/apps/$APP_ID/deployments \ -H "Authorization: Bearer $OCP_KEY" \ -F 'metadata={"main_module": "index.js"}' \ -F 'index.js=@index.js' ``` The call answers once the code is live. The same steps with the TypeScript SDK are in the [SDK reference](/api/sdk). # Compute (/services/compute) Compute is where an app's code runs. Add it once per app; it answers at the app's own address. Until you deploy, a starter page answers. **Good for:** websites, APIs, webhooks. **Billed:** per request and processing time. Nothing while idle. ## Your code [#your-code] Your code is a JavaScript module whose default export has a `fetch` function. It gets each request, and the app's other services and variables in `env`: ```js title="index.js" export default { async fetch(request, env) { const url = new URL(request.url) if (url.pathname === '/orders') { const { results } = await env.DB.prepare('select * from orders').all() return Response.json(results) } return new Response(`Welcome to ${env.SHOP_NAME}`) }, } ``` You can upload several files: the main module and any it imports (`.js`, `.mjs`, `.cjs`, `.wasm`, plus text and data files). Up to 100 files and 10 MB per deployment. If you use a bundler, upload what it builds. Websites can also bring their pages, styles, scripts and images as static files, served at their own paths before your code runs (your code can fetch them as `env.ASSETS`). A site with no code at all works too: upload the folder your build makes, like `dist/`. Up to 20,000 files, 25 MB each. Settings for single-page apps and 404 pages are in the [API reference](/api/code#static-files). ## Deploy [#deploy] ```bash curl https://opencloudport.com/api/v1/apps/$APP_ID/deployments \ -H "Authorization: Bearer $OCP_KEY" \ -F 'metadata={"main_module": "index.js"}' \ -F 'index.js=@dist/index.js' ``` ```ts await ocp.app(appId).deploy({ main: 'index.js', files: { 'index.js': await readFile('dist/index.js', 'utf8') }, }) // A static site: files only. await ocp.app(appId).deploy({ assets: { 'index.html': await readFile('dist/index.html'), 'style.css': await readFile('dist/style.css') }, assetsConfig: { not_found_handling: '404-page' }, }) ``` Ask: "Deploy my site to the Bakery orders app." It uses the `deploy` tool. The call answers once the new code is live. Every deployment is kept, so going back is one click: **Roll back** on the Deployments tab, `POST …/deployments/{id}/redeploy`, or the `rollback` tool. Details are in the [API reference](/api/code). {/* Paused for launch (src/lib/git/enabled.ts). Uncomment when deploying from GitHub is back. ## Deploy from GitHub If your code lives in a GitHub repo, link it instead of uploading files. On Compute's **Git** tab: 1. **Connect GitHub.** GitHub asks which repos opencloudport may read; you can change that later on GitHub. 2. **Pick the repo and branch**, and the folder the app is in if it isn't at the top of the repo. 3. **Import and deploy.** The first build starts straight away. After that, each push to the branch builds and goes live, and **Deploy now** builds the newest commit whenever you like. Turn off **Deploy on push** to only deploy by hand. Builds run in a sandbox, so their running time is charged like any sandbox's. Click a build to watch its log as it runs. From there you can **Cancel** it, or **Redeploy this commit** to build an earlier commit again. A build that succeeds is an ordinary deployment, so you can roll back from **Deployments** as usual. ### What gets built opencloudport looks at the repo and picks the install and build commands for you (pnpm, yarn or npm, from your lockfile): | Your project | What happens | |---|---| | Vite, Create React App, Vue CLI | Built, then served as a single-page app from `dist` or `build` | | Astro, SvelteKit with `adapter-static` | Built, then served as a static site with its own 404 page | | A folder of HTML, CSS and images | Served as it is | | A `wrangler.jsonc` or `wrangler.toml` with `main` | Bundled as a Worker with its static files: plain Workers, Hono, and frameworks with Cloudflare adapters | | Next.js | Needs the [OpenNext Cloudflare adapter](https://opennext.js.org/cloudflare) and a wrangler config | If the guess is wrong, set the **build command** and **output folder** in the Git settings. A build can run for up to 15 minutes. Agents can do the same: `deploy_from_git` builds the newest commit, and `get_build` shows how it's going. If the opencloudport app loses access on GitHub (it's uninstalled, or the repo is taken off its list), pushes stop deploying and the Git tab says why. The app keeps running the code it has. */} ## On a schedule [#on-a-schedule] Your code can run on a timetable too: every day at 9:00, every 15 minutes, on weekdays. Add up to 5 schedules under **Schedules**; each calls your code's `scheduled()` handler. See [Scheduled runs](/guides/schedules). ## Settings and secrets [#settings-and-secrets] Variables are how your code gets settings and API keys without putting them in the code. Each one is `env.NAME`. * **Secret** (the default): the value can be set and replaced, but never shown again. * **Plain:** the value is shown in the dashboard and the API. Set them on Compute's **Variables** tab, with `PUT /api/v1/apps/{id}/env/{NAME}`, or the `set_variable` tool. They apply at once, without a new deployment. An app can have up to 100. Removing Compute removes the app's deployments and all its variables. # Databases (/services/databases) A database keeps your app's data in tables, using SQL (the SQLite kind). Add as many as you need; each has its own name, and your code reaches it as `env.NAME`. The first one is called `DB` unless you name it. **Good for:** users and accounts, orders, settings. **Billed:** per row read and written, plus how much it holds. ## From your app's code [#from-your-apps-code] No connection string and no password: the database is just there. ```js const { results } = await env.DB .prepare('select * from orders where customer = ?') .bind(email) .all() await env.DB.prepare('insert into orders (item, qty) values (?, ?)').bind('Sourdough', 2).run() ``` ## From the dashboard [#from-the-dashboard] Open the database to see its **Data**: your tables and views in a list (with how many rows each, and a box to find one by name), and the picked table's rows like a spreadsheet. * **Search** looks in the text columns. Pick a column under **In** to search just that one; numbers in a number column match exactly. Tables with more than 10,000 rows search when you press Enter. * **Click a row** to see every field, edit it, or delete it. **Add row** fills in defaults and the next id for anything you leave empty. * **Columns** shows what each column holds in plain words, whether it must be filled in, and which tables it points to. * **Views** (saved queries over other tables) can be read, searched and sorted, but not changed. * **Binary data** (files kept in a column) shows its size. Open the row to download it, replace it with a file up to 900 KB, or clear it. For anything else, choose **Run SQL**. Type SQL and press **Run** (or Ctrl/⌘ + Enter). You can run several statements at once. ## From anywhere else [#from-anywhere-else] For migrations, seeding and scripts on your own machines: ```ts const db = ocp.app(appId).database('DB') await db.query('create table if not exists orders (id integer primary key, item text, qty integer)') const rows = await db.rows('select * from orders where id = ?', [1]) // Refused before it runs if it would change anything (and allowed for read-only keys): await db.query('select count(*) from orders', [], { readOnly: true }) ``` ```bash curl https://opencloudport.com/api/v1/apps/$APP_ID/services/$DB_ID/query \ -H "Authorization: Bearer $OCP_KEY" \ -H "Content-Type: application/json" \ -d '{"sql": "select * from orders where id = ?", "params": [1]}' ``` Each statement returns up to 1,000 rows. SQL that only reads counts as reading, so read-only keys can run it. See [Data](/api/data#run-sql) for the details. Agents read with the `query_database` tool, which never changes anything, and change data with `execute_sql`, which asks the person first. ## Take a copy [#take-a-copy] **Download a copy** (on the Data view, or the database's Settings) saves the whole database as a `.sql` file: every table's definition and rows, then its indexes, triggers and views. It's made with ordinary queries a page at a time, so your app keeps working while it runs, and text, numbers, empty values and binary data come back exactly. Use it for backups, or to take your data elsewhere: `sqlite3 new.db < copy.sql` loads it into SQLite. **Load a SQL file** (Settings) runs a `.sql` file against the database, up to 10 MB: a copy you downloaded, or any SQL. Load a copy into an empty database: rows already there stay, so tables that exist already make it stop. If a statement fails it stops there and says how far it got; what ran before stays. ```bash # A copy, as a file (read-only keys may take one). curl https://opencloudport.com/api/v1/apps/$APP_ID/services/$DB_ID/export -H "Authorization: Bearer $OCP_KEY" -o copy.sql # A link that downloads a fresh copy without a key, for 15 minutes. curl https://opencloudport.com/api/v1/apps/$APP_ID/services/$DB_ID/export-link -H "Authorization: Bearer $OCP_KEY" # Load a file into a database. curl -X POST https://opencloudport.com/api/v1/apps/$APP_ID/services/$DB_ID/import \ -H "Authorization: Bearer $OCP_KEY" -H "content-type: application/sql" --data-binary @copy.sql ``` With the SDK: `db.export()` (the file as text), `db.exportUrl()` and `db.import(sql)`. Agents have `export_database`, which hands back the download link, not the file. ## Names [#names] Names are letters, digits and underscores, starting with a letter or underscore: `DB`, `ORDERS`, `CUSTOMERS`. A name can't be used twice in one app, including by a variable. An app can have up to 20 named services (databases, buckets and email together). Removing a database deletes everything in it. # Email (/services/email) Add Email to an app and it can send email from its own address. You don't set up a mail server or a domain. The address is shown on the Email panel in the dashboard. When you want email to come from your own domain ([orders@bakery.com](mailto:orders@bakery.com)), [set one up](#your-own-domain). **Good for:** welcome emails, password resets, receipts. **Billed:** per email sent. ## Send a test [#send-a-test] Open Email in the dashboard and choose **Send a test email**. It goes to you. **Send an email** on the Sent tab writes one to anyone, from the app's address. ## See what was sent [#see-what-was-sent] The **Sent** tab lists every email the app sent, or tried to, in the last 30 days: who it went to, the subject, when, and whether it came from your code, the API or the dashboard. Open one to see it as its recipient did, or as plain text. Images from the internet stay hidden until you choose **Show images**. Emails that didn't send say why. The list checks for new emails every 15 seconds while it's open. The dashboard keeps each email's content for those 30 days (up to 100 KB of it), then deletes it. Removing the Email service deletes its list straight away. "Sent" means the email provider accepted it. When the provider reports back, the email shows what happened next: **Delivered** (the person's email server received it), **Retrying** (that server asked to try later), **Bounced** (the address doesn't exist or won't take mail), **Rejected**, **Not delivered**, or **Marked as spam**, with the receiving server's reason. **Problems** lists every email that didn't send or didn't arrive. Addresses that bounced or reported spam before are refused straight away, and the email says so. ## From your app's code [#from-your-apps-code] Your code reaches the service as `env.EMAIL`, and sends with one call. Nothing to install, no key to handle: it can only send email for this app. ```js const { id } = await env.EMAIL.send({ to: order.email, subject: 'Thanks for your order', html: '

Your sourdough will be ready at 9am.

', end_user: order.customerId, // optional: counts toward that customer's spending }) ``` It takes what [the API's send](/api/data) takes: `to`, `subject`, `text` and/or `html`, and optionally `from` (on [your own domain](#your-own-domain)), `from_name`, `reply_to` and `end_user`. It returns the email's id, and throws with a plain reason if the email can't go: the app is paused, an end user's limit is reached, or too many are sent at once (the same limits as the API). Emails sent this way show as **From your code** in the Sent tab. ### The connection address [#the-connection-address] The same service is also at `env.EMAIL_URL`: an address carrying a key that can only send for it, for code that calls the API itself (or runs somewhere without the binding): ```js const url = new URL(env.EMAIL_URL) const res = await fetch(`${url.origin}${url.pathname}/send`, { method: 'POST', headers: { authorization: `Bearer ${decodeURIComponent(url.username)}`, 'content-type': 'application/json' }, body: JSON.stringify({ to, subject, html }), }) ``` `env.EMAIL` used to be that address. Code written for it (`new URL(env.EMAIL)`) should switch to `env.EMAIL_URL`, or to `env.EMAIL.send()`. ## From anywhere else [#from-anywhere-else] ```ts await ocp.app(appId).email('EMAIL').send({ to: 'ann@example.com', subject: 'Welcome', text: 'Hi Ann!' }) ``` ## What you can send [#what-you-can-send] | Field | What it’s for | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `to` | One address. | | `subject` | Up to 500 characters. | | `text`, `html` | At least one. | | `from_name` | The name people see. | | `from` | With a [verified domain](#your-own-domain): the address to send from, as the part before the @ (`orders`) or the whole address. Without one, email comes from the app's own address. | | `reply_to` | Where replies go, like your support inbox. | | `end_user` | Your own id for the person this is for, to see usage per customer. | Email isn't sent while the app is paused for lack of credit (`app_paused`). If the provider refuses a message, nothing is billed. ## Your own domain [#your-own-domain] Email can come from your own domain instead of the app's address, so receipts say `orders@bakery.com`. 1. Open Email, then **Settings**, and add your domain under **Send from your own domain**. Optionally give a usual address (`hello`) to send from when an email doesn't say. 2. Add the DNS records it shows where you manage the domain: SPF and an MX for bounces (on a `cf-bounce` subdomain), a DKIM key that signs each email, and DMARC (it starts at `p=none`, which only reports). Each has a copy button. If the domain already has an SPF record on the same name, merge them: a domain may only have one. 3. It's checked again on its own until it's **Verified**. That can take from a few minutes to a day. Then send with `from`: ```js await sendEmail(env, { to: order.email, from: 'orders', from_name: 'Holt’s Bakery', subject: 'Your receipt', html: '…' }) ``` Until it's verified, email keeps coming from the app's own address, and asking for `from` is refused (`sender_domain_not_verified`). Each domain can be used by one email service. From the API: `PUT /api/v1/apps/:id/services/:serviceId/sender-domain` with `{ "domain": "bakery.com", "default_from": "hello" }`, `GET` to see its records and status, `DELETE` to go back to the app's address. With the SDK: `app.email('EMAIL').senderDomain.set('bakery.com')`. Cloudflare sends from domains that are on our Cloudflare account. If yours isn't, setting it up says so; contact us and we'll add it. # File storage (/services/file-storage) File storage holds files of any size. Your code reads and writes them by key (like a path: `uploads/cake.jpg`), and visitors' browsers can upload and download directly, without going through your code. The first bucket is called `FILES` unless you name it. **Good for:** image uploads, documents, backups. **Billed:** per upload or download, and by how much is stored. ## From your app's code [#from-your-apps-code] ```js await env.FILES.put('avatars/ada.png', imageBytes) const file = await env.FILES.get('avatars/ada.png') ``` ## Uploads from a browser [#uploads-from-a-browser] Your server asks for a short-lived upload link, and the browser sends the file straight to storage: ```ts // On your server: const { url } = await ocp.app(appId).storage('FILES').signedUrl('uploads/photo.jpg', { method: 'PUT' }) // In the browser: await fetch(url, { method: 'PUT', body: file }) ``` Links last 15 minutes by default, and up to 7 days. The same works for downloads (`GET`) and deletes (`DELETE`). ## From the dashboard [#from-the-dashboard] Open the bucket and choose the **Files** tab. You can browse folders, see pictures as thumbnails, preview pictures, video, audio, PDFs, text, Markdown, CSV and JSON, upload files or whole folders (drag them in, or use **Upload**), rename and move files and folders, and delete several at once. The dashboard keeps two kinds of small files of its own in your bucket, which your code will see if it lists everything: * `/.keep`: an empty file that keeps a folder made in the dashboard until something is put in it. Buckets don't have real folders, only file names with `/` in them. * `.thumbs/.webp`: a small preview of each picture uploaded from the dashboard, so the grid loads quickly. Pictures your code saves don't get one, and the dashboard shows the picture itself instead. Both are hidden in the dashboard and are moved and deleted along with their files. Renaming or moving a file copies it to the new name and then deletes the old one, so it counts as a copy and a delete. ## From your own scripts [#from-your-own-scripts] ```ts const files = ocp.app(appId).storage('FILES') await files.put('backups/2026-10-21.sql', dump, { contentType: 'application/sql' }) const res = await files.get('backups/2026-10-21.sql') // a Response, or null const { data } = await files.list({ prefix: 'backups/' }) ``` The SDK asks once for temporary credentials that reach only this bucket, then talks to it directly. You can use those credentials with any S3 tool too: see [credentials](/api/data#temporary-credentials). Removing a bucket deletes every file in it. # Sandboxes (/services/sandboxes) A sandbox is a private Linux machine with Node, pnpm and git, ready in seconds. Put files in, run commands, start a server and get a preview link. Each one is kept apart from your app and from every other sandbox, so it's a safe place for generated or untrusted code. **Good for:** builds and tests, dev servers with live preview, running code an AI wrote. **Billed:** by size, only while running. Add the **Sandboxes** service to an app once, then start as many sandboxes as you need (up to 50 per app). ## Sizes [#sizes] | Size | In the API | Machine | Good for | | ----------- | ------------ | ----------------------- | ------------------------- | | Tiny | `lite` | 1/16 CPU, 256 MB memory | Quick scripts | | Small | `basic` | ¼ CPU, 1 GB memory | Scripts and small tools | | Medium | `standard-1` | ½ CPU, 4 GB memory | Dev servers (the default) | | Large | `standard-2` | 1 CPU, 6 GB memory | Bigger builds | | Extra large | `standard-3` | 2 CPUs, 8 GB memory | Heavy builds and tests | | Huge | `standard-4` | 4 CPUs, 12 GB memory | The heaviest workloads | ## Asleep costs nothing [#asleep-costs-nothing] A sandbox falls asleep after 15 minutes without use (you can choose 1 minute to 24 hours), and you stop paying. **Its files are kept**: everything in `/workspace`, installed packages included, is saved when it falls asleep or stops and is back when it wakes, so you pick up where you left off with nothing to reinstall. Up to 2 GB is kept; set `keep_dependencies: false` to save less and reinstall instead. Turn it off with `keep_files: false` for a fresh machine every time. **Set it up once.** Give it a `setup_command` (or "Run when it starts" in the dashboard), like `pnpm install`. It runs before anything else, and only again if its saved files are gone. For tools installed outside `/workspace`, like `apt-get install -y ffmpeg`, set `setup_runs: "every_start"`. **Previews are private.** A preview link from the dashboard, the API or the `sandbox_preview_url` tool works for an hour; open it, or share it, and that browser gets in. Without the link, the preview says it's private. Set `public_preview: true` for a preview anyone with the address can open. Platforms that show previews in their own pages can limit who frames them, renew access without reloading and add their own script: see [Previews inside your own pages](/api/sandboxes#previews-inside-your-own-pages). **Need a browser?** Start it with `image: "browser"` (or "With a browser" in the dashboard) for Chromium and everything Playwright needs, for screenshots, visual checks and browser tests. It costs the same. Your own images aren't supported yet. **Watching a long command?** The dashboard terminal shows output as it comes, and the API sends it as it happens with `stream: true` (`execStream()` in the SDK). ## Use one [#use-one] ```ts const app = ocp.app(appId) const sandbox = await app.sandboxes.create({ size: 'standard-1', label: 'Preview build' }) const box = app.sandbox(sandbox.id) await box.writeFiles({ 'package.json': pkg, 'src/index.ts': code }) const { exit_code, stdout } = await box.exec('pnpm install && pnpm test') await box.startProcess('dev', 'pnpm dev --host 0.0.0.0 --port 5173') const url = await box.previewUrl(5173) // share this ``` * `exec` runs a command and waits (up to 10 minutes). A string runs in a shell; an array runs without one. * `startProcess` runs something that keeps going, like a dev server. Check on it with `process(name)`, stop it with `stopProcess(name)`. * Change files while a server runs: tools with hot reload pick the changes up. Agents do the same with the `create_sandbox`, `sandbox_write_files`, `sandbox_exec` and `sandbox_start_process` tools. In the dashboard, the Sandboxes panel has a terminal to try commands. ## Stop or delete [#stop-or-delete] Stop a sandbox when you're done to stop paying straight away (**Stop** in the dashboard, `stop()`, or the `stop_sandbox` tool). Start it again with **Start**, `start()` or `start_sandbox`: it comes back with its files (unless it doesn't keep them). Delete it to remove it from the list.