diff --git a/.vale.ini b/.vale.ini
index 8efd643b..fa97736f 100644
--- a/.vale.ini
+++ b/.vale.ini
@@ -30,3 +30,18 @@ Spacefast.Hype = NO
Spacefast.Wordiness = NO
Spacefast.Condescension = NO
Spacefast.ApiKey = NO
+Spacefast.Hosting = NO
+
+; Generated error pages are a verified snapshot synced from the monorepo, and
+; the OpenAPI prose extraction reads generated/openapi. Both stay exempt from
+; the "hosting" rule until the snapshot is refreshed with the monorepo fixes;
+; then remove these carve-outs.
+[content/(reference)/errors/**]
+BasedOnStyles = Vale, Spacefast
+Vale.Spelling = NO
+Spacefast.Hosting = NO
+
+[*openapi-prose.md]
+BasedOnStyles = Vale, Spacefast
+Vale.Spelling = NO
+Spacefast.Hosting = NO
diff --git a/AGENTS.md b/AGENTS.md
index dac7e55f..7f3cfb6b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -89,8 +89,11 @@ alerts is the bar.
rules, which mirror the vocabulary registry in the product monorepo
(`packages/common/src/vocabulary.ts`): "API key" never "access token". The
infra brand is "WP Cloud" (never dotted or hyphenated). Prefer "infra";
- name WP Cloud only when the external reference is necessary — never the
- euphemism "the hosting provider". Marketing may use the family line "the
+ name WP Cloud only when the external reference is necessary. Never use the
+ word "hosting" in prose: say publish, serve, live, infrastructure, or
+ runtime (`Hosting.yml`). Generated error pages, OpenAPI prose, and the
+ shipped changelog are exempt until the generated snapshot is refreshed.
+ Marketing may use the family line "the
same infra as Automattic, WP Cloud, Pressable, and WordPress.com".
- Unknown technical words go in `styles/Spacefast/spelling-exceptions.txt`
(sorted, case matters for proper nouns). Identifiers in prose — commands,
diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md
index dd2fa3b5..2d9f9642 100644
--- a/STYLE_GUIDE.md
+++ b/STYLE_GUIDE.md
@@ -211,9 +211,9 @@ that is.
(`ProductName.yml`).
- **WP Cloud** — exact casing, never "wp cloud," "wp.cloud," or hyphenated
(`WpCloudBrand.yml`). Prefer "infra" generally; name WP Cloud only when
- the external reference is genuinely necessary. Never say "the hosting
- provider" for Spacefast's own infra (`HostingProvider.yml`) — generic
- references to *other* hosting providers are fine.
+ the external reference is genuinely necessary. Never use the word
+ "hosting" for Spacefast or its features (`Hosting.yml`). Say publish,
+ serve, live, infrastructure, or runtime.
- **"API key," never "access token"** (`ApiKey.yml`) — "OAuth access
token" is the one exempted phrase, since that's the protocol's own term.
diff --git a/blume.config.ts b/blume.config.ts
index 706e1557..60a40685 100644
--- a/blume.config.ts
+++ b/blume.config.ts
@@ -8,7 +8,7 @@ import { pagefind } from "blume/search";
import { loadPricing, pricingVariables } from "./scripts/pricing.mjs";
import { redirects } from "./redirects.ts";
-import { pricingTableMarkdown } from "./scripts/pricing-next.mjs";
+import { planVariables, pricingTableMarkdown } from "./scripts/pricing-next.mjs";
const deploymentBase = "/docs";
// Agent guidance for llms.txt, kept as Markdown so Vale lints it with the pages.
@@ -62,8 +62,14 @@ const icons = {
export default defineConfig({
title: "Spacefast Docs",
- variables: pricingVariables(await loadPricing({ fixture: new URL("./content/_pricing/document.json", import.meta.url) })),
- description: "Publish and host the sites your agents build.",
+ variables: {
+ ...pricingVariables(await loadPricing({ fixture: new URL("./content/_pricing/document.json", import.meta.url) })),
+ // Plan values from scripts/pricing-next.mjs, the source Pricing & Limits renders.
+ ...planVariables(),
+ },
+ description: "Publish what your agents build.",
+ // Two links (components/Logo.astro): the mark goes to the Website home at the
+ // origin root, outside the /docs base; "Docs" goes to the Docs home.
logo: { href: "/", image: { light: "/spacefast-wordmark.svg", alt: "Spacefast" }, text: "Docs" },
// The 2026-10 brand: neutral greys, one green, dark first like the Website.
// The green is a ground with ink on it, never type on white, so light mode
@@ -168,12 +174,12 @@ export default defineConfig({
},
agents: {
llmsTxt: { details: llmsDetails },
- // The WP Cloud footer, written out as Markdown for .md pages, llms-full.txt
+ // The upstream reference footer, written out as Markdown for .md pages, llms-full.txt
// and MCP: without this the component ships as raw JSX.
markdownComponents: {
- PricingTable: ({ props }) => pricingTableMarkdown(String(props.table)),
+ PricingTable: ({ props }) => pricingTableMarkdown(String(props.table), props.row ? String(props.row) : undefined),
WpCloudDocs: ({ props }) =>
- `Spacefast is built on Automattic's [WP Cloud](https://wp.cloud/). Using the [Partner API](${deploymentBase}/platforms/partner-api)? See [${String(props.title)}](${String(props.href)}) on WP Cloud Docs.`,
+ `Using the [Partner API](${deploymentBase}/platforms/partner-api)? See [${String(props.title)}](${String(props.href)}) in the upstream infrastructure reference.`,
},
// The Website publishes Spacefast's agent skill at the site root; a second,
// generated Docs skill would compete with it.
diff --git a/components.ts b/components.ts
index 1ab402d2..3eca02e6 100644
--- a/components.ts
+++ b/components.ts
@@ -1,12 +1,13 @@
import { defineComponents } from "blume";
import Breadcrumbs from "./components/Breadcrumbs.astro";
+import Logo from "./components/Logo.astro";
import Pagination from "./components/Pagination.astro";
import Sidebar from "./components/Sidebar.astro";
import PricingTable from "./components/PricingTable.astro";
import WpCloudDocs from "./components/WpCloudDocs.astro";
export default defineComponents({
- layout: { Breadcrumbs, Pagination, Sidebar },
+ layout: { Breadcrumbs, Logo, Pagination, Sidebar },
mdx: { PricingTable, WpCloudDocs },
});
diff --git a/components/Logo.astro b/components/Logo.astro
new file mode 100644
index 00000000..46942b2d
--- /dev/null
+++ b/components/Logo.astro
@@ -0,0 +1,41 @@
+---
+// The brand lockup: the Spacefast mark links to the Website home, "Docs" links
+// to the Docs home. Docs are always served inside the Website artifact at
+// /docs, so the configured root-relative `logo.href` ("/") is emitted as is,
+// without the deployment base, and reaches the Website home on whatever origin
+// serves the page. The Website is not part of the Docs' client router, so the
+// mark's link does a plain page load instead of a router fetch first.
+import { withBase } from "blume/components/islands/base-path.ts";
+import type { BlumeLogo } from "blume/core/data.ts";
+
+interface Props {
+ site: { title: string };
+ logo?: BlumeLogo | null;
+ locale?: string;
+}
+
+const { site, logo } = Astro.props;
+const label = logo?.text ?? site.title;
+const link = "inline-flex items-center transition-opacity hover:opacity-80";
+// The config inlines the wordmark so it follows the theme. Fail the build
+// rather than ship a header whose mark silently disappeared.
+if (!logo?.svg) throw new Error("components/Logo.astro needs blume.config.ts `logo` to resolve to an inline SVG.");
+---
+
+
diff --git a/components/PricingTable.astro b/components/PricingTable.astro
index d8595e26..ed11c48c 100644
--- a/components/PricingTable.astro
+++ b/components/PricingTable.astro
@@ -10,14 +10,16 @@ import { pricingTable } from "../scripts/pricing-next.mjs";
interface Props {
table: "plans" | "team" | "space" | "features" | "examples" | "payments";
+ /** Show only these rows, joined with " | ", so another page can quote plan limits from the same data. */
+ row?: string;
}
-const { table } = Astro.props;
-const { columns, groups, highlight } = pricingTable(table);
+const { table, row: rowLabel } = Astro.props;
+const { columns, groups, highlight } = pricingTable(table, rowLabel);
const span = columns.length + 1;
---
-
+
diff --git a/components/WpCloudDocs.astro b/components/WpCloudDocs.astro
index 231e9d75..0e50858a 100644
--- a/components/WpCloudDocs.astro
+++ b/components/WpCloudDocs.astro
@@ -11,6 +11,6 @@ const { title, href } = Astro.props;
diff --git a/content/(account)/api-keys.mdx b/content/(account)/api-keys.mdx
index 506283b6..dfb59f91 100644
--- a/content/(account)/api-keys.mdx
+++ b/content/(account)/api-keys.mdx
@@ -82,12 +82,12 @@ Keys created through the API can also be scoped to a single Space instead of the
Some actions are reserved for a signed-in person, at every permission level. No API key, agent grant, or OAuth token can do them:
-- Invite a member, cancel or resend an invitation
+- Cancel or resend an invitation
- Change a member's role
- Remove a member
- Accept a team invitation
-A credential that tries gets a `403` with `authorization_level_not_allowed`. Do those in the dashboard. Once you accept an invitation there, the device credentials you already approved pick the new team up on their own. More detail in [what agents are allowed to do](/agents/permissions).
+A credential that tries gets a `403` with `authorization_level_not_allowed`. Do those in the dashboard. Creating an invitation is the exception: a credential with the `teams:members.write` scope, such as Agent access, can invite people with [`createTeamInvitation`](/api/reference/teams/createteaminvitation). Once you accept an invitation in the dashboard, the device credentials you already approved pick the new team up on their own. More detail in [what agents are allowed to do](/agents/permissions).
## Rotate a key
diff --git a/content/(account)/auto-enroll.mdx b/content/(account)/auto-enroll.mdx
new file mode 100644
index 00000000..295d4182
--- /dev/null
+++ b/content/(account)/auto-enroll.mdx
@@ -0,0 +1,76 @@
+---
+title: Allowed email domains
+description: Let people with a verified company email address find and join your team without an invitation
+---
+
+An allowed email domain offers your team to anyone whose verified email address is at that domain. Spacefast never adds anyone automatically. A person becomes a member only when they press **Join**.
+
+Only owners and admins can add or remove domains. An OAuth token needs the `teams:members.write` scope, including to list them.
+
+## Add a domain
+
+1. Open the team and go to **Members**.
+2. Press **Join Link**. The **Let Teammates Join** dialog opens.
+3. Type the domain, for example `example.com`, in **Company email domain**.
+4. Press **Add Domain**.
+
+Over HTTP, send [`createTeamAutoEnrollEntry`](/api/reference/teams/createteamautoenrollentry):
+
+```bash
+curl "https://api.spacefast.com/v1/teams/$TEAM_ID/auto-enroll" \
+ -H "Authorization: Bearer $SPACEFAST_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"value":"example.com"}'
+```
+
+The `201` response holds the entry: `id`, `teamId`, `kind` (always `domain`), `value`, and `createdAt`. Adding a domain the team already has returns the existing entry. [`listTeamAutoEnrollEntries`](/api/reference/teams/listteamautoenrollentries) returns all entries, newest first.
+
+Adding a domain needs a credential that acts for a person. A team API key or OAuth app acts for the person who created it. A key with no person behind it gets `forbidden`.
+
+## Which domains Spacefast accepts
+
+A domain is accepted when all of these are true:
+
+- **A current member already uses it.** At least one member of this team must have a verified email address at exactly that domain. A subdomain does not count.
+- **It is a domain, not an address.** A value with `@` is refused. Invite that person by email instead.
+- **It is a registrable domain.** IP addresses, single labels such as `localhost`, and public suffixes such as `co.uk` are refused.
+- **It is not a shared mailbox provider.** These are refused: `gmail.com`, `googlemail.com`, `outlook.com`, `hotmail.com`, `live.com`, `yahoo.com`, `proton.me`, `protonmail.com`, `icloud.com`, `me.com`, `aol.com`, `mail.com`, `gmx.com`, and `wordpress.com`.
+
+Spacefast stores the canonical form: lowercase ASCII, with international names converted to their `xn--` form. `Bücher.de` becomes `xn--bcher-kva.de`. Matching uses the same form, so either spelling works for people who join.
+
+## What a person sees
+
+A signed-in person with a verified address at the domain sees your team under **Open invites** on the **Teams** page. The row reads **You have an @example.com email**. Their team's Spaces page also shows a **Teams you can join** notice with **View Teams**.
+
+Every verified address on the account counts, including secondary addresses. The account does not need to use that address to sign in.
+
+Pressing **Join** makes them a member with the `member` role. Owners and admins can change the role later on **Members**. Joining needs a signed-in browser session. An API key or an agent cannot join for a person. The API operation is [`joinTeam`](/api/reference/teams/jointeam).
+
+## Seats and billing
+
+A person who joins through a domain takes a seat like any other member. Members on Pro: {{plan-team-members-pro}}.
+
+Personal and Go allow one member, the owner. On those plans, **Join** fails with `team_member_quota_exceeded`, so nobody can join until the team moves to Pro. See [Pricing & Limits](/pricing).
+
+## Remove a domain
+
+In **Join Link**, press **Remove** next to the domain. Confirm with **Remove Domain**.
+
+Over HTTP, send [`deleteTeamAutoEnrollEntry`](/api/reference/teams/deleteteamautoenrollentry) with the entry `id`. It answers `204`.
+
+- **Existing members stay.** Removing a domain changes no membership and no role.
+- **New people need an invitation.** The team stops appearing under their **Open invites**.
+
+To take access away from someone who joined through a domain, remove them on **Members**. See [Teams and members](/teams#change-a-role-or-remove-someone).
+
+A member you remove cannot rejoin through the domain. To bring them back, send an invitation. A member who leaves on their own can rejoin through the domain.
+
+## Errors
+
+| Code | Status | Meaning |
+| --- | --- | --- |
+| `invalid_domain_name` | 422 | The value is not a registrable domain. IP addresses and public suffixes land here |
+| `validation_error` | 422 | `details.reason` says why. `email_not_supported`: the value is an email address. `domain_not_allowed`: the domain is a shared mailbox provider. `domain_not_represented`: no member has a verified address there |
+| `not_found` | 404 | The entry does not exist or belongs to another team |
+| `forbidden` | 403 | Your role cannot manage members, or the credential has no person behind it |
+| `team_member_quota_exceeded` | 409 | A person pressed **Join** while the team was at its member limit |
diff --git a/content/(account)/billing.mdx b/content/(account)/billing.mdx
index 2f4b560c..21fe6260 100644
--- a/content/(account)/billing.mdx
+++ b/content/(account)/billing.mdx
@@ -21,11 +21,11 @@ Use the returned entitlements for feature checks.
## When a payment fails
-Your sites keep serving through a grace period, but you cannot change anything.
+Your Spaces keep serving through a grace period, but you cannot change anything.
| Stage | How long | What visitors get | What you get |
| --- | --- | --- | --- |
-| Past due | 7 days from the failed payment | Sites serve normally | Reads work. Every write returns `402` with code `tenant_past_due` |
+| Past due | 7 days from the failed payment | Spaces serve normally | Reads work. Every write returns `402` with code `tenant_past_due` |
| Suspended | After the grace period ends | Every space serves `402` and the text `This space is paused.` | Reads work. Every write returns `403` with code `tenant_suspended` |
Settle the payment in the billing portal to restore access. API reads remain available in either state.
diff --git a/content/(account)/identity-connections.mdx b/content/(account)/identity-connections.mdx
new file mode 100644
index 00000000..1a8b3ef4
--- /dev/null
+++ b/content/(account)/identity-connections.mdx
@@ -0,0 +1,175 @@
+---
+title: Identity connections
+description: Connect an OIDC provider or your own signer to a team, then admit its people to Spaces with grants
+---
+
+An identity connection lets visitors prove who they are with a sign-in system you already run. The connection admits no one by itself. A Space admits a person only through a grant that names the connection and that person's subject.
+
+| Type | `type` | Use it when | You provide |
+| --- | --- | --- | --- |
+| **OpenID Connect** | `oidc` | Your company signs people in through an OIDC provider | Issuer, client ID, and client secret |
+| **White-label signer** | `signer` | You run your own portal and sign each visitor's identity yourself | Issuer, sign-in URL, and Ed25519 public keys |
+
+Owners and admins create, update, and revoke connections. A connection that team members use to sign in to Spacefast can be changed or revoked only by a team owner, in the dashboard. Every team member can list them. An OAuth token needs the `teams:create` scope to change a connection and `teams:read` to list them.
+
+## Register Spacefast with your OIDC provider
+
+To use `oidc`, register Spacefast in your provider first as a web app with a client secret. Use these values:
+
+| Setting | Value |
+| --- | --- |
+| Redirect URI | `https://api.spacefast.com/identity/callback` |
+| Grant type | Authorization code. Spacefast also sends a PKCE `S256` challenge and a nonce |
+| Client authentication | HTTP Basic (`client_secret_basic`) |
+| Scopes | `openid email profile` |
+| ID token algorithms | `RS256`, `PS256`, `ES256`, or `EdDSA` |
+
+Spacefast reads `/.well-known/openid-configuration` over HTTPS. The `issuer` value in that document must match the issuer you enter. If your tenant sets `presentation.accessHandoffOrigin`, the redirect URI is `/identity/callback` on that origin instead. See [Partner API configuration](/platforms/partner-api/configuration).
+
+## Add a connection in the dashboard
+
+1. Open the team and go to **Team settings → Access**.
+2. In **SSO Providers**, press **Add Identity Provider**. The **Add SSO Provider** dialog opens.
+3. Pick a **Connection type**: **OpenID Connect** or **White-label signer**.
+4. Enter a **Name** and the **Issuer**.
+5. For OpenID Connect, enter the **Client ID** and **Client secret**.
+6. For a signer, enter the **Sign-in URL**. Add the **Ed25519 public keys**, one `kid:base64url-key` per line.
+7. Press **Add Connection**.
+
+The connection appears as a row with an **OIDC** or **Signer** badge and its issuer.
+
+## Add a connection with the API or CLI
+
+Send [`createTeamIdentityConnection`](/api/reference/teams/createteamidentityconnection):
+
+```bash
+curl "https://api.spacefast.com/v1/teams/$TEAM_ID/identity-connections" \
+ -H "Authorization: Bearer $SPACEFAST_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"type":"oidc","name":"Acme SSO","issuer":"https://login.example.com","clientId":"","clientSecret":""}'
+```
+
+A signer body uses `authorizeUrl` and `publicKeys` instead of the client fields:
+
+```json
+{
+ "type": "signer",
+ "name": "Customer portal",
+ "issuer": "https://portal.example.com",
+ "authorizeUrl": "https://portal.example.com/spacefast/authorize",
+ "publicKeys": [{ "kid": "2026-01", "publicKey": "<43-character-base64url-key>" }]
+}
+```
+
+The `201` response holds the connection. Its `id` starts with `acn_`, and grants use that ID. A team can have one active connection per issuer. A second one fails with `409 identity_connection_issuer_exists`. From a terminal, run `sf share identity create`. See [`sf share`](/cli/share#sf-share-identity-create).
+
+| Field | Applies to | Rules |
+| --- | --- | --- |
+| `type` | Both | `oidc` or `signer` |
+| `name` | Both | 1 to 200 characters |
+| `issuer` | Both | An absolute HTTPS URL, up to 1,024 characters. No user info and no fragment. Spacefast removes trailing slashes |
+| `clientId` | `oidc` | Up to 512 characters |
+| `clientSecret` | `oidc` | Up to 2,048 characters. Write-only. Responses show only `clientSecretSet` |
+| `authorizeUrl` | `signer` | An absolute HTTPS URL, up to 2,048 characters. No user info and no fragment |
+| `publicKeys` | `signer` | 1 to 5 entries of `kid` and `publicKey`. Each `kid` is unique and up to 64 characters. `publicKey` is a raw Ed25519 public key, base64url, 43 characters |
+| `sessionTtlSeconds` | Both | Optional. `oidc`: 60 to 86,400, default 28,800. `signer`: 60 to 1,800, default 1,800 |
+
+## Admit people to a Space
+
+The connection proves identity. A grant on the Space decides what that identity can open.
+
+To add a grant in the dashboard, open the Space and go to **Share**. Open **Advanced**, then find **SSO and Identity**. Pick the **Connection**, then fill in **Subject**, **Name**, **Paths**, and an optional **Expires**. Choose **What this identity can do** and press **Add Identity Grant**. If the team has no connection, the card shows **No SSO Provider**.
+
+From a terminal, run `sf share identity grant`:
+
+```bash
+sf share identity grant --connection acn_xxxxxxxx --subject alex@example.com --name "Alex (Acme SSO)" --path '/docs/**' --space my-site
+```
+
+Over HTTP, send [`createSpaceGrant`](/api/reference/access/createspacegrant) with an `external` audience:
+
+```json
+{
+ "name": "Alex (Acme SSO)",
+ "audience": { "kind": "external", "connectionId": "acn_xxxxxxxx", "subject": "alex@example.com" },
+ "resources": { "include": ["/docs/**"] },
+ "capabilities": ["page.view"],
+ "target": { "kind": "live" }
+}
+```
+
+- **The subject must equal the `sub` claim.** Spacefast compares it exactly. It holds up to 255 characters.
+- **A name is required.** Spacefast stores the subject as a hash, so the grant list cannot show it.
+- **The connection must belong to the Space's team.** The Space must be claimed.
+- **Verified email follows the provider.** A grant that requires a verified email admits an OIDC visitor only when the ID token has `email_verified: true`. A signer proof never counts as a verified email.
+
+Roles, paths, and targets work as they do for every other grant. See [Spacefast Share](/share).
+
+A visitor who cannot open a page sees the Space's access page. It shows **Continue with** and the connection name for each connection that an active grant on that Space trusts. A connection with no grant on the Space does not appear there.
+
+## Build a signer
+
+A signer connection lets your portal prove a visitor's identity with an Ed25519 key registered on the connection. The proof's subject must match the subject on a Space grant. A signer proof lives at most 30 minutes, and never longer than the connection's session lifetime.
+
+The proof-exchange protocol is not part of the public API reference. Contact [support@spacefast.com](mailto:support@spacefast.com) for the current integration steps.
+
+## Update a connection
+
+In **SSO Providers**, press **Update** on the connection's row.
+
+| Type | Fields | Button |
+| --- | --- | --- |
+| OpenID Connect | **Client ID**, **Replacement client secret**, **Session lifetime in minutes** | **Update Connection** |
+| White-label signer | **Replacement Ed25519 key set**, **Session lifetime in minutes** | **Rotate Key Set** |
+
+Leave **Replacement client secret** blank to keep the current secret. To rename a connection or change a signer's sign-in URL, use the API or the CLI.
+
+[`updateTeamIdentityConnection`](/api/reference/teams/updateteamidentityconnection) accepts `name`, `clientId`, `clientSecret`, `authorizeUrl`, `publicKeys`, and `sessionTtlSeconds`. It accepts `sessionTtlSeconds` from 60 to 86,400. For a signer, Spacefast lowers any value above 1,800 to 1,800. `publicKeys` replaces the whole key set. The `type` and `issuer` cannot change.
+
+Grants that use the connection survive rotation. Changing the client ID, client secret, key set, or session lifetime ends sessions admitted through the connection, so those visitors sign in again. Renaming a connection does not end sessions.
+
+To keep both signing keys valid during a transition:
+
+1. Send a key set that holds the old key and the new key.
+2. Sign new proofs with the new key.
+3. Send a key set that holds only the new key.
+
+### Replace a connection
+
+A team can have one active connection per issuer. Revoking a connection also revokes its grants. Plan the order:
+
+- **To move to a new issuer**, create the new connection and its grants first. Then revoke the old connection.
+- **To change the type for the same issuer**, revoke the old connection first, because the new one uses the same issuer. Then create the new connection and add its grants again. Visitors cannot sign in through that issuer until the new grants exist.
+
+## Revoke a connection
+
+Press **Revoke** on the row, then **Revoke Connection** in the dialog. Over HTTP, send [`revokeTeamIdentityConnection`](/api/reference/teams/revoketeamidentityconnection). It answers `204`. From a terminal, run `sf share identity revoke`.
+
+Revoking removes the grants too. Spacefast revokes every grant on the team's Spaces that trusts the connection's issuer, then the connection itself. If team members sign in to Spacefast through the connection, revoking it also turns member sign-in off.
+
+A revoked connection stays in [`listTeamIdentityConnections`](/api/reference/teams/listteamidentityconnections) with `revokedAt` set. It cannot be updated. To restore access, create a new connection and add its grants again.
+
+## Errors
+
+| Code | Status | Meaning |
+| --- | --- | --- |
+| `identity_connection_invalid_issuer` | 422 | The issuer is not an absolute HTTPS URL, or it holds user info or a fragment |
+| `identity_connection_invalid_authorize_url` | 422 | The sign-in URL is not an absolute HTTPS URL, or it holds user info or a fragment |
+| `identity_connection_duplicate_kid` | 422 | Two signer keys share a `kid` |
+| `identity_connection_type_mismatch` | 422 | An update sent signer fields to an OIDC connection, or OIDC fields to a signer |
+| `validation_error` | 422 | The body failed the schema, or an `external` grant has no `name` |
+| `not_found` | 404 | The connection does not exist, belongs to another team, or is revoked |
+| `forbidden` | 403 | Your role cannot change connections, or a signer proof was rejected |
+| `team_owner_required` | 403 | Team members sign in to Spacefast through this connection, so only an owner can change it, in the dashboard |
+| `identity_connection_issuer_exists` | 409 | The team already has an active connection for that issuer |
+
+OIDC sign-in failures show to the visitor during the round trip:
+
+| Code | Status | Meaning |
+| --- | --- | --- |
+| `oidc_discovery_failed` | 503 or 422 | The discovery document did not load, or it is not valid |
+| `oidc_discovery_issuer_mismatch` | 422 | The discovery document names a different issuer |
+| `oidc_token_exchange_rejected` | 422 | The provider refused the authorization code or the client credentials |
+| `oidc_issuer_mismatch` | 422 | The ID token `iss` does not match the connection |
+| `oidc_audience_mismatch` | 422 | The ID token `aud` does not include the client ID |
+| `oidc_state_expired` | 422 | The sign-in took longer than 10 minutes. Start again |
diff --git a/content/(account)/meta.ts b/content/(account)/meta.ts
index 026c3e4d..96b0ad27 100644
--- a/content/(account)/meta.ts
+++ b/content/(account)/meta.ts
@@ -4,5 +4,5 @@ export default defineMeta({
title: "Account",
collapsed: false,
order: 6,
- pages: ["authentication", "api-keys", "pricing", "billing", "usage"],
+ pages: ["authentication", "api-keys", "identity-connections", "auto-enroll", "subteams", "pricing", "billing", "usage"],
});
diff --git a/content/(account)/pricing.mdx b/content/(account)/pricing.mdx
index 4b575b33..8c751383 100644
--- a/content/(account)/pricing.mdx
+++ b/content/(account)/pricing.mdx
@@ -87,7 +87,7 @@ Yes. Contact sales.
| Soon | Not available yet; may change before launch |
| Generally available | Out of beta. Billing starts at the listed price after a month's notice |
| Fair use | Normal use for a website or app. If a Space puts an unreasonable or disproportionately large load on our infrastructure, usually file sharing or storage, we ask you to adjust before restricting it. Deployed Spaces keep serving cached traffic |
-| Storage | Free for a limited time while we finish measuring it. Once billed: measured daily, averaged over the month, and the first 1 GB per Space isn't counted; it covers platform files on each site. See [Server specs](/infrastructure/server-specs-settings) |
+| Storage | Free for a limited time while we finish measuring it. Once billed: measured daily, averaged over the month, and the first 1 GB per Space isn't counted; it covers platform files on each Space. See [Server specs](/infrastructure/server-specs-settings) |
| Business minimum | $500 a month, which includes $500 of usage. Members and usage past the $500 are added on top |
| Example bills | Illustrations. Your bill depends on your actual usage |
| Abuse | We may restrict a Space that harms others or breaks the [Terms of Service](https://automattic.com/tos/). We tell you why, and you can always export your data |
diff --git a/content/(account)/subteams.mdx b/content/(account)/subteams.mdx
new file mode 100644
index 00000000..a3b963f0
--- /dev/null
+++ b/content/(account)/subteams.mdx
@@ -0,0 +1,99 @@
+---
+title: Subteams
+description: Split an Enterprise team into subteams, each with its own members and Spaces, on the parent's plan and seats
+---
+
+Enterprise
+
+A subteam is a team inside your team. It has its own name, URL, members, and Spaces. It uses the parent team's plan, seats, and billing.
+
+Subteams need an Enterprise parent team. On every other plan, subteam operations return `enterprise_required`. Plans are on [Plans and billing](/billing).
+
+## How subteams work
+
+- **The parent's owners and admins are in every subteam.** They hold the same role there. To change that role, change it on the parent team.
+- **Parent members can join a subteam.** Parent members see it on the parent team's **Subteams** page, and under the parent team on **Teams**, with **Join**. Pressing **Join** makes them a subteam `member`, with no new seat. A person removed from a subteam does not see it there. An owner or admin of the parent team must add them again.
+- **Seats are shared.** Seats count across the parent and all its subteams. A person in several teams of the family uses one seat.
+- **Subteams do not nest.** A subteam cannot have subteams of its own.
+- **Leaving the parent leaves every subteam.** When someone leaves or is removed from the parent team, they also lose every subteam membership.
+
+Each subteam owns its Spaces. Access to them follows subteam membership and the member's subteam role. Teams, roles, and Space ownership work as described in [Teams and members](/teams).
+
+## Create a subteam
+
+Any member of the parent team can create a subteam and becomes its admin. To keep creation with owners and admins, turn on **Only owners and admins can create subteams** in the parent's **Team settings → Access**.
+
+1. Open the parent team and go to **Subteams**.
+2. Press **New Subteam**.
+3. Enter a **Name**, then press **Create Subteam**.
+
+Spacefast creates the subteam and opens it. Its URL slug starts with the parent's slug, followed by the name.
+
+Over HTTP, send [`createSubTeam`](/api/reference/teams/createsubteam) with a `name` of 1 to 255 characters:
+
+```bash
+curl "https://api.spacefast.com/v1/teams/$TEAM_ID/subteams" \
+ -H "Authorization: Bearer $SPACEFAST_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"name":"Design"}'
+```
+
+The `201` response holds the subteam: `id`, `teamId` (the parent), `name`, `slug`, `memberCount`, `createdAt`, and `updatedAt`. From a terminal, run `sf teams subteams create Design`.
+
+## List subteams
+
+[`listSubTeams`](/api/reference/teams/listsubteams) returns the parent's subteams, sorted by name. Every member of the parent team can list them. From a terminal, run `sf teams subteams list`.
+
+The **Subteams** page shows the subteams you are in, each with **Open**. Below them are the subteams you can join, each with **Join**.
+
+## Rename a subteam
+
+Send [`updateSubTeam`](/api/reference/teams/updatesubteam) with a new `name`. The slug does not change. From a terminal:
+
+```bash
+sf teams subteams rename "$SUBTEAM_ID" "Brand design"
+```
+
+You can also open the subteam and rename it in **Team settings → General**.
+
+## Manage subteam members
+
+Adding and removing members needs an owner or admin of the parent team.
+
+```bash
+sf teams subteams members list "$SUBTEAM_ID"
+sf teams subteams members add "$SUBTEAM_ID" "$USER_ID"
+sf teams subteams members remove "$SUBTEAM_ID" "$USER_ID"
+```
+
+| Operation | What it does |
+| --- | --- |
+| [`listSubTeamMembers`](/api/reference/teams/listsubteammembers) | Lists members with `id`, `subTeamId`, `userId`, `createdAt`, and a `user` object holding `id`, `name`, `email`, and `image`. Subteam members and the parent's owners and admins can read it |
+| [`addSubTeamMember`](/api/reference/teams/addsubteammember) | Adds a parent member as a subteam `member`. No invitation is sent and no seat is used. Repeating the request keeps one membership |
+| [`removeSubTeamMember`](/api/reference/teams/removesubteammember) | Removes the person from the subteam and its Spaces. They stay on the parent team |
+
+Find a user ID with `sf teams members ls --json` on the parent team. You can also invite someone from the **Members** page of the subteam. The subteam role picker offers **Admin** and **Member**. A subteam has no owner of its own.
+
+A person you remove from a subteam cannot rejoin it with **Join**. To bring them back, add them again.
+
+## Delete a subteam
+
+A subteam with Spaces cannot be deleted. Delete or move its Spaces first. See [Move a Space to another team](/teams#move-a-space-to-another-team).
+
+Send [`deleteSubTeam`](/api/reference/teams/deletesubteam), or run `sf teams subteams delete `. In the dashboard, open the subteam and go to **Team settings → Danger Zone → Delete this team**. Deleting a subteam ends its memberships.
+
+A parent team cannot be deleted while it has subteams. Delete every subteam first.
+
+## Errors
+
+| Code | Status | Meaning |
+| --- | --- | --- |
+| `enterprise_required` | 403 | The parent team is not on Enterprise |
+| `parent_authority_required` | 403 | Restoring a deleted subteam, or changing a setting the parent team controls, needs an owner or admin of the parent team |
+| `subteam_nesting_unsupported` | 422 | The team is already a subteam, or it is a system or partner team |
+| `role_managed_by_parent` | 409 | The person's subteam role comes from the parent team. Change it there |
+| `role_managed_by_parent` | 422 | A subteam has no owners of its own. Grant or invite **Admin** or **Member** |
+| `team_has_spaces` | 409 | The subteam still has Spaces |
+| `team_has_subteams` | 409 | The parent team still has subteams |
+| `not_found` | 404 | The subteam does not exist, or the user is not a member of the parent team |
+| `forbidden` | 403 | Your role cannot manage members, or you are not in the subteam whose members you asked for |
diff --git a/content/(account)/usage.mdx b/content/(account)/usage.mdx
index cd3e90e5..c44e74a9 100644
--- a/content/(account)/usage.mdx
+++ b/content/(account)/usage.mdx
@@ -21,7 +21,7 @@ Two endpoints return the numbers those pages draw. `GET /v1/teams/{teamId}/usage
| Meter | Field | What it counts | At the limit |
| --- | --- | --- | --- |
-| Spaces | `spaces` | Live spaces in the team | Blocks at the plan limit. `409` `space_quota_exceeded` |
+| Spaces | `spaces` | Live spaces in the team | Blocks at the plan limit. `409` `quota_exceeded` |
| Custom domains | `domains` | External domain groups against the plan quota | Blocks. `409` `domain_quota_exceeded` |
| Team members | `members` | Billable seats, live members plus pending invitations | Blocks at the plan limit. `409` `team_member_quota_exceeded` |
| Storage | `storageBytes` | Disk your spaces occupy | Displayed only. Nothing blocks |
@@ -46,7 +46,7 @@ These measurements are usage counters, not invoice line items.
## Measurement timing
-Storage is measured by the infrastructure that serves your sites, and it lags the present by about an hour. `storageCalculatedAt` tells you how far back the weakest measurement behind the total reaches.
+Storage is measured by the infra that serves your Spaces, and it lags the present by about an hour. `storageCalculatedAt` tells you how far back the weakest measurement behind the total reaches.
Nothing is deduplicated. A file present in two spaces sits on two disks and counts twice in your team total.
diff --git a/content/(concepts)/spaces.mdx b/content/(concepts)/spaces.mdx
index d76f4e69..65bb3ab4 100644
--- a/content/(concepts)/spaces.mdx
+++ b/content/(concepts)/spaces.mdx
@@ -5,11 +5,11 @@ sidebar:
order: 1
---
-A Space is one site with a stable hostname. Publishing creates versions inside it, and the Space decides which one visitors see.
+A Space is what you publish to: one stable hostname. Publishing creates versions inside it, and the Space decides which one visitors see.
## What a Space is
-A Space is one site. It owns a slug, a hostname, an owner, and one pointer that says which version visitors get. A publish can create a new immutable version or report no changes, and the Space decides which version is live.
+A Space owns a slug, a hostname, an owner, and one pointer that says which version visitors get. A publish can create a new immutable version or report no changes, and the Space decides which version is live.
Ids are `spc_` plus 32 hex characters:
@@ -48,7 +48,7 @@ The substring class survives numeric suffixing, so `paypal-2` fails for the same
Dashboard route names are reserved too, and the reservation is scoped to the position. `settings` is refused as a Space slug and accepted as a team slug. Refused as Space slugs: `agents`, `api-keys`, `billing`, `connect`, `domains`, `drop`, `handoffs`, `members`, `new`, `partners`, `resume`, `settings`, `variables`, `webhooks`. The `422` carries a `details.suggestion` you can take as-is.
-A retired slug stays taken. After a rename the old slug belongs to that Space forever, and anyone else asking for it gets `422` `slug_unavailable`.
+A rename keeps the old slug reserved for that Space, and only that Space can take it back. Another Space asking for it gets `slug_unavailable`: `422` on create, `409` on rename.
Check before you commit with [`getSlugAvailability`](/api/reference/spaces/getslugavailability).
@@ -72,7 +72,7 @@ To move a Space to another team, run `sf spaces transfer --space docs`, o
A new team-owned Space starts at one of two presets. A Space published without an account has no owning team. Its space key and claim link open it until the claim window ends; see [Publish without an account](/anonymous-and-claim).
-| Preset | Who can open the live site |
+| Preset | Who can open the live Space |
| --- | --- |
| `private` | The owning team, plus anyone you add |
| `public` | The owning team, plus anyone on the web |
@@ -91,10 +91,10 @@ Everything after that, including people, share links, passwords, and access requ
```bash
sf spaces ls
sf spaces ls --team acme --limit 100
-sf spaces get --space docs
+sf spaces inspect --space docs
```
-`sf spaces get` prints the live runtime state and the current version. `sf status` tells you which Space the current directory is linked to.
+`sf spaces inspect` prints the live runtime state and the current version. `sf status` tells you which Space the current directory is linked to.
diff --git a/content/(concepts)/teams.mdx b/content/(concepts)/teams.mdx
index b234f266..ce4d8ae2 100644
--- a/content/(concepts)/teams.mdx
+++ b/content/(concepts)/teams.mdx
@@ -9,9 +9,9 @@ A team owns its Spaces, domains, API keys, and billing. Your role in that team c
## Why work in a team
-Personal and Go allow only the owner. Adding another member needs [Pro](/pricing), which is free up to 5 members.
+Personal and Go allow only the owner. Adding another member needs [Pro](/pricing). Members on Pro: {{plan-team-members-pro}}.
-- **Publish together.** Members can create Spaces, publish new versions, and roll back. The site stays with the team when a member leaves.
+- **Publish together.** Members can create Spaces, publish new versions, and roll back. The Space stays with the team when a member leaves.
- **Keep access in one place.** Invite someone to the team to give them access to team-owned work at their role. Remove them to take that access away across the team's Spaces. Set the default access for each new Space to `private` or `public`.
- **Separate routine work from administration.** Members can publish, while owners and admins manage invitations, domains, and billing. The [role table](#roles) shows the full boundary.
@@ -19,6 +19,8 @@ Personal and Go allow only the owner. Adding another member needs [Pro](/pricing
A team is the thing that owns work in a self-serve account. Spaces, custom domains, API keys, billing, and members all hang off it. A Space you claim belongs to the team you select, even when you are its only member. [Partner API customers](/platforms/partner-api/customers) use external principals instead.
+An Enterprise team can split into [subteams](/subteams), each with its own members and Spaces. A team can also hold [identity connections](/identity-connections), so people from your own sign-in system can open the Spaces you grant them.
+
A team can also hold a [Design System](/design-system) for colors, type, voice, and brand files. Saving those standards does not change any Space until you apply them to its project.
A team has a name and a slug. The slug is the first segment of its dashboard URLs. You can change it in **Team settings → General**, where a live check tells you whether the new one is free.
@@ -55,7 +57,7 @@ Three roles, and the one you hold in a team caps everything you do there, whethe
| Grant the owner role | Yes | No | No |
| Transfer ownership | Yes | No | No |
-Invitation and membership changes require a signed-in person in the dashboard. CLI and API credentials cannot perform them, even for an owner or admin. See [credential limits](/api-keys#what-an-agent-grant-cannot-do).
+A credential with the `teams:members.write` scope, such as Agent access, can invite people. Cancelling or resending an invitation and every other membership change need a signed-in person in the dashboard, even for an owner or admin. See [credential limits](/api-keys#what-an-agent-grant-cannot-do).
Owner and admin differ in exactly one place. An admin can hand out `admin` and `member`, but only an owner can make someone else an owner.
@@ -75,7 +77,9 @@ Your team needs [Pro](/pricing) to add another member. Personal and Go include o
-From a terminal, `sf teams invitations ls` lists invitations. Use the dashboard to send, resend, cancel, or accept one.
+From a terminal, `sf teams invitations ls` lists invitations. Send one from the dashboard, with `sf teams members invite`, or through an agent with Agent access. Resend, cancel, or accept one in the dashboard.
+
+To skip invitations for coworkers, add your company's [allowed email domain](/auto-enroll).
Things that catch people out:
@@ -129,4 +133,4 @@ Only owners and admins can transfer a Space. Two consequences to plan for: the o
**Leave team** is blocked while you are the only member ("Add another owner before you can leave") or the only owner ("Transfer ownership before you can leave").
-**Delete this team** asks you to type the team slug. A team that still has Spaces refuses to be deleted, so delete or move its Spaces first. The delete itself is reversible for 30 days, after which it is final.
+**Delete this team** asks you to type the team slug. A team that still has live Spaces refuses to be deleted, so delete or move them first. For 30 days after the delete, an owner or admin of the team can restore it. The dashboard has no restore button yet, so ask support. Only the person who deleted the team can list it, with `GET /v1/teams?deleted=true`. After 30 days it can't be restored, but Spacefast keeps its records.
diff --git a/content/(concepts)/versions.mdx b/content/(concepts)/versions.mdx
index 32d532ef..e54bcb1c 100644
--- a/content/(concepts)/versions.mdx
+++ b/content/(concepts)/versions.mdx
@@ -96,7 +96,7 @@ An explicit promote is never held by a manual policy. That hold only applies to
## Per-version URLs
-Every version that reaches ready gets a permanent hostname of its own, computed once and stored:
+Every version that reaches ready gets a fixed hostname of its own, computed once and stored. It serves that snapshot while the version is retained:
```text
https://my-site--v7.space.fast/
@@ -108,13 +108,13 @@ A version published from a connected Git branch also gets a branch hostname, `ht
## Drafts expire in 24 hours
-A version that is declared but never finalized is a draft. Drafts live for 24 hours. Past that they flip to `expired` and their file declaration is dropped, so an abandoned draft cannot be resumed. Publish again instead.
+A version that is declared but never finalized is a draft. Drafts live for 24 hours. Past that they flip to `expired` and stop accepting uploads, so an abandoned draft cannot be resumed. Publish again instead. The expired draft stays in the version history.
This only affects versions still in `created`, `uploading`, or `uploaded`. A ready version has no clock on it.
## How many versions are kept
-Ready versions stay until you delete them, with one exception. Personal Spaces have a retained-version cap, counting everything that is not failed, canceled, or expired. The publish that takes a Personal Space past its cap still goes through. The oldest ready versions that nothing serves expire in the background — their bytes are purged, but their history stays. A version the live pointer or a branch alias serves is never expired. Paid plans have no cap. See [Limits](/limits).
+All of them. Ready versions stay until you delete them, on every plan.
To delete one:
@@ -144,7 +144,7 @@ To browse rather than diff, [`listSpaceVersionFiles`](/api/reference/versions/li
| Task | CLI | Dashboard |
| --- | --- | --- |
| List versions | `sf versions ls` | **Versions** |
-| Inspect one | `sf versions get v12` | A version row, then the detail page |
+| Inspect one | `sf versions inspect v12` | A version row, then the detail page |
| Make one live | `sf promote v12` | **Make live** |
| Go back | `sf rollback v12` | **Roll back to** on Overview |
| Delete one | `sf versions rm v12` | Version detail page |
diff --git a/content/(dynamic)/crons.mdx b/content/(dynamic)/crons.mdx
index e251eacc..fef675bd 100644
--- a/content/(dynamic)/crons.mdx
+++ b/content/(dynamic)/crons.mdx
@@ -9,7 +9,7 @@ There's no dashboard editor for crons. Declare schedules in `sf.jsonc`, then pub
## How a run happens
-A cron is a scheduled `GET` against a path on your own site. It goes through the same front door a visitor gets, so whatever serves that path answers it: a [Zero](/zero-runtime) endpoint, a [Functions](/functions) route, or a static file.
+A cron is a scheduled `GET` against a path on your own Space. It goes through the same front door a visitor gets, so whatever serves that path answers it: a [Zero](/zero-runtime) endpoint, a [Functions](/functions) route, or a static file.
A response under 400 is a success and is silent. A response of 400 or worse, or a fatal error, is a failure and is reported.
@@ -73,13 +73,7 @@ Each entry gets a **key** derived by slugging its path. `/api/cron/digest` becom
Each Space runs a set number of crons, by plan. Advanced (crontab) crons come with Boost and count toward the total:
-| Plan | Crons per Space | Advanced crons |
-| --- | --- | --- |
-| Personal | 3 | None |
-| Go | 10 | None |
-| Pro | 20 | None |
-| Boost (Go and up) | 40 | Up to 10 |
-| Enterprise | Custom | Custom |
+
Boost is a per-Space add-on on Go and up. See [Pricing & Limits](/pricing) for every plan's limits.
diff --git a/content/(dynamic)/database.mdx b/content/(dynamic)/database.mdx
index f49b7949..a77a1fc0 100644
--- a/content/(dynamic)/database.mdx
+++ b/content/(dynamic)/database.mdx
@@ -13,7 +13,7 @@ For [Zero](/zero-runtime), start with `sf init --runtime zero`, or add the full
## What it is
-Every Space that runs [Zero](/zero-runtime) gets one database, the MySQL that lives on the machine serving the site. A [Functions](/functions) worker gets one too, as `env.DB`.
+Every Space that runs [Zero](/zero-runtime) gets one database, the MySQL that lives on the machine serving the Space. A [Functions](/functions) worker gets one too, as `env.DB`.
You do not create it, size it, or connect to it. Your capsule declares the tables. The platform applies the migration when a version finalizes.
diff --git a/content/(dynamic)/functions.mdx b/content/(dynamic)/functions.mdx
index 5e6b5e35..139e8ffa 100644
--- a/content/(dynamic)/functions.mdx
+++ b/content/(dynamic)/functions.mdx
@@ -1,6 +1,6 @@
---
title: Functions
-description: Ship a worker with your site. A single handler, a functions/ file router, or an OpenNext build, detected at publish and dispatched only for the paths it claims.
+description: Ship a worker with your Space. A single handler, a functions/ file router, or an OpenNext build, detected at publish and dispatched only for the paths it claims.
sidebar:
order: 1
---
@@ -17,8 +17,8 @@ Detection is the normal case. `sf publish` looks at the tree and picks the first
| Layout | What it is | Route table |
| --- | --- | --- |
-| `.open-next/worker.js` | An OpenNext build of a Next.js app | The build's own classified routes, or the whole site when the publish did not run the build |
-| `handler.ts`, `handler.js`, `handler.mjs` | One hand-written worker at the root | The whole site |
+| `.open-next/worker.js` | An OpenNext build of a Next.js app | The build's own classified routes, or the whole Space when the publish did not run the build |
+| `handler.ts`, `handler.js`, `handler.mjs` | One hand-written worker at the root | The whole Space |
| `functions/` with at least one route module | A file router | Exactly the routes its files declare |
A directory matching none of these is a static site, which is the safe default.
@@ -82,7 +82,7 @@ export default {
};
```
-`sf init my-app --runtime functions` writes exactly that file plus the matching `sf.jsonc`.
+`sf init my-app --runtime functions` writes exactly that file plus the matching `spacefast.config.ts`.
Requests and responses are the standard `Request` and `Response`. There is no Spacefast wrapper around either.
@@ -145,7 +145,7 @@ Two groups of variable names never reach a worker, dropped rather than blanked s
| Limit | Value |
| --- | --- |
-| Compiled bundle | 8 MiB |
+| Compiled bundle | 24 MiB |
| Routes per version | 256 |
| Next cache seed, one entry | 2 MiB minus 8192 bytes |
| Next cache seed, total | 8 MiB |
diff --git a/content/(dynamic)/meta.ts b/content/(dynamic)/meta.ts
index 92d186e2..922f5c9b 100644
--- a/content/(dynamic)/meta.ts
+++ b/content/(dynamic)/meta.ts
@@ -4,5 +4,5 @@ export default defineMeta({
title: "Dynamic",
collapsed: false,
order: 5,
- pages: ["zero-runtime", "functions", "connectors", "database", "environment-variables", "crons", "object-storage", "logs", "wordpress"],
+ pages: ["zero-runtime", "functions", "connectors", "team-connectors", "database", "environment-variables", "crons", "object-storage", "logs", "wordpress"],
});
diff --git a/content/(dynamic)/team-connectors.mdx b/content/(dynamic)/team-connectors.mdx
new file mode 100644
index 00000000..ff0ca14e
--- /dev/null
+++ b/content/(dynamic)/team-connectors.mdx
@@ -0,0 +1,201 @@
+---
+title: Team connectors
+description: Register an OpenAPI spec, GraphQL endpoint, or remote MCP server for your team, connect an account, decide what agents may call, and run tools
+sidebar:
+ badge: Private Beta
+---
+
+Private Beta
+
+A connector is a source of tools that a team registers once: an OpenAPI spec, a GraphQL endpoint, or a remote MCP server. A connection is one account on that connector. Agents get the tools. The credential stays in Spacefast, and no endpoint ever returns it.
+
+Connectors are in Private Beta. A team without access gets `feature_unavailable`.
+
+## Before you start
+
+- Only team owners and admins can open the connectors pages. A member sees **Only Team Owners and Admins Can Manage Connectors**.
+- An API key needs connector scopes. `connectors:read` reads everything. `connectors:write` registers connectors, connects accounts, and sets policy. `connectors:execute` runs tools. `write` and `execute` both include `read`, but neither includes the other. See [API keys](/api-keys).
+
+The pages live under **Connectors** in the team sidebar. A switch in the header moves between **Connectors**, **Runs**, and **Policies**.
+
+## Add a connector from the catalog
+
+The catalog lists curated presets, the public registry, and your team's own connectors. It has one row per surface, so a service with an API and an MCP server is two rows.
+
+1. Open **Connectors** in the team sidebar.
+2. Click **Browse Connectors**.
+3. Search, or filter by **All**, **API**, **GraphQL**, or **MCP**.
+4. Click **Add** on the row you want.
+
+The row changes to **View** when the connector exists. A row that needs a provider-specific endpoint shows **Set Up** instead of **Add**, and opens the add form.
+
+From the CLI, pass a catalog slug:
+
+```bash
+sf connectors create linear
+```
+
+## Add a connector from a URL
+
+Paste any URL the service published. Detection looks behind it, so the URL it registers can differ from the one you pasted.
+
+1. On **Browse**, paste the URL into the search box.
+2. Click **Detect**.
+3. Click **Add Connector** on the candidate you want.
+4. Check **Name**, **Namespace**, and **Description**.
+5. Click **Register**.
+
+**Namespace** is the connector slug. It takes lowercase letters, digits, and dashes, and starts with a letter or a digit. Every tool address of the connector starts with it. Agents read **Description** when they choose between connectors.
+
+You enter no credential here. Authentication comes from the source. Spacefast asks an MCP server how it authenticates, so a server that wants OAuth gets an OAuth connector.
+
+```bash
+sf connectors detect https://api.example.com
+sf connectors create https://api.example.com/openapi.json
+```
+
+## Connect an account
+
+1. On **Connectors**, open the connector row.
+2. Click **Connect Account**.
+3. Under **Who can use it**, choose **Team** or **Personal**.
+4. If the connector offers more than one method, choose one under **Method**.
+5. Optional: enter a **Label**. Agents see it when a connector has more than one account.
+6. Click **Connect**.
+
+**Team** shares the account with everyone on the team and every agent that acts for it. **Personal** keeps it yours, and only within this team. An API key can connect **Team** accounts only.
+
+Connecting ends in one of three ways:
+
+| Result | What to do |
+| --- | --- |
+| `connected` | Nothing. The account is ready |
+| `authorization_required` | Sign in at the provider. In the dashboard, click **Open Sign-In**. The CLI prints the URL. The callback finishes the connection, so there is nothing to paste back |
+| `input_required` | Give the values the connector asks for, then connect again |
+
+For an `input_required` answer, the dashboard shows each field with the provider's own help when the registry has some: setup notes, steps, a **Create one** link, and a **Provider docs** link. The CLI prints the same help under each `--field`. The same help is available from [`getConnectorCredentialGuidance`](/api/reference/connectors/getconnectorcredentialguidance).
+
+```bash
+sf connectors connections connect stripe --api-key "$STRIPE_KEY"
+sf connectors connections connect linear --owner personal
+```
+
+A tool address is `/`. While exactly one usable account matches, `/` also works. Connect a second account and the short form fails with `connection_ambiguous`, so use the connection id. No account is the default once a connector has two.
+
+## Keep a connection working
+
+Open a connector, then the **Accounts** tab. Each account has an actions menu.
+
+| Action | What it does | CLI |
+| --- | --- | --- |
+| **Edit…** | Changes the **Label** and the agent note, **What it's for** | `sf connectors connections update` |
+| **Check Health** | Probes the provider and reports what came back. It changes nothing | `sf connectors connections health` |
+| **Re-Sync Tools** | Reads the tool list again, so new or dropped tools show up | `sf connectors connections refresh` |
+| **Reconnect…** | Signs in again, or replaces the key. The account keeps its id, label, policies, and Space bindings | `sf connectors connections reconnect` |
+| **Disconnect…** | Destroys the stored credential. Anything that uses the account stops working | `sf connectors connections disconnect` |
+
+An account in `needs_reauth` or `degraded` also shows a **Reconnect** button. Token refresh happens on its own, so reconnect only when the status asks for it.
+
+Credentials cannot be edited. To rotate a key, reconnect the account. A disconnected account cannot be reconnected. Connecting the service again makes a new account with a new id.
+
+## Decide what agents can call
+
+A policy rule pairs an address pattern with an action.
+
+| Pattern | Matches |
+| --- | --- |
+| `*` | Every tool |
+| `/*` | Every tool on every account of one connector |
+| `/` | One tool on every account of one connector |
+| `/*` | Every tool of one account |
+| `/` | One tool on one account |
+
+| Action | Dashboard label | When an agent calls a matching tool |
+| --- | --- | --- |
+| `approve` | **Runs freely** | The call runs |
+| `require_approval` | **Ask first** | The run parks until a person decides |
+| `block` | **Blocked** | The run fails with `connector_tool_blocked` |
+
+How rules combine:
+
+- The most specific pattern wins. Spacefast orders rules by specificity, not by the order you add them.
+- A **Team** rule is a floor. A **Personal** rule can tighten it but never loosen it.
+- When no rule matches, the tool's own default from its source applies. A tool can also ask for approval on its own.
+- A rule's pattern and owner are fixed. To change either, delete the rule and add a new one.
+- Deleting a rule hands its tools to the next matching rule. Deleting a block can open a tool again.
+
+To add a rule:
+
+1. Click **Policies** in the connectors header.
+2. Type a **Pattern**. The hint below it counts the tools it matches today.
+3. Choose an action under **Then**.
+4. Choose **Team** or **Personal** under **Applies to**.
+5. Click **Add Rule**.
+
+A pattern that names an unknown connector or connection is refused. An API key can add **Team** rules only.
+
+To check one address without calling it, run `sf connectors policies explain`. It prints the effect, the rule that decided it, and the reason. The API equivalent is [`resolveConnectorPolicy`](/api/reference/connectors/resolveconnectorpolicy).
+
+```bash
+sf connectors policies add '*' --action require_approval
+sf connectors policies explain cxn_abc123/charges.create
+```
+
+## Find a tool
+
+Click **See tools** on a connector row, or open **Connector tools** from the command palette. The **Tools** page lists every tool the team's accounts reach. Each row shows the tool name, its summary, its resolved effect, and its safety: **Read-only**, **Writes**, or **Destructive**. Safety comes from the source and is not a setting.
+
+Open a tool to see its address, the rule that decided its effect, and its **Input schema**. The API equivalents are [`listConnectorTools`](/api/reference/connectors/listconnectortools) and [`listConnectionTools`](/api/reference/connectors/listconnectiontools).
+
+## Run a tool
+
+To try a tool yourself, open it on **Tools**, fill in **Arguments** as a form or as JSON, and click **Run**. Your run follows the same policy an agent gets.
+
+From the CLI, pass the tool address and its arguments:
+
+```bash
+sf connectors run --tool cxn_abc123/issues.list --args '{"limit":5}'
+```
+
+A run is one tool call, or a program that calls several tools. Both get one run id (`cxr_…`) and the same statuses.
+
+| Status | Meaning |
+| --- | --- |
+| `running` | Still working. Read it again with `sf connectors run inspect` |
+| `awaiting_approval` | Parked on a decision |
+| `succeeded` | Done. The result is in `result` |
+| `failed` | Done. The reason is in `error`, a problem document |
+| `expired` | Parked too long. Submit it again |
+
+The request waits up to 30 seconds for a final status by default. Set `--wait` to change that, up to 120 seconds. A failed run is still a successful request, so read the run's status before you report success. The CLI exits `1` for a `failed` or `expired` run.
+
+## Decide a parked run
+
+A parked run carries an `approvalUrl` that opens the dashboard on the decision. You can also find it in two places:
+
+- **Approvals**, under **Connector calls awaiting approval**.
+- **Runs**, with the **Waiting on you** filter. Click **Review** to see the rule, the arguments, and the deadline, then approve. **Deny** sits on the row.
+
+**Approve** runs the call once. **Deny** fails the run with `connector_tool_blocked`. A run left parked for 15 minutes expires.
+
+A run parks for a person. A CLI login or an API key cannot approve it, and an attempt fails with `human_approval_required`. After a person decides, read the outcome with `sf connectors run inspect`.
+
+To fail fast instead of parking when nobody is watching, pass `--deny-unattended`. The run then fails with `connector_approval_required`.
+
+## Let an agent use connectors
+
+On-device MCP, `sf mcp`, has one gateway tool, `search_connector_tools`. It searches the team's connected services, calls a tool, and resumes a parked call. Hosted MCP and `sf mcp proxy` do not offer it, and they have no connector operation tools. See [MCP server](/agents/mcp-server).
+
+The CLI and the [connectors API](/api/connectors) give an agent everything else.
+
+## Use a connection in a Space
+
+To show a connection's data on a Space's pages, declare a role and bind the connection to it. See [Connectors](/connectors).
+
+## Remove a connector
+
+```bash
+sf connectors delete linear --yes
+```
+
+Removing a connector disconnects every account on it, then deletes the connector and its tools. Built-in connectors cannot be removed.
diff --git a/content/(publish)/anonymous-and-claim.mdx b/content/(publish)/anonymous-and-claim.mdx
index faad42eb..ff4e8009 100644
--- a/content/(publish)/anonymous-and-claim.mdx
+++ b/content/(publish)/anonymous-and-claim.mdx
@@ -20,7 +20,7 @@ What comes back is a `claim` block. Four fields:
| Field | What it is |
| --- | --- |
| `key` | The space key, `sfc_` plus 22 characters. The only credential for this Space |
-| `url` | The site door. Opening it exchanges the key for a private view session |
+| `url` | The live URL with the Space key in `/__/` form. Opening it exchanges the key for a private view session |
| `claimUrl` | The dashboard claim page, with the key in the URL fragment |
| `expiresAt` | When the Space stops serving unless someone claims it |
@@ -74,7 +74,7 @@ One more difference. An unclaimed Space accepts every file type, but only *serve
Open the `claimUrl` from your receipt, or run `sf spaces claim` in the directory you published from.
-The claim page header reads **Handed off to you**. Before you claim anything you can already manage **Private links** on it, so you can share the site while you decide.
+The claim page header reads **Handed off to you**. Before you claim anything you can already manage **Private links** on it, so you can share the Space while you decide.
@@ -114,7 +114,7 @@ After the claim lands the page says **<space> is yours.** and offers a **C
The dashboard says the last row out loud, under **Sharing that stays with this space**. Claiming changes ownership, not who you already shared with.
-Two things can block a claim. A claim lands the Space in the team, so it counts against that team's space limit like any other create — a team already at its Space limit answers `space_quota_exceeded`. And the slug is re-checked against the receiving team's namespace, so an anonymous Space whose slug collides there answers `slug_unavailable` and you pick a new one.
+Two things can block a claim. A claim lands the Space in the team, so it counts against that team's space limit like any other create — a team already at its Space limit answers `quota_exceeded`. And the slug is re-checked against the receiving team's namespace, so an anonymous Space whose slug collides there answers `slug_unavailable` and you pick a new one.
## Keeping your agent publishing
@@ -131,10 +131,10 @@ For the full picture of what a credential like this can and cannot do, see [what
If the key leaked, replace it:
```bash
-sf spaces rotate-claim --space spc_9f2c1ab34d5e4f6789ab01cd23ef4567
+sf spaces key rotate --space spc_9f2c1ab34d5e4f6789ab01cd23ef4567
```
-You get a fresh `claim` receipt. The old key, its site door, its claim link, and every view session opened through it stop working immediately. Explicit recipient links you created keep working, because those are separate credentials.
+You get a fresh `claim` receipt. The old key, its `url`, its claim link, and every view session opened through it stop working immediately. Explicit recipient links you created keep working, because those are separate credentials.
## Checking on it
diff --git a/content/(publish)/ci.mdx b/content/(publish)/ci.mdx
index 616b8218..56161074 100644
--- a/content/(publish)/ci.mdx
+++ b/content/(publish)/ci.mdx
@@ -123,7 +123,7 @@ Every value shows up on the version, so version history says which commit and wh
echo "Preview: $(jq -r '.data.version.immutableUrl' receipt.json)"
```
-`version.immutableUrl` is that version's permanent hostname, which keeps serving that exact build no matter what goes live later. `activation.outcome` comes back `unpromoted`, and promoting it later is one call to the URL in `links.promote`.
+`version.immutableUrl` is that version's fixed hostname. It serves that exact build while the version is retained, regardless of what goes live later. `activation.outcome` comes back `unpromoted`, and promoting it later is one call to the URL in `links.promote`.
A preview on an existing Space refuses `--name`, `--mode`, and a non-`auto` `--spa`, because a preview must not change settings live traffic shares.
diff --git a/content/(publish)/frameworks.mdx b/content/(publish)/frameworks.mdx
index 46a2264a..838b6a19 100644
--- a/content/(publish)/frameworks.mdx
+++ b/content/(publish)/frameworks.mdx
@@ -66,7 +66,7 @@ Where the table says "package script", the build command comes from your `packag
2. Only if no dependency matched, the first row whose config file is present.
3. Otherwise nothing is detected, and the output directory falls back to `dist` if a `dist` directory exists.
-A `vercel.json`, `netlify.toml`, or `wrangler.toml` is read **after** framework detection and overrides whatever it set for root directory, install command, build command, and output directory. Vercel's `framework` field also overrides the detected framework. Redirects, rewrites, headers, and crons in those files are converted into `_redirects`, `_headers`, and `sf.jsonc` crons. Anything Spacefast cannot convert, such as Vercel functions or Netlify edge functions, is flagged, and `--allow-unsupported-platform-features` publishes anyway.
+A `vercel.json`, `netlify.toml`, or `wrangler.toml` is read **after** framework detection and overrides whatever it set for root directory, install command, build command, and output directory. Vercel's `framework` field also overrides the detected framework. Redirects, rewrites, headers, and crons in those files are converted into `_redirects`, `_headers`, and `sf.jsonc` crons. Anything Spacefast cannot convert, such as Vercel functions or Netlify edge functions, is flagged, and `--allow-unsupported-platform-features` publishes anyway. Moving a whole site from one of those platforms? See [Move from another platform](/migrate-from).
### Install commands
diff --git a/content/(publish)/git.mdx b/content/(publish)/git.mdx
index 226821f4..b82d249d 100644
--- a/content/(publish)/git.mdx
+++ b/content/(publish)/git.mdx
@@ -152,7 +152,7 @@ The API exposes the same list.
## Managing the connection
```bash
-sf git ls --space docs
+sf git inspect --space docs
sf git sync --space docs --ref main
sf git update --space docs --credential "$TOKEN"
sf git disconnect --space docs
@@ -168,7 +168,7 @@ The exact values each command family uses:
| Command family | Values | Meaning |
| --- | --- | --- |
-| `sf git connect`, `git ls`, `git sync`, `git build` | `connected`, `hosted` | `connected` is an external host with a webhook. `hosted` is the Spacefast remote |
+| `sf git connect`, `git inspect`, `git sync`, `git build` | `connected`, `hosted` | `connected` is an external host with a webhook. `hosted` is the Spacefast remote |
| `sf source ls`, `source cat`, `source diff`, `source branch`, `source merge`, `source tags`, `source commits` | `remote`, `push`, `import` | `remote` is the same thing as `connected`, `push` the same as `hosted`, and `import` is a one-off import remote |
`--connection-type` defaults to `connected` on the `git` commands and to `remote` on most `source` commands. Which commands require it, and which values each one takes, is on [sf git](/cli/git) and [sf source](/cli/source).
diff --git a/content/(publish)/meta.ts b/content/(publish)/meta.ts
index a50756c1..129b14d7 100644
--- a/content/(publish)/meta.ts
+++ b/content/(publish)/meta.ts
@@ -12,5 +12,7 @@ export default defineMeta({
"ci",
"wordpress-data-sources",
"recipes",
+ "migrate",
+ "migrate-from",
],
});
diff --git a/content/(publish)/migrate-from/cloudflare-pages.mdx b/content/(publish)/migrate-from/cloudflare-pages.mdx
new file mode 100644
index 00000000..461ae4cc
--- /dev/null
+++ b/content/(publish)/migrate-from/cloudflare-pages.mdx
@@ -0,0 +1,79 @@
+---
+title: Move from Cloudflare Pages
+description: Publish a Cloudflare Pages site on Spacefast. _redirects and _headers publish as they are, wrangler.toml build settings are read, and Pages Functions are rewritten as Spacefast Functions
+---
+
+Your `_redirects` and `_headers` files publish with your pages, and Spacefast reads the build settings in `wrangler.toml`. A Pages Functions folder needs rewriting, because Spacefast Functions uses different file and handler conventions.
+
+## Move the site
+
+
+
+ In the project directory, create an empty Space. Later publishes from this directory update it.
+
+ ```bash
+ sf spaces create --slug my-site
+ ```
+
+
+ Export the project's variables as a dotenv file, then import it before the first build so the build can read them.
+
+ ```bash
+ sf env import .env --from cloudflare --space my-site
+ ```
+
+ A line with an empty value fails the import, and empty values aren't supported. Remove the line, or give the variable a real value. See [Environment variables](/environment-variables).
+
+
+ Run `sf publish --space my-site` in the project and let Spacefast build it, or publish the output folder you deploy today.
+
+ ```bash
+ sf publish --space my-site
+ ```
+
+ To build on every push instead, connect the repository with [Publish from Git](/git).
+
+
+ Move each Pages function to the [Functions file router](/functions#the-functions-file-router). See the next section.
+
+
+ Add the domain to the Space and add the DNS records it asks for. Delete the old address records, because a leftover record blocks verification. If a proxy sits in front of the hostname, check that it does not block the HTTP validation request. Spacefast issues the certificate once DNS points at it. See [Custom domains](/domains) and [TLS certificates](/infrastructure/tls-certificates).
+
+
+
+## What carries over
+
+| From Cloudflare Pages | On Spacefast |
+| --- | --- |
+| `_redirects` and `_headers` files | Published with your pages and applied as routing rules |
+| `wrangler.toml` build settings | Read after framework detection, and they override the detected build command and output directory |
+| Environment variables | Imported from a dotenv export with `sf env import --from cloudflare` |
+
+Rule order, conditions, and limits are on [Redirects, rewrites, and headers](/routing).
+
+## Functions
+
+Spacefast [Functions](/functions) detects two layouts, and neither takes Pages Functions files as they are:
+
+- **A `functions/` folder** is a file router. A module's file name is its route. It exports one function per HTTP method, such as `GET` or `POST`, or a default export that answers every method. Each one takes `(request, context)`, with route parameters on `context.params`.
+- **A root `handler.ts`**, `handler.js`, or `handler.mjs` default-exports an object with a `fetch` method and answers the whole Space.
+
+When you rewrite, check these differences:
+
+| Rule | On Spacefast |
+| --- | --- |
+| Catch-all routes | `[...rest].ts`, and only as the last segment |
+| Files starting with `_` | Never routed. A `_middleware.ts` does nothing |
+| Bindings on `env` | Environment variables, plus the Space's database as the D1-shaped `env.DB`. No other bindings are documented |
+
+Requests and responses are the standard `Request` and `Response`, so code inside a handler that uses only those usually needs little change.
+
+## What doesn't
+
+- **`cloudflare-cdn-cache-control`** and the other CDN-specific cache headers are rejected with `header_cdn_cache_unsupported`. Spacefast manages shared caching; `Cache-Control` still applies to browsers. See [Caching](/caching).
+- **`_routes.json`** and the `.wrangler` folder are dropped at publish. See [what gets uploaded](/publish#what-gets-uploaded).
+- **Features Spacefast cannot convert** are flagged at publish. Move them to a supported equivalent, or pass `--allow-unsupported-platform-features` to publish without them.
+
+## Choose who can open it
+
+A team's new Spaces start private unless the team changed its default. Make the Space public, or share it with a link, in [Spacefast Share](/share).
diff --git a/content/(publish)/migrate-from/github-pages.mdx b/content/(publish)/migrate-from/github-pages.mdx
new file mode 100644
index 00000000..241c2b5a
--- /dev/null
+++ b/content/(publish)/migrate-from/github-pages.mdx
@@ -0,0 +1,49 @@
+---
+title: Move from GitHub Pages
+description: Connect the repository behind a GitHub Pages site and Spacefast builds it, with Jekyll detected from _config.yml
+---
+
+Connect the repository and Spacefast builds it on every push to the production branch. Jekyll is detected from its config file, and other generators are detected the same way.
+
+## Move the site
+
+
+
+ In the dashboard, open the Space's **Builds** page, click **Connect GitHub**, and pick the repository. If it is missing from the list, **Add repositories** takes you to GitHub to grant the Spacefast app access to it.
+
+ From the CLI, create the Space, then connect it:
+
+ ```bash
+ sf spaces create --slug my-site
+ sf git connect --space my-site --provider github --repository owner/repo --production-branch main --build-now
+ ```
+
+ Pushes to the production branch publish. See [Publish from Git](/git) for every flag.
+
+
+ Spacefast detects Jekyll from `_config.yml`, `_config.yaml`, or `_config.toml`, builds the site, and publishes `_site`, or the `destination` your config names. A project site built with `baseurl: /repo` links its assets under `/repo/`, but a Space serves from the root, so set `baseurl: ""` and set `url` to the new address. Commit a `Gemfile` that lists the `jekyll` gem, because the build command runs through `bundle exec`.
+
+
+ A team's new Spaces start private unless the team changed its default. Make the Space public, or share it with a link, in [Spacefast Share](/share).
+
+
+ Add the domain to the Space and add the DNS records it asks for. Delete the address records that point at GitHub Pages, because a leftover record blocks verification. Spacefast issues the certificate once DNS points at it. See [Custom domains](/domains).
+
+
+
+## What carries over
+
+| From GitHub Pages | On Spacefast |
+| --- | --- |
+| The repository | Stays on GitHub. Spacefast builds from its webhooks |
+| A Jekyll site with a `Gemfile` | Detected from its config file and built with `bundle exec jekyll build` |
+| Hugo, Eleventy, Astro, MkDocs, and other generators | Detected from their dependency or config file. See [Frameworks and builds](/frameworks) |
+| Pages served from a folder such as `/docs` | Set **Output directory**, or **Root directory** for a generator, under **Build settings** |
+
+## What you get on top
+
+- **Previews.** Pushes to other branches and pull requests build previews with their own alias, alongside the production branch.
+- **Overrides.** When detection picks the wrong command or folder, set **Root directory**, **Install command**, **Build command**, or **Output directory** under **Build settings**.
+- **Logs.** Every run is listed under **Runs** on the **Builds** page, with its log.
+
+To publish from GitHub Actions instead of a connected repository, see [Publish from CI](/ci).
diff --git a/content/(publish)/migrate-from/glitch.mdx b/content/(publish)/migrate-from/glitch.mdx
new file mode 100644
index 00000000..5482a96e
--- /dev/null
+++ b/content/(publish)/migrate-from/glitch.mdx
@@ -0,0 +1,45 @@
+---
+title: Move from Glitch
+description: Publish a Glitch project you downloaded on Spacefast. Static projects publish as they are; a Node server is rewritten as Functions routes
+---
+
+Glitch stopped serving projects on July 8, 2025, and its dashboard offered code downloads through the end of 2025 ([Glitch's announcement](https://blog.glitch.com/post/changes-are-coming-to-glitch), checked 2026-11-14). If you kept the download, your AI can publish it on Spacefast.
+
+## Move the project
+
+
+
+ Put the project in a folder of its own.
+
+
+ Connect Spacefast to your agent ([Agents](/agents) lists the ways), open the folder, and send:
+
+
+ Publish the project in this folder on Spacefast. It used to run on Glitch. Keep it working the same way and send me the link.
+
+
+ A project with only HTML, CSS, and browser scripts publishes as it is. A Node server needs its routes rewritten as [Functions](/functions) routes or moved to a [supported framework](/frameworks), and your agent can do that rewrite.
+
+
+ `.env` files are never published. Set each value on the Space instead, with `sf env set` or `sf env import .env`. See [Environment variables](/environment-variables).
+
+
+ A team's new Spaces start private unless the team changed its default. Make the Space public or create a share link in [Spacefast Share](/share) once it works.
+
+
+
+No agent? A folder with an `index.html` and no `package.json` publishes from [drop.fast](https://drop.fast) or with `sf publish`. See the [plain HTML recipe](/recipes/html).
+
+## What carries over
+
+| From Glitch | On Spacefast |
+| --- | --- |
+| Static files | Published as they sit in the folder |
+| Server routes | Rewritten as [Functions](/functions) routes, which take a standard `Request` and return a `Response` |
+| Secrets from `.env` | Set as [environment variables](/environment-variables) and read from `env` |
+
+## What doesn't
+
+- **Timers inside a running server.** A Functions worker answers requests. Work that ran on a timer becomes a [cron](/crons), a scheduled `GET` against one of your own paths.
+- **Local data files.** A Functions worker reaches the Space's [database](/database) through the D1-shaped `env.DB` binding, not through a file in the project. Code that read or wrote a JSON or SQLite file needs rewriting against `env.DB`, and existing rows go in through the [SQL console](/database#the-sql-console).
+- **The old project address.** Point your own domain at the Space with [Custom domains](/domains), and update links to the old address.
diff --git a/content/(publish)/migrate-from/here-now.mdx b/content/(publish)/migrate-from/here-now.mdx
new file mode 100644
index 00000000..c524cc4c
--- /dev/null
+++ b/content/(publish)/migrate-from/here-now.mdx
@@ -0,0 +1,42 @@
+---
+title: Move from here.now
+description: Publish the same folder from the same agent. Connect Spacefast to your agent, ask it to publish, and choose who can open the result
+---
+
+If an agent published your site to here.now, the same agent can publish the same folder to Spacefast. Connect Spacefast to it once, then ask.
+
+## Move the site
+
+
+
+ Add Spacefast to the agent you publish from. [Agents](/agents) covers the three ways to connect and links the setup page for each client.
+
+
+ Ask your agent to publish the site's folder. It sends back the live link.
+
+
+ Publish this site's folder to Spacefast and give me the live URL and claim link if it needs claiming.
+
+
+
+ A team's new Spaces start private unless the team changed its default. Make the Space public, add people, or create a share link in [Spacefast Share](/share).
+
+
+ Add the domain to the Space and add the DNS records it asks for. Spacefast issues the certificate once DNS points at it. See [Custom domains](/domains).
+
+
+
+Without an agent, drag the folder onto [drop.fast](https://drop.fast), or run `sf publish` in it. See [Publishing](/publish).
+
+## What carries over
+
+| From here.now | On Spacefast |
+| --- | --- |
+| A folder of HTML, CSS, images, and scripts | Published exactly as it sits on disk. See the [plain HTML recipe](/recipes/html) |
+| Your agent workflow | The same prompt, sent to Spacefast instead |
+
+An agent that publishes without a Spacefast account gets a live URL and a claim link. Claim the Space before it expires to keep it. See [Publish without an account](/anonymous-and-claim).
+
+## When the site needs a backend
+
+A site that calls an API with a secret, stores form entries, or runs on a schedule needs a server side. On Spacefast that is a [Functions](/functions) route, which reads secrets from [environment variables](/environment-variables) and the Space's [database](/database) on `env.DB`, plus [crons](/crons) for schedules.
diff --git a/content/(publish)/migrate-from/index.mdx b/content/(publish)/migrate-from/index.mdx
new file mode 100644
index 00000000..03bfaf7d
--- /dev/null
+++ b/content/(publish)/migrate-from/index.mdx
@@ -0,0 +1,37 @@
+---
+title: Move from another platform
+description: Move a site to Spacefast from Netlify, Vercel, Cloudflare Pages, GitHub Pages, here.now, Glitch, or Val Town
+---
+
+Most sites move with one publish. Spacefast builds the same source, reads the config files other platforms leave in a project, and flags what it cannot convert. Pick the platform you are leaving.
+
+
+
+ `netlify.toml` build settings, redirects, and headers carry across.
+
+
+ `vercel.json` build settings, redirects, headers, and crons carry across.
+
+
+ `_redirects`, `_headers`, and `wrangler.toml` build settings carry across.
+
+
+ Connect the repository. Jekyll and other generators are detected.
+
+
+ Publish the same folder from the same agent.
+
+
+ Publish the code you downloaded before Glitch closed.
+
+
+ Move HTTP vals to Functions and scheduled vals to crons before Val Town closes.
+
+
+
+## What every move has in common
+
+1. **Publish it.** Hand Spacefast the source and let it build, so it reads your old platform's config file, or publish the built output. Set environment variables on the Space before the first build. See [Frameworks and builds](/frameworks).
+2. **Check what was flagged.** Platform features Spacefast cannot convert are flagged at publish. Move them to a supported equivalent, or pass `--allow-unsupported-platform-features` to publish without them.
+3. **Choose who can open it.** A team's new Spaces start private unless the team changed its default. Make the Space public or create a share link with [Spacefast Share](/share).
+4. **Point your domain.** Add the domain to the Space and add the DNS records it asks for. Delete the address records that point at your old platform, because a leftover record blocks verification. Spacefast issues the certificate once DNS points at it. See [Custom domains](/domains).
diff --git a/content/(publish)/migrate-from/meta.ts b/content/(publish)/migrate-from/meta.ts
new file mode 100644
index 00000000..34de76d0
--- /dev/null
+++ b/content/(publish)/migrate-from/meta.ts
@@ -0,0 +1,16 @@
+import { defineMeta } from "blume";
+
+export default defineMeta({
+ title: "Move from",
+ collapsed: true,
+ pages: [
+ "index",
+ "netlify",
+ "vercel",
+ "cloudflare-pages",
+ "github-pages",
+ "here-now",
+ "glitch",
+ "val-town",
+ ],
+});
diff --git a/content/(publish)/migrate-from/netlify.mdx b/content/(publish)/migrate-from/netlify.mdx
new file mode 100644
index 00000000..de4ad14f
--- /dev/null
+++ b/content/(publish)/migrate-from/netlify.mdx
@@ -0,0 +1,63 @@
+---
+title: Move from Netlify
+description: Publish a Netlify site on Spacefast. netlify.toml build settings, redirects, and headers carry across when Spacefast builds the project
+---
+
+Spacefast reads `netlify.toml` and the `_redirects` and `_headers` files you already have, so a static or prerendered Netlify site usually moves with one publish.
+
+## Move the site
+
+
+
+ In the project directory, create an empty Space. Later publishes from this directory update it.
+
+ ```bash
+ sf spaces create --slug my-site
+ ```
+
+
+ Export the site's variables from Netlify as a dotenv file, then import it before the first build so the build can read them.
+
+ ```bash
+ sf env import .env --from netlify --space my-site
+ ```
+
+ A line with an empty value fails the import, and empty values aren't supported. Remove the line, or give the variable a real value. See [Environment variables](/environment-variables).
+
+
+ Run `sf publish --space my-site` in the project and let Spacefast build it. `netlify.toml` is read when Spacefast builds, so a folder you built yourself carries over only the `_redirects` and `_headers` files inside it.
+
+ ```bash
+ sf publish --space my-site
+ ```
+
+ To build on every push instead, connect the repository with [Publish from Git](/git).
+
+
+ Anything Spacefast cannot convert is flagged at publish. Move it to a supported equivalent, or pass `--allow-unsupported-platform-features` to publish without it.
+
+
+ Add the domain to the Space and add the DNS records it asks for. Delete the address records that point at Netlify, because a leftover record blocks verification. Spacefast issues the certificate once DNS points at it. See [Custom domains](/domains).
+
+
+
+## What carries over
+
+| From Netlify | On Spacefast |
+| --- | --- |
+| `netlify.toml` build command and publish directory | Read after framework detection, and they override the detected build command and output directory |
+| Redirects and headers in `netlify.toml` | Converted to `_redirects` and `_headers` rules |
+| `_redirects` and `_headers` files | Published with your pages and applied as routing rules |
+| Environment variables | Imported from a dotenv export with `sf env import --from netlify` |
+
+The full rules for build detection are on [Frameworks and builds](/frameworks). Rule order, conditions, and limits are on [Redirects, rewrites, and headers](/routing).
+
+## What doesn't
+
+- **Netlify Functions and edge functions.** Move request handling to a [Functions](/functions) route or a supported framework's server. Edge functions are flagged at publish.
+- **`netlify-cdn-cache-control`** is rejected with `header_cdn_cache_unsupported`. Spacefast manages shared caching; `Cache-Control` still applies to browsers. See [Caching](/caching).
+- **The `.netlify` folder** is dropped at publish, along with `node_modules` and `.env` files. See [what gets uploaded](/publish#what-gets-uploaded).
+
+## Choose who can open it
+
+A team's new Spaces start private unless the team changed its default. Make the Space public, or share it with a link, in [Spacefast Share](/share).
diff --git a/content/(publish)/migrate-from/val-town.mdx b/content/(publish)/migrate-from/val-town.mdx
new file mode 100644
index 00000000..5bdb38ab
--- /dev/null
+++ b/content/(publish)/migrate-from/val-town.mdx
@@ -0,0 +1,46 @@
+---
+title: Move from Val Town
+description: Move vals to Spacefast before Val Town closes. HTTP vals become Functions routes and scheduled vals become crons
+---
+
+Val Town closes on January 10, 2027, and code on Val Town becomes read-only on December 10, 2026 ([Val Town's announcement](https://blog.val.town/winding-down), checked 2026-11-14). Get your code out first, then have your AI move it to Spacefast.
+
+## Move your vals
+
+
+
+ Copy your vals and the vals they import into a folder. Val Town's [migration guides](https://docs.val.town/migration) cover exporting to GitHub.
+
+
+ Connect Spacefast to your agent ([Agents](/agents) lists the ways), open the folder, and send:
+
+
+ Publish the project in this folder on Spacefast. It used to run on Val Town. Keep it working the same way and send me the link.
+
+
+
+ Set each environment variable your vals read on the Space, with `sf env set` or `sf env import`. See [Environment variables](/environment-variables).
+
+
+ A team's new Spaces start private unless the team changed its default. Make the Space public or create a share link in [Spacefast Share](/share) once it works.
+
+
+
+## Where each kind of val goes
+
+| On Val Town | On Spacefast |
+| --- | --- |
+| HTTP val | A [Functions](/functions) route. A module in `functions/` exports a handler per HTTP method, or a root `handler.ts` default-exports `{ fetch }` and answers the whole Space |
+| Scheduled val | A [cron](/crons) in `sf.jsonc`: a scheduled `GET` against a Functions route on your own Space |
+| SQLite tables | The Space's [database](/database), through the D1-shaped `env.DB` binding |
+| Environment variables | [Environment variables](/environment-variables) on the Space, read from the handler's `env` argument |
+| Static pages | Published files. See the [plain HTML recipe](/recipes/html) |
+
+Handlers take a standard `Request` and return a standard `Response`, which is the shape HTTP vals use too.
+
+## What to check
+
+- **Imports and runtime calls.** Replace `esm.town` imports with local files or npm packages, and replace `Deno.env.get` with the handler's `env` argument. The [Functions](/functions) page lists the supported module types.
+- **SQL.** Queries move from SQLite to the Space's database through `env.DB`. Check SQLite-specific syntax, and load existing rows through the [SQL console](/database#the-sql-console).
+- **Schedules.** A five-field crontab schedule is an advanced cron, which comes with Boost. For now every crontab runs at its closest simple schedule until advanced scheduling is enabled, and a Space can declare at most 8 crons. A cron runs approximately on schedule, with no retries. See [Crons](/crons).
+- **Outbound requests.** A worker's `fetch()` reaches any public host once the Space is claimed. See [Functions outbound HTTP](/functions#outbound-http).
diff --git a/content/(publish)/migrate-from/vercel.mdx b/content/(publish)/migrate-from/vercel.mdx
new file mode 100644
index 00000000..ef41d8fe
--- /dev/null
+++ b/content/(publish)/migrate-from/vercel.mdx
@@ -0,0 +1,80 @@
+---
+title: Move from Vercel
+description: Publish a Vercel project on Spacefast. vercel.json settings, redirects, headers, and crons carry across; Vercel functions are flagged at publish
+---
+
+Spacefast reads `vercel.json`, converts its routing rules and crons, and runs supported Next.js versions with their server. Vercel functions are the part that needs moving by hand.
+
+## Move the project
+
+
+
+ In the project directory, create an empty Space. Later publishes from this directory update it.
+
+ ```bash
+ sf spaces create --slug my-app
+ ```
+
+
+ Export the project's variables from Vercel as a dotenv file, then import it before the first build so the build can read them.
+
+ ```bash
+ sf env import .env --from vercel --space my-app
+ ```
+
+ A line with an empty value fails the import, and empty values aren't supported. Remove the line, or give the variable a real value. See [Environment variables](/environment-variables).
+
+
+ Run `sf publish --space my-app` in the project and let Spacefast build it. `vercel.json` is read when Spacefast builds.
+
+ ```bash
+ sf publish --space my-app
+ ```
+
+ To build on every push instead, connect the repository with [Publish from Git](/git).
+
+
+ Anything Spacefast cannot convert, such as Vercel functions, is flagged at publish. Move it to a supported equivalent, or pass `--allow-unsupported-platform-features` to publish without it.
+
+
+ Add the domain to the Space and add the DNS records it asks for. Delete the address records that point at Vercel, because a leftover record blocks verification. Spacefast issues the certificate once DNS points at it. See [Custom domains](/domains).
+
+
+
+## What carries over
+
+| From Vercel | On Spacefast |
+| --- | --- |
+| `vercel.json` root directory, install command, build command, and output directory | Read after framework detection, and they override what detection set |
+| The `framework` field in `vercel.json` | Overrides the detected framework |
+| Redirects, rewrites, and headers in `vercel.json` | Converted to `_redirects` and `_headers` rules |
+| Crons in `vercel.json` | Converted to `sf.jsonc` crons |
+| Environment variables | Imported from a dotenv export with `sf env import --from vercel` |
+
+## Crons
+
+A Spacefast cron is a scheduled `GET` against a path on your own Space, like a Vercel cron. The schedule is where they differ:
+
+- A five-field crontab schedule is an **advanced** cron. Advanced crons come with Boost. For now every crontab runs at its closest simple schedule, which may not match the times it names, until advanced scheduling is enabled. See [Crons](/crons#allowance-by-plan).
+- Your plan sets how many crons each Space runs, and for now a Space can declare at most 8.
+
+[Crons](/crons) has the schedule grammar and the allowance by plan.
+
+## Next.js
+
+| Next.js | What runs |
+| --- | --- |
+| 16.2.x, 16.3.x | Server-rendered builds, through the adapter `sf publish` wires in for you |
+| 15.5.24 and later 15.5 releases | Server-rendered builds |
+| 14.2, 15.0 to 15.5.23, 16.0, 16.1, 16.4 | `output: "export"` only |
+
+Other versions are refused before anything builds. The [Next.js recipe](/recipes/next) has the details and the export fallback.
+
+## What doesn't
+
+- **Vercel functions** are flagged at publish. Move request handling to a [Functions](/functions) route or a supported framework's server.
+- **The `.vercel` folder** is dropped at publish, along with `node_modules` and `.env` files. See [what gets uploaded](/publish#what-gets-uploaded).
+
+## Choose who can open it
+
+A team's new Spaces start private unless the team changed its default. Make the Space public, or share it with a link, in [Spacefast Share](/share).
diff --git a/content/(publish)/migrate.mdx b/content/(publish)/migrate.mdx
new file mode 100644
index 00000000..fc6af85b
--- /dev/null
+++ b/content/(publish)/migrate.mdx
@@ -0,0 +1,132 @@
+---
+title: Copy a website
+description: Capture a public website with sf migrate, publish it as a static Space, and finish the move with a claim and a domain
+sidebar:
+ order: 3.5
+---
+
+`sf migrate` takes the URL of a live website and publishes a static copy of it to a new Space. It does not read your current directory, and the result is a new Space every time.
+
+## Copy a site
+
+```bash
+sf migrate https://example.com
+```
+
+The CLI starts a capture build, follows it until the copy is published, and prints the result. A capture takes a while. The build can run for up to 75 minutes, and the CLI waits 15 minutes by default. To wait for the whole capture, raise the timeout:
+
+```bash
+sf migrate https://example.com --wait-timeout 4500
+```
+
+```text
+sf migrate URL [--team ] [--name ] [--idempotency-key ]
+ [--wait | --no-wait] [--wait-timeout ] [--show-secret]
+```
+
+### Arguments
+
+| Argument | What it is |
+| --- | --- |
+| `URL` | The public `https://` address of the site to copy. Required |
+
+### Flags
+
+| Flag | Default | What it does |
+| --- | --- | --- |
+| `--team=` | default team | Team that receives the new Space. Requires a login or `--token` |
+| `--name=` | source hostname | Title of the new Space |
+| `--idempotency-key=` | new key | Recover an earlier attempt. See [Recover an attempt](#recover-an-attempt) |
+| `--wait` / `--no-wait` | `--wait` | Wait until the copy is published before returning |
+| `--wait-timeout=` | `900` | Seconds to wait for the capture and the publish. Minimum 1 |
+| `--show-secret` | off | Print the **Open** and **Claim** links in JSON or non-interactive output |
+
+The global flags (`--api-url`, `--profile`, `--token`, `-y`, `--json`) also apply. See [the CLI overview](/cli).
+
+## Choose what the URL can be
+
+The URL must:
+
+- use `https://`
+- have no user name, password, or `#fragment`
+- resolve to a public address, not `localhost` or a private or loopback IP address
+- not be a Spacefast hostname
+- be at most 2,048 characters long
+
+## Know what the copy contains
+
+The capture renders the site as portable HTML and publishes those files as a static Space. If the capture produces no HTML file, the build fails.
+
+The copy is static files only. Server-side code, accounts, stored data, and domains stay with the old site.
+
+Without a login, the copy publishes as an anonymous Space and gets the anonymous limits:
+
+| Limit | Value |
+| --- | --- |
+| Files | 1,000 |
+| Size of one file | 50 MB |
+| Total size | 100 MB |
+
+If the copy is larger, the build fails with the matching code. To lift these limits, sign in and run `sf migrate` again. The copy then publishes to your team under its plan's limits. See [Limits](/limits).
+
+## Read the result
+
+Progress goes to stderr. The result goes to stdout:
+
+```text
+Space spc_0123456789abcdef
+Build bld_0123456789abcdef (succeeded)
+Status https://api.spacefast.com/v1/builds/bld_0123456789abcdef/receipt
+Replay key 5f0c6a1e-8a51-4c3b-9d1e-2a7f4b6c8d90
+Open https://swift-otter-4821.space.fast/...
+Capture {"state":"preview_ready",...}
+```
+
+| Line | What it is |
+| --- | --- |
+| `Space` | The new Space ID |
+| `Build` | The capture build ID and its status |
+| `Status` | The URL that reports the build receipt |
+| `Replay key` | The idempotency key for this attempt |
+| `Open` | A link that opens the copy, or `Pending publication` |
+| `Claim` | The claim link. Anonymous copies only |
+| `Capture` | Capture progress and quality evidence |
+
+New Spaces are private. Use the **Open** link, or share the copy with [Spacefast Share](/share). An unmeasured visual comparison in `Capture` is not a passing comparison. Open the copy and check it yourself.
+
+With `--json`, `data` is the build receipt plus `idempotencyKey`. The receipt carries `space`, `build`, `next`, `links`, and, when they exist, `version`, `access`, `claim`, and `diagnostics`. JSON and non-interactive output replace the access link, the claim link, and `access.token` with `[REDACTED]`. Pass `--show-secret` to print them. The space key in `claim.key` is always masked.
+
+## Recover an attempt
+
+Each run of `sf migrate` starts a new migration. To continue an earlier attempt instead, repeat it with the key from `Replay key`:
+
+```bash
+sf migrate https://example.com --idempotency-key 5f0c6a1e-8a51-4c3b-9d1e-2a7f4b6c8d90
+```
+
+Use the same URL, `--team`, `--name`, and credential as the first run. The CLI keeps each attempt under `~/.spacefast/migrations/`, and a repeat with matching inputs resumes it without a new capture. When a run fails after the request starts, the error prints the repeat command.
+
+To return at once and check later, pass `--no-wait`. The receipt has the build ID. Follow it with [`sf builds logs`](/cli/builds), or repeat the run with the key.
+
+## Finish the move
+
+1. To keep an anonymous copy, open the **Claim** link and pick a team. An unclaimed copy stops serving after 33 hours and 20 minutes. See [The clock](/anonymous-and-claim#the-clock).
+2. To serve the copy on your own domain, attach the domain to the new Space. See [Custom domains](/domains).
+3. To edit the copy after you claim it, download it with `sf spaces download --space` and the ID from the `Space` line. Change the files, then publish them with `sf publish --space` and the same ID.
+
+## Errors
+
+| Code | Exit | When |
+| --- | --- | --- |
+| `validation_error` | 2 | The URL is not `https://`, carries credentials or a fragment, or does not parse. Raised before the request |
+| `auth_required` | 10 | `--team` without a login or `--token` |
+| `not_found` | 20 | No team matches `--team` |
+| `invalid_publish_payload` | 2 | The API refused the URL, for example a Spacefast hostname or `localhost` |
+| `egress_target_denied` | 2 | The hostname resolves to a private, loopback, or other blocked address |
+| `egress_target_unresolvable` | 2 | The hostname does not resolve |
+| `build_failed` | 1 | The capture did not publish. The build's diagnostic has `details.captureCode`. Read it with `sf builds inspect --json` |
+| `manifest_too_many_files` | 1 | The copy has more files than the limit |
+| `version_file_too_large` | 1 | One file in the copy is over the limit |
+| `version_total_bytes_exceeded` | 1 | The copy is over the total size limit |
+| `timeout_error` | 50 | `--wait-timeout` ran out before the build finished. Repeat with the key |
+| `migration_state_invalid` | 2 | The saved attempt file for this key cannot be read. Repair it, or run without the key to start over |
diff --git a/content/(publish)/publish.mdx b/content/(publish)/publish.mdx
index 3fcb9efe..c5f47254 100644
--- a/content/(publish)/publish.mdx
+++ b/content/(publish)/publish.mdx
@@ -5,7 +5,7 @@ sidebar:
order: 1
---
-A folder can be published from the CLI, dashboard, or API, and the receipt it returns tells you whether the site is live.
+A folder can be published from the CLI, dashboard, or API, and the receipt it returns tells you whether the Space is live.
## The mental model
@@ -80,7 +80,7 @@ Symlinks are skipped, not followed. The CLI prints `Warning: skipped symlink
+
+
+```ts spacefast.config.ts
+import { defineConfig } from "spacefast/config";
+
+export default defineConfig({
+ // Which Space this project publishes to, and what it is called.
+ space: "my-app",
+ name: "My App",
+
+ // Serving shape.
+ index: "index.html",
+ fallback: { path: "index.html", status: 200 },
+ cleanUrls: true,
+ listing: false,
+
+ // Listing, sharing, and browser icon metadata.
+ meta: {
+ title: "My App",
+ description: "What this site is",
+ image: "/og.png",
+ favicon: "/favicon.ico",
+ },
+
+ // Visual tokens for Spacefast-authored pages (404, access, index).
+ theme: { accent: "#0d9488", background: "#ffffff", name: "My App" },
+
+ // Files that receive {{ vars.NAME }} substitution at finalize.
+ templates: ["index.html"],
+
+ // Analytics tags and custom snippets.
+ superpowers: {
+ googleAnalytics: { enabled: true, measurementId: "G-XXXXXXX" },
+ },
+
+ // Settings for builds the platform runs from a connected repository.
+ build: {
+ rootDirectory: "apps/web",
+ installCommand: "pnpm install",
+ buildCommand: "pnpm build",
+ outputDirectory: "dist",
+ timeoutSeconds: 900,
+ },
+
+ // Run code alongside the files.
+ runtime: { kind: "zero", server: "server/index.ts", client: "client/index.tsx" },
+
+ // Scheduled GETs against this space's own paths.
+ crons: [{ path: "/api/digest", schedule: "daily" }],
+
+ // Paths that stay public even on a restricted Space.
+ access: { public: ["/health", "/assets/**"] },
+
+ // Routing rules; see the routing page.
+ redirects: [{ source: "/old", destination: "/new", status: 301 }],
+ headers: [{ source: "/assets/*", headers: [{ key: "X-Frame-Options", value: "DENY" }] }],
+});
+```
+
+
+
+
```jsonc sf.jsonc
{
"$schema": "https://spacefast.com/schemas/sf.json",
@@ -66,21 +148,104 @@ sidebar:
}
```
-## Where the file lives
+
+
+
+## TypeScript config
-The canonical name is `sf.jsonc` at the root of the directory you publish. Six aliases are accepted too. The first file found wins, in this order.
+`spacefast.config.ts` is an ES module. Its default export is the config. The export can be an object, a promise of an object, or a function that returns either. An async function works.
-1. `sf.jsonc`
-2. `spacefast.jsonc`
-3. `spacefast.json`
-4. `sf.json`
-5. `.sf/sf.json`
-6. `.sf/config.jsonc`
-7. `.sf/config.json`
+```ts spacefast.config.ts
+import { defineConfig } from "spacefast/config";
+import { siteTitle } from "./site/meta";
+
+export default defineConfig(async () => ({
+ name: siteTitle,
+ cleanUrls: true,
+}));
+```
-All seven are private files. They are never served. Keep exactly one, since two config files in the same tree is an error.
+The CLI bundles the file and its relative imports, then imports the bundle into the CLI process. Package imports and Node.js built-ins are not bundled. They resolve from the project directory when the config loads.
-The file is JSON with comments, capped at 256 KiB. Point `$schema` at `https://spacefast.com/schemas/sf.json` for editor completion and validation.
+`sf build`, `sf publish`, and hosted Git builds load the config before they run the install command. Package imports other than `spacefast/config` must already be installed when the config loads. For a fresh checkout, use relative files, Node.js built-ins, and `spacefast/config` so the config does not depend on that install step.
+
+The resolved value must be a JSON object. Strings, numbers, booleans, `null`, arrays, and plain objects are allowed. A key set to `undefined` is dropped. Any other value, for example a `Date`, fails the load.
+
+The config is code. It runs on your machine and in hosted builds. Do not put secrets in it, because the resolved values are stored with the version.
+
+### `defineConfig` and types
+
+The CLI provides the `spacefast/config` module.
+
+| Export | Kind | What it is |
+| --- | --- | --- |
+| `defineConfig` | function | Returns its argument unchanged. Its signature type-checks the default export |
+| `SpacefastConfig` | type | The config object |
+| `SpacefastConfigInput` | type | `SpacefastConfig`, a promise of it, or a function that returns either |
+
+The CLI supplies the module when it loads the file, so the project needs no dependency for it.
+
+:::note[Editor types]
+The published `spacefast` package includes `spacefast/config` and its types. For editor type checking, add it as a development dependency with `npm install --save-dev spacefast`. The CLI supplies the helper when loading the config even without that project dependency.
+:::
+
+`defineConfig` is optional. A plain default export works the same way.
+
+```ts spacefast.config.ts
+import type { SpacefastConfig } from "spacefast/config";
+
+export default { name: "My App", cleanUrls: true } satisfies SpacefastConfig;
+```
+
+## Precedence and failure rules
+
+- The first file in the discovery order wins. The CLI prints a warning for each lower file it ignores.
+- An explicit `--config` path names the file whose `build` block the command sends. The command still reads the config in the current directory to pick the Space.
+- The selected file must be valid. If it fails to load or validate, the command stops with `config_invalid` (exit 2). The CLI never falls back to a lower file.
+- A selected file that exists but cannot be read also fails with `config_invalid`.
+- A `--config` path that does not exist fails with `config_not_found` (exit 2).
+
+A TypeScript file fails to load when it throws, has no default export, imports a package that is not installed, or resolves to a value that is not JSON. A file that loads but sets an invalid key fails validation. For example, `cleanUrls: "yes"` fails with `config_invalid` and a message that starts with `spacefast.config.ts:: cleanUrls:`. For a TypeScript file, the line and column count in the resolved JSON, not in your source.
+
+## Which commands read it
+
+Every command that selects a Space from the current directory reads the config. That includes commands that take `--space`, so an invalid config stops them too. `sf publish`, `sf build`, `sf dev`, `sf doctor`, and `sf routing inspect` also read it for their own work. `sf doctor` reports the selected file and any ignored files on its `project-config` line.
+
+Two command groups take an explicit path.
+
+| Command | Flag | What it reads |
+| --- | --- | --- |
+| `sf git connect`, `sf git update`, `sf git build` | `--config=`, env `SPACEFAST_CONFIG` | The `build` block, sent as repository build settings |
+| `sf rules validate`, `sf rules test` | `--file=` | The `firewall` and `cache` blocks |
+
+## How the TypeScript config ships
+
+The server does not run TypeScript. When `sf publish` or `sf build` packs a version, the CLI evaluates `spacefast.config.ts`. It writes the result into the version as `sf.jsonc`, in place of any `sf.jsonc` on disk. Your source files do not change.
+
+Files that the config imports are ordinary files. If they are in the published directory, they are published and served.
+
+Hosted Git builds run the same CLI build, so they evaluate a committed `spacefast.config.ts` too.
+
+Loose-file publishing through the API or MCP does not evaluate TypeScript config. This includes local MCP publishing when a local directory becomes a file upload. If the upload includes `spacefast.config.ts`, it fails. Run `sf publish` from the project directory to evaluate the config, or run `sf build` and publish its `.spacefast/build-output.tgz` archive with `--prebuilt`. Sending the framework output directory alone does not carry the resolved project config.
+
+## Migrate from sf.jsonc
+
+Migration is optional. `sf.jsonc` stays supported. If the project uses another JSON file name, use that file in step 1.
+
+1. Move `sf.jsonc` to `spacefast.config.ts` in the project directory.
+2. Add `import { defineConfig } from "spacefast/config";` as the first line.
+3. Put `export default defineConfig(` before the opening `{`.
+4. Put `);` after the closing `}`.
+5. Run `sf doctor`.
+6. Make sure that the `project-config` line starts with `ok` and names `spacefast.config.ts`.
+
+Quoted keys, `//` comments, and trailing commas are valid TypeScript. The pasted object needs no other change. The `$schema` key is allowed but has no effect in TypeScript. Your Space link in `.spacefast/space.json` does not change.
+
+## File format
+
+`sf.jsonc` and the other JSON names are JSON with comments. Point `$schema` at `https://spacefast.com/schemas/sf.json` for editor completion and validation. The schema covers the JSON files only.
+
+The config is capped at 256 KiB. For `spacefast.config.ts`, the cap applies to the resolved JSON, not to the source file.
## Identity
@@ -96,14 +261,14 @@ While `name` is set in the file, the API refuses to rename the Space. The file w
| Key | Type | Default | What it does |
| --- | --- | --- | --- |
-| `index` | string or `false` | inferred, see below | The directory index file. `false` disables index resolution |
-| `fallback` | string, or `{ path, status }` | none | The committed file served when no exact route matches. `status` is 100 to 599 |
-| `cleanUrls` | boolean | `true`, unless a 200-status `fallback` is set | Serves `about.html` at `/about` |
+| `index` | string or `false` | inferred, see below | The homepage file `/` serves. Folders always serve their own `index.html`. `false` stops homepage and SPA inference. A root `index.html` still answers `/` unless `listing` is on |
+| `fallback` | string, `false`, or `{ path, status }` | inferred for a single-page app, see below | The committed file served when no exact route matches. `status` is 100 to 599. `false` turns the inference off |
+| `cleanUrls` | boolean | `true` | Serves `about.html` at `/about`, ahead of any fallback |
| `listing` | boolean | on when no index could be inferred and the version runs no worker | Automatic directory listings |
-`index` defaults to `index.html` when the version has one at the root. Otherwise, if the version publishes exactly one HTML file, that file. Otherwise, if there is exactly one HTML file at the root, that one. Otherwise `false`.
+`index` defaults to `index.html` when the version has one at the root. Otherwise, if a 200-status `fallback` names a committed file and the version runs no worker, that file. Otherwise, if the version publishes exactly one HTML file, that file. Otherwise, if there is exactly one HTML file at the root, that one. Otherwise `false`.
-`"fallback": { "path": "index.html", "status": 200 }` is the SPA declaration. It names the shell that owns every unresolved route, so it also turns `cleanUrls` and `listing` off unless you set them yourself. A `fallback` path that is not a committed file fails the publish.
+`"fallback": { "path": "index.html", "status": 200 }` is the SPA declaration. It names the shell that owns every unresolved route, so it turns `listing` off unless you set it yourself. Committed pages still answer their own URLs. With `"cleanUrls": false`, a flat `about.html` no longer answers `/about`, so that route reaches the shell. A `fallback` path that is not a committed file fails the publish. Left unset, `fallback` is inferred: a root `200.html`, else `_shell.html`, else a root `index.html` beside a build tool's output when there's no root `404.html`. See [SPA fallback](/routing#spa-fallback).
## Metadata and appearance
@@ -121,7 +286,7 @@ While `name` is set in the file, the API refuses to rename the Space. The file w
Colors accept hex, `rgb()`, `rgba()`, `hsl()`, `hsla()`, and CSS color names. `theme` is a closed object, so an unknown key is an error.
-A `theme.json` file next to `sf.jsonc` is an inference input rather than configuration. Only `settings.color.palette` and `settings.typography.fontFamilies` are read from it. A [Zero](/zero-runtime) capsule's Tailwind compile reads the same file.
+A `theme.json` file next to the config is an inference input rather than configuration. Only `settings.color.palette` and `settings.typography.fontFamilies` are read from it. A [Zero](/zero-runtime) capsule's Tailwind compile reads the same file.
What these tokens paint, and how to replace those pages outright, is on [Layout, theme, and site pages](/site-pages).
@@ -172,7 +337,7 @@ Every field is nullable, and `build` is a closed object.
| Key | Type | Default | What it does |
| --- | --- | --- | --- |
-| `placement.burstable` | boolean | none | Whether the site may burst its PHP workers |
+| `placement.burstable` | boolean | none | Whether the Space may burst its PHP workers |
Data location is chosen once, when the Space is created, and never changes.
@@ -206,7 +371,7 @@ See [Functions](/functions).
| `crons[].path` | string, up to 512 chars | none | Path to GET. Starts with `/`, no query, no wildcards, no `:placeholders` |
| `crons[].schedule` | string | none | A named schedule, a shorthand, or a five-field crontab |
-`sf.jsonc` is the only writer. See [Crons](/crons) for the schedule grammar.
+The project config is the only writer. See [Crons](/crons) for the schedule grammar.
## Connectors
@@ -228,10 +393,19 @@ See [Connectors](/connectors) for each role's keys.
## Routing
-`redirects`, `rewrites`, and `headers` are real `sf.jsonc` keys, owned by the routing compiler and merged behind the `_redirects` and `_headers` files. Their grammar, precedence, and every rule field live on one page.
+`redirects`, `rewrites`, and `headers` are real config keys, owned by the routing compiler and merged behind the `_redirects` and `_headers` files. Their grammar, precedence, and every rule field live on one page.
See [Redirects, rewrites, and headers](/routing).
+## Traffic rules
+
+| Key | Type | Default | What it does |
+| --- | --- | --- | --- |
+| `firewall` | array, up to 50 rules | none | Requests to block or challenge before they reach the Space |
+| `cache.bypass` | array, up to 50 rules | none | Requests that skip the cache |
+
+A publish puts these rules at the edge. The Space settings can declare the same two keys, and their rules answer first. See [Traffic rules](/traffic-rules) for the rule fields.
+
## Accepted but not yet enforced
| Key | Type | What it does |
@@ -256,7 +430,7 @@ A plan may lower these caps. None of them can be raised. The `crons` cap is the
## The strict v1 shape
-There is a second, stricter contract for the same filename, selected by a top-level `"version": 1`. Its schema is published separately at `https://spacefast.com/schemas/sf.v1.json`, and `sf routing inspect` compiles against it.
+There is a second, stricter contract for the same files, selected by a top-level `"version": 1`. Its schema is published separately at `https://spacefast.com/schemas/sf.v1.json`, and `sf routing inspect` compiles against it.
In that shape the serving keys move into a `serve` block.
@@ -282,6 +456,6 @@ Under `version: 1` the flat spellings are rejected. Four of them get `config_unk
| `cleanUrls` | `serve.cleanUrls` |
| `meta` | `metadata` |
-The v1 root accepts `$schema`, `version`, `build`, `serve`, `redirects`, `rewrites`, `headers`, `metadata`, `substitute`, `access`, and `crons`. Anything else is an error. `listing`, `superpowers`, `templates`, `theme`, `placement`, and `markdownNegotiation` report `config_key_removed`; `space` reports `config_identity_moved`.
+The v1 root accepts `$schema`, `version`, `build`, `serve`, `redirects`, `rewrites`, `headers`, `firewall`, `cache`, `metadata`, `substitute`, `access`, and `crons`. Anything else is an error. `listing`, `superpowers`, `templates`, `theme`, `placement`, and `markdownNegotiation` report `config_key_removed`; `space` reports `config_identity_moved`.
The flat shape above is what the finalizer reads and what the CLI writes. Use `version: 1` only if you want the stricter contract.
diff --git a/content/(reference)/feedback.mdx b/content/(reference)/feedback.mdx
new file mode 100644
index 00000000..3b300e5c
--- /dev/null
+++ b/content/(reference)/feedback.mdx
@@ -0,0 +1,129 @@
+---
+title: Send feedback
+description: Tell Spacefast what broke or what is missing, from the CLI, the API, an agent, or the docs, with the error code and request ID attached
+sidebar:
+ order: 5
+---
+
+Every note gets a reference, and a person reads it. The fastest report names what you tried, what happened, and the request ID from the error.
+
+## Send feedback from the CLI
+
+```bash
+sf feedback --message "The continuation docs are unclear" --category docs
+```
+
+```text
+sf feedback --message [--category ] [--error-code ] [--request-id ] [--space ] [--url ]
+```
+
+| Flag | Default | What it does |
+| --- | --- | --- |
+| `--message=` | required | The feedback itself |
+| `--category=` | `other` | One of `bug`, `docs`, `limit`, `idea`, or `other` |
+| `--error-code=` | none | The `code` of the error you hit |
+| `--request-id=` | none | The `requestId` of the failed request |
+| `--space=` | none | The Space the feedback is about |
+| `--url=` | none | A page or URL the feedback is about |
+
+`sf feedback` works without a login. When you are signed in, the note is filed under your account.
+
+The CLI prints the reference:
+
+```text
+Feedback ref: fdb_0123456789abcdef
+Thanks. We read every one.
+Signed in with `sf login`? Read it back with `sf feedback inspect fdb_0123456789abcdef`.
+```
+
+With `--json`, `data` is `{ "ref": "fdb_…", "status": "stored" }`.
+
+## Attach an error to your feedback
+
+Every API error carries a stable `code` and a `requestId`. Send both, and the team can find the exact failed request.
+
+When the API fails with a server error, or the CLI hits a bug, the CLI prints the report command, with both values filled in:
+
+```text
+To report this: sf feedback --error-code internal_error --request-id 00000000-0000-4000-8000-000000000000 --message "Describe what happened"
+```
+
+With `--json`, the CLI does not print the line. Build it from `error.code` and `error.requestId`. Copy the command, replace the message, and run it.
+
+```bash
+sf feedback --message "Publish failed after claim" --category bug --error-code space_claimed_credential_available --request-id 00000000-0000-4000-8000-000000000000
+```
+
+The dashboard shows the same values. An error screen lists the code, the HTTP status, and the request ID under **Error details**. **Copy** puts them on the clipboard with the page URL.
+
+## Read back what you sent
+
+```bash
+sf feedback list
+sf feedback list --limit 5 --json
+sf feedback inspect fdb_0123456789abcdef
+```
+
+`sf feedback list` shows your notes, newest first, as a `REF | CATEGORY | SENT | MESSAGE` table. The message column shows the first 60 characters. `--limit` defaults to 20, with a maximum of 100. With nothing sent, it prints `No feedback. You haven't sent any yet.`
+
+`sf feedback inspect` prints one note in full: the reference, the category, the time sent, any context, and the message.
+
+Only the person who sent a note can read it. Sign in with `sf login` or the dashboard first. A note sent with a team API key, OAuth client, or partner key belongs to the person who created that key, who can read it after signing in. A note sent anonymously or with a Space key has no person sender and cannot be read back.
+
+## Send feedback through the API
+
+[`createFeedback`](/api/reference/spaces/createfeedback) takes the same fields as the CLI. It needs no credential.
+
+```bash
+curl -X POST https://api.spacefast.com/v1/feedback \
+ -H "Content-Type: application/json" \
+ -d '{"message":"Publish failed after claim","category":"bug","context":{"errorCode":"space_claimed_credential_available","requestId":"00000000-0000-4000-8000-000000000000"}}'
+```
+
+The response is `201` with `{ "data": { "ref": "fdb_…" } }`. The body can also be form-encoded or multipart.
+
+| Field | Required | Limits |
+| --- | --- | --- |
+| `message` | yes | 1 to 4,000 characters after trimming |
+| `category` | no | `bug`, `docs`, `limit`, `idea`, or `other`. Default `other` |
+| `context.errorCode` | no | 1 to 128 characters |
+| `context.requestId` | no | 1 to 128 characters |
+| `context.spaceId` | no | 1 to 128 characters |
+| `context.url` | no | An `http` or `https` URL, up to 2,000 characters |
+
+Spacefast identifies the sender from the credential on the request:
+
+| Credential | Sender |
+| --- | --- |
+| A login (dashboard, `sf login`, or MCP sign-in) | That person |
+| A team API key, OAuth client, or partner key | The person who created it |
+| A space key (`sfc_…`) | The unclaimed Space the key belongs to |
+| None | Anonymous |
+
+To read notes back, call [`listFeedback`](/api/reference/spaces/listfeedback) (`limit` 1 to 100, default 20) or [`getFeedback`](/api/reference/spaces/getfeedback) with the reference. Both need a login as the person who sent the note. Team API keys, OAuth clients, partner keys, and Space keys can send notes but can't read them back.
+
+## Send feedback from an agent
+
+Agents call the same `createFeedback` operation. On the [MCP server](/agents/mcp-server), the agent calls the `createFeedback` tool by name. From a terminal, the agent runs `sf feedback`.
+
+The Spacefast agent instructions tell an agent to ask you once, after a task with friction, whether to send feedback. If you agree, the agent sends later notes in the same conversation without asking again and tells you what it sent. If you decline, it does not ask again. The agent includes the error code and request ID when it has them, and never includes credentials or private links.
+
+## Rate a docs page
+
+Each docs page ends with **Was this page helpful?** and the buttons **Yes** and **No**. To suggest a fix to the text, choose **Edit on GitHub** on the page.
+
+## Limits and errors
+
+| Sender | Limit |
+| --- | --- |
+| Signed-in account | 100 notes per hour |
+| Space key | 50 notes per hour for each Space |
+| Anonymous | 5 notes per hour for each IP address, and 15 per hour for each client fingerprint |
+
+| Code | Exit | When |
+| --- | --- | --- |
+| `validation_error` | 2 | The message is empty or too long, a context field is out of range, or the category is unknown |
+| `rate_limited` | 1 | You reached the limit for your sender type. Wait for the `Retry-After` time |
+| `unauthorized` | 10 | `sf feedback list` or `inspect` with no credential |
+| `forbidden` | 11 | `sf feedback list` or `inspect` with a Space key, team API key, OAuth client key, or partner key |
+| `not_found` | 20 | The reference does not exist, or another person sent it |
diff --git a/content/(reference)/glossary.mdx b/content/(reference)/glossary.mdx
index 038fb617..5e9aa0e0 100644
--- a/content/(reference)/glossary.mdx
+++ b/content/(reference)/glossary.mdx
@@ -5,7 +5,7 @@ sidebar:
order: 3
---
-The terms below show up starting on the homepage and get used without re-explanation everywhere after. If a word stopped you somewhere else in these docs, it's probably here.
+Definitions of Spacefast terms, with links to the guides that explain them.
## Ability
@@ -29,7 +29,7 @@ The drag-and-drop publish flow at [drop.fast](https://drop.fast): drag a folder,
## Functions
-The runtime for shipping a worker alongside your site — npm packages, framework server output, outbound HTTP. The other option, alongside [Zero](#zero), for sites that need more than static files. See [Functions](/functions).
+The runtime for shipping a worker alongside your Space — npm packages, framework server output, outbound HTTP. The other option, alongside [Zero](#zero), for Spaces that need more than static files. See [Functions](/functions).
## Grant
@@ -41,7 +41,7 @@ The pointer that selects the version served at the live URL. Promoting or rollin
## Space
-A container for a site's versions and access settings. It can exist before anything is published. Custom domains belong to a team and are assigned to a Space. See [Spaces](/spaces).
+A container for versions and access settings, served at its own hostname. It can exist before anything is published. Custom domains belong to a team and are assigned to a Space. See [Spaces](/spaces).
## Team
diff --git a/content/(reference)/limits.mdx b/content/(reference)/limits.mdx
index 47ebd709..cc34f0b7 100644
--- a/content/(reference)/limits.mdx
+++ b/content/(reference)/limits.mdx
@@ -13,7 +13,7 @@ Every plan's allowances and per-Space limits, and what Boost raises, are on [Pri
## Enforcement
-Read `GET /v1/teams/{teamId}/entitlements` and `GET /v1/teams/{teamId}/plan-policy` for the limits active on a team. A blocked Space creation or claim returns `409` with `space_quota_exceeded`; domain and team-member admission use `domain_quota_exceeded` and `team_member_quota_exceeded`.
+Read `GET /v1/teams/{teamId}/entitlements` and `GET /v1/teams/{teamId}/plan-policy` for the limits active on a team. A blocked Space creation or claim returns `409` with `quota_exceeded`; domain and team-member admission use `domain_quota_exceeded` and `team_member_quota_exceeded`.
The daily publish counter warns rather than refusing a publish. It pools direct publishes and CI builds and resets at 00:00 UTC. The storage usage counter reports overage without blocking writes. These counters are not invoice line items.
@@ -66,12 +66,14 @@ Until a space is claimed, the runtime serves a restricted set of content types:
| --- | --- | --- |
| Unclaimed space | {{anonymous-ttlSeconds}} from creation or the latest publish that changes files, capped at {{anonymous-publishAbsoluteCapSeconds}} from creation | The space stops serving |
| Claiming an expired anonymous space | 7 more days | The claim link stops working |
-| Unfinished publish drafts | 24 hours | The draft is swept |
-| Deleted team | 30 days | The team is reaped |
+| Unfinished publish drafts | 24 hours | The draft expires and can't be resumed; it stays in your version history |
+| Deleted team | 30 days | It can no longer be restored. Its records stay |
| Past-due account | 7 days | The account is suspended and its spaces go dark |
| Activity history | Plan window, see [Pricing & Limits](/pricing) | Older entries are hidden, not deleted |
| Logs | Plan window, see [Pricing & Limits](/pricing) | Reads are clamped to the window |
+Nothing is deleted when a window ends. Spacefast keeps your data until you delete it. Most deletes are soft deletes: the record stays but no longer appears, and you can list teams and domains you deleted yourself. Removing data for good is a manual step our team takes on request.
+
The two anonymous rows work together. A space that stops serving at its deadline is still recoverable through the claim link for another 7 days. Claim-page loads can push that deadline out by up to 6 hours. See [Publish without an account](/anonymous-and-claim).
## What a paused or removed space serves
@@ -80,15 +82,15 @@ A space that stops serving does not fall back to a blank 404. The status code an
| Situation | Status | Body | Writes to the space |
| --- | --- | --- | --- |
-| Account past due, inside the grace period | Serves normally | Your site | `402` `tenant_past_due` |
+| Account past due, inside the grace period | Serves normally | Your Space | `402` `tenant_past_due` |
| Account suspended | `402` | `This space is paused.` | `403` `tenant_suspended` |
| Space disabled, expired, or taken down | `404` | `This space is no longer available.` | `409` with the disable reason |
| Removed after a legal complaint | `451` | `This content is unavailable for legal reasons.` | `409` |
-| Removed under the strictest safety category | `503` | `This space hasn't been published yet.` | `409` |
+| Removed under the strictest safety category | `404` | `This space hasn't been published yet.` | `409` |
-Reads are never blocked by account state. Only writes are. A past-due account keeps serving every site at full speed for the whole grace period, and only the dashboard, CLI, and API stop accepting changes.
+A past-due account blocks only writes. A suspended account also stops serving its Spaces. A past-due account keeps serving every Space at full speed for the whole grace period, and only the dashboard, CLI, and API stop accepting changes.
-The 451 and 404 pages are customizable like any other error page. The 503 case is byte-identical to a host that has never published anything.
+The legal-removal and no-longer-available pages are customizable like any other error page. The strictest-safety response is byte-identical to a host that has never published anything: `404` with `X-Spacefast-Undeployed: 1` and the unpublished body. That header tells it apart from a missing page on a published Space.
## Rate limits
diff --git a/content/(reference)/meta.ts b/content/(reference)/meta.ts
index 7745cf16..6c207182 100644
--- a/content/(reference)/meta.ts
+++ b/content/(reference)/meta.ts
@@ -4,5 +4,5 @@ export default defineMeta({
title: "Reference",
collapsed: false,
order: 7,
- pages: ["glossary", "limits", "config-file", "errors"],
+ pages: ["glossary", "limits", "config-file", "errors", "feedback"],
});
diff --git a/content/(serve)/caching.mdx b/content/(serve)/caching.mdx
index 8eeeb8b5..63704215 100644
--- a/content/(serve)/caching.mdx
+++ b/content/(serve)/caching.mdx
@@ -101,7 +101,7 @@ For the browser, a publish changes nothing that needs waiting on. `max-age=0` me
```
- Every version keeps a permanent hostname of its own, which serves that exact build regardless of what is live. See [URLs and hostnames](/urls).
+ Each retained, ready version has a fixed hostname that serves its exact build, regardless of what is live. See [URLs and hostnames](/urls).
diff --git a/content/(serve)/comments.mdx b/content/(serve)/comments.mdx
new file mode 100644
index 00000000..16909cc5
--- /dev/null
+++ b/content/(serve)/comments.mdx
@@ -0,0 +1,215 @@
+---
+title: Comments
+description: Turn on Comments, leave feedback on a version or the live Space, reply, mention, react, resolve, and export threads
+---
+
+Comments put feedback threads on the page they are about. A reviewer pins a thread to an element, a whole page, or a file. You answer from the page, the dashboard, the CLI, or the API.
+
+## Before you start
+
+- Comments run only on claimed Spaces that belong to a team. Other Spaces get `team_required`.
+- **Page Enhancements** must be on. Turn it on in the Space's settings under **General**. With it off, the API answers `comments_disabled`. The switch is `superpowers.enabled` in [the config file](/config-file#superpowers).
+- Who can comment comes from the Space's grants. A grant needs the `commenter` role or higher. See [Spacefast Share](/share#roles).
+
+## Choose where Comments run
+
+| Setting | Dashboard label | Where people can comment |
+| --- | --- | --- |
+| `off` | **Off** | Nowhere. Spacefast keeps the threads, but nobody can read them until you turn Comments on again |
+| `previews` | **Previews only** | Version preview URLs only. The live Space shows nothing |
+| `everywhere` | **Everywhere** | Version preview URLs and the live Space |
+
+A Space gets **Previews only** at its first publish. A change applies right away, without a republish. While Comments are off, the **Collab** page shows **Turn On Comments**, which sets **Previews only**.
+
+To change the setting in the dashboard:
+
+1. Open the Space and go to **Collab**.
+2. Open the settings control in the page header. It shows the current setting.
+3. Under **Where people can comment**, choose an option.
+
+To change it from the terminal, run `sf comments settings set --surface everywhere`. See [sf comments](/cli/comments#sf-comments-settings-set).
+
+Over HTTP, read the settings with [`getSpaceCollaboration`](/api/reference/spaces/getspacecollaboration). Then send only the settings to change to [`updateSpaceCollaboration`](/api/reference/spaces/updatespacecollaboration), with the `revision` you read as `expectedRevision`. If someone changed the settings after your read, the update returns 409 `resource_revision_changed` with the current settings in `details.current`. The dashboard shows **Comment Settings Changed Elsewhere** with **Apply Your Change** and **Use Latest**.
+
+:::note[Grants must match the setting]
+With **Previews only**, a new `commenter` grant on the `live` target is refused with `comments_target_unavailable`. With **Off**, every new `commenter` grant is refused. Point the grant at a version, or change the setting. Existing grants do not change when you change the setting.
+:::
+
+## Comment on a page
+
+1. Open a version preview URL. In the dashboard, **Open Page to Comment** opens one. With **Everywhere**, the live Space works too.
+2. Select **Comments** on the page to open the toolbar.
+3. Select **Comment**, then select the element or the page area.
+4. Write the comment and select **Comment**.
+
+To draw on the page, select **Draw** in the toolbar. To add a file, select **Attach a file** in the composer. The **Sidebar** lists the threads on the page and on other pages.
+
+A visitor without a Spacefast account comments under a name that Spacefast picks. To change it, the visitor selects the name. In the same dialog, the visitor can give an email and confirm it from the inbox to get reply emails.
+
+## Comment on a version or the live Space
+
+A thread belongs to the version where someone left it.
+
+- A thread on the live version belongs to the page path. It stays on that page after you publish again.
+- A thread on any other version stays with that version. It does not appear on the live Space.
+
+The version page in the dashboard has a **Comments on This Version** section. Select a row to open the thread on the **Collab** page.
+
+To start a thread on a version from the CLI, pass `--version`. Without it, the thread goes on the live version:
+
+```bash
+sf comments create --version v3 --page /pricing --body "The annual price is wrong."
+```
+
+Over HTTP, use [`createSpaceVersionComment`](/api/reference/versions/createspaceversioncomment) to start one and [`listSpaceVersionComments`](/api/reference/versions/listspaceversioncomments) to list one version's threads.
+
+## Comment from the CLI or the API
+
+A thread points at one of three anchors.
+
+| Anchor | CLI | API `target` |
+| --- | --- | --- |
+| A whole page | `--page /pricing` | Omit `target`. The API stores a page anchor |
+| An element on a page | `--page / --selector "#hero-title"` | An element anchor with CSS `selectors` |
+| A file, optionally some lines | `--file src/app.js --lines 7-14` | A file anchor. Its `file.path` becomes the thread's page path |
+
+Writing a comment needs a credential that acts as a person and carries `spaces:write`. Any other credential gets 403 "Comments require a credential that acts as a user."
+
+If a request can be retried, send the same `idempotencyKey` each time. A repeated reply returns `status: "existing"` instead of a second reply.
+
+## Reply and mention teammates
+
+To reply, write in the thread's reply box on the page or the **Collab** page. From the terminal, run `sf comments reply`. Over HTTP, call [`createSpaceCommentReply`](/api/reference/spaces/createspacecommentreply).
+
+A mention notifies one teammate. You can mention the members of the Space's team who can open the Space. The `listSpaceCommentMentionable` operation returns them.
+
+- A comment or reply takes up to 50 mentions, each person once.
+- An ID outside that list fails with `validation_error` and a `pointer` such as `/mentions/0`.
+- `sf comments create --mention` and `sf comments reply --mention` take an email, a name, or a user ID.
+- On a page, only people with the `editor` or `manager` role on the live Space see the list of people to mention.
+
+## React to a thread
+
+A thread takes six reactions: 👍, ❤️, 🎉, 👀, 🚀, and 🙏. Select one on the page or the **Collab** page. Select it again to take yours back.
+
+From the terminal, run `sf comments react` and `sf comments unreact`. Over HTTP, send `emoji` and `op` (`add` or `remove`) to [`updateSpaceCommentReaction`](/api/reference/spaces/updatespacecommentreaction).
+
+## Resolve and reopen a thread
+
+A resolved thread leaves the open list and keeps every reply. Reopen it to put it back.
+
+- On the page, you can resolve your own threads. People with the `editor` or `manager` role can resolve any thread. The **Sidebar** menu has **Resolve all**.
+- On the **Collab** page, open the thread and select **Resolve** or **Reopen**.
+- From the terminal, run `sf comments resolve` or `sf comments reopen`.
+- Over HTTP, send `status: "archived"` to resolve or `status: "open"` to reopen with [`updateSpaceComment`](/api/reference/spaces/updatespacecomment).
+
+The API, its exports, and the CLI's JSON name the resolved state `archived`. The dashboard, the page, and the CLI's human output say resolved.
+
+## Track what you have read
+
+A thread is unread for you when it is open and someone else posted in it after your last read. A resolved thread is never unread. Each thread carries `unread` and `unreadCount`.
+
+| Where | How to mark a thread read |
+| --- | --- |
+| **Collab** page | Opening the thread |
+| Page | **Mark all as read** in the **Sidebar** menu |
+| CLI | `sf comments read`. `sf comments inspect` and `sf comments list` never mark a thread read |
+| API | [`markSpaceCommentRead`](/api/reference/spaces/markspacecommentread). Reading a thread with [`getSpaceComment`](/api/reference/spaces/getspacecomment) does not mark it |
+
+The **Collab** item in the Space's sidebar shows a badge with the unread count.
+
+## Use the Comments page
+
+Open the Space and go to **Collab**. The list is on the left and the open thread is on the right.
+
+| Control | What it does |
+| --- | --- |
+| Status | **Open**, **Resolved**, **All**, or **Spam**. Only team owners and admins see **Spam** |
+| Version | **All versions**, or one version. The live version shows `· live` |
+| Page | **All pages**, or one page path |
+| Author | **Anyone**, or one author |
+| **Search comments** | Text in thread and reply bodies |
+| **Unread** | Only threads you have not read |
+| **Download Comments** | A Markdown file of every thread in the Space |
+
+Filters and the open thread live in the URL, so you can share a filtered view. `?thread=cmt_...` opens one thread. Each thread from the API and the CLI carries this link as `dashboardUrl`.
+
+A thread on a file opens in **Files**, at that file. Other threads open their page in a new tab.
+
+## Handle spam
+
+**Spam filtering** is on by default. It holds suspected spam out of the open list.
+
+Team owners and admins can review held threads in the **Spam** folder. Open a thread and select **Not spam** to release it. Select **Mark as spam** on any thread to hold it.
+
+Over HTTP, team owners and admins send `status: "spam"` to [`updateSpaceComment`](/api/reference/spaces/updatespacecomment) to hold a thread, and `status: "open"` to release one. Add `spamCommentId` to judge one held reply. List held threads with `filter=spam` on [`listSpaceComments`](/api/reference/spaces/listspacecomments). Other callers get 403, except that `status: "open"` on a held thread answers 404 for them.
+
+## Get notified
+
+Spacefast sends comment notifications to the dashboard **Notifications** panel and by email.
+
+| Event | Who hears about it | Setting that controls it |
+| --- | --- | --- |
+| New thread | The Space's creator. If the creator is not on the team, the team owners | `newThreads` |
+| New reply | Everyone who follows the thread. Posting in a thread follows it | `newReplies` |
+| Resolve or reopen | Everyone who follows the thread | `statusChanges` |
+| Mention | The person mentioned | None. Mentions always notify |
+
+- Nobody hears about their own action.
+- A recipient must still be able to read comments on that page.
+- Only a mention from a member of the Space's team notifies.
+- A visitor without an account gets reply emails only after they confirm an address. Each email has a **Stop reply emails** link.
+
+All three settings are on by default. In the settings control on the **Collab** page, under **Advanced**, **Notify on new comments** sets `newThreads` and `newReplies` together. **Notify on status changes** sets `statusChanges`. Over HTTP, send `notificationPreferences` to [`updateSpaceCollaboration`](/api/reference/spaces/updatespacecollaboration). Each key you leave out keeps its value.
+
+## Export comments
+
+| Where | Format | What it holds |
+| --- | --- | --- |
+| **Download Comments** on the **Collab** page | Markdown, `spacefast-comments-.md` | Every thread in the Space |
+| **Download Comments** on a version page | JSON, `spacefast-comments-.json` | The version's threads |
+| `sf comments export` | Markdown by default, or JSON | All threads, or one version's with `--version` |
+| [`exportSpaceComments`](/api/reference/spaces/exportspacecomments) | `format=markdown` (default) or `format=json` | All threads |
+| [`exportSpaceVersionComments`](/api/reference/versions/exportspaceversioncomments) | `format=markdown` (default) or `format=json` | One version's threads |
+
+Both endpoints take `status=open` or `status=archived`, and the CLI takes `--status open` or `--status resolved`. The JSON export is `{ "data": [...] }` with the same thread objects the list returns.
+
+The Markdown export starts with `# Spacefast comments`. Each thread is a `## ` section. The API writes `open` or `archived` there, and `sf comments export` writes `open` or `resolved`. A section lists the page, URL, permalink, author, `viewport`, `selectors`, and attachments, then the body and one `- Reply by : ` line per reply.
+
+## Let another site embed Comments
+
+A site that you list can run this Space's Comments for its own visitors. There is no dashboard control for the list. Use the CLI or [`updateSpaceCollaboration`](/api/reference/spaces/updatespacecollaboration):
+
+```bash
+sf comments settings set --embed-allow https://docs.example.com
+```
+
+- Each entry is one exact `https` origin. Wildcards and paths are refused.
+- A Space takes up to 20 origins.
+- Each site gets its own threads. They are separate from the threads on your Space's own pages.
+- An embedded site gets no screenshots and no attachments.
+- Comments must be on, and the Space must have a ready live version.
+
+The site calls [`getSpaceCommentsEmbedConfig`](/api/reference/spaces/getspacecommentsembedconfig) and [`createSpaceCommentsEmbedTicket`](/api/reference/spaces/createspacecommentsembedticket) from the browser, with no credential. Both answer only to a listed origin. Every other caller gets the same 404.
+
+Allowing a site lets its visitors comment in this Space without an account. `sf comments settings set` asks for confirmation before it adds or removes a site.
+
+## Limits
+
+| Limit | Value |
+| --- | --- |
+| Comment or reply body | 1 to 16,000 characters |
+| Page path | 2,048 bytes (UTF-8) |
+| Mentions per comment or reply | 50 |
+| Attachments per thread or reply | 10 |
+| Attachment size | 5 MB |
+| Screenshots | 1 per thread, none on replies |
+| List page size | 1 to 100, default 20 |
+| Search text, author filter | 160 characters |
+| Embed origins | 20 |
+| New threads and replies from visitors without an account | 10 per minute per network |
+| Reactions | 60 per minute per person. Without an account, per network |
+| Embed configuration reads | 120 per minute per IP address, 600 per minute per Space |
+| Embed tickets | 30 per minute per IP address, 120 per minute per site, 600 per minute per Space |
+
+A request over an embed rate limit fails with `rate_limited`.
diff --git a/content/(serve)/domains.mdx b/content/(serve)/domains.mdx
index f6875d29..240ea406 100644
--- a/content/(serve)/domains.mdx
+++ b/content/(serve)/domains.mdx
@@ -89,7 +89,7 @@ Every observed `A` record has to point at Spacefast. A leftover record from your
Most domains need no TXT record. One appears only when the hostname is already in use elsewhere and you have to prove it is yours (see [Domain verification records](/infrastructure/domain-verification-records) for how they interact with older proofs):
- **Another host still holds it.** The record is named for the hostname, its value starts with `atomic-domain-`, and the domain waits until it is published. Remove any CNAME at that name first; a TXT record cannot sit beside one.
-- **Another Space serves it.** Adding it here creates a claim instead of failing, and returns a `_spacefast-claim.` TXT record. If you can also manage the Space that holds it, verify with move and the domain moves without the record. Otherwise publish the record at your DNS host and verify: once Spacefast sees it, the domain moves to this Space and the old Space stops serving it. Either way, a claim expires 24 hours after it was created; add the domain again for a new one.
+- **Another Space serves it.** Adding it to this Space creates a claim instead of failing, even when the other Space is on your team, and returns a `_spacefast-claim.` TXT record made for this claim. Publish it at your DNS host and verify. Once Spacefast sees it, the domain moves to this Space and the old Space stops serving it. A claim doesn't expire, and it holds nothing until you prove it. If you manage both Spaces, you can also remove the domain from the other one and add it here.
Keep exactly one `atomic-domain-` record per hostname. Two of them, even when one is correct, make the claim fail.
@@ -114,9 +114,9 @@ The apex is decided by the public suffix list, not by counting labels. `example.
| `verified` | A current positive signal proves control. | Nothing. |
| `failed` | No current proof. Either the records were never observed, or the domain verified in the past and has stopped proving. | Run `sf domains diagnostics` to see which records are observed. |
-`failed` on a domain that used to work almost always means someone edited the zone. It does not mean the site is down.
+`failed` on a domain that used to work almost always means someone edited the zone. It does not mean the Space is down.
-**Verification failure never takes a live site down.** Compiled routes and certificates stay in place while the domain re-proves itself. Only sustained failure past the notice window blocks the attachment.
+**Verification failure never takes a live Space down.** Compiled routes and certificates stay in place while the domain re-proves itself. Only sustained failure past the notice window blocks the attachment.
## Certificates
@@ -227,7 +227,7 @@ www.example.com purpose=www dns=not_configured txt=missing ssl=missing
| `domain_wildcard_label` | 400 | A label contained `*`. Add the apex or the concrete hostname instead. |
| `domain_reserved_suffix` | 400 | The hostname ends in a Spacefast-managed suffix. |
| `managed_domain_protected` | 400 | You passed the id of a built-in `space.fast` or `view.fast` hostname. |
-| `domain_already_exists` | 409 | The team already has that hostname. |
+| `domain_already_exists` | 409 | Adding a hostname to the team's domain list when the team already has it. Adding it to a Space attaches it, or creates a claim while another Space still serves it. |
| `domain_quota_exceeded` | 409 | The plan's external domain group limit is reached. |
| `domain_in_use` | 409 | The hostname is already on this Space, a previous attachment is still being removed, or you tried to delete a domain that is still assigned. |
| `domain_detach_in_progress` | 409 | A previous unassign is still draining. |
@@ -237,7 +237,7 @@ www.example.com purpose=www dns=not_configured txt=missing ssl=missing
### Checking before you commit
-`GET /v1/domains/preflight?name=example.com` runs the same name validation and hosting check as the add, so terminal problems surface before any record exists. Its `canHost` verdict is advisory. A `false` still lets you add the domain and claim it with the TXT proof, and `null` means the check was unavailable rather than negative.
+`GET /v1/domains/preflight?name=example.com` runs the same name validation and serving check as the add, so terminal problems surface before any record exists. Its `canHost` verdict is advisory. A `false` still lets you add the domain and claim it with the TXT proof, and `null` means the check was unavailable rather than negative.
## Next steps
diff --git a/content/(serve)/meta.ts b/content/(serve)/meta.ts
index 19a81121..13367410 100644
--- a/content/(serve)/meta.ts
+++ b/content/(serve)/meta.ts
@@ -8,9 +8,11 @@ export default defineMeta({
"urls",
"domains",
"routing",
+ "traffic-rules",
"caching",
"stats",
"share",
+ "comments",
"customization",
"site-pages",
],
diff --git a/content/(serve)/routing.mdx b/content/(serve)/routing.mdx
index 1c428a1e..c1fd1fc1 100644
--- a/content/(serve)/routing.mdx
+++ b/content/(serve)/routing.mdx
@@ -6,6 +6,8 @@ sidebar: { order: 3 }
You can redirect, rewrite, proxy, and set response headers on a published Space. Redirect rules run in order, while matching header blocks accumulate. Rewrites and 404 rules can yield to real files.
+To block, challenge, or skip the cache for requests, see [Traffic rules](/traffic-rules).
+
Nothing here is evaluated per request. Spacefast compiles your rules at publish time into a table the edge reads, so a rule that behaves unexpectedly is almost always a compile-time problem. `sf routing inspect` shows you the compiled result before you publish.
## Three sources, one order
@@ -20,7 +22,7 @@ They merge into one list in that order: `_redirects` rules first, then `redirect
A config rule whose source is already claimed by an unconditional file rule never runs, and publish reports `redirect_shadowed_by_file`: `_redirects already routes "{source}", so this rule never runs.`
-The config file is `sf.jsonc`. These names also work, in this precedence order: `spacefast.jsonc`, `spacefast.json`, `sf.json`, `.sf/sf.json`, `.sf/config.jsonc`, `.sf/config.json`.
+The project config is `spacefast.config.ts` or `sf.jsonc`. When both exist, `spacefast.config.ts` wins and the CLI warns that it ignored the other file. See [the project config reference](/config-file#file-names-and-discovery-order) for the full discovery order.
`_redirects` and `_headers` are published but never served as content. Requesting one returns 404.
@@ -211,18 +213,18 @@ There is no generic trailing-slash normalizer. The behavior comes from what the
| Situation | Behavior |
| --- | --- |
-| `dir/index.html` exists | `/dir/` serves, `/dir` answers 308 to `/dir/` |
+| `dir/index.html` exists | `/dir/` serves and `/dir` answers 308 to `/dir/`. With clean URLs on, `/dir/index` and `/dir/index.html` also redirect there |
| A directory listing exists for `dir` | `/dir/` serves, `/dir` answers 308 to `/dir/` |
-| Clean URLs are on and `page.html` exists | `/page` serves, `/page/` answers 308 to `/page` |
+| Clean URLs are on and `page.html` exists | `/page` serves; `/page/` and `/page.html` answer 308 to `/page` |
| Anything else | No normalization. The other spelling falls through to the SPA fallback or a 404 |
-Those compiled 308s accept every method and carry the incoming query across.
+Those 308s carry the incoming query across. The `/dir` and `/page/` ones accept every method. The `.html` ones answer only GET and HEAD, and files mode never sends them.
## Index files and clean URLs
-`index.html` at the root answers `/`. `docs/index.html` answers `/docs/`, and it keeps its literal `/docs/index.html` URL too.
+`index.html` at the root answers `/`. `docs/index.html` answers `/docs/`, and `/docs` redirects there with a 308. With clean URLs on, `/docs/index` and `/docs/index.html` also redirect there. A folder always serves its own `index.html`, the file every static site generator and framework writes for a folder.
-With no config, Spacefast infers the index: a root `index.html`, else a single public HTML file, else the one root-level HTML file, else none, which puts the Space in files mode with directory listings.
+With no `index` set, Spacefast infers the homepage: a root `index.html`, else the file a 200 `fallback` names (unless the version runs a worker, which keeps `/`), else a single public HTML file, else the one root-level HTML file, else none, which puts the Space in files mode with directory listings.
Two keys in `sf.jsonc` change it:
@@ -233,9 +235,9 @@ Two keys in `sf.jsonc` change it:
}
```
-`index` names the directory index file, defaulting to `index.html`. Setting it to `false` disables index resolution.
+`index` names the homepage, the file `/` serves. It doesn't rename folder indexes: with `"index": "home.html"`, `docs/home.html` doesn't answer `/docs/`, and the publish warns with `custom_index_not_folder_index`. Setting it to `false` stops Spacefast picking a homepage and turns SPA inference off. A root `index.html` still answers `/` unless `listing` is on.
-`cleanUrls` serves a flat `about.html` at `/about` as well as `/about.html`, and redirects `/about/` to `/about` with a 308. It defaults to on, unless a 200-status SPA fallback is configured, in which case it defaults to off.
+`cleanUrls` serves a flat `about.html` at `/about`, and redirects `/about.html` and `/about/` there with a 308, query string included. A rewrite that lands on `/about.html` still serves the file, and a page keeps its `.html` URL when a redirect rule names either of its URLs or a `_headers` rule matches only one of them. With `"cleanUrls": false`, flat pages answer only at their file URL. Folders still serve their `index.html` at the folder URL: `/docs/` serves and `/docs` redirects there. The literal `/docs/index.html` also serves, while `/docs/index` has no generated alias. `cleanUrls` defaults to on, with or without a fallback, so a committed page always wins its URL over the SPA shell.
In a config that declares `"version": 1`, these live under `serve` as `serve.index`, `serve.cleanUrls`, `serve.spaFallback`, and `serve.directoryListing`.
@@ -249,7 +251,15 @@ Without a `404.html`, Spacefast renders its own page: status 404, the heading "P
## SPA fallback
-Point `fallback` at a committed file to serve it whenever no exact route matches:
+A single-page app needs no config. When `fallback` isn't set and the version runs no worker, publish picks the shell from the files and says so with the note `spa_fallback_inferred`:
+
+1. A root `200.html`, the name Surge, SvelteKit, and Nuxt use.
+2. Otherwise `_shell.html`, which TanStack Start writes.
+3. Otherwise a root `index.html` next to a build tool's output, as long as there is no root `404.html`. That means `_app/`, `_next/static/`, `_nuxt/`, `asset-manifest.json`, or a script with a content hash in its name directly under `assets/` or under `static/js/`, such as `assets/index-DiwrgTda.js`. A hand-written `assets/app.js` doesn't count.
+
+A root `404.html` blocks the third rule, so a static export that ships one keeps its real 404s. Files mode (`"listing": true` or `"index": false`) is never an SPA. Set `"fallback": false` to turn the inference off.
+
+Point `fallback` at a committed file to choose the shell yourself:
```jsonc sf.jsonc
{
@@ -257,9 +267,11 @@ Point `fallback` at a committed file to serve it whenever no exact route matches
}
```
-The long form takes a status: `{"path": "/index.html", "status": 200}`. Only `200` and `404` are accepted, and anything else resolves to 200. A bare string means 200. The path has to be a committed, non-private file, otherwise publish fails with `config_invalid`.
+The long form takes a status: `{"path": "/index.html", "status": 200}`. `false` serves no fallback, so a miss gets the nearest `404.html`. Only `200` and `404` are accepted, and anything else resolves to 200. A bare string means 200. The path has to be a committed, non-private file, otherwise publish fails with `config_invalid`.
+
+`{"path": "404.html", "status": 404}` is a custom error page, not an SPA fallback. A 200-status fallback is the SPA one. It turns directory listings off by default. When `index` isn't set, there's no root `index.html` and the version runs no worker, its file is also the homepage.
-`{"path": "404.html", "status": 404}` is a custom error page, not an SPA fallback. A 200-status fallback is the SPA one, and it turns clean URLs off by default and forces directory listings off.
+Committed files win over the fallback, as on Netlify, Vercel, and Cloudflare Pages. A framework that builds some routes as static pages and leaves the rest to a client-side shell needs no config beyond the fallback: `about.html` answers `/about`, `docs/index.html` answers `/docs/`, and every other route gets the shell. Set `"cleanUrls": false` and a flat `about.html` no longer answers `/about`, so that route reaches the shell; folders still serve their `index.html`.
The fallback is skipped for around 110 asset extensions, including `css`, `js`, `json`, `png`, `svg`, `woff2`, `pdf` and `zip`, so a missing asset returns 404 instead of your HTML shell. It is a denylist on purpose, which keeps dotted client routes such as `/users/jane.doe` and `/v1.2.3` reaching the shell.
diff --git a/content/(serve)/share.mdx b/content/(serve)/share.mdx
index 54f6cb4d..ed97210b 100644
--- a/content/(serve)/share.mdx
+++ b/content/(serve)/share.mdx
@@ -68,13 +68,13 @@ sf share grant --to public --role viewer --path '/docs/**' --exclude '/docs/inte
A public grant cannot carry publish or manage capabilities.
-Grants default to `live`. Opening the live site does not open branch previews or version URLs. To make immutable version URLs public too, add a grant for `all-versions`:
+Grants default to `live`. Opening the live Space does not open branch previews or version URLs. To make immutable version URLs public too, add a grant for `all-versions`:
```bash
sf share grant --to public --role viewer --path '/**' --target all-versions --space my-site
```
-Use `version:` to open only one version. Branch previews need a `branch:` grant; `all-versions` does not cover them or the live site. A team grant admits the owning team; it cannot name another team you belong to.
+Use `version:` to open only one version. Branch previews need a `branch:` grant; `all-versions` does not cover them or the live Space. A team grant admits the owning team; it cannot name another team you belong to.
## Share links
@@ -221,7 +221,7 @@ Two behaviors are worth knowing because they look like bugs and are not. A rewri
## Revoking everything at once
```bash
-sf access logout-all --space my-site
+sf access sessions revoke --space my-site
```
This ends every active visitor session on the Space, whatever admitted them: share link, password, private open, or signed-in account. It takes effect immediately, without a republish. Grants themselves are untouched, so anyone still holding a valid link or password can open the Space again on their next visit. Revoke the grant when you want the credential itself dead.
diff --git a/content/(serve)/stats.mdx b/content/(serve)/stats.mdx
index 7b2ae562..7e987a50 100644
--- a/content/(serve)/stats.mdx
+++ b/content/(serve)/stats.mdx
@@ -17,21 +17,21 @@ Open the Space and go to **Settings → Stats**. A segmented control labelled **
Thirty days is the widest window. There is no longer one.
-The page shows two totals, **Requests** and **Views**, then three sections.
+The page shows **Requests**, **Visitor requests**, and **Visitors per day**. Visitor requests include assets, not only page loads. Visitors per day averages complete UTC days in the selected window.
| Section | What it lists |
| --- | --- |
-| Traffic by period | One row per bucket, with requests, views, and bandwidth |
-| Unique visitors | Distinct visitors per day |
+| Traffic by period | One row per bucket, with requests, visitor requests, visitors on daily windows, and bandwidth |
+| Visitors | Daily distinct counts in the period table. The hourly window lists daily counts under the visitor card |
| Top paths | The busiest paths in the window |
-Buckets are labelled in UTC, not your timezone, because that is the boundary the edge measured. Bandwidth is available in the table's field picker but hidden by default, since bandwidth is unlimited on every plan.
+Buckets are labelled in UTC. Bandwidth is available in the table's field picker but hidden by default. Cached and uncached traffic have [plan allowances and usage rates](/pricing).
## What counts as a view
A **request** is any HTTP request the Space answered. A **view** is a request from something that is not a crawler with a status under 400, assets included. So `views` cannot exceed `requests`, and the gap comes from crawlers and responses with a status of 400 or higher.
-**Unique visitors** come from a separate daily measurement. It ignores the crawler and status filters, and it merges every hostname mapped to the Space into one count. Daily is the finest resolution available, and days are never summed into a window total. That's why the page lists them one by one instead of showing a single number.
+**Unique visitors** come from a separate daily measurement. It ignores the crawler and status filters, and it merges every hostname mapped to the Space into one count. Daily is the finest resolution available, and days are never summed into a window total. The dashboard shows daily counts and an average over complete days, rather than adding daily visitors into a window total.
**Top paths** and top hosts are capped at 10 entries each.
@@ -74,6 +74,6 @@ curl "https://api.spacefast.com/v1/spaces/spc_xxxxxxxx/analytics?window=7d" \
There is no window-level request total in the payload. Sum `series[].requests` for it, which is what the dashboard does.
-## This is not Google Analytics
+## Add browser analytics
-These numbers are the edge's own accounting, and they are all you get from Spacefast. If you want page-level product analytics, add your own tag. **Customization → Google Analytics** takes a GA4 measurement id or a Tag Manager container id and injects it into every published page. That is a separate feature with a separate switch. See [Customization](/customization).
+For browser analytics, add your own tag. **Customization → Google Analytics** takes a GA4 measurement id or a Tag Manager container id. Its own switch controls whether the tag loads. See [Customization](/customization).
diff --git a/content/(serve)/traffic-rules.mdx b/content/(serve)/traffic-rules.mdx
new file mode 100644
index 00000000..63060c08
--- /dev/null
+++ b/content/(serve)/traffic-rules.mdx
@@ -0,0 +1,244 @@
+---
+title: Traffic rules
+description: Block or challenge requests, skip the cache for some paths, test a URL against your rules, and pause every rule at once
+---
+
+A traffic rule matches requests on facts such as the path, the country, the client address, or a header. A firewall rule blocks or challenges a matching request before it reaches your Space. A cache-bypass rule serves a matching request fresh instead of from the edge cache.
+
+The **Traffic Rules** page in the dashboard also lists your redirects, rewrites, and headers, with where each one runs. Their grammar is on [Redirects, rewrites, and headers](/routing). This page covers firewall and cache-bypass rules.
+
+## Pick where a rule lives
+
+You declare a rule in one of two places. Spacefast puts it at the edge for you.
+
+| Where | `origin` in the rule list | A change reaches the edge after |
+| --- | --- | --- |
+| The `firewall` and `cache.bypass` keys of your config file | `config` | A publish |
+| The Space settings: the dashboard, `sf rules add`, or `updateSpace` | `overlay` | A settings save. No publish |
+
+The config file is `sf.jsonc`, one of its aliases, or `spacefast.config.ts`. See the [project config reference](/config-file).
+
+Settings rules answer before config file rules. A settings rule with the same name and kind as a config file rule replaces it at the edge.
+
+Keep rules that ship with your code in the config file. Put an urgent fix, such as a block on an abusive network, in the settings, because it applies without a publish.
+
+A Space gets its edge at its first publish. Publish once before you add rules. Until then, validation, pause, and resume fail with [`traffic_rules_unavailable`](/errors/traffic_rules_unavailable).
+
+## Write a rule
+
+```jsonc sf.jsonc
+{
+ "firewall": [
+ { "name": "protect-admin", "action": "challenge", "match": { "path": "/admin/*" } },
+ { "name": "block-logins", "action": "block", "status": 403, "match": { "path": "/wp-login.php*" } }
+ ],
+ "cache": {
+ "bypass": [
+ { "name": "fresh-api", "match": { "path": "/api/*" } },
+ { "name": "previews", "match": { "query": { "preview": { "op": "exists" } } } }
+ ]
+ }
+}
+```
+
+The same keys work in `spacefast.config.ts`:
+
+```ts spacefast.config.ts
+import { defineConfig } from "spacefast/config";
+
+export default defineConfig({
+ firewall: [{ name: "protect-admin", action: "challenge", match: { path: "/admin/*" } }],
+});
+```
+
+| Field | Type | Default | What it does |
+| --- | --- | --- | --- |
+| `name` | string | none | Lowercase letters, digits, `-`, and `_`, up to 61 characters. Not all digits and not a reserved word such as `add` or `validate`. The CLI and the API address the rule by it. Unique within its list |
+| `description` | string, up to 255 characters | none | What the rule is for |
+| `action` | `block` or `challenge` | required | Firewall rules only. `block` answers without reaching the Space. `challenge` asks for proof of a human |
+| `status` | integer, 400 to 499 | `403` | The status of a blocked request. Only with `block` |
+| `match` | object | required | The request facts to compare. At least one field |
+| `any` | boolean | `false` | When `true`, one matching field is enough. Otherwise every field has to match |
+
+Each list answers with its first matching rule, so put the specific rule above the general one.
+
+### Match fields
+
+| Field | Compares with |
+| --- | --- |
+| `path`, `host` | `eq`, `neq`, `in`, `contains`, `startsWith`, `endsWith`, `wildcard` |
+| `method`, `country`, `asn` | `eq`, `neq`, `in` |
+| `ip` | `eq`, `neq`, `in`, `inCidr` |
+| `userAgent`, `referer` | `eq`, `neq`, `in`, `contains`, `startsWith`, `endsWith`, `wildcard`, `exists` |
+| `header`, `cookie`, `query` | The same as `userAgent`, per name |
+| `extension` | `eq`, `neq`, `in`, `exists` |
+| `ja3`, `ja4` | `eq`, `in`, `exists` |
+
+`country` takes two-letter codes. `extension` takes the extension without the dot. `header`, `cookie`, and `query` take an object of names, for example `{ "x-api-client": { "op": "exists" } }`.
+
+A value can be a shorthand or an explicit clause.
+
+| Value | Reads as |
+| --- | --- |
+| `"/admin/*"` | `wildcard`. A `*` matches any run of characters |
+| `"GET"` | `eq`. Text fields ignore case |
+| `["US", "CA"]` | `in`. Any of the values |
+| `"203.0.113.0/24"` on `ip` | `inCidr`. A range |
+| `{ "op": "startsWith", "value": "/admin/" }` | The named comparison |
+| `{ "op": "in", "values": ["RU"], "not": true }` | The opposite of the comparison |
+
+`in` and `inCidr` take `values`. `exists` takes no value. Every other comparison takes `value`. A comparison that a field does not offer fails validation, and so does a `*` in a field without `wildcard`.
+
+[`getTrafficRuleCatalog`](/api/reference/spaces/gettrafficrulecatalog) returns this catalog of fields, comparisons, and limits as JSON.
+
+Firewall rules never match the platform's own transport paths, such as `/__spacefast/health.php` and paths under `/__stattic/blob/`.
+
+## Add a rule from the CLI
+
+To add a rule to the Space settings, run `sf rules add`:
+
+```bash
+sf rules add --kind firewall --action challenge --path '/wp-login.php*'
+sf rules add --kind firewall --action block --country US,CA --not country
+sf rules add --kind cache-bypass --path '/api/*' --name fresh-api
+```
+
+The new rule goes first in its list. The command waits for the edge to answer for the rule, then prints the result, for example `Live at the edge.` Every flag is on [`sf rules`](/cli/rules).
+
+## Add a rule in the dashboard
+
+1. Open the Space and go to **Traffic Rules**.
+2. Click **New Rule**.
+3. Pick **Firewall** or **Cache**.
+4. Enter a **Path pattern**, or click **Match on Something Else** to compare other request facts.
+5. For a firewall rule, pick the **Action** and, for **Block**, an optional **Status code**.
+6. Click **Create Rule**.
+
+The dashboard validates the whole list before it saves. A problem shows on the control that can fix it.
+
+A Space with no rules shows three starters. **Protect a Path** opens a firewall rule that challenges everything under `/admin/`.
+
+To change the order of your dashboard rules, use **Move up** and **Move down** on the rule.
+
+## Test a URL against your rules
+
+To see which rule answers a request, run `sf rules test`:
+
+```bash
+sf rules test /wp-login.php --country RU
+sf rules test /api/orders --file sf.jsonc
+```
+
+The CLI evaluates the rules locally, the way the edge evaluates them. It sends no request to your Space and changes nothing. It prints the first matching rule of each list. With `--file`, it reads rules from a config file you did not publish yet.
+
+In the dashboard, open **Test a request against your rules** under the list. Enter a path and click **Test Path**. **Add request details** sets a **Country**, an **IP address**, a **User agent**, a **Header**, or a **Cookie**. The dashboard tester has no method, ASN, referrer, JA3, or JA4 facts, so rules on those can match differently there than at the edge.
+
+Both testers check firewall and cache-bypass rules only. Redirects and headers have their own checker, `sf routing inspect`. See [Redirects, rewrites, and headers](/routing#checking-your-rules).
+
+## Validate rules before you publish
+
+To check the rules in your config file against the rule grammar and the edge, run:
+
+```bash
+sf rules validate
+```
+
+It reports every problem with the field it is in, for example `firewall[0].match.country`, and exits `2`. It writes nothing. The same check is [`validateSpaceTrafficRules`](/api/reference/spaces/validatespacetrafficrules), which takes up to 50 rules per list.
+
+## Read what the edge holds
+
+```bash
+sf rules list
+```
+
+The list is in the order the edge reads the rules. Each rule shows its kind, its action, where it was declared, where it runs, and what the edge last said about it. [`listSpaceTrafficRules`](/api/reference/spaces/listspacetrafficrules) returns the same data.
+
+| Dashboard | `edgeState` or `desiredState` | Meaning |
+| --- | --- | --- |
+| **Live** | `live` | The edge answers with the rule |
+| **Applying** | `pending` | The edge accepted the rule and is still applying it |
+| **Over quota** | `over_quota` | The Space has more rules than the edge allows, so the edge left this one off |
+| **Invalid**, **Failed** | `invalid`, `failed` | The edge refused the rule. `lastError` says why |
+| **Paused** | `suspended` | Rules are paused |
+| **Off** | `disabled` | The rule is turned off |
+| **Serving** | `unknown`, at the Space | The edge does not hold this rule. The Space answers it |
+
+The dashboard row also shows **Edge** or **Origin**. **Origin** means the Space answers the rule, and the row says why.
+
+The **Edge Allowance** card shows **Rules at the edge**: how many rules the Space has at the edge, and how many the edge allows. The edge sets that allowance, and it can be lower than the 50 rules per list a Space may declare. When the edge does not answer, the card shows Spacefast's own count and **limit unknown**.
+
+A declared rule that duplicates an earlier rule, or reuses its name, never reaches the edge. The list reports it under the rules, and the API reports it in `collapsed`.
+
+## Remove a rule
+
+To remove a rule from the Space settings, run:
+
+```bash
+sf rules remove block-logins --kind firewall
+```
+
+In the dashboard, click **Remove** on the rule, then **Remove Rule**.
+
+A rule from your config file is not removable here. Delete it from the file and publish. To change it without a publish, click **Override in Dashboard**. The dashboard copies the rule into the settings, where it answers first.
+
+## Pause and resume all rules
+
+To stop every rule at once, run `sf rules pause`, or click **Pause All Edge Rules** in the dashboard. The Space then serves as if it declared no rules. Requests that rules blocked or challenged reach the Space. Requests that skipped the cache can serve from it again.
+
+The rules stay saved. A rule you add while paused is parked when it arrives.
+
+To put the rules back, run `sf rules resume`, or click **Resume** in the **Traffic Rules Are Paused** notice. Resume is safe on a Space that is not paused.
+
+Pause and resume apply at once, with no publish. Both need a Space with an edge. The API operations are [`pauseSpaceTrafficRules`](/api/reference/spaces/pausespacetrafficrules) and [`resumeSpaceTrafficRules`](/api/reference/spaces/resumespacetrafficrules).
+
+## Run redirects and headers at the edge
+
+By default your Space answers its own redirects and headers. To move the ones the edge can answer to the edge, set `config.placement.edge` to `true` with [`updateSpace`](/api/reference/spaces/updatespace). It takes effect at the next publish.
+
+Some rules stay at the Space anyway. Examples are a rule that captures part of the path, a proxy, a `404` rule, and a redirect with a status other than 301, 302, 307, or 308. The rule list names the reason for each one.
+
+While rules are paused, a publish that places redirects or headers at the edge fails with [`traffic_rules_placement_incomplete`](/errors/traffic_rules_placement_incomplete). Resume, then publish again.
+
+## Know what needs a publish
+
+| Change | Takes effect after |
+| --- | --- |
+| A firewall or cache-bypass rule in the config file | A publish |
+| A firewall or cache-bypass rule in the Space settings | The settings save |
+| A redirect, rewrite, or header in the Space settings | The settings apply on a Space with a live version. Otherwise the first publish |
+| A rule in `_redirects`, `_headers`, or the config file `redirects`, `rewrites`, and `headers` | A publish |
+| Pause or resume | Immediately |
+| `placement.edge` | The next publish |
+
+## Limits
+
+| Limit | Value |
+| --- | --- |
+| Rules in one `firewall` or `cache.bypass` list | 50 |
+| Comparisons in one firewall rule | 47 |
+| Comparisons in one cache-bypass rule | 50 |
+| Values in one `in` or `inCidr` | 1,000 |
+| Characters in one value | 4,096 |
+| Characters in a header, cookie, or query name | 128 |
+| Characters in a `description` | 255 |
+| Rules at the edge | Set by the edge. See **Edge Allowance** |
+
+The Space settings and the config file each hold their own lists. A keyed field counts one comparison per name. A firewall rule with `any: true` takes one edge rule per comparison.
+
+## API operations
+
+| Operation | What it does |
+| --- | --- |
+| [`listSpaceTrafficRules`](/api/reference/spaces/listspacetrafficrules) | The rules, the edge allowance, the pause state, and the hostnames the rules answer on |
+| [`validateSpaceTrafficRules`](/api/reference/spaces/validatespacetrafficrules) | Checks firewall and cache-bypass rules. Writes nothing |
+| [`pauseSpaceTrafficRules`](/api/reference/spaces/pausespacetrafficrules) | Parks every rule |
+| [`resumeSpaceTrafficRules`](/api/reference/spaces/resumespacetrafficrules) | Puts parked rules back |
+| [`getTrafficRuleCatalog`](/api/reference/spaces/gettrafficrulecatalog) | The match fields, comparisons, and limits |
+| [`updateSpace`](/api/reference/spaces/updatespace) | Writes settings rules in `config.firewall` and `config.cache.bypass` |
+
+```bash curl
+curl -sS "https://api.spacefast.com/v1/spaces/$SPACE_ID/rules" \
+ -H "Authorization: Bearer $SPACEFAST_API_KEY"
+```
+
+To write settings rules, read the Space with [`getSpace`](/api/reference/spaces/getspace). Send its whole `config` back with your lists changed, because `config` replaces the stored settings. Send its `settingsDigest` as `baseSettingsDigest`. The write then fails with `publish_base_changed` instead of overwriting a change saved in between.
diff --git a/content/(serve)/urls.mdx b/content/(serve)/urls.mdx
index 67b27e35..6e201d01 100644
--- a/content/(serve)/urls.mdx
+++ b/content/(serve)/urls.mdx
@@ -26,13 +26,13 @@ A slug cannot contain `--`, because `--` is the separator between a version or b
## Version URLs
-Every version that reaches ready gets a permanent hostname of its own:
+Every version that reaches ready gets a fixed hostname of its own:
```text
https://{label}--v{number}.{apex}/
```
-The apex is the Space's original domain, either `space.fast` or `view.fast`. Version 7 of a new Space named `my-site` is `https://my-site--v7.space.fast/`. The URL is computed once when the version turns ready and stored on the version row. It survives a slug rename and serves that build while the version is retained. [Deleting or expiring a version](/versions#how-many-versions-are-kept) removes its files.
+The apex is the Space's original domain, either `space.fast` or `view.fast`. Version 7 of a new Space named `my-site` is `https://my-site--v7.space.fast/`. The URL is computed once when the version turns ready and stored on the version row. It survives a slug rename and serves that build while the version is retained. [Deleting a version](/versions#how-many-versions-are-kept) stops its hostname from serving.
Existing URLs such as `https://v7--my-site.view.fast/` retain their format. Use the returned `immutableUrl` rather than composing a version URL yourself.
@@ -94,4 +94,4 @@ Any hostname ending in `.space.fast` or `.view.fast` is refused as a custom doma
## Hostnames that do not serve
-A request to a hostname Spacefast does not route, or to a Space that has never published, answers `503` with `Cache-Control: no-store` and the body `This space hasn't been published yet.` It is deliberately not a 404, so a monitoring check can tell "nothing here yet" apart from "this page is missing".
+A request to a hostname Spacefast does not route, or to a Space that has never published, answers `404` with `Cache-Control: no-store` and the body `This space hasn't been published yet.` The response includes `X-Spacefast-Undeployed: 1`. Use that header or the body to tell "nothing here yet" apart from a missing page on a published Space. A Space removed under the strictest safety category sends the same response, so the header alone does not prove the Space was never published.
diff --git a/content/_llms-preamble.md b/content/_llms-preamble.md
index fa155b63..bc566a40 100644
--- a/content/_llms-preamble.md
+++ b/content/_llms-preamble.md
@@ -23,7 +23,7 @@ source named under Reference surfaces.
## Set up an agent
-Any agent that can fetch a URL learns to publish from one prompt:
+An agent that can fetch URLs and make HTTP requests can follow this publishing guide:
```
Fetch https://spacefast.com/setup.md
diff --git a/content/agents/claude-code.mdx b/content/agents/claude-code.mdx
index 8bf61e87..2015ef59 100644
--- a/content/agents/claude-code.mdx
+++ b/content/agents/claude-code.mdx
@@ -96,8 +96,8 @@ A first publish with no account returns a claim link. Claim it in the browser to
Publish ./dist to Spacefast and tell me the live URL and the version URL.
-
-Ship a new version of this site with the changelog "fix the pricing table".
+
+Ship a new version of this Space with the changelog "fix the pricing table".
diff --git a/content/agents/cursor.mdx b/content/agents/cursor.mdx
index 88cc8b29..06780597 100644
--- a/content/agents/cursor.mdx
+++ b/content/agents/cursor.mdx
@@ -86,8 +86,8 @@ Publish this project to Spacefast and give me the live URL.
Publish ./dist to Spacefast and tell me the live URL and the version URL.
-
-Push a new version of this site with the changelog "new landing page".
+
+Push a new version of this Space with the changelog "new landing page".
diff --git a/content/agents/index.mdx b/content/agents/index.mdx
index 0091f71d..18d132b8 100644
--- a/content/agents/index.mdx
+++ b/content/agents/index.mdx
@@ -104,4 +104,4 @@ The default connection method is `remote-proxy`, which points your client at `sf
## Share an existing artifact
-[Share a Claude artifact publicly](/agents/share-claude-artifact) walks through exporting a page, publishing it, choosing its audience, and keeping the same address for updates.
+[Share a Claude artifact publicly](/agents/share-claude-artifact) walks through exporting a page, publishing it, choosing its audience, and keeping the same address for updates. [Put HTML from ChatGPT online](/agents/share-chatgpt-html) does the same for a page ChatGPT wrote.
diff --git a/content/agents/mcp-server.mdx b/content/agents/mcp-server.mdx
index 7896f0fd..64e6d318 100644
--- a/content/agents/mcp-server.mdx
+++ b/content/agents/mcp-server.mdx
@@ -17,7 +17,7 @@ The same server ships in two runtimes. Both expose the eligible API operations b
|---|---|---|
| Transport | Streamable HTTP | stdio |
| Endpoint or command | `https://mcp.spacefast.com` | `sf mcp`, or `npx -y spacefast mcp` |
-| Filesystem | None. `publish` requires inline `files` | Reads your workspace. `publish` takes a `path` or publishes the current workspace |
+| Filesystem | None. `publish` takes inline `files` or a completed `uploadId` | Reads your workspace. `publish` also takes a `path` or publishes the current workspace |
| Auth | OAuth, per client | Your CLI login, or `SPACEFAST_TOKEN` |
| Where it runs | Spacefast | Your machine |
@@ -39,15 +39,22 @@ By default a client asks for these scopes.
|---|---|
| `teams:read` | Read the teams the account belongs to |
| `teams:create` | Create teams |
-| `spaces:read` | Read spaces, versions, and build status |
-| `spaces:write` | Create and update spaces and their settings |
-| `spaces:rename` | Rename space slugs and managed addresses |
+| `teams:members.write` | Invite people to teams that the account can administer |
+| `sell:write` | Set up Sell, enable live sales, resend downloads, and mark orders shipped |
+| `spaces:read` | Read Spaces, versions, and build status |
+| `spaces:write` | Create and update Spaces and their settings |
+| `spaces:rename` | Rename Space slugs and managed addresses |
+| `spaces:access.write` | Change who can open a Space, including making it public through its published configuration |
+| `versions:download` | Read files from deployment versions |
| `publish:write` | Publish new immutable versions |
-| `domains:read` | Read domain inventory and DNS diagnostics |
-| `domains:write` | Attach, verify, and mutate domains and DNS records |
-| `offline_access` | Refresh access without another sign-in |
+| `domains:read` | Read Space hostnames and certificate status. Read domain registrations and DNS records when enabled |
+| `domains:write` | Manage Space hostnames. Start domain purchases and manage DNS records when enabled |
+| `oauth-apps:write` | Register and manage the team's Sign in with Spacefast apps |
+| `offline_access` | Refresh access without another interactive sign-in |
-Each tool declares the scopes it needs, and clients can read them from `tools/list` before calling.
+The default leaves out `spaces:delete` and `billing:purchase`. Add `spaces:delete` to the request, or approve it when an agent asks to delete a Space. Hosted MCP carries no connector scopes.
+
+Each tool declares the scopes it needs in `tools/list`, under `_meta["spacefast/requiredScopes"]`, so clients can read them before calling.
Read each operation's required scopes in its tool schema and the [API reference](/api/reference). `publish` requires `publish:write`; deployment and analytics reads require `spaces:read`. Domain inspection also needs the domain scopes declared by its tool.
@@ -75,7 +82,9 @@ Use the API operation tool directly. For example, `searchDocs` searches document
Space Apps provide visual views through tools such as `show_space_deployments`, `show_space_domains`, and `show_space_analytics`. Use `show_domains` for the domain library. These views and the publish card remain available alongside operation tools.
-`publish` publishes files, `operation_status` checks an active publish, and `resume_execution` continues a saved execution after approval. Read each tool's schema before calling it.
+`publish` publishes files, `operation_status` checks an active publish, and `resume_execution` continues a saved execution after approval. `choose_space` asks you to pick a Space when the target is unclear, and `cli_login` signs the `sf` CLI in with this connection's access when you ask for it. Read each tool's schema before calling it.
+
+Results from the built-in tools are capped at 24,000 characters of text across the whole structured value, with an explicit truncation marker. Source-file views refuse results over 1 MiB with `source_result_too_large`.
### Connector access
@@ -87,8 +96,9 @@ Only On-Device MCP (`sf mcp` and the desktop extension) exposes `search_connecto
| Field | Type | What it does |
|---|---|---|
-| `path` | string | Local runtime only. One local directory, file, or archive. Omit `path` and `files` to publish the current workspace |
-| `files` | array of 1 to 1000 | Inline files. Required on hosted. Each is `{ path, content, encoding }` with `encoding` one of `utf8` or `base64`, default `utf8` |
+| `path` | string | Local runtime only. One local directory, file, or archive. Omit `path`, `files`, and `uploadId` to publish the current workspace |
+| `uploadId` | string | A completed durable upload from `completeUpload`, for files too big to send inline. Reuse it when retrying |
+| `files` | array of 1 to 1000 | Inline files. Each is `{ path, content, encoding }` with `encoding` one of `utf8` or `base64`, default `utf8` |
| `spaceId` | string starting `spc_` | Update this Space. Omit to create one, or on local to use the Space linked to this workspace |
| `teamId` | string | Destination team id or slug for a new Space. Send one after a `team_required` error |
| `access` | `private` or `public` | Initial root access for a Space this publish creates. `private` admits the owning team, `public` also admits anyone on the web. Omit to use the team default. Invalid with `spaceId` |
@@ -98,6 +108,8 @@ Only On-Device MCP (`sf mcp` and the desktop extension) exposes `search_connecto
| `description` | string, up to 2000 | Changelog for this version. It does not change the Space description |
| `requestId` | string, 16 to 200 | Idempotency handle. Reuse the same value only when retrying a call that timed out |
+Send at most one of `path`, `files`, and `uploadId`. Hosted MCP needs exactly one of `files` or `uploadId`.
+
```json
{
"path": "dist",
@@ -105,7 +117,7 @@ Only On-Device MCP (`sf mcp` and the desktop extension) exposes `search_connecto
}
```
-The result carries `status` of `publishing` or `published`, `liveUrl`, `immutableUrl`, and a `receipt` with the Space, the version, and any diagnostics. When `status` is `publishing`, `result.job.poll.operationId` is the handle for `operation_status`.
+The result carries `status` of `publishing` or `published`, `liveUrl`, `immutableUrl`, and a `receipt` with the Space, the version, and any diagnostics. When `status` is `publishing`, `result.job.poll` holds the handle for `operation_status`.
Archives are recognized by extension: `.zip`, `.tar`, `.tgz`, `.tar.gz`.
@@ -113,7 +125,10 @@ Archives are recognized by extension: `.zip`, `.tar`, `.tgz`, `.tar.gz`.
| Field | Type | What it does |
|---|---|---|
-| `operationId` | string, required | Copy `publish.result.job.poll.operationId` |
+| `url` | string | Copy `publish.result.job.poll.url`. Prefer it when present |
+| `operationId` | string | Copy `publish.result.job.poll.operationId` |
+
+Send exactly one of the two.
```json
{ "operationId": "opr_7f2b8c1d4e5a6b7c" }
@@ -131,7 +146,7 @@ For an error, open the problem document's `type` URL to read its matching error
An operation can pause before its API request is dispatched. Follow its approval link, review the exact action, and accept, decline, or cancel in the dashboard.
-Keep the returned execution ID and call `resume_execution` using the fields its schema requests. Resume continues the saved operation, including after a server restart. Do not start a second operation to replace a pending approval or repeat a call whose dispatch outcome is uncertain.
+A paused operation waits up to 24 hours for a decision. Keep the returned execution ID and call `resume_execution` with that same `executionId`, using the fields its schema requests. Resume continues the saved operation, including after a server restart. Do not start a second operation to replace a pending approval or repeat a call whose dispatch outcome is uncertain.
See [Agent permissions](/agents/permissions) for the actions that require a person and the scope of standing access.
diff --git a/content/agents/meta.ts b/content/agents/meta.ts
index 4ec2e399..e5c8c408 100644
--- a/content/agents/meta.ts
+++ b/content/agents/meta.ts
@@ -5,6 +5,7 @@ export default defineMeta({
pages: [
"index",
"share-claude-artifact",
+ "share-chatgpt-html",
"mcp-server",
"claude-code",
"codex",
diff --git a/content/agents/permissions.mdx b/content/agents/permissions.mdx
index f55cac2a..9da7255d 100644
--- a/content/agents/permissions.mdx
+++ b/content/agents/permissions.mdx
@@ -22,7 +22,7 @@ Device login is covered step by step in [sign in and security](/authentication).
Interactive approval screens ask for two things: how much the agent may do, and where.
-**Access.** The device page names the profile it asked for. **Read-only access** reads your teams, sites, and domains and changes nothing. **Agent access** can create and publish sites, manage domains and DNS, create teams, and use Spacefast tools. Its own limits line says it cannot delete sites, manage team members, change billing, or mint credentials.
+**Access.** The device page names the profile it asked for. **Read-only access** reads your teams, Spaces, and domains and changes nothing. **Agent access** can create and publish Spaces, and manage their access, domains, and DNS. It can invite team members, create teams, manage Sell, and register sign-in apps. Its limits line says that to delete Spaces it asks you first, and your OK lasts five minutes. A team admin confirms every purchase, and it can't remove team members or change roles or billing.
**Teams.** You pick **All teams** ("Includes teams you join or create later"), **Choose teams** with a picker, or **No team access** ("Sign-in only. This device can't touch any team").
@@ -37,7 +37,7 @@ Two behaviors matter more than the checkboxes:
These are refused for every agent credential, at every permission level:
-- Inviting a member, or cancelling or resending an invitation
+- Cancelling or resending an invitation
- Changing a member's role
- Removing a member
- Accepting a team invitation
@@ -62,7 +62,7 @@ Press **Review** to see the rationale the agent gave, which Space or domain it t
- **Denying is final.** The agent cannot re-ask on the same request. It has to start over.
- Decided requests move to a **Decided** table with **What**, **Outcome**, and **Decided** columns, showing **Approved**, **Completed**, **Executing**, **Denied**, or **Expired**.
-Only genuinely risky operations prompt: publishing over a live site, deploying, rolling a version back, buying a domain, and changing DNS. Routine publishing does not, on purpose. An approval prompt on every publish is an approval prompt nobody reads.
+Only genuinely risky operations prompt: publishing over a live Space, deploying, rolling a version back, buying a domain, and changing DNS. Routine publishing does not, on purpose. An approval prompt on every publish is an approval prompt nobody reads.
Ask for one of those to see the flow end to end.
@@ -93,6 +93,6 @@ A handoff link lives 15 minutes and can be redeemed once. A team can have 10 pen
**Account settings → Connected apps** lists everything acting as you, labelled **Agent**, **App**, **CLI**, **One-time handoff**, **API key**, or **Machine OAuth client**. Opening one shows which teams it reaches, which permissions it holds, and its recent activity.
- Revoking one connection kills the whole thing: its API keys, its OAuth tokens, its handoff links, and any temporary elevation.
-- **Stop all agent access** is the account-wide emergency stop. It revokes everything acting as you and cancels handoff links that were never redeemed. Team-owned automation is not yours alone, so it survives.
+- **Stop All Agent Access** is the account-wide emergency stop. It revokes everything acting as you and cancels handoff links that were never redeemed. Team-owned automation is not yours alone, so it survives.
Team owners and admins have the same control from the team's **Agents** page, under **Who can act here**. **Remove from team** takes an agent's reach into that one team away and leaves the rest alone.
diff --git a/content/agents/share-chatgpt-html.mdx b/content/agents/share-chatgpt-html.mdx
new file mode 100644
index 00000000..a52e7a48
--- /dev/null
+++ b/content/agents/share-chatgpt-html.mdx
@@ -0,0 +1,44 @@
+---
+title: Put HTML from ChatGPT online
+description: Save the HTML ChatGPT wrote as index.html and drop it on drop.fast, or connect Spacefast to ChatGPT and ask it to publish
+---
+
+Save the page ChatGPT wrote as one `index.html` and drop it on drop.fast, with no account. Or connect Spacefast to ChatGPT and ask it to publish the page for you.
+
+## Get one working file
+
+Ask ChatGPT for the whole page in a single file, with its styles and scripts inside:
+
+
+Make this a complete website in one index.html file, with the CSS and JavaScript inside it. Use no build step and no packages.
+
+
+Copy the code into a file named `index.html`, and put any images beside it in the same folder. Open the file in a browser and check it before you publish. If ChatGPT wrote a framework project instead, follow [Frameworks and builds](/frameworks) to produce the directory to publish.
+
+## Publish the file
+
+Open [drop.fast](https://drop.fast) and drop the file on the window. If the page uses images or other files, drop the whole folder so they upload too. You can publish without an account. Keep the returned claim link and open it to claim the Space before anonymous expiry; see [Publish without an account](/anonymous-and-claim). When the claim lands, the claim page asks who can see the Space.
+
+Keep the folder to the page and its assets. A `package.json`, a lockfile, or a framework config next to `index.html` makes Spacefast build the folder instead of publishing it as it is. See the [plain HTML recipe](/recipes/html).
+
+## Publish from ChatGPT
+
+Connect Spacefast to ChatGPT on the web once. [Connect ChatGPT to Spacefast](/setup/chatgpt) has the plugin directory link and the custom MCP server steps. Custom MCP servers need a Business, Enterprise, or Edu workspace.
+
+Then ask ChatGPT to publish the page:
+
+
+Publish this page to Spacefast, enable public access, and give me the live URL.
+
+
+## Choose who can open it
+
+A team's new Spaces start private unless the team changed its default, and a bare URL to a private Space does not grant access. To let anyone on the web open it, set **General access** to **Public** in the Space's **Share** settings.
+
+For a restricted audience, keep the Space private and create a share link on the **Share link** card instead. Anyone with the share link can open the Space without an account. [Spacefast Share](/share) explains the choices and how to revoke access.
+
+## Update the same link
+
+Publish the changed file to the same Space: sign in on drop.fast and choose **Existing space**, or publish to it with `sf publish --space `. Its live address stays the same, so recipients can keep using the link you sent. [Versions and rollback](/versions) explains retained versions and restoring an earlier one.
+
+To use your own address, [attach a custom domain](/domains). Check [Plans and billing](/billing) for the current plan allowances before choosing a plan.
diff --git a/content/api/authentication.mdx b/content/api/authentication.mdx
index cba08d90..c3877055 100644
--- a/content/api/authentication.mdx
+++ b/content/api/authentication.mdx
@@ -131,11 +131,11 @@ Authorization code is the flow for people, client credentials for machines. The
| --- | --- |
| `teams:read` | Read the teams the account belongs to. |
| `teams:create` | Create teams. |
-| `spaces:read` | Read spaces, versions, and build status. |
-| `spaces:write` | Create and update spaces and their settings. |
-| `spaces:rename` | Rename space slugs and managed addresses. |
+| `spaces:read` | Read Spaces, versions, and build status. |
+| `spaces:write` | Create and update Spaces and their settings. |
+| `spaces:rename` | Rename Space slugs and managed addresses. |
| `publish:write` | Publish new immutable versions. |
-| `spaces:publish` | Publish a version to an existing space. |
+| `spaces:publish` | Publish a version to an existing Space. |
| `builds:trigger` | Start a build for an existing version. |
| `domains:read` | Read domain inventory and DNS diagnostics. |
| `domains:write` | Attach, verify, and mutate domains and DNS records. |
diff --git a/content/api/connectors.mdx b/content/api/connectors.mdx
new file mode 100644
index 00000000..ad0ad586
--- /dev/null
+++ b/content/api/connectors.mdx
@@ -0,0 +1,351 @@
+---
+title: Connectors
+description: Register tool sources, connect accounts, set policy rules, and run connector tools over the API, with the scopes, shapes, and error codes each step uses
+sidebar:
+ order: 9
+ badge: Private Beta
+---
+
+Private Beta
+
+Connectors are in Private Beta. A team without access gets `feature_unavailable`.
+
+The connectors API lets a team register tool sources, connect accounts, set policy rules, and run tools. Every operation sits under `/v1/connectors` with the `Connectors` tag. For the concepts and the dashboard, see [Team connectors](/team-connectors). To show a connection's data on a Space's pages, see [Connectors](/connectors).
+
+## Scopes and team selection
+
+| Scope | Grants |
+| --- | --- |
+| `connectors:read` | Read the catalog, connectors, connections, tools, policy rules, and runs |
+| `connectors:write` | Register and remove connectors, connect and disconnect accounts, set policy rules. Includes `connectors:read` |
+| `connectors:execute` | Create and resume runs. Includes `connectors:read` |
+
+`connectors:write` does not include `connectors:execute`, and `connectors:execute` does not include `connectors:write`. A dashboard session has these actions for team owners and admins only. See [Authentication](/api/authentication).
+
+Connector routes are team-scoped but carry no team in the path. Pass `?team=` when your credential can act for more than one team. Without it, the request fails with `team_required`. A team with connectors turned off answers `feature_unavailable`.
+
+## Register a connector
+
+
+
+```bash curl
+curl -sS -X POST "https://api.spacefast.com/v1/connectors?team=acme" \
+ -H "Authorization: Bearer $SPACEFAST_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "slug": "widgets",
+ "title": "Widgets",
+ "source": { "kind": "openapi", "specUrl": "https://api.example.com/openapi.json" }
+ }'
+```
+
+```ts SDK
+const connector = await client.post("/v1/connectors", {
+ query: { team: "acme" },
+ body: {
+ slug: "widgets",
+ title: "Widgets",
+ source: { kind: "openapi", specUrl: "https://api.example.com/openapi.json" },
+ },
+});
+```
+
+
+
+| Field | Type | What it does |
+| --- | --- | --- |
+| `slug` | string | Team-unique slug and tool namespace. 1 to 64 characters matching `^[a-z0-9][a-z0-9-]*$`. Required. It can't be `add`, `browse`, `catalog`, `connections`, `detect`, `policies`, `runs`, `spacefast`, or `tools` |
+| `title` | string | Display name, 1 to 255 characters. Required |
+| `description` | string | Agent-visible note, up to 1,000 characters |
+| `source` | object | `{ kind: "openapi", specUrl \| spec, baseUrl? }`, `{ kind: "graphql", endpoint }`, or `{ kind: "mcp", endpoint }`. Required unless `presetId` carries one |
+| `presetId` | string | A catalog row `id`. It supplies the source and the authentication recipe |
+| `authMethods` | array | Override the authentication the source declares. Each item is an object with `kind` (`api_key`, `oauth2`, `none`, or `first_party`), `slug`, and `label` |
+| `specOverrides` | array | Up to 200 RFC 6902 JSON Patch operations applied to the fetched spec |
+
+The call fetches the source, builds the tool list, and answers `201` with the connector. An MCP endpoint must be remote. Spacefast asks it how it authenticates, so a server that wants OAuth gets an `oauth2` method.
+
+```json
+{
+ "data": {
+ "slug": "widgets",
+ "title": "Widgets",
+ "description": null,
+ "source": "openapi",
+ "sourceUrl": "https://api.example.com/openapi.json",
+ "firstParty": false,
+ "presetId": null,
+ "authMethods": [{ "kind": "api_key", "slug": "api_key", "label": "API key" }],
+ "toolCount": 12,
+ "connections": [],
+ "specSyncedAt": "2026-10-24T09:14:02.111Z",
+ "createdAt": "2026-10-24T09:14:02.111Z",
+ "updatedAt": "2026-10-24T09:14:02.111Z"
+ }
+}
+```
+
+To find what is behind a URL first, call `detectConnectorSource` with `{ "url": "..." }`. It answers an array of candidates, most confident first. Each carries `kind`, `confidence` (`high`, `medium`, or `low`), `endpoint`, `title`, `slug`, and `presetId`. Register the `endpoint`, which can differ from the URL you sent.
+
+The catalog lists curated presets, the public registry, and the team's own connectors. Filter it with `query`, `surface` (`openapi`, `graphql`, `mcp`), and `kind` (`preset`, `registry`, `team`). Each row carries `alreadyAdded` and `connectorSlug`. Pass a row's `id` as `presetId` to register it. A row with `endpointInputRequired: true` also needs a `source` with the provider-specific endpoint.
+
+| Operation | Path | Scope |
+| --- | --- | --- |
+| [`getConnectorCatalog`](/api/reference/connectors/getconnectorcatalog) | `GET /v1/connectors/catalog` | `connectors:read` |
+| [`detectConnectorSource`](/api/reference/connectors/detectconnectorsource) | `POST /v1/connectors/detect` | `connectors:write` |
+| [`registerConnector`](/api/reference/connectors/registerconnector) | `POST /v1/connectors` | `connectors:write` |
+| [`listConnectors`](/api/reference/connectors/listconnectors) | `GET /v1/connectors` | `connectors:read` |
+| [`getConnector`](/api/reference/connectors/getconnector) | `GET /v1/connectors/{connectorRef}` | `connectors:read` |
+| [`updateConnector`](/api/reference/connectors/updateconnector) | `PATCH /v1/connectors/{connectorRef}` | `connectors:write` |
+| [`deleteConnector`](/api/reference/connectors/deleteconnector) | `DELETE /v1/connectors/{connectorRef}` | `connectors:write` |
+| [`getConnectorCatalogLogo`](/api/reference/connectors/getconnectorcataloglogo) | `GET /v1/connectors/catalog/logo/{domain}` | none |
+
+`updateConnector` accepts `title`, `description`, `refreshSpec`, and `specOverrides`. `refreshSpec: true` fetches the source again and rebuilds the tools. Connections and policy rules survive it. `deleteConnector` disconnects every account on the connector, then removes it. A connector with `firstParty: true` cannot be removed.
+
+Registration, detection, and source refreshes share one budget of 60 per team per hour. Past it, requests fail with `429` and `rate_limited`. See [Rate limits](/api/rate-limits).
+
+## Connect an account
+
+```bash
+curl -sS -X POST "https://api.spacefast.com/v1/connectors/connections?team=acme" \
+ -H "Authorization: Bearer $SPACEFAST_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "connector": "widgets",
+ "owner": "team",
+ "credential": { "kind": "api_key", "value": "sk_example_key" }
+ }'
+```
+
+| Field | Type | What it does |
+| --- | --- | --- |
+| `connector` | string | Connector slug. Required |
+| `owner` | `team` or `personal` | `team` shares it with the team. `personal` keeps it yours, within this team. Required |
+| `displayName` | string | Label, 1 to 255 characters. Defaults to the connector's title |
+| `description` | string | Agent-visible note, up to 1,000 characters |
+| `authMethod` | string | Auth method slug. Required only when more than one method applies |
+| `credential` | object | `{ kind: "api_key", value }` or `{ kind: "fields", values }`. Omit it and the server says what it needs |
+| `returnPath` | string | Same-origin dashboard path to land on after authorization. Up to 512 characters, starting with `/` |
+
+Credentials are write-only. No endpoint ever returns one. Only a person can create a `personal` connection. An API key gets `connection_forbidden`.
+
+The `200` answer has one of three `status` values. Branch on it.
+
+```json
+{ "data": { "status": "connected", "connection": { "id": "cxn_3f9c2d1a4b5e6f70", "status": "active" } } }
+```
+
+```json
+{
+ "data": {
+ "status": "authorization_required",
+ "connection": { "id": "cxn_3f9c2d1a4b5e6f70", "status": "pending_authorization" },
+ "authorizeUrl": "https://provider.example.com/oauth/authorize?state=example",
+ "expiresAt": "2026-10-24T09:24:02.111Z"
+ }
+}
+```
+
+```json
+{
+ "data": {
+ "status": "input_required",
+ "connector": "widgets",
+ "authMethod": "api_key",
+ "fields": [{ "name": "value", "label": "API key", "secret": true, "required": true, "guidance": null }]
+ }
+}
+```
+
+The connection objects above are shortened. For `authorization_required`, send the person to `authorizeUrl`. The provider callback completes the connection, and the dashboard calls `completeConnection` with the `state` and `code`. For `input_required`, nothing was created. Collect `fields`, then call again with `credential.values` and the same `authMethod`. `getConnectorCredentialGuidance` returns the provider's help for a domain: `label`, `mintUrl`, `docsUrl`, `notes`, and `steps`.
+
+A connection carries `id`, `connector`, `owner`, `displayName`, `description`, `status`, `identityLabel`, `credentialConfigured`, `authMethod`, `scopes`, `isDefault`, `serving`, `servingRequest`, `toolCount`, `createdBy`, `lastCheckedAt`, `revokedAt`, `createdAt`, and `updatedAt`. `status` is `pending_authorization`, `active`, `degraded`, `needs_reauth`, or `revoked`. `isDefault` is true when this is the only live connection on its connector, so an agent that names the connector alone gets it. Nobody sets it.
+
+| Operation | Path | Scope |
+| --- | --- | --- |
+| [`createConnection`](/api/reference/connectors/createconnection) | `POST /v1/connectors/connections` | `connectors:write` |
+| [`completeConnection`](/api/reference/connectors/completeconnection) | `POST /v1/connectors/connections/complete` | `connectors:write` |
+| [`getConnectorCredentialGuidance`](/api/reference/connectors/getconnectorcredentialguidance) | `GET /v1/connectors/catalog/{domain}/guidance` | `connectors:read` |
+| [`listConnections`](/api/reference/connectors/listconnections) | `GET /v1/connectors/connections` | `connectors:read` |
+| [`getConnection`](/api/reference/connectors/getconnection) | `GET /v1/connectors/connections/{connectionId}` | `connectors:read` |
+| [`updateConnection`](/api/reference/connectors/updateconnection) | `PATCH /v1/connectors/connections/{connectionId}` | `connectors:write` |
+| [`checkConnectionHealth`](/api/reference/connectors/checkconnectionhealth) | `POST /v1/connectors/connections/{connectionId}/health` | `connectors:read` |
+| [`refreshConnection`](/api/reference/connectors/refreshconnection) | `POST /v1/connectors/connections/{connectionId}/refresh` | `connectors:write` |
+| [`reconnectConnection`](/api/reference/connectors/reconnectconnection) | `POST /v1/connectors/connections/{connectionId}/reconnect` | `connectors:write` |
+| [`disconnectConnection`](/api/reference/connectors/disconnectconnection) | `POST /v1/connectors/connections/{connectionId}/disconnect` | `connectors:write` |
+
+`listConnections` filters by `owner` (`team`, `personal`, or `any`, the default), `connector`, `status`, and `includeRevoked`. `updateConnection` changes `displayName`, `description`, and `serving`. It cannot change a credential. `reconnectConnection` takes an optional `credential`, answers with the same three statuses as `createConnection`, and keeps the connection's id, label, policy rules, and Space bindings. `checkConnectionHealth` returns `connectionId`, `status`, `reachable`, `latencyMs`, `detail`, `toolCount`, and `checkedAt`. `refreshConnection` reads the provider's tools again. `disconnectConnection` purges the credential and marks the connection `revoked`. Calling it again changes nothing.
+
+A connection the credential may not use answers `404`, the same as one that does not exist.
+
+## Read tools and policy
+
+A tool address is `/`. While exactly one usable connection matches, `/` also works. A second usable connection turns the short form into `connection_ambiguous`, and no connection is then the default.
+
+Each tool carries `address`, `name`, `summary`, `safety` (`read_only`, `write`, or `destructive`), `effect`, `decidedBy`, and `inputSchema`. `effect` is the policy result: `approve`, `require_approval`, or `block`. `decidedBy` is `{ ruleId, pattern, owner }`, or `null` when the tool's own default applied.
+
+A policy rule has `id`, `pattern`, `action`, `owner`, `position`, and `createdAt`.
+
+| `pattern` | Matches |
+| --- | --- |
+| `*` | Every tool |
+| `/*` | Every tool on every connection of one connector |
+| `/` | One tool on every connection of one connector |
+| `/*` | Every tool of one connection |
+| `/` | One tool on one connection |
+
+The server sets `position` from the pattern's specificity, so the most specific rule wins. A `team` rule is a floor that a `personal` rule can tighten but never loosen. Only a person can create a `personal` rule. A rule's `pattern` and `owner` are fixed. `updateConnectorPolicy` changes `action` only.
+
+```bash
+curl -sS "https://api.spacefast.com/v1/connectors/policies/resolve?team=acme&address=cxn_3f9c2d1a4b5e6f70/charges.create" \
+ -H "Authorization: Bearer $SPACEFAST_API_KEY"
+```
+
+```json
+{
+ "data": {
+ "address": "cxn_3f9c2d1a4b5e6f70/charges.create",
+ "effect": "require_approval",
+ "decidedBy": { "ruleId": "pol_example", "pattern": "*", "owner": "team" },
+ "explanation": "The team rule \"*\" resolves this address to require_approval."
+ }
+}
+```
+
+| Operation | Path | Scope |
+| --- | --- | --- |
+| [`listConnectorTools`](/api/reference/connectors/listconnectortools) | `GET /v1/connectors/{connectorRef}/tools` | `connectors:read` |
+| [`listConnectionTools`](/api/reference/connectors/listconnectiontools) | `GET /v1/connectors/connections/{connectionId}/tools` | `connectors:read` |
+| [`listConnectorPolicies`](/api/reference/connectors/listconnectorpolicies) | `GET /v1/connectors/policies` | `connectors:read` |
+| [`createConnectorPolicy`](/api/reference/connectors/createconnectorpolicy) | `POST /v1/connectors/policies` | `connectors:write` |
+| [`updateConnectorPolicy`](/api/reference/connectors/updateconnectorpolicy) | `PATCH /v1/connectors/policies/{policyRuleId}` | `connectors:write` |
+| [`deleteConnectorPolicy`](/api/reference/connectors/deleteconnectorpolicy) | `DELETE /v1/connectors/policies/{policyRuleId}` | `connectors:write` |
+| [`resolveConnectorPolicy`](/api/reference/connectors/resolveconnectorpolicy) | `GET /v1/connectors/policies/resolve` | `connectors:read` |
+
+The tool lists filter by `query` and `effect`.
+
+## Run a tool
+
+```bash
+curl -sS -X POST "https://api.spacefast.com/v1/connectors/runs?team=acme" \
+ -H "Authorization: Bearer $SPACEFAST_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "input": { "kind": "tool", "address": "cxn_3f9c2d1a4b5e6f70/issues.list", "args": { "limit": 5 } },
+ "idempotencyKey": "list-issues-1"
+ }'
+```
+
+| Field | Type | What it does |
+| --- | --- | --- |
+| `input` | object | `{ kind: "tool", address, args? }` for one call, or `{ kind: "code", code }` for a program. Required |
+| `waitMs` | integer | How long to wait for a final status, 0 to 120000. Defaults to 30000 |
+| `idempotencyKey` | string | Retry with the same key to get the original run back, 1 to 320 characters |
+| `unattendedApprovals` | `park` or `deny` | When a call needs approval: park it, the default, or fail at once |
+
+`args` is validated against the tool's `inputSchema` and defaults to `{}`. A program can call any tool the credential may use, and it runs for at most 10 seconds.
+
+A new run answers `201`, whatever its `status` when the wait ends. A retry with the same `idempotencyKey` and the same input answers `200` with the original run. The same key with a different input fails with `409` and `idempotency_key_reused`.
+
+A run that fails is not an HTTP error. The request succeeded and the run did not, so read `status`.
+
+```json
+{
+ "data": {
+ "id": "cxr_8c1d4a7f2e6b0c3d",
+ "status": "awaiting_approval",
+ "origin": "api",
+ "space": null,
+ "handler": null,
+ "visitor": false,
+ "input": { "kind": "tool", "address": "cxn_3f9c2d1a4b5e6f70/issues.create", "digest": "sha256-example" },
+ "usage": { "toolCalls": 0, "egressBytes": 0, "durationMs": 41 },
+ "createdAt": "2026-10-24T09:14:02.111Z",
+ "approval": {
+ "decisionId": null,
+ "mode": "human",
+ "address": "cxn_3f9c2d1a4b5e6f70/issues.create",
+ "preview": { "title": "Broken link on pricing page" },
+ "reason": "policy_require_approval",
+ "matchedPattern": "*",
+ "approvalUrl": "https://my.spacefast.com/approvals?run=cxr_8c1d4a7f2e6b0c3d",
+ "expiresAt": "2026-10-24T09:29:02.111Z"
+ }
+ }
+}
+```
+
+| `status` | Extra fields | Meaning |
+| --- | --- | --- |
+| `running` | none | Still working. Read the run again |
+| `awaiting_approval` | `approval` | Parked on a decision |
+| `succeeded` | `completedAt`, `result` | Done |
+| `failed` | `completedAt`, `error` | Done. `error` is a problem document with `code`, `status`, and `detail` |
+| `expired` | `completedAt` | Parked for 15 minutes with no decision. Submit it again |
+
+`input` never echoes the arguments. `approval.preview` masks values whose keys look like secrets. `approval.reason` is `policy_require_approval` when a rule parked the call, or `annotation_requires_approval` when the tool asked for approval itself.
+
+| Operation | Path | Scope |
+| --- | --- | --- |
+| [`createConnectorRun`](/api/reference/connectors/createconnectorrun) | `POST /v1/connectors/runs` | `connectors:execute` |
+| [`getConnectorRun`](/api/reference/connectors/getconnectorrun) | `GET /v1/connectors/runs/{runId}` | `connectors:read` |
+| [`listConnectorRuns`](/api/reference/connectors/listconnectorruns) | `GET /v1/connectors/runs` | `connectors:read` |
+| [`resumeConnectorRun`](/api/reference/connectors/resumeconnectorrun) | `POST /v1/connectors/runs/{runId}/resume` | `connectors:execute` |
+
+`listConnectorRuns` returns the team's runs newest first, and filters by `status`, `origin`, `space`, and `connection`. `status=awaiting_approval` lists the runs waiting on a decision.
+
+Runs share a budget of 1,200 per team per hour. Past it, requests fail with `429` and `rate_limited`.
+
+## Resume a parked run
+
+`resumeConnectorRun` takes `action` (`accept`, `decline`, or `cancel`) and `content`, the values a parked call asked for. A `decline` fails the run with `connector_tool_blocked`.
+
+A run parked in `human` mode refuses a decision from an API key or an agent with `human_approval_required`. A person decides at `approvalUrl`. Then call `resumeConnectorRun` with no `action` to read back the outcome and let the run continue. While nobody has decided, that call fails with `connector_approval_required`.
+
+## Errors
+
+Every failure is an RFC 9457 problem document. See [Errors](/api/errors).
+
+| Code | Status | When |
+| --- | --- | --- |
+| `team_required` | 422 | The credential can act for several teams and `team` is missing |
+| `feature_unavailable` | 422 | Connectors are turned off for the team |
+| `connector_slug_taken` | 409 | The team already has a connector with that slug |
+| `connector_source_unreachable` | 422 | The source did not load when registering or refreshing |
+| `connector_source_blocked` | 422 | The source URL is not reachable from Spacefast |
+| `connector_source_invalid` | 422 | `source.kind` does not match the catalog row named by `presetId` |
+| `connector_not_found` | 422 | `presetId` matches no catalog row, or a policy pattern names an unknown connector or connection |
+| `connector_first_party_immutable` | 409 | The connector is built into Spacefast |
+| `connection_forbidden` | 422 | An API key asked for a `personal` connection or rule |
+| `connection_needs_reauth` | 409 | A health probe found no credential, or the authorization is not finished |
+| `connection_needs_reauth` | 422 | Connecting failed: the method doesn't fit the connector or the credential, the authorization failed, or the sign-in expired |
+| `connection_revoked` | 409 | The connection is disconnected |
+| `connection_serving_not_granted` | 422 | `serving` was set on a personal connection |
+| `malformed_policy_pattern` | 422 | The pattern does not fit the grammar |
+| `malformed_tool_address` | 422 | `resolveConnectorPolicy` got an address that does not resolve |
+| `idempotency_key_reused` | 409 | The run key was used with a different input |
+| `human_approval_required` | 403 | A non-person tried to decide a run parked for a person |
+| `connector_approval_required` | 422 | No decision is recorded yet |
+| `connector_run_not_resumable` | 409 | The run already settled, or the decision was already claimed |
+| `rate_limited` | 429 | The team passed the hourly budget for registrations or runs |
+
+These codes appear in a failed run's `error`, inside a successful HTTP response (`200` or `201`):
+
+| Code | Status in `error` | When |
+| --- | --- | --- |
+| `connector_tool_blocked` | 403 | A `block` rule matched, or a person denied the call |
+| `connector_approval_required` | 403 | The call needed approval and `unattendedApprovals` was `deny` |
+| `connection_ambiguous` | 409 | A `/` address matched more than one connection |
+| `connection_not_found` | 404 | The address names no connection the credential can use |
+| `connection_forbidden` | 403 | The credential may not use that connection |
+| `connection_needs_reauth` | 401 | The provider refused the stored credential. Reconnect it |
+| `connector_tool_not_found` | 404 | The connection has no tool by that name |
+| `connector_tool_failed` | varies | The provider call failed |
+| `connector_program_failed` | 422 | A code program threw |
+| `connector_run_budget_exhausted` | 429 | The run passed its budget of 400 outbound requests or 64 MiB read |
+| `run_replay_diverged` | 409 | A resumed program made different calls than before the pause |
+
+## Space bindings and agents
+
+A Space binds its connector roles to team connections through `listSpaceConnectorBindings`, `bindSpaceConnector`, and `unbindSpaceConnector` under the `Spaces` tag. See [Connectors](/connectors#bind-an-account).
+
+Hosted MCP exposes no connector operations. On-device MCP, `sf mcp`, reaches connectors through one gateway tool. See [MCP server](/agents/mcp-server).
diff --git a/content/api/errors.mdx b/content/api/errors.mdx
index 90ced264..a2bb14bb 100644
--- a/content/api/errors.mdx
+++ b/content/api/errors.mdx
@@ -164,11 +164,9 @@ Two retry rules that come from the wire rather than the table. A `429` carries `
| --- | --- | --- | --- |
| `rate_limited` | wait | yes | Too many requests were made in a short window. |
| `anonymous_publish_rate_limited` | wait | yes | Too many anonymous publishes came from this network in a short window. |
-| `space_quota_exceeded` | fix_input | no | Creating or claiming this space would exceed the owner's space allowance. |
+| `quota_exceeded` | fix_input | no | The action would exceed a plan quota, such as the Space limit. The problem details name the quota and its limit. |
| `storage_quota_exceeded` | fix_input | no | The operation would exceed its storage allowance. |
| `team_member_quota_exceeded` | fix_input | no | Inviting this person would exceed the team's seat limit. |
-| `deploy_quota_exceeded` | wait | yes | The owner has used its daily publish allowance. It resets at 00:00 UTC. |
-| `build_minutes_quota_exceeded` | fix_input | no | This build would exceed the owner's build minutes for the month. |
| `idempotency_key_reused` | fix_input | no | The Idempotency-Key was reused with a different request body. |
| `idempotency_conflict_in_progress` | wait | yes | Another request with the same Idempotency-Key is still in flight. |
| `invalid_idempotency_key` | fix_input | no | The Idempotency-Key is missing or invalid, or an anonymous publish omitted its secret replay principal. |
@@ -180,8 +178,8 @@ Two retry rules that come from the wire rather than the table. A `429` carries `
| `internal_error` | retry | yes | Something went wrong on our side. |
| `maintenance_in_progress` | wait | yes | This change is temporarily unavailable while maintenance finishes. |
| `provider_error` | retry | yes | Spacefast could not complete the operation. |
-| `provider_job_failed` | retry | yes | A hosting-provider job failed while processing this operation. |
-| `provider_job_timeout` | retry | yes | A hosting-provider job timed out while processing this operation. |
+| `provider_job_failed` | retry | yes | A provider job failed while processing this operation. |
+| `provider_job_timeout` | retry | yes | A provider job timed out while processing this operation. |
| `space_disabled` | fix_input | no | The space is disabled and cannot be served or modified. |
## Every error code
diff --git a/content/api/index.mdx b/content/api/index.mdx
index 346296f2..823d8ea3 100644
--- a/content/api/index.mdx
+++ b/content/api/index.mdx
@@ -163,6 +163,4 @@ Stop when `data.status` is `succeeded`, `failed`, or `canceled`. Then read `spac
## Connect external tools
-Connectors make OpenAPI, GraphQL, and remote MCP tools available to a team. Register a connector, connect an account, then choose which tools a policy allows. Account credentials remain server-side.
-
-Use the [connector reference](/api/reference/connectors/registerconnector) to register a source, [create a connection](/api/reference/connectors/createconnection), and [run a tool or program](/api/reference/connectors/createconnectorrun). A policy can require approval before a run proceeds. Read the run's result before reporting success. To show a connection's data on a Space's pages, declare a role instead; see [Connectors](/connectors).
+Connectors make OpenAPI, GraphQL, and remote MCP tools available to a team, with credentials that stay in Spacefast. Connectors are in Private Beta; a team without access gets `feature_unavailable`. The [connectors API guide](/api/connectors) covers the operations, scopes, and errors, and [Team connectors](/team-connectors) covers the dashboard and the CLI. To show a connection's data on a Space's pages, declare a role instead; see [Connectors](/connectors).
diff --git a/content/api/long-running-operations.mdx b/content/api/long-running-operations.mdx
index e8e6bee6..b442d748 100644
--- a/content/api/long-running-operations.mdx
+++ b/content/api/long-running-operations.mdx
@@ -122,7 +122,7 @@ curl -sS "https://api.spacefast.com/v1/operations?spaceId=spc_4a7f9c2e8b1d0a6f5c
| `resourceType` | Everything touching one resource type, for example `space`. |
| `resourceId` | Everything touching one resource. |
-The list is cursor-paginated like every other list. See [Pagination](/api/pagination).
+This list uses cursor pagination. See [Pagination](/api/pagination) for the cursor fields and endpoint-specific exceptions.
Authorization is checked per row through the resource each operation touches, so the feed never shows work your credential could not already reach.
diff --git a/content/api/meta.ts b/content/api/meta.ts
index e92c5860..c4582f93 100644
--- a/content/api/meta.ts
+++ b/content/api/meta.ts
@@ -11,6 +11,7 @@ export default defineMeta({
"idempotency",
"long-running-operations",
"webhooks",
+ "connectors",
"sdk",
"reference",
],
diff --git a/content/cli/agent-commands.mdx b/content/cli/agent-commands.mdx
index 3c9990b2..2d3bd11d 100644
--- a/content/cli/agent-commands.mdx
+++ b/content/cli/agent-commands.mdx
@@ -14,7 +14,7 @@ Every command here also takes the [global flags](/cli#global-flags).
Look up a Space by ID, slug, live URL, or attached domain.
```text
-sf inspect TARGET [-o ]
+sf inspect TARGET [-T ]
```
### Arguments
@@ -27,7 +27,7 @@ sf inspect TARGET [-o ]
| Flag | Default | What it does |
| --- | --- | --- |
-| `-o, --team=` | default team | Team slug, ID, or name. Env `SPACEFAST_TEAM`. |
+| `-T, --team=` | default team | Team slug, ID, or name. Env `SPACEFAST_TEAM`. |
```bash
sf inspect docs
@@ -71,7 +71,7 @@ sf fetch [PATH] (--output | --status | --headers | --verify )
| `--headers` | off | Include response headers, minus any that carry credentials. |
| `--verify=` | none | Require a successful response serving this exact version ID. |
| `--space=` | linked Space | Space ID, live URL, domain, or slug. Env `SPACEFAST_SPACE`. |
-| `-o, --team=` | default team | Team slug, ID, or name. Env `SPACEFAST_TEAM`. |
+| `-T, --team=` | default team | Team slug, ID, or name. Env `SPACEFAST_TEAM`. |
| `--claim-token=` | none | Space key (`sfc_…`) for an unclaimed anonymous Space. Env `SPACEFAST_CLAIM_TOKEN`. |
You must pass at least one of `--output`, `--status`, `--headers`, or `--verify`, or the command fails rather than guessing what to do with the response.
@@ -118,7 +118,7 @@ sf apply [--space ] [--no-wait] [--wait-timeout ]
| `--wait` / `--no-wait` | `--wait` | Wait until the apply finishes before returning. |
| `--wait-timeout=` | `900` | Seconds to wait. Minimum 1. |
| `--space=` | linked Space | Space ID, live URL, domain, or slug. Env `SPACEFAST_SPACE`. |
-| `-o, --team=` | default team | Team slug, ID, or name. Env `SPACEFAST_TEAM`. |
+| `-T, --team=` | default team | Team slug, ID, or name. Env `SPACEFAST_TEAM`. |
| `--claim-token=` | none | Space key (`sfc_…`) for an unclaimed anonymous Space. Env `SPACEFAST_CLAIM_TOKEN`. |
```bash
@@ -180,7 +180,7 @@ Saved a durable API key to .spacefast/state.json.
Check local CLI state, auth, API reachability, and Space selection for the current directory.
```text
-sf doctor [--space ] [-o ] [--claim-token ]
+sf doctor [--space ] [-T ] [--claim-token ]
```
```bash
@@ -256,21 +256,21 @@ Without `--paths`, one line per command with its usage and summary, and `data.co
Send feedback to Spacefast with optional error, request, Space, or URL context.
```text
-sf feedback -m [--category ] [--error-code ] [--request-id ] [--space-id ] [--url ]
+sf feedback --message [--category ] [--error-code ] [--request-id ] [--space ] [--url ]
```
| Flag | Default | What it does |
| --- | --- | --- |
-| `-m, --message=` | required | The feedback itself. |
+| `--message=` | required | The feedback itself. |
| `--category=` | `other` | One of `bug`, `docs`, `limit`, `idea`, `other`. |
| `--error-code=` | none | Related API error code, straight from an `{ error }` receipt. |
| `--request-id=` | none | Related API request ID, also from the receipt. |
-| `--space-id=` | none | Related Space ID. |
+| `--space=` | none | Related Space. |
| `--url=` | none | Related URL. |
```bash
-sf feedback -m "The continuation docs are unclear" --category docs
-sf feedback -m "Publish failed after claim" --category bug --error-code space_claimed_credential_available --request-id req_123
+sf feedback --message "The continuation docs are unclear" --category docs
+sf feedback --message "Publish failed after claim" --category bug --error-code space_claimed_credential_available --request-id req_123
```
Every failure receipt carries `error.code` and `error.requestId`. Passing both turns a complaint into something traceable.
@@ -278,11 +278,12 @@ Every failure receipt carries `error.code` and `error.requestId`. Passing both t
### Output
```text
-Feedback ref: fb_0123456789abcdef
-Delivered — thanks!
+Feedback ref: fdb_0123456789abcdef
+Thanks. We read every one.
+Signed in with `sf login`? Read it back with `sf feedback inspect fdb_0123456789abcdef`.
```
-`data` carries `ref` and `delivered`.
+`data` carries `ref` and `status`. `status` is `stored`.
## sf plugins
diff --git a/content/cli/agents.mdx b/content/cli/agents.mdx
index a3fe85c0..3d6f0441 100644
--- a/content/cli/agents.mdx
+++ b/content/cli/agents.mdx
@@ -31,7 +31,7 @@ That detects your agent clients, installs the Spacefast skill, and writes MCP co
Pick `--local` instead when you want the MCP server to publish files from your working directory. Pick `--remote --oauth` when you want the editor to own the credential rather than the CLI.
-For the tool list, scopes, the `execute` sandbox, and the approval model, see [the MCP server](/agents/mcp-server). For the long form of the setup flow, including what each client gets written where, see [sf setup agent](/agents/sf-setup).
+For API operation tools, Apps, scopes, and saved approvals, see [the MCP server](/agents/mcp-server). For setup steps and the files written for each client, see [sf setup agent](/agents/sf-setup).
## sf mcp install
diff --git a/content/cli/api-keys.mdx b/content/cli/api-keys.mdx
index faf00ff0..7bbb59be 100644
--- a/content/cli/api-keys.mdx
+++ b/content/cli/api-keys.mdx
@@ -72,14 +72,14 @@ Use the key by exporting `SPACEFAST_TOKEN`, passing `--token`, or storing it wit
List API keys for the selected team.
```text
-sf api-keys list [-o ] [--limit ]
+sf api-keys list [-T ] [--limit ]
```
Aliases: `sf api-keys ls`.
| Flag | Default | What it does |
| --- | --- | --- |
-| `-o, --team=` | default team | Team slug, ID, or name. Env `SPACEFAST_TEAM`. |
+| `-T, --team=` | default team | Team slug, ID, or name. Env `SPACEFAST_TEAM`. |
| `--limit=` | `50` | Maximum keys to return. Between 1 and 100. |
```bash
diff --git a/content/cli/builds.mdx b/content/cli/builds.mdx
index 3447e50f..94c6d345 100644
--- a/content/cli/builds.mdx
+++ b/content/cli/builds.mdx
@@ -70,7 +70,7 @@ sf builds ls
| Flag | Default | What it does |
| --- | --- | --- |
| `--space=` | linked Space | Space ID, live URL, domain, or slug. Env `SPACEFAST_SPACE` |
-| `-o, --team=` | default team | Team slug, ID, or name. Env `SPACEFAST_TEAM` |
+| `-T, --team=` | default team | Team slug, ID, or name. Env `SPACEFAST_TEAM` |
| `--claim-token=` | saved key | Space key (`sfc_…`) for an unclaimed anonymous Space. Env `SPACEFAST_CLAIM_TOKEN` |
| `--limit=` | `20` | Maximum builds to return. 1 to 100 |
| `--cursor=` | none | Pagination cursor from a previous response |
@@ -88,12 +88,12 @@ Newest first, as a table of `ID`, `STATUS`, `INPUT`, `VERSION`, `CREATED`. When
Build statuses are `waiting_for_source`, `uploading_source`, `queued`, `running`, `succeeded`, `failed`, `canceled`, and `skipped`. The first four are in flight; the rest are terminal. `skipped` means the runner decided not to run the build at all, for example because a newer build for the same branch superseded it.
-## sf builds get
+## sf builds inspect
Show a build.
```text
-sf builds get BUILD
+sf builds inspect BUILD
```
### Arguments
@@ -105,7 +105,7 @@ sf builds get BUILD
### Example
```bash
-sf builds get bld_123
+sf builds inspect bld_123
```
### Output
@@ -171,7 +171,7 @@ sf builds cancel bld_123
### Output
-The same build block `sf builds get` prints, in its new state. `--json` returns `{ build }`.
+The same build block `sf builds inspect` prints, in its new state. `--json` returns `{ build }`.
## sf builds retry
@@ -205,12 +205,12 @@ sf builds retry bld_123
| --- | --- | --- |
| `build_retry_requires_terminal_state` | 2 | The build is still running. Wait, or cancel it first |
-## sf builds refresh-upload
+## sf builds upload refresh
Refresh source archive upload.
```text
-sf builds refresh-upload BUILD
+sf builds upload refresh BUILD
```
Refreshes the upload instruction for a build waiting on its source archive. Source-archive upload URLs expire after 60 minutes; this mints a new one without creating a new build.
@@ -224,7 +224,7 @@ Refreshes the upload instruction for a build waiting on its source archive. Sour
### Example
```bash
-sf builds refresh-upload bld_123
+sf builds upload refresh bld_123
```
### Output
@@ -246,7 +246,7 @@ Inspects a Space's repository connection and shows the detected app, ranked cand
| Flag | Default | What it does |
| --- | --- | --- |
| `--space=` | linked Space | Space ID, live URL, domain, or slug. Env `SPACEFAST_SPACE` |
-| `-o, --team=` | default team | Team slug, ID, or name. Env `SPACEFAST_TEAM` |
+| `-T, --team=` | default team | Team slug, ID, or name. Env `SPACEFAST_TEAM` |
| `--claim-token=` | saved key | Space key (`sfc_…`) for an unclaimed anonymous Space. Env `SPACEFAST_CLAIM_TOKEN` |
| `--connection-type=connected\|hosted` | `connected` | Repository connection type |
| `--ref=` | default branch | Git ref to inspect |
diff --git a/content/cli/comments.mdx b/content/cli/comments.mdx
new file mode 100644
index 00000000..4fdabe95
--- /dev/null
+++ b/content/cli/comments.mdx
@@ -0,0 +1,513 @@
+---
+title: sf comments
+description: List, read, start, reply to, react to, resolve, and export comment threads, and set where Comments run
+sidebar:
+ order: 13
+---
+
+`sf comments` works with a Space's comment threads and its Comments settings. For what Comments do and who can comment, see [Comments](/comments).
+
+Every command here takes the global flags (`--api-url`, `--profile`, `--token`, `-y`/`--yes`, `--json`) and the space selection flags (`--space`, `-T`/`--team`, `--claim-token`). See [the CLI overview](/cli).
+
+`sf comments` with no subcommand prints the help for the group.
+
+Comments need a claimed Space that belongs to a team, with **Page Enhancements** on. `create`, `reply`, `react`, `unreact`, `read`, `resolve`, and `reopen` need a credential that acts as a person.
+
+## Common arguments and values
+
+| Value | What it is |
+| --- | --- |
+| `` | A thread ID, for example `cmt_123` |
+| `--version=` | A version ID (`ver_...`), a ref such as `v3`, or a number. `3` matches `v3`. A ref or a number must be in the Space's 100 newest versions, or the command fails with `validation_error` (exit 2). An unknown `ver_` ID fails with `not_found` (exit 20) |
+| `--status=` | `open` or `resolved` |
+
+The API names the resolved state `archived`. The CLI translates `resolved` to `archived` in requests, and `--json` output keeps `archived`.
+
+## sf comments list
+
+List comment threads across a Space. Aliased as `sf comments ls`. Listing never marks a thread read.
+
+```text
+sf comments list [--status open|resolved] [--unread] [--path ] [--version ]
+ [--author ] [--query ] [--limit ] [--space ]
+```
+
+### Flags
+
+| Flag | Default | What it does |
+| --- | --- | --- |
+| `--status=` | both | Only `open` or only `resolved` threads |
+| `--unread` | `false` | Only threads with comments you have not read |
+| `--path=` | none | Only threads on this page or file path, for example `/pricing` |
+| `--version=` | none | Only threads left on this version |
+| `--author=` | none | Only threads started by this author name |
+| `--query=` | none | Only threads whose text contains this |
+| `--limit=` | every thread | Maximum threads to return. Minimum 1 |
+
+Without `--limit`, the command reads every page of results.
+
+```bash
+sf comments list --unread
+sf comments list --status open --path /pricing
+sf comments list --version v3
+```
+
+### Output
+
+A table of `ID | STATUS | ON | REPLIES | COMMENT`. Open threads come first. An unread open thread has `●` in front of its ID. `ON` is the anchor: `page `, `element on `, or `file :`. `COMMENT` is the body on one line, cut to 59 characters and `…` when longer.
+
+With `--limit`, `More threads match. Raise --limit to see them.` follows the table when more threads match.
+
+With no threads: `No comments found.`, or `You're all caught up.` with `--unread`.
+
+With `--json`, the output is `{ data, pagination }`. `data` holds the threads in the [`listSpaceComments`](/api/reference/spaces/listspacecomments) shape. `pagination` is `{ nextCursor, hasMore }`.
+
+### Errors
+
+| Code | Exit | When |
+| --- | --- | --- |
+| `validation_error` | 2 | `--status` is not `open` or `resolved`, or `--limit` is below 1 |
+| `validation_error` | 2 | `--version` is a ref or a number that matches none of the Space's 100 newest versions |
+| `not_found` | 20 | `--version` is a `ver_` ID that does not exist in the Space |
+
+## sf comments inspect
+
+Show one thread with its replies and reactions. Reading a thread does not mark it read.
+
+```text
+sf comments inspect [--space ]
+```
+
+### Arguments
+
+| Argument | What it is |
+| --- | --- |
+| `` | Thread ID, for example `cmt_123`. Required |
+
+No command-specific flags.
+
+```bash
+sf comments inspect cmt_123
+```
+
+### Output
+
+```text
+cmt_123 open unread
+On: element #hero-title on /
+Author: Ana, 2026-10-12 17:03 UTC
+Page:
+Open:
+Reactions: 👍 2
+
+
+
+Milo replied 2026-10-12 17:20 UTC:
+
+```
+
+Lines without a value are left out. A thread with no author name shows `Started: