Connectors
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.
Private BetaConnectors 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:
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:
{
"$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 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 |
args |
no | Argument values pinned per tool, applied on the server. See 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 |
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:
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:
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, and 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:
teamon a Space anyone can open. When the Space is public, or shared by link or password, ateamrole serves only after a team owner or admin allows it:sf connectors public-serving set crm --enable --space example. Until then, calls answerconnection_forbidden. The allowance covers the account bound when it was given; rebinding the role takes it away.personalneeds a private Space. Apersonalrole works only while the Space is private and has no share link or password. Otherwise every call answersconnection_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:
npm install @spacefast/client
Then generate the typed module and call the role:
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.
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:
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:
<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:
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:
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 |
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 |
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 |
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 |
403 | The team’s connector policy blocks this write tool on a team or personal role |
connector_write_in_read_handler |
403 | A GET to a tool that writes. Send a POST |
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 counts which of the Space’s users connected each role, and revokeVisitorConnection disconnects one user’s account for a role.
Pinned arguments
args fixes argument values per tool on the server:
{
"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. cachedAtappears on anokanswer 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:
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:
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. 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 first and send it back with your change, along with its settingsDigest as baseSettingsDigest.
Call it from Functions
A Functions worker whose version declares roles gets env.CONNECTORS:
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:
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. 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 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.