npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-sdk

Node 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.userId

How it works

  1. The SDK asks Spaces for a sign-in request and gets back a link and a short code such as BCDF-GHJK.
  2. The user opens the link, signs in to Spaces if needed, checks the page shows the same code as their terminal, and clicks Approve.
  3. 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 listTools with write: true, but call refuses them with a forbidden error.
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 flag

call 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.