A port inside a computer, served on its own HTTPS address. A deployment can switch the feature off, for every account or for all but some: making or changing one is then a 404 with code PUBLIC_URLS_DISABLED, while listing (enabled: false) and deleting still work.
Base URL https://app.mandala.computer/api/v1. Every request carries an API key, and failures take the shape under Errors.
GET/computers/{id}/public-urls
List a computer’s public URLsviewer+
A public URL serves a port inside the computer at https://<label>.<base_domain> (e.g. mandala.dev), on port 443 with no port in the address. The app must listen on 0.0.0.0:<port>, not 127.0.0.1.
enabled is false, with a reason, where public URLs are off here or the account’s plan includes none; the computer’s URLs are listed either way, so you can see and delete what you made. If the plan drops below the number a computer has, the oldest within the new limit keep answering and the rest answer 404 until the plan allows them again.
While a change of this computer’s workspace is unsettled, this is a 409 with code WORKSPACE_MOVING: reason: "contention" while the move is being made (try again once it has finished), or reason: "unsupported" when its outcome was lost (check the workspace change in the dashboard). How a URL answers, and who may open a private one, is set out under POST /computers/{id}/public-urls.
Set MANDALA_API_KEY to your bearer key. MANDALA_BASE_URL, when set, is the full API base including /api/v1. Replace the illustrative resource IDs with yours. These pinned 0.16.0 SDK and direct HTTP examples do not read saved device-login credentials.
curl
base_url="${MANDALA_BASE_URL:-}"
if [ -z "$base_url" ]; then base_url='https://app.mandala.computer/api/v1'; fi
curl --fail-with-body "${base_url%/}"'/computers/vm-a1b2c3d4e5f6/public-urls' \
-H "Authorization: Bearer $MANDALA_API_KEY"
Python — Mandala SDK 0.16.0
# Python 3.10+: pip install mandala-computer==0.16.0
import os
from mandala_computer import Client
with Client(api_key=os.environ["MANDALA_API_KEY"], base_url=os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1") as client:
id = "vm-a1b2c3d4e5f6"
result = client.public_urls.list(id)
print(result)
TypeScript — Mandala SDK 0.16.0
// Node.js 22+: npm install mandala-computer@0.16.0
import { Client } from "mandala-computer";
const apiKey = process.env.MANDALA_API_KEY;
if (!apiKey) throw new Error("Set MANDALA_API_KEY");
const client = new Client({apiKey, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"});
const id = "vm-a1b2c3d4e5f6";
const result = await client.publicUrls.list(id);
console.log(JSON.stringify(result, null, 2));
A suspended account may still make this read: a 403 here is about the credential or the person holding it, never the account’s suspension.
POST/computers/{id}/public-urls
Make a public URL for a port on the computermember+
Serve port on https://<label>.<base_domain>. Without a label, a name like brave-otter-3f2a is picked. The body is at most 4096 bytes. The errors this route adds are { error, code }, except four that carry error alone: a body over that size (413), a body that is not JSON or not a JSON object (400), a computer that is not found (404), and the 503 at the end of this list.
- 400INVALID_BODY: a field other than port, label and access, named in the error. Nothing is made.
- 400INVALID_PORT: not an integer from 1 to 65535, or 22, which belongs to the computer.
- 400INVALID_LABEL: 3 to 40 letters, digits and hyphens, starting and ending with a letter or digit, not all digits, no hyphens in the third and fourth places (ab--), not shaped like a computer id, and not a name that reads as the platform’s or another company’s own address: a reserved word such as www or login, numbered, spelled with look-alikes or split by hyphens (www2, l0gin, sign-in); mandala as a part, or run together with reserved words (mandala-login, mandalalogin); a well-known brand among its parts, whole or split (paypal-refunds, pay-pal-refunds), or for the most distinctive brands anywhere in a part (paypalrefunds); or nothing but reserved words and numbers (secure-login, securelogin). Surrounding spaces are trimmed and it is lowercased.
- 400INVALID_ACCESS: access is public (the default) or private.
- 402PLAN_REQUIRED: the account’s plan includes no public URLs.
- 403PUBLIC_URLS_BLOCKED: the platform has turned public URLs off for this computer or account.
- 404PUBLIC_URLS_DISABLED: public URLs are not enabled here.
- 409LABEL_TAKEN or PUBLIC_URL_LIMIT: the name is taken, or the computer has as many as its plan allows (Solo 2, Studio 5, Swarm 10). A name the platform has blocked reads as taken, and so does one another account has ever held: a name is never given to a second account.
- 409WORKSPACE_MOVING: a change of this computer’s workspace is unsettled. reason is contention while the move is being made, so try again in a moment, and unsupported when its outcome was lost, so check the workspace change in the dashboard.
- 429RATE_LIMITED: this account has made 25 in the last 24 hours, deleted ones included. Retry-After says when to try again.
- 503, with no code: no free generated name was found. Try again, or give a label.
WHO MAY OPEN IT. A public URL answers anyone with the link. A private one answers two credentials and nothing else (401): an API key of this account sent as Authorization: Bearer, where a key confined to a workspace opens only the URLs of that workspace’s computers; or a member of the account whose browser is signed in to that one URL from the dashboard, whose Open button sends it through a one-time sign-in link good for a minute. That sign-in lasts at most 12 hours and ends with the dashboard session it came from, and it admits requests from the URL’s own pages and a top-level visit to it, never a fetch, script, frame or form post from another site. A browser opening a private URL with neither gets a page saying how to open it, still a 401; any other request without one gets an empty 401. While a change of the computer’s workspace is unsettled, a private URL answers 503 to everyone: which workspace’s keys may open it is not settled yet. Neither credential reaches the app, and neither does a Mandala API key or MCP token sent to a public URL. Any other Authorization header is the app’s own and passes through. Cookies the app sets stay on its own host name.
A NEW ACCOUNT’S URLS WARN A BROWSER FIRST. For an account created in the last 14 days, a browser opening one of its URLs, public or private, is sent to a warning page at /__mandala/warning first; Continue takes it on, and that browser is not warned about that URL again for 30 days. A browser navigation is told by fetch metadata — Sec-Fetch-Mode: navigate, or a Sec-Fetch-Dest of document or a frame — or, from a browser that sends no fetch metadata, by an Accept naming text/html with a User-Agent beginning Mozilla/. A programmatic client that sends none of those never sees the page. An agent driving a real browser skips it by sending the request header mandala-skip-warning, with any value.
WHAT THE APP CANNOT HAVE. /__mandala and every path under /__mandala/ belong to the platform and never reach the app. A service worker cannot be registered: a request for a worker script (Service-Worker: script) is answered 403. The app cannot set a cookie named __Host-mandala_*, which is dropped, and never sees the platform’s own. Every answer is marked X-Robots-Tag: noindex, nofollow and is never cached at the edge; a private URL’s answers are also sent Cache-Control: no-store, without ETag or Last-Modified.
HOW IT ANSWERS. A URL serves a running computer. A stopped or suspended computer’s URLs answer 503, and traffic never starts or resumes it; traffic does count as use, so it keeps a running computer from being suspended for idleness. Nothing listening on the port is a 503 with Retry-After, and a browser opening a page gets one saying nothing is listening yet. A request body is at most 100 MiB (413), one computer takes at most 256 requests through its URLs at once (503 past that), the app has 100 seconds to start answering (504), and a request that moves no bytes either way for five minutes, a WebSocket included, is ended.
Set MANDALA_API_KEY to your bearer key. MANDALA_BASE_URL, when set, is the full API base including /api/v1. Replace the illustrative resource IDs with yours. These pinned 0.16.0 SDK and direct HTTP examples do not read saved device-login credentials.
curl
base_url="${MANDALA_BASE_URL:-}"
if [ -z "$base_url" ]; then base_url='https://app.mandala.computer/api/v1'; fi
curl --fail-with-body -X POST "${base_url%/}"'/computers/vm-a1b2c3d4e5f6/public-urls' \
-H "Authorization: Bearer $MANDALA_API_KEY" \
-H "Content-Type: application/json" \
--data-raw '{"port":3000,"label":"cool-thing"}'
Python — Mandala SDK 0.16.0
# Python 3.10+: pip install mandala-computer==0.16.0
import json, os
from mandala_computer import Client
with Client(api_key=os.environ["MANDALA_API_KEY"], base_url=os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1") as client:
id = "vm-a1b2c3d4e5f6"
body = json.loads("{\"port\":3000,\"label\":\"cool-thing\"}")
result = client.public_urls.create(id, body["port"], label=body["label"])
print(result)
TypeScript — Mandala SDK 0.16.0
// Node.js 22+: npm install mandala-computer@0.16.0
import { Client } from "mandala-computer";
const apiKey = process.env.MANDALA_API_KEY;
if (!apiKey) throw new Error("Set MANDALA_API_KEY");
const client = new Client({apiKey, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"});
const id = "vm-a1b2c3d4e5f6";
const body = {"port":3000,"label":"cool-thing"};
const result = await client.publicUrls.create(id, body);
console.log(JSON.stringify(result, null, 2));
405 Unsupported method on this public path; HEAD returns these headers without a body.
HTTP 405
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
Allow: GET, HEAD, POST, OPTIONS
{
"error": "method not allowed",
"request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}
Path parameters
idrequired
string
The computer id.
Request body
portrequired
integer
The port inside the computer to serve, 1 to 65535 except 22.
label
string
The name before the domain, 3 to 40 characters once trimmed; see INVALID_LABEL. Omit to have one picked.
access
"public" | "private"
public (the default) or private.
Response
The URL as stored.
Response fields
idalways
string
pu- and sixteen hex characters.
urlalways
string
The address to open: https://<label>.<base_domain>, on port 443, with no port in it.
hostnamealways
string
<label>.<base_domain>.
labelalways
string
The name before the domain: 3 to 40 lowercase letters, digits and hyphens, a letter or digit at each end.
portalways
integer
The port inside the computer this URL serves. The app must listen on 0.0.0.0, not 127.0.0.1.
accessalways
"public" | "private"
public: anyone with the link. private: only a request carrying an API key of this account as Authorization: Bearer (a key confined to a workspace, only for that workspace’s computers), or a browser signed in to this URL through the dashboard’s Open; neither credential reaches the app. See POST /computers/{id}/public-urls.
disabledalways
boolean
true when the platform has turned this URL off, by its name, its computer or its account; it answers 404 until the platform turns it back on. Deleting it and making it again does not.
created_atalways
string
RFC 3339 timestamp.
updated_atalways
string
RFC 3339 timestamp of the last change to its port or access.
PATCH/computers/{id}/public-urls/{urlId}
Point a public URL at another portmember+
Change the port the URL serves, who may open it, or both. The label is the URL and does not change. The body is at most 4096 bytes (413). The errors this route adds are { error, code }, except that a body that is not JSON or not a JSON object, the 413, and a computer or URL that is not found carry error alone:
- 400INVALID_BODY: the body names neither port nor access, or has a field other than port and access (named in the error, and nothing changes).
- 400INVALID_PORT or INVALID_ACCESS: as on create.
- 404PUBLIC_URLS_DISABLED: public URLs are not enabled here.
- 409WORKSPACE_MOVING: a change of this computer’s workspace is unsettled, with reason as on create.
Set MANDALA_API_KEY to your bearer key. MANDALA_BASE_URL, when set, is the full API base including /api/v1. Replace the illustrative resource IDs with yours. These pinned 0.16.0 SDK and direct HTTP examples do not read saved device-login credentials.
curl
base_url="${MANDALA_BASE_URL:-}"
if [ -z "$base_url" ]; then base_url='https://app.mandala.computer/api/v1'; fi
curl --fail-with-body -X PATCH "${base_url%/}"'/computers/vm-a1b2c3d4e5f6/public-urls/pu-0123456789abcdef' \
-H "Authorization: Bearer $MANDALA_API_KEY" \
-H "Content-Type: application/json" \
--data-raw '{"port":8000}'
Python — Mandala SDK 0.16.0
# Python 3.10+: pip install mandala-computer==0.16.0
import json, os
from mandala_computer import Client
with Client(api_key=os.environ["MANDALA_API_KEY"], base_url=os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1") as client:
id = "vm-a1b2c3d4e5f6"
urlId = "pu-0123456789abcdef"
body = json.loads("{\"port\":8000}")
result = client.public_urls.update(id, urlId, port=body["port"])
print(result)
TypeScript — Mandala SDK 0.16.0
// Node.js 22+: npm install mandala-computer@0.16.0
import { Client } from "mandala-computer";
const apiKey = process.env.MANDALA_API_KEY;
if (!apiKey) throw new Error("Set MANDALA_API_KEY");
const client = new Client({apiKey, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"});
const id = "vm-a1b2c3d4e5f6";
const urlId = "pu-0123456789abcdef";
const body = {"port":8000};
const result = await client.publicUrls.update(id, urlId, body);
console.log(JSON.stringify(result, null, 2));
A public URL id from GET /computers/{id}/public-urls (pu- followed by 16 hex characters).
Request body
port
integer
The port inside the computer to serve, 1 to 65535 except 22.
access
"public" | "private"
public or private.
Response
The URL as stored.
Response fields
idalways
string
pu- and sixteen hex characters.
urlalways
string
The address to open: https://<label>.<base_domain>, on port 443, with no port in it.
hostnamealways
string
<label>.<base_domain>.
labelalways
string
The name before the domain: 3 to 40 lowercase letters, digits and hyphens, a letter or digit at each end.
portalways
integer
The port inside the computer this URL serves. The app must listen on 0.0.0.0, not 127.0.0.1.
accessalways
"public" | "private"
public: anyone with the link. private: only a request carrying an API key of this account as Authorization: Bearer (a key confined to a workspace, only for that workspace’s computers), or a browser signed in to this URL through the dashboard’s Open; neither credential reaches the app. See POST /computers/{id}/public-urls.
disabledalways
boolean
true when the platform has turned this URL off, by its name, its computer or its account; it answers 404 until the platform turns it back on. Deleting it and making it again does not.
created_atalways
string
RFC 3339 timestamp.
updated_atalways
string
RFC 3339 timestamp of the last change to its port or access.
DELETE/computers/{id}/public-urls/{urlId}
Remove a public URLmember+
The URL stops answering at once. Its label stays this account’s: no other account can ever take it, and this one may use it again unless the platform has blocked it. The app inside the computer keeps running. Allowed at any time, an unsettled workspace change included, and with public URLs switched off here. A computer or URL that is not found is a 404.
Set MANDALA_API_KEY to your bearer key. MANDALA_BASE_URL, when set, is the full API base including /api/v1. Replace the illustrative resource IDs with yours. These pinned 0.16.0 SDK and direct HTTP examples do not read saved device-login credentials.
curl
base_url="${MANDALA_BASE_URL:-}"
if [ -z "$base_url" ]; then base_url='https://app.mandala.computer/api/v1'; fi
curl --fail-with-body -X DELETE "${base_url%/}"'/computers/vm-a1b2c3d4e5f6/public-urls/pu-0123456789abcdef' \
-H "Authorization: Bearer $MANDALA_API_KEY"
Python — Mandala SDK 0.16.0
# Python 3.10+: pip install mandala-computer==0.16.0
import os
from mandala_computer import Client
with Client(api_key=os.environ["MANDALA_API_KEY"], base_url=os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1") as client:
id = "vm-a1b2c3d4e5f6"
urlId = "pu-0123456789abcdef"
result = client.public_urls.delete(id, urlId)
print(result)
TypeScript — Mandala SDK 0.16.0
// Node.js 22+: npm install mandala-computer@0.16.0
import { Client } from "mandala-computer";
const apiKey = process.env.MANDALA_API_KEY;
if (!apiKey) throw new Error("Set MANDALA_API_KEY");
const client = new Client({apiKey, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"});
const id = "vm-a1b2c3d4e5f6";
const urlId = "pu-0123456789abcdef";
const result = await client.publicUrls.delete(id, urlId);
console.log(JSON.stringify(result, null, 2));