`. Spacefast runs the tool with the account's credentials on the server and answers the page with JSON.
+
+Connectors are in Private Beta. A team without access gets `feature_unavailable`.
+
+## Declare a role
+
+A role names one connector, the tools a page may call through it, and whose account answers. Put it under `connectors` in `spacefast.config.ts`:
+
+```ts spacefast.config.ts
+import { defineConfig } from "spacefast/config";
+
+export default defineConfig({
+ connectors: {
+ crm: {
+ connector: "hubspot",
+ tools: ["list_deals"],
+ args: { list_deals: { pipeline: "sales" } },
+ cache: "60s",
+ },
+ },
+});
+```
+
+Or in `sf.jsonc`, with the same shape:
+
+```jsonc sf.jsonc
+{
+ "$schema": "https://spacefast.com/schemas/sf.json",
+ "connectors": {
+ "crm": {
+ "connector": "hubspot",
+ "tools": ["list_deals"],
+ "args": { "list_deals": { "pipeline": "sales" } },
+ "cache": "60s"
+ }
+ }
+}
+```
+
+When both files exist, `spacefast.config.ts` wins. Publish to apply the roles: each version freezes the roles it was published with.
+
+The tool names and arguments on this page, such as `list_deals` and `stage`, are examples. Use the names your connection lists: [`listConnectionTools`](/api/reference/connectors/listconnectiontools) returns each tool with its input schema.
+
+| Key | Required | What it does |
+| --- | --- | --- |
+| `connector` | yes | The slug your team registered the connector under, such as `hubspot`. `sf connectors list` shows them |
+| `tools` | yes | A list of tool names, or `"*"` for every tool on the connection, including tools it gains later |
+| `as` | no | Whose account answers: `team` (the default), `personal`, or `visitor`. See [Who reads and who writes](#who-reads-and-who-writes) |
+| `args` | no | Argument values pinned per tool, applied on the server. See [Pinned arguments](#pinned-arguments) |
+| `cache` | no | How long viewers share one read: `"30s"` by default, up to a day (`"1440m"`). `"0"` turns sharing off. See [Shared reads](#shared-reads) |
+
+Role names are camelCase and start with a lowercase letter, up to 64 characters. A Space declares at most 32 roles, and a role lists at most 200 tools. Tool names use letters, digits, `_`, `.`, `:`, and `-`. `connect`, `callback`, and `disconnect` are reserved.
+
+## Bind an account
+
+A `team` or `personal` role needs an account bound to it. Add the connector to your team and connect an account from the team's **Connectors** page, or from the CLI:
+
+```bash
+sf connectors create hubspot --slug hubspot
+sf connectors connections connect hubspot
+```
+
+Publishing binds a role for you when exactly one eligible connection exists. The publish receipt lists the roles that still need one. Bind the rest yourself:
+
+```bash
+sf connectors bindings --space example
+sf connectors bind crm cxn_abc123 --space example
+```
+
+In the dashboard, a team owner or admin opens the Space's **Integrations** and chooses **Bind account**. Through the API, it's [`bindSpaceConnector`](/api/reference/spaces/bindspaceconnector), and [`listSpaceConnectorBindings`](/api/reference/spaces/listspaceconnectorbindings) shows each role's state.
+
+A `visitor` role binds nothing. Each viewer connects their own account.
+
+## Who reads and who writes
+
+| `as` | Whose account | Who reads through it | Who writes through it |
+| --- | --- | --- | --- |
+| `team` | A team connection with site serving on | Everyone who can open the Space | A signed-in member of the team or one of the Space's users |
+| `personal` | The connection of the person who bound it | Everyone who can open the Space, which only works while the Space is private | A signed-in member of the team or one of the Space's users |
+| `visitor` | Each viewer's own connection | That viewer | That viewer |
+
+The connectors route sits behind the Space's own access check, so the data is never more public than the pages. A private Space's data stays private. A share link or password admits anyone with the share link or the password, and they read too.
+
+That's why two roles have extra rules:
+
+- **`team` on a Space anyone can open.** When the Space is public, or shared by link or password, a `team` role serves only after a team owner or admin allows it: `sf connectors public-serving set crm --enable --space example`. Until then, calls answer `connection_forbidden`. The allowance covers the account bound when it was given; rebinding the role takes it away.
+- **`personal` needs a private Space.** A `personal` role works only while the Space is private and has no share link or password. Otherwise every call answers `connection_forbidden`.
+
+Writes need a signed-in viewer. An anonymous visitor of a public Space reads through a `team` role and never writes through it: the call answers `connector_write_sign_in_required`. Send viewers to sign in with `space.access.signIn()`. A `visitor` role asks the viewer to sign in first, then to connect their account.
+
+A team's connector policy still applies to every call. A tool it blocks is refused, and a call it holds for approval answers `approval_pending`.
+
+## Call it from a page
+
+Install the client:
+
+```bash
+npm install @spacefast/client
+```
+
+Then generate the typed module and call the role:
+
+```ts
+import { space } from "./spacefast.generated";
+
+const answer = await space.connectors.crm.list_deals({ stage: "won" });
+if (answer.state === "ok") render(answer.data);
+else showNotice(answer.message);
+```
+
+Each tool knows its HTTP method. A read-only tool reads with `GET`, and a tool that writes sends a `POST`. Pass `{ idempotencyKey }` as the last argument to make a retried write run once.
+
+To keep a read live, watch it. A watched read refreshes on an interval and when the page regains focus, and identical reads share one request. Only reads can be watched, since every refresh would run a write again.
+
+```ts
+const deals = space.connectors.crm.list_deals.watch({ stage: "won" }, { every: "60s" });
+const stop = deals.subscribe(() => render(deals.getSnapshot()));
+```
+
+`every` takes milliseconds or `"30s"`, `"5m"`, `"1h"`, never under one second.
+
+### React
+
+`useConnector` from `@spacefast/client/react` watches a read:
+
+```tsx
+import { useConnector } from "@spacefast/client/react";
+import { space } from "./spacefast.generated";
+
+export function Deals() {
+ const deals = useConnector(space.connectors.crm.list_deals, { stage: "won" }, { every: "60s" });
+ if (deals.state === "ok") return ;
+ return {deals.message ?? "Loading…"}
;
+}
+```
+
+It returns `data`, `state`, `message`, `connectUrl`, `approvalUrl`, `cachedAt`, `refreshing`, `error`, and `refresh`. `data` keeps the last good answer while a refresh runs or fails. Components that ask the same question share one request.
+
+### Pages without a build
+
+A plain HTML page imports the same client from the Space itself:
+
+```html
+
+```
+
+Without generated types, every tool reads with `GET`. Pass `{ method: "POST" }` to a tool that writes. The published version's types are at `/__spacefast/client.d.ts`.
+
+The route also answers plain `fetch`. Query parameters are string arguments (`?stage=won`), and `__args` takes a JSON object for typed values:
+
+```js
+const args = encodeURIComponent(JSON.stringify({ stage: "won", limit: 5 }));
+const response = await fetch(`/__spacefast/connectors/crm/list_deals?__args=${args}`);
+const { state, data } = await response.json();
+```
+
+A `POST` sends its arguments as a JSON body of at most 64 KiB. Other sites' pages can't call the route with a viewer's cookies. A browser read that says it came from elsewhere, through `Sec-Fetch-Site` or an `Origin` naming another host, answers `browser_origin_not_allowed`. A read that sends neither header passes that check but still goes through the Space's access check. A write always needs an `Origin` naming this host or `Sec-Fetch-Site: same-origin`, which browsers send for you. Every answer is `private, no-store`.
+
+## The typed client
+
+`sf client` writes `spacefast.generated.ts` next to your config file, typed from the connections behind your roles:
+
+```bash
+sf client
+```
+
+You rarely run it by hand. `sf dev` and `sf publish` regenerate the module before the build in any project that installs `@spacefast/client`. With roles declared, a publish whose regeneration fails stops rather than ship against stale types, and `sf dev` warns and keeps going. `--out` writes the module somewhere else.
+
+In the generated module, pinned arguments are left out of each tool's type. A tool name that isn't an identifier becomes a quoted key, such as `space.connectors.github["issues.list"]()`. A tool whose source publishes no output schema returns `unknown`.
+
+## What a page renders
+
+A call answers `{ state, data }`. When `state` is `ok`, `data` holds the tool's result. Any other state carries `data: null` and a `message` you can show as is.
+
+| State | What it means | What the page can do |
+| --- | --- | --- |
+| `ok` | `data` is the provider's payload. An HTTP-backed tool adds `http` with the upstream `status` and `headers`, and an answer served from a shared read adds `cachedAt` | Render it |
+| `unbound` | No account serves this role yet | Tell the viewer it isn't set up. The Space's owner binds an account |
+| `connect_required` | A `visitor` role needs this viewer's account | Link to `connectUrl`. See [Visitor accounts](#visitor-accounts) |
+| `reconnect_required` | The account's authorization expired | On a `visitor` role, link to `connectUrl`. On a `team` or `personal` role, the team reconnects the account; the answer still carries a `connectUrl`, but it only works for `visitor` roles, so don't show it |
+| `approval_pending` | The call is waiting for approval | Say so, and link to `approvalUrl` when it's there |
+| `running` | The call is still going | Try again shortly. A watched read retries by itself |
+
+Failures a page can't wait out come back as an RFC 9457 problem with a stable `code`, which the client throws as a `ConnectorError` (`status`, `code`, `message`). With `useConnector`, `state` is `error` and `error` holds it.
+
+| Code | Status | When |
+| --- | --- | --- |
+| [`connection_forbidden`](/errors/connection_forbidden) | 403 | The team's policy blocks the connector, a read tool, or a write on a `visitor` role; an approval was denied; the account doesn't serve sites; or the Space is too open for the role |
+| [`connector_role_unknown`](/errors/connector_role_unknown) | 404 | The live version doesn't declare this role |
+| `connector_tool_not_allowed` | 403 | The role's `tools` doesn't list this tool |
+| `connector_arg_pinned` | 422 | The call sent a different value for a pinned argument |
+| `connector_write_sign_in_required` | 403 | A write through a `team` or `personal` role from a viewer who isn't signed in as a member |
+| [`connector_tool_blocked`](/errors/connector_tool_blocked) | 403 | The team's connector policy blocks this write tool on a `team` or `personal` role |
+| [`connector_write_in_read_handler`](/errors/connector_write_in_read_handler) | 403 | A `GET` to a tool that writes. Send a `POST` |
+| [`browser_origin_not_allowed`](/errors/browser_origin_not_allowed) | 403 | The call came from another site, or a write without same-origin proof |
+| `connectors_unavailable` | 502 or 503 | The provider or Spacefast couldn't answer. Try again |
+
+### Visitor accounts
+
+A `visitor` role needs a connector with an OAuth sign-in method, since each viewer signs in to their own account. A connector that only takes API keys can't serve one: connecting answers `connector_oauth_required`. When the connector needs an OAuth app your team hasn't registered, it answers `connector_oauth_app_required`.
+
+A `visitor` role's `connectUrl` is `/__spacefast/connectors//connect`. It signs the viewer in if needed, then sends them to the provider and back. Add `?return_to=/some/path` to land somewhere other than `/`. A `POST` to `/__spacefast/connectors//disconnect` from the Space's own page forgets the viewer's account.
+
+[`listVisitorConnectionUsers`](/api/reference/spaces/listvisitorconnectionusers) counts which of the Space's users connected each role, and [`revokeVisitorConnection`](/api/reference/spaces/revokevisitorconnection) disconnects one user's account for a role.
+
+## Pinned arguments
+
+`args` fixes argument values per tool on the server:
+
+```jsonc sf.jsonc
+{
+ "connectors": {
+ "crm": {
+ "connector": "hubspot",
+ "tools": ["list_deals"],
+ "args": { "list_deals": { "pipeline": "sales" } }
+ }
+ }
+}
+```
+
+Every call to `list_deals` through `crm` runs with `pipeline: "sales"`, whatever the page sends. A call may repeat the pinned value. A different value is refused with `connector_arg_pinned`, and its `pointer` names the argument. Pin anything a viewer shouldn't choose, such as which pipeline, project, or account a public page shows.
+
+## Shared reads
+
+A `team` or `personal` role shows every viewer the same account, so every viewer asks the same question. Spacefast answers them together: one upstream read serves everyone for the role's `cache` window.
+
+- **The key** is the Space, role, tool, and the final arguments with pins applied. Argument order doesn't matter.
+- **One viewer fills a cold key.** Others arriving meanwhile wait for that read rather than starting their own.
+- **The budget** is 60 upstream reads per role per rolling minute, for each Space.
+- **Stale serving.** When the budget is spent, the last value keeps answering for up to an hour past its window. With no value to fall back on, the call answers `connectors_unavailable`.
+- **`cachedAt`** appears on an `ok` answer served from the shared value and says when it was fetched. The read that fetches it answers without one.
+
+Only successful reads are shared. `cache: "0"` turns sharing off, but each call still spends the budget. Writes, `visitor` roles, and tools the team's policy holds for approval are never shared: each call runs.
+
+## Serving a team account on Spaces
+
+A team connection reads for a Space's viewers only once site serving is on for it. Until then, the role's state is `needs_serving_grant` and calls answer `connection_forbidden`.
+
+If you may turn serving on, do it from the connection in the dashboard, or with `sf connectors connections update cxn_abc123 --serving`. If you can't, ask:
+
+```bash
+sf connectors serving create crm --space example
+```
+
+The answer is a pending request with a `csr_` id, and asking again returns the same one. When you're allowed to grant serving yourself, it turns on at once and the request comes back `approved`.
+
+Team owners and admins get a notification. They decide in the dashboard, or with:
+
+```bash
+sf connectors serving list
+sf connectors serving approve csr_abc123
+sf connectors serving deny csr_abc123
+```
+
+Approving turns serving on, and the role's state flips to ready. A team can keep serving decisions to owners and admins, and can limit which connectors may serve sites at all.
+
+The API operations are `requestSpaceConnectorServing`, `listConnectorServingRequests`, `approveConnectorServingRequest`, and `denyConnectorServingRequest`. Public serving for a `team` role is `setSpaceConnectorPublicServing`.
+
+## Change roles from the dashboard
+
+The Space's **Integrations** page lists every role. An override lives with the Space, not in your config file, and replaces that one role:
+
+- **Turn off** removes a role your config declares. It moves to **Removed roles**, where **Restore** brings it back.
+- **Use config file** drops an override and returns the role to what the file says.
+
+Saving re-applies the live version, so the change takes effect without a publish. The next publish keeps the overrides.
+
+Through the API, overrides are `config.connectors` on [`updateSpace`](/api/reference/spaces/updatespace). An entry replaces the file's role of the same name, and `null` removes it. `config` replaces the Space's whole stored overlay, so read it from [`getSpace`](/api/reference/spaces/getspace) first and send it back with your change, along with its `settingsDigest` as `baseSettingsDigest`.
+
+## Call it from Functions
+
+A [Functions](/functions) worker whose version declares roles gets `env.CONNECTORS`:
+
+```js functions/deals.js
+export async function GET(request, { env }) {
+ const deals = await env.CONNECTORS.crm.list_deals({ stage: "won" });
+ return Response.json(deals);
+}
+```
+
+The role proxy returns the tool's result, or throws a `ConnectorCallError` whose `state.code` says why, such as `connect_required` or `forbidden`. `env.CONNECTORS.call("crm", "list_deals", args)` returns `{ state, result }` instead of throwing. The worker forwards the request's signed-in viewer, so `visitor` roles and writes follow the same rules as a page. Calls from a worker are never shared reads.
+
+## Call it from outside the Space
+
+The page route is for the Space's own pages: it acts for the viewer, and other sites' pages can't use it. From anywhere else, such as a script, a CI job, or an agent, run the tool with your own API key through [`createConnectorRun`](/api/reference/connectors/createconnectorrun):
+
+```bash
+curl -X POST https://api.spacefast.com/v1/connectors/runs \
+ -H "Authorization: Bearer $SPACEFAST_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"input":{"kind":"tool","address":"cxn_abc123/list_deals","args":{"stage":"won"}},"waitMs":10000}'
+```
+
+The address is `/`, straight off [`listConnectionTools`](/api/reference/connectors/listconnectiontools). From the CLI, it's `sf connectors run --tool cxn_abc123/list_deals --args '{"stage":"won"}'`. A run answers to your credential and the team's policy, not to a role: no pins, no shared reads. Read the run's status and result before treating it as done.
+
+## First
+
+Pages in a [First](https://first-docs.space.fast/) app call roles with `@spacefast/client`, like any other page. Declare the roles in `spacefast.config.ts` or `sf.jsonc` as above. First's server code can't call connectors yet.
diff --git a/content/(dynamic)/functions.mdx b/content/(dynamic)/functions.mdx
index 8f96dcb0..5e6b5e35 100644
--- a/content/(dynamic)/functions.mdx
+++ b/content/(dynamic)/functions.mdx
@@ -130,6 +130,8 @@ Outbound requests from a worker leave from Cloudflare's shared network addresses
`env` carries the space's environment variables. See [Environment variables](/environment-variables) for how to set them.
+`env.CONNECTORS` calls your Space's [connector roles](/connectors#call-it-from-functions) when the version declares any.
+
`env.DB` is always there. It is D1-shaped, so `env.DB.prepare(sql).bind(...).all()` is the call you already know. It reaches the space's own database through a broker, so no connection string ever lands in the bundle.
Two groups of variable names never reach a worker, dropped rather than blanked so your code reads them as absent instead of as "configured, but empty".
diff --git a/content/(dynamic)/meta.ts b/content/(dynamic)/meta.ts
index d0c68f8e..92d186e2 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", "database", "environment-variables", "crons", "object-storage", "logs", "wordpress"],
+ pages: ["zero-runtime", "functions", "connectors", "database", "environment-variables", "crons", "object-storage", "logs", "wordpress"],
});
diff --git a/content/(reference)/config-file.mdx b/content/(reference)/config-file.mdx
index 1e3dbb22..b4687663 100644
--- a/content/(reference)/config-file.mdx
+++ b/content/(reference)/config-file.mdx
@@ -208,6 +208,14 @@ See [Functions](/functions).
`sf.jsonc` is the only writer. See [Crons](/crons) for the schedule grammar.
+## Connectors
+
+| Key | Type | Default | What it does |
+| --- | --- | --- | --- |
+| `connectors` | object, up to 32 roles | none | Connector roles your pages call at `/__spacefast/connectors//` |
+
+See [Connectors](/connectors) for each role's keys.
+
## Sharing
diff --git a/content/api/index.mdx b/content/api/index.mdx
index 2519d45b..346296f2 100644
--- a/content/api/index.mdx
+++ b/content/api/index.mdx
@@ -165,4 +165,4 @@ Stop when `data.status` is `succeeded`, `failed`, or `canceled`. Then read `spac
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.
+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).