@xyne/spaces-sdk
v0.1.1
Published
TypeScript SDK for the Xyne Spaces API
Readme
@xyne/spaces-sdk
TypeScript SDK for Xyne Spaces. 488 methods across 26 resources — every read and write the Spaces product itself performs — plus Xyne Claw remote agents.
Zero runtime dependencies. Runs in Node 18+ and in the browser.
import { createClient } from '@xyne/spaces-sdk';
const sdk = createClient({
baseUrl: 'https://spaces.example.com',
});
const me = await sdk.users.me();
const channels = await sdk.channels.list();
const { id: channelId } = await sdk.channels.create({
scopeType: 'DEFAULT',
projectId: 'project-1',
name: 'Deployments',
});
const { conversationId } = await sdk.conversations.create({
channelId,
content: 'Deploy is green.',
});
await sdk.messages.send({ conversationId, content: 'Ship it.' });Install
pnpm add @xyne/spaces-sdkNode 18+ or any modern browser. No runtime dependencies — nothing is pulled into your bundle, and there are no transitive supply-chain surprises.
Authentication
The SDK authenticates with your existing Spaces session. Every request is sent
with credentials: 'include', so the session cookie travels automatically and
there is no option to set:
const sdk = createClient({
baseUrl: 'https://spaces.example.com',
});This is why the browser is the SDK's native home: it is the context that holds the session. A headless process has no cookie jar, and must supply the session token itself:
const sdk = createClient({ baseUrl, apiKey: token });
// or later: sdk.setApiKey(token)apiKey is sent as Authorization: Bearer <token>.
This means the SDK acts as the currently logged-in user with exactly their permissions. Access is decided by the same permission rules the Spaces app uses.
Xyne SSO
Xyne SSO signs a user in to Spaces from your app. The user approves the
request once in the browser, and Spaces issues that user's session cookie,
xyne_ws_<workspaceId>_token, the same cookie the Spaces web app uses.
import { createClient, xyneSsoLoginAndWait } from '@xyne/spaces-sdk';
const baseUrl = 'https://spaces.example.com'; // your Spaces deployment
const session = await xyneSsoLoginAndWait({ baseUrl, openBrowser: true });
// {
// cookie: { name: 'xyne_ws_<workspaceId>_token', value: '<jwt>' },
// expiresAt: 1759152000000, // epoch ms
// userId: '<user id>',
// workspaceId: '<workspace id>',
// }
const sdk = createClient({ baseUrl, session }); // sent as a Cookie header on every request
const me = await sdk.users.me(); // me.id === session.userIdHow it works
- The SDK asks Spaces for a sign-in request and gets back a link and a short
code such as
BCDF-GHJK. - The user opens the link, signs in to Spaces if needed, checks the page shows the same code as their terminal, and clicks Approve.
- The SDK polls every 2 seconds. When the user approves, it returns the session cookie, its expiry, the user id and the workspace id. Spaces does not set any cookie in the process; what to do with the session is up to you.
The link is valid for 5 minutes. The session acts as the user who approved it, in the workspace they were signed in to, with their current role.
xyneSsoLoginAndWait(options)
Starts sign-in and resolves once the user approves.
| Option | Default | Description |
|---|---|---|
| baseUrl | — | Your Spaces deployment. Required. Use the same value you pass to createClient. |
| openBrowser | false | Open the approval link in the default browser (Node.js only). |
| onUserCode | prints the link and code | (userCode, verificationUrl, verificationUrlComplete) => void. Use it to show the link yourself. Open verificationUrlComplete: it already contains the code. Always show userCode too — see below. |
| timeoutMs | 300000 | How long to wait for approval. |
| pollIntervalMs | 2000 | Delay between polls. The server's own interval is used if it is longer. |
Returns an SsoSession:
| Field | Type | Description |
|---|---|---|
| cookie.name | string | xyne_ws_<workspaceId>_token |
| cookie.value | string | The session token. |
| expiresAt | number | When the cookie expires, in epoch milliseconds. |
| userId | string | The signed-in user. |
| workspaceId | string | The workspace the session is scoped to. |
const session = await xyneSsoLoginAndWait({
baseUrl: 'https://spaces.example.com',
onUserCode: (code, _url, link) => console.log(`Approve at ${link} — check it shows ${code}`),
});Driving the steps yourself
Use xyneSsoLogin and xyneSsoPoll when you need control over how the link is
shown or how polling runs, for example in a UI.
import { xyneSsoLogin, xyneSsoPoll } from '@xyne/spaces-sdk';
const init = await xyneSsoLogin({ baseUrl });
// init: { deviceCode, userCode, verificationUrl, verificationUrlComplete, expiresIn, interval }
showLink(init.verificationUrlComplete);
let result;
do {
await new Promise((r) => setTimeout(r, init.interval * 1000));
result = await xyneSsoPoll(init.deviceCode, baseUrl);
} while (result.status === 'pending');
if (result.status !== 'approved') throw new Error(`Sign-in ${result.status}`);
const { cookie, expiresAt, userId, workspaceId } = result.session;xyneSsoPoll returns { status: 'pending' | 'approved' | 'denied' | 'expired', session? },
where session is the SsoSession above and is present only when approved.
The session is handed out once: after approved, later polls for the same
deviceCode return expired.
Keep deviceCode private. Anyone holding it can collect the session once the
user approves.
Why the code matters
Anyone can start a sign-in and send the approval link to someone else; if that
person approves, the sender gets their session. The defence is the code: the
approval page shows it large, next to where the request came from, and asks the
user to confirm it matches their terminal before Approve is enabled. So
always show userCode wherever you show the link.
Using the cookie
Pass the session to createClient, and it is sent as a Cookie header on
every request:
const session = await xyneSsoLoginAndWait({ baseUrl, openBrowser: true });
const sdk = createClient({ baseUrl, session });
// later, after signing in again:
sdk.setSession(await xyneSsoLoginAndWait({ baseUrl }));The header carries the token cookie and xyne_last_workspace, which tells
Spaces which workspace's cookie to read. sdk.clearSession() removes it and
sdk.hasSession() says whether one is set.
A client authenticates with either a session or an API key, not both:
createClient throws if given both, and setSession / setApiKey each
replace the other.
This is for Node.js and other non-browser runtimes. Browsers do not let code
set a Cookie header; a page on the Spaces origin is already signed in, and
createClient({ baseUrl: location.origin }) with no credentials uses that session.
Errors
xyneSsoLoginAndWait throws SsoAuthError, whose code is one of:
| code | Meaning |
|---|---|
| denied | The user clicked Deny. |
| expired | The 5-minute link expired before approval. |
| timeout | timeoutMs passed without a decision. |
| network_error | Spaces could not be reached or returned an unexpected response. |
import { SsoAuthError } from '@xyne/spaces-sdk';
try {
await xyneSsoLoginAndWait({ baseUrl });
} catch (err) {
if (err instanceof SsoAuthError && err.code === 'denied') process.exit(1);
throw err;
}Session lifetime
The session lasts as long as a Spaces login does on that deployment (24 hours
by default); expiresAt says exactly when. It cannot be refreshed: when it
expires, API calls fail with AuthError; run xyneSsoLoginAndWait({ baseUrl }) again.
Check expiresAt before reusing a stored session so you only ask the user to
approve when it has run out.
Treat the cookie value like a password: it grants everything the user can do
and stays valid until expiresAt, even if the user signs out of Spaces.
Identity
const me = await sdk.users.me();
// { id, email, name, displayName, workspaceId, orgId, memberId, role, orgRole }This is a request, not a local decode: role and orgRole are read from the
database on every server-side request rather than carried in the credential, so
this call is the only way to get a full picture.
A few operations take the acting user's id as an argument rather than inferring
it. Pass me.id:
await sdk.dashboards.upsert({ name: 'Ops', createdBy: me.id });Resources
| Resource | Methods | | Resource | Methods |
|---|---|---|---|---|
| sdk.tickets | 46 | | sdk.userGroups | 20 |
| sdk.channels | 41 | | sdk.preferences | 19 |
| sdk.canvases | 40 | | sdk.collections | 15 |
| sdk.messages | 35 | | sdk.forms | 15 |
| sdk.admin | 31 | | sdk.activities | 14 |
| sdk.email | 26 | | sdk.automations | 13 |
| sdk.calls | 25 | | sdk.projects | 13 |
| sdk.boards | 24 | | sdk.recaps | 10 |
| sdk.incidents | 24 | | sdk.dashboards | 8 |
| sdk.workspace | 24 | | sdk.supportTickets | 6 |
| sdk.conversations | 21 | | sdk.users | 5 |
| sdk.claw | 4 | | sdk.search | 2 |
| sdk.attachments | 2 | | sdk.connectors | 5 |
Every method carries a description, a documented parameter list, an example, and
a concrete return type — no method returns unknown. Hover any of them in your
IDE.
Working with it
Things that are easy to get wrong, gathered here so you don't have to discover them.
Channels contain threads, threads contain messages.
sdk.conversations.create starts a thread; sdk.messages.send replies into one.
Reaching for messages.send to start a conversation is the most common early
mistake.
Ids come back from creates. You never construct them:
const { conversationId, messageId } = await sdk.conversations.create({ ... });
const { id: channelId } = await sdk.channels.create({ ... });
const { id: ticketId } = await sdk.tickets.create({ ... });You also never see the participant ids, mapping ids, or timestamps the underlying operations require — the SDK generates them.
File bytes use a different transport, transparently. Pass browser File
objects directly, or { file: blob, filename: 'report.pdf' } in Node:
const uploaded = await sdk.attachments.uploadDraft({ channelId, files: [reportFile] });
await sdk.messages.send({
conversationId,
content: 'Report attached.',
attachmentIds: uploaded.uploadedAttachments
.filter((item) => item.success)
.map((item) => item.attachmentId),
});Some updates replace rather than patch. These take the complete collection and delete anything you leave out — read the current set first:
sdk.boards.update({ stages })sdk.boards.updateFlowPlan({ nodes })sdk.boards.syncTransitions()sdk.forms.update({ fields })sdk.recaps.saveSubscriptions()
Some operations toggle rather than set. channels.toggleStarred,
conversations.togglePin, and canvases.toggleStarred flip the current value.
Read state first if you need a specific outcome.
Seven methods return a Page<T>, not an array. messages.listByConversation,
messages.listByChannel, channels.listBrowsable, tickets.listByProject,
tickets.listActivities, users.list, and users.listBasic sit on operations
that have no server-side cursor — the operation returns every matching row in one
response. Rather than hand back an unbounded array, those methods window it:
const page = await sdk.messages.listByConversation(conversationId);
page.items; // the rows — at most 100
page.total; // how many the underlying result held
page.hasMore; // whether anything sits beyond this page
page.nextOffset; // pass as `offset` to get the next one
const next = await sdk.messages.listByConversation(conversationId, {
offset: page.nextOffset,
});limit defaults to 100 and 100 is a hard cap — a larger value is clamped, not
rejected, since it is a request for how much to return rather than a claim about the
data. DEFAULT_LIMIT and MAX_LIMIT are exported; prefer them over a literal 100.
Two things to know before you build on this. The windowing is client-side, so it
does not make the request cheaper — the full result still crosses the wire each call,
and looping to collect everything re-fetches it every time. And every other list
method returns a plain array; this is exactly these seven, not a general convention.
Where a real server-side cursor exists — tickets.list, messages.listByUser,
activities.listPaginated — prefer it.
Search filters are plural; result types are singular. SearchOptions.type
takes 'messages', 'tickets', …; SearchResult.type returns 'message',
'ticket', …. Feeding a result type back as a filter fails with
validation_failed. Both are literal unions, so TypeScript catches it — but the
vocabularies genuinely differ, so translate deliberately.
For "the latest N", use orderBy: 'newest' — the default is relevance, which
cannot be paged through time reliably.
Reading someone's history: use messages.listByUser, not search. Search ranks
by relevance and has a practical offset ceiling, so a thin page cannot be told
apart from a truncated one. listByUser orders by createdAt and cursors
cleanly:
let cursor: MessageCursor | undefined;
for (;;) {
const page = await sdk.messages.listByUser({ userId, limit: 100, start: cursor });
const last = page[page.length - 1];
if (page.length < 100 || !last) break;
cursor = { messageId: last.messageId, createdAt: last.createdAt };
}Support tickets are read-only here. sdk.supportTickets is the desk view of
the same rows sdk.tickets writes — reassigning or restaging goes through
sdk.tickets.
Collaborative canvases. When a canvas has isCollaborative set, a realtime
server owns its content and canvases.update is not a safe read-modify-write.
Save a version first.
conversations.getMyParticipation returns your own row, not every
participant — the underlying operation is scoped to the caller. There is no
all-participants operation for threads. (sdk.channels.listParticipants is a real
list, for channels.)
Claw: remote agents
sdk.claw dispatches tasks to Xyne Claw agents. It is relayed through Spaces, so
it needs no separate credential — your API key is the only one involved.
const agents = await sdk.claw.listAgents();
const run = await sdk.claw.runAndWait({
agent: 'ask-ai',
task: 'Summarise what happened in #deployments today',
timeoutMs: 120_000,
});
console.log(run.status, run.result);Or dispatch and poll yourself:
const { sessionId } = await sdk.claw.run({ agent: 'ask-ai', task: '…' });
const run = await sdk.claw.getRun(sessionId);Passing channelId makes the agent post its reply into that Spaces thread as
well as returning it — the one place the two systems meet:
await sdk.claw.run({ agent: 'ask-ai', task: 'Draft a status update', channelId });runAndWait backs off gently and gives up after timeoutMs (default 5 minutes).
A timeout stops the waiting, not the run — the error names the sessionId so
you can keep polling with getRun.
Connectors: external data through your connections
sdk.connectors lets an app read from external services such as Pulse, GitHub or
Grafana through the viewer's own connection. Spaces relays each call to Xyne
Claw, which loads the viewer's credential and runs the tool server-side.
- No secrets in app code. The token never leaves the server. The app names a connector and a tool, and gets back the tool's output.
- Runs as the viewer. Two people opening the same app each see their own data. It never runs as the app's author.
- Read-only. Write tools show up in
listToolswithwrite: true, butcallrefuses them with aforbiddenerror.
import { ConnectorNotConnectedError } from '@xyne/spaces-sdk';
type Series = { points: { ts: string; value: number }[] };
async function loadLatency(): Promise<Series | null> {
try {
return await sdk.connectors.callJson<Series>('pulse', 'query_metrics', {
service: 'api',
metric: 'p95_latency',
window: '1h',
});
} catch (err) {
if (err instanceof ConnectorNotConnectedError) {
// The viewer hasn't connected Pulse yet. Offer to connect, then retry.
const result = await sdk.connectors.connect(err.connector);
if (result.kind === 'oauth') window.open(result.authUrl, '_blank');
else if (result.settingsUrl) window.open(result.settingsUrl, '_blank');
return null;
}
throw err;
}
}To check up front instead of waiting for the error:
const connectors = await sdk.connectors.list();
const pulse = connectors.find((c) => c.type === 'pulse');
// pulse.connected: whether the viewer can call it now
// pulse.source: 'personal' | 'org' | null
const tools = await sdk.connectors.listTools('pulse'); // names, inputSchema, write flagcall returns the tool's raw text. callJson<T> parses it, and throws
SdkError with code: 'api_error' and the first 200 characters when the text
isn't JSON. The T is your assertion, and nothing checks it.
connect returns { kind: 'oauth', authUrl } for an OAuth connector. The viewer
comes back to returnTo, which defaults to the page that made the request. For
a connector that uses entered credentials, it returns
{ kind: 'manual', settingsUrl } instead, and the viewer enters them in Spaces
settings. An app never collects them.
Errors
The API speaks one code per status, so the class you catch already tells you what happened:
import {
AuthError,
ConnectorNotConnectedError,
NotFoundError,
SdkError,
} from '@xyne/spaces-sdk';
try {
await sdk.tickets.update(ticketId, { statusV2: 'COMPLETED' });
} catch (err) {
if (err instanceof AuthError) {
// 401. Missing, expired, or revoked — the server does not distinguish.
// Mint a new key; retrying will not help.
} else if (err instanceof ConnectorNotConnectedError) {
// 409. Only from sdk.connectors: the viewer has no connection to
// err.connector. Offer sdk.connectors.connect(err.connector).
} else if (err instanceof NotFoundError) {
// 404. Gone, or not visible to this key. The API is not an existence oracle.
} else if (err instanceof SdkError && err.code === 'validation_error') {
// 400. Bad arguments, or a business rule refused it.
// err.message is written for a person — show it.
} else if (err instanceof SdkError && err.code === 'forbidden') {
// 403. The key's user cannot reach this. From sdk.connectors, a write tool:
// err.details is { connector, tool, reason: 'write_tool' }.
} else if (err instanceof SdkError && err.code === 'api_error') {
// 500. Logged server-side against the request id; the message is generic.
}
}| Status | Class / code | serverCode |
|---|---|---|
| 400 | SdkError, code: 'validation_error' | validation_failed |
| 401 | AuthError | unauthenticated |
| 403 | SdkError, code: 'forbidden' | forbidden |
| 404 | NotFoundError | not_found |
| 409 | ConnectorNotConnectedError, code: 'not_connected' | not_connected |
| 500 | SdkError, code: 'api_error' | internal |
serverCode carries the API's own vocabulary. It is absent for failures that
never reached the server, where code is network_error or timeout — which is
the main thing it is still useful for telling apart. When the API sends
structured context with an error, it is on details. For example, a refused
connector write carries { connector, tool, reason: 'write_tool' }.
400 is the one whose message matters. A business rule that refuses a write ("Ticket not found", "You are not a participant of this channel") arrives as a 400 with that text intact, because a caller can act on it. 5xx messages are replaced server-side with a generic string, so nothing there is worth showing.
RateLimitError is still exported but nothing throws it: this API has no rate
limiter yet. Do not build a retry strategy around it.
Every response carries an X-Request-Id, echoed into error bodies — quote it in
support requests.
Versioning
Each release of this package targets one version of the Spaces API
(/api/sdk/v1), fixed in the package rather than configurable. Upgrading to a
new API version means upgrading the package, so an installed client keeps
working when the server gains a newer version.
