---
raw: true
title: Connectors
description: Show live data from your team's tools on any Space page. Declare a role in your config, call it from the page, and the account's credentials never reach the browser.
sidebar:
  badge: Private Beta
---

Private Beta

Connectors put live data from your team's tools on any page of a Space, with no backend to write and no keys in the page. You declare a role in your config, bind an account to it, and the page calls `/__spacefast/connectors/<role>/<tool>`. 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`](/docs/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`](/docs/api/reference/spaces/bindspaceconnector/), and [`listSpaceConnectorBindings`](/docs/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 <DealList deals={deals.data} />;
  return <p>{deals.message ?? "Loading…"}</p>;
}
```

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
<script type="module">
  import { space } from "/__spacefast/client.js";

  const { state, data, message } = await space.connectors.crm.list_deals({ stage: "won" });
  document.querySelector("#deals").textContent = state === "ok" ? JSON.stringify(data) : message;
</script>
```

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`](/docs/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`](/docs/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`](/docs/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`](/docs/errors/connector_write_in_read_handler/) | 403 | A `GET` to a tool that writes. Send a `POST` |
| [`browser_origin_not_allowed`](/docs/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/<role>/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/<role>/disconnect` from the Space's own page forgets the viewer's account.

[`listVisitorConnectionUsers`](/docs/api/reference/spaces/listvisitorconnectionusers/) counts which of the Space's users connected each role, and [`revokeVisitorConnection`](/docs/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`](/docs/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`](/docs/api/reference/spaces/getspace/) first and send it back with your change, along with its `settingsDigest` as `baseSettingsDigest`.

## Call it from Functions

A [Functions](/docs/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`](/docs/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 `<connectionId>/<tool>`, straight off [`listConnectionTools`](/docs/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.
