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

@wiseteam/workspace-sdk

v1.4.0

Published

Client SDK for the WiseTeam Workspace external API v1: rooms, messages, attachments, runtime surfaces and live events.

Readme

@wiseteam/workspace-sdk

Client SDK for the WiseTeam Workspace external API v1. It lets a host application (for example, Inspark) render its own chat UI on top of WiseTeam rooms: list rooms, send messages, upload files, show runtime surfaces (forms, tables, charts) and receive live events over one WebSocket.

  • No runtime dependencies. Works in browsers and in Node 18+ (pass a WebSocket implementation in Node).
  • Every type is bundled: WorkspaceRoom, WorkspaceMessage, WorkspaceEvent, RuntimeSurface, RuntimeBlock and the rest are exported from the package.
  • The HTTP contract is described in openapi.json (OpenAPI 3.1), shipped with the package: import spec from "@wiseteam/workspace-sdk/openapi.json".

Install

npm install @wiseteam/workspace-sdk

Connect

Your backend signs a user JWT with the project signing secret (aud = the project id, sub = your user id, short exp). The SDK sends it as Authorization: Bearer <token> and asks you for a new one before it expires.

import { connect, WorkspaceError } from "@wiseteam/workspace-sdk";

const workspace = connect({
  baseUrl: "https://team.wiseorg.io",
  projectId: "YOUR_PROJECT_ID",
  token: await fetchTokenFromYourBackend(),
  // Called ~60 s before `exp` and whenever the server answers `token_expired`
  // (the failed request is then retried once with the new token).
  onTokenExpiring: () => fetchTokenFromYourBackend(),
});

Options:

| Option | Default | Meaning | | --- | --- | --- | | baseUrl, projectId, token | required | Where and as whom to connect. | | onTokenExpiring | none | Returns a fresh JWT (sync or async). | | refreshLeewaySec | 60 | How early to refresh before exp. | | fetch | global fetch | Custom fetch. | | WebSocket | global WebSocket | Required in Node (for example ws). | | XMLHttpRequest | global | Used for upload progress; null forces fetch. | | reconnect | { minDelayMs: 500, maxDelayMs: 30000 } | Socket backoff. |

Host capabilities

Read the project configuration before rendering a room. It declares the exact semantic blocks and host commands configured for this host; each block carries its schema version. Do not infer support from the platform name.

const config = await workspace.config.get();

for (const block of config.capabilities.runtimeBlocks) {
  console.log(block.type, block.schemaVersion);
}

if (config.capabilities.hostCommands.includes("inspark.route.open")) {
  enableRouteActions();
}

config.responsePolicy.surfaceFirst; // project-level semantic preference
config.responsePolicy.semanticGuidance; // domain terms and block-choice rules

surfaceFirst only affects when employees choose a surface. Rendering remains entirely host-owned: the runtime contract contains data and actions, never HTML, CSS, JavaScript, fonts, colors, or layout instructions.

semanticGuidance is the same project-owned text given to employees. Use it for domain terminology and rules such as “a blocker uses a danger callout” or “requirements use a table”. The supported host commands still come only from capabilities.hostCommands, so the two sources cannot drift.

Example: a new conversation

Rooms are personal to the user in the JWT. A new room starts as a local draft: nothing is sent to the server until the first message.

const draft = workspace.rooms.create();

// Context can be attached to the draft; it travels with the first message.
await workspace.rooms.setContext(draft.draftId, {
  type: "client",
  id: "client-42",
  title: "ACME Ltd",
  data: { segment: "enterprise" },
});

const fileId = (await workspace.attachments.upload(file, {
  onProgress: ({ loaded, total }) => console.log(loaded, total),
})).attachmentId;

const { room, message } = await workspace.messages.send(
  { draftId: draft.draftId, text: "Prepare a summary", attachmentIds: [fileId] },
  { idempotencyKey: crypto.randomUUID() }, // reuse the same key when retrying
);
// The draft is now `room`. Passing the old draftId again is safe: the SDK maps it to room.id.

Other calls:

const { rooms, nextCursor } = await workspace.rooms.list({ limit: 20 });
const { messages } = await workspace.messages.list(room.id); // also marks the room as "open"
await workspace.rooms.rename(room.id, "Q3 summary");
await workspace.rooms.markRead(room.id, message.id);
await workspace.rooms.setContext(room.id, { type: "page", id: "/reports", title: "Reports" });
await workspace.rooms.close(room.id);
workspace.messages.release(room.id); // stop keeping this room's history in memory

// Stop button: stop the employee's running turn. The room and its context stay,
// the next message starts a new turn. `cancelled: false` - the turn had already
// finished (not an error). Clients also get `turn.completed` and `team.status: idle`.
const { cancelled } = await workspace.rooms.cancelTurn(room.id);

const surfaces = await workspace.surfaces.list(room.id);
await workspace.surfaces.dispatchAction(room.id, surfaces[0].id, {
  type: "submit_form",
  actionId: "pending-id-from-the-form",
  result: { confirmationId: "minted-by-your-backend" },
});

Every method accepts { signal } (an AbortSignal).

Events

const unsubscribe = workspace.events.subscribe((event) => {
  switch (event.type) {
    case "room.created":
    case "room.updated":
      upsertRoom(event.room);
      break;
    case "message.started":
      addMessage(event.message); // status "streaming"
      break;
    case "message.delta":
      appendText(event.roomId, event.messageId, event.delta);
      break;
    case "message.completed":
      replaceMessage(event.message);
      break;
    case "surface.upserted":
      showSurface(event.roomId, event.surface);
      break;
    case "surface.closed":
      hideSurface(event.roomId, event.surfaceId);
      break;
    case "team.status":
    case "turn.completed":
    case "notification.created":
      break;
    case "resync":
      // The SDK has re-read the state; re-render from workspace.getState().
      render(workspace.getState());
      break;
    case "error":
      console.warn(event.code, event.message);
      break;
  }
});
  • The first subscription opens one WebSocket; the last unsubscribe() closes it. The SDK asks POST .../events/ticket for a one-time 30-second ticket and passes it only in Sec-WebSocket-Protocol (wiseteam.v1, ticket.<value>), never in the URL.
  • Dropped connections are re-established with exponential backoff and jitter.
  • Unknown event types (from a newer server) are ignored.
  • room.created is delivered once per room. If the room already came back in the messages.send response, the socket event is delivered as room.updated.
  • workspace.getState() returns the SDK's view: rooms, drafts, history of open rooms, active surfaces and team status. You can render from it or from your own store.

Resync

The socket does not replay history: history is the source of truth, the socket only fills the gaps between reads. The SDK resyncs when the server sends resync, after every reconnect, and on its own when it detects lost frames:

  1. rooms.list() replaces the room list completely.
  2. For every open room (one you called messages.list for) it drops messages still in streaming (their partial text cannot be restored), reads the tail after the last finished message and replaces the room's surfaces.
  3. Duplicates are removed by message.id, surface.id and room.id.
  4. Team status is reconciled with the new room list. A room whose status is not working cannot have a running team, so a stale working/warming (left behind when the final team.status: idle frame was lost) becomes { state: "idle", responders: [] } and is emitted as a team.status event. A room that is still working keeps its last status and responders. A room missing from the list loses its entry. A team.status that arrives while the resync is in flight is newer than the snapshot and is never overwritten. turn.completed alone does not change team status.
  5. It then emits { type: "resync" } so you can re-render.

Frames carry a per-room sequence and a streamEpoch:

  • a new streamEpoch resets all cursors and triggers a resync;
  • a sequence not greater than the last one seen in the same epoch triggers a resync (a repeated message.delta is not applied, so text is never doubled);
  • a gap in sequence triggers a resync.

Concurrent reasons are merged into one pass. A failed resync emits an error event and is retried with backoff. You can also call workspace.events.resync() yourself.

Surfaces: presentation

presentation is a layout hint, not a deletion.

  • A new version of the same scene is a re-upsert with the same id. It replaces the previous snapshot whatever presentation says.
  • replace (default): the surface comes to the foreground and supersedes surfaces shown earlier from the same source (source.kind and source.id). Superseded surfaces are not closed. They stay in surfaces.list, and you may keep them reachable (tabs, history).
  • stack: show it next to what is already visible.
  • A surface from one source never dismisses a surface from another source. A surface leaves the room only through surface.closed.

To remove a surface for good (not just hide it locally), call workspace.surfaces.close(roomId, surfaceId). The server closes it, emits surface.closed to every client of the room, drops it from surfaces.list, and tells the employee who created it not to publish the same surface again.

Questions belong in the composer

surfaceQuestion(surface) returns a framework-neutral ComposerQuestion or null for a non-interactive surface. Render a question instead of the normal composer, in document flow, not as a timeline card or an overlay. Use the host's design system. Do not also render its original surface as a second card. It supports a legacy callout plus form, a single confirmation button, multiple choices and text-only questions; there is no two-choice minimum.

import { surfaceQuestion } from "@wiseteam/workspace-sdk";

const question = surfaceQuestion(surface);
// Show question.prompt, descriptions, context, fields and choices.
// Choices are unnumbered; highlight selection and keep a custom text answer.
// For the custom answer (including a refusal or correction):
await workspace.surfaces.dispatchAction(roomId, surface.id, {
  type: "send_message", text: writtenAnswer,
});

The server routes this answer to the question's author and closes the question only after accepting it. Keep it open with an actionable error if submission fails. Never synthesize confirmationId from a written answer.

Choice labels and descriptions

Both fields[].options[] (for kind: "radio" or "select") and block actions[] accept an optional description: string of up to 1000 characters. Keep label short; use description for helpful plain-text context, not a duplicate label. Do not append explanations to labels with a dash. There is no wire-level single_choice field kind: use radio or select for single selection.

// A radio/select option:
const option = { value: "draft", label: "Keep draft", description: "Continue editing before publication." };
// A reply button:
const replyButton = { label: "Continue", description: "Use the existing draft.",
  action: { type: "send_message", text: "Keep the existing draft" } };

surfaceQuestion preserves option descriptions in question.fields[].options[] and button descriptions in question.choices[]. They are separate from the form-level question.descriptions array. Render a non-empty explicit description in preference to any temporary legacy label-splitting fallback. The SDK does not split labels on dashes or append descriptions to submitted values/action text. Old choices without descriptions remain valid; descriptions are optional and the protocol stays v1. Existing stored data is not rewritten.

Confirmations and final replies

For submit_form with requiresConfirmation !== false, first use the host's explicit confirmation flow and then dispatch result.confirmationId. The receipt is permission, not proof that the mutation completed. Ordinary data-entry forms must declare requiresConfirmation: false; do not interpret a failed or missing host confirmation as an ordinary data-entry form.

Questions have no automatic expiry. An optional action expiresAt belongs to the host's authorization; null or absent means the authorization does not expire. An expired action returns confirmation_expired without deleting the question. Request a fresh authorization through the host; do not extend dates locally. A missing receipt returns confirmation_required. Both errors preserve the question and must not be displayed as a busy turn.

External topics publish and store one final answer per employee responseMessageId. While the employee works, render team.status; the message.started / message.delta / message.completed sequence is emitted only once the final answer completes. Text before tools stays in the privileged trace, not the timeline. An interrupted stream does not publish partial text as a completed answer. Independent employees/turns are never merged.

Compatibility and rollout

The optional interaction metadata and action fields are additive to v1; old consumers still parse the original blocks. They will not automatically acquire the new composer UI: adopt this helper (or equivalent semantics) in the host renderer. Deploy/validate that renderer before enabling question-only employee responses. Existing generic data forms must opt out of mutation confirmation explicitly. Existing confirmations must supply a genuine host receipt; omission is now rejected rather than silently accepted. Publish the updated SDK as a new version before asking hosts to import the helper; this PR does not publish npm.

Who answers

In a room with several employees, a message without mentions is taken by one lead employee: the author of the scene that is open, otherwise the one who answered last. Pass mentions to address someone explicitly. A mentioned employee who is allowed for this user but is not in the room yet joins the room and answers; in a new room the mention picks who answers. Mentioning an employee the user is not allowed to reach does nothing. A button pressed on an employee's form is delivered to that employee.

Context usage

Each employee in a room has its own provider session, so an agent participant may carry contextUsage: how full that employee's context was at the end of its last completed turn in this room.

const room = await workspace.rooms.get(roomId);
for (const p of room.participants) {
  if (p.kind !== "agent" || !p.contextUsage) continue; // no data: draw nothing
  const { percent, usedTokens, limitTokens, compactedAt } = p.contextUsage;
  // e.g. "Nick: 75% of context (150k / 200k)", suggest a new room near 80%+
}
  • usedTokens / limitTokens are tokens; percent is round(usedTokens / limitTokens * 100), capped at 100.
  • measuredAt is the end of the turn the snapshot was taken at; the value does not change between turns. Re-read the room (rooms.get or rooms.list) after turn.completed to pick up the new value.
  • compactedAt is the last time the provider compacted (summarised) this employee's context. After it, usedTokens drops while older details of the conversation may be lost.
  • The field is absent, never zero, when the provider does not report usage or its context window (API-compatible providers today) or before the first turn. Only token counts, the provider and the model name are exposed, never the context content.

Errors

Every failure is a WorkspaceError:

try {
  await workspace.messages.send({ roomId, text });
} catch (error) {
  if (error instanceof WorkspaceError) {
    error.code;       // stable machine code, pick your user-facing text by it
    error.status;     // HTTP status or null
    error.requestId;  // send this to WiseTeam support
    error.retryAfter; // seconds, for rate_limited
  }
}

| Code | HTTP | Meaning | | --- | --- | --- | | unauthorized | 401 | Bad or missing token (wrong signature, wrong aud). | | token_expired | 401 | Token expired; handled by onTokenExpiring. | | forbidden | 403 | Not allowed. | | external_disabled | 403 | External API is switched off for the project. | | origin_not_allowed | 403 | The page origin is not in the project allowlist. | | rate_limited | 429 | Too many requests; see retryAfter. | | not_found | 404 | No such room, surface or file for this user. | | validation_failed | 400 | The request body is invalid. | | attachment_conflict | 409 | The attachment belongs to another user or room. | | idempotency_conflict | 409 | The idempotency key was used for a different request. | | not_implemented | 501 | Not available yet. | | internal | 500 | Server error; the message carries no details. | | network_error | - | The request did not reach the server. | | aborted | - | Cancelled by your AbortSignal or disconnect(). | | invalid_response | - | The server answered with something the SDK cannot read. |

The message of an error is English and meant for logs only.

Keep code and requestId when an action fails

When surfaces.dispatchAction (a button, a form, a confirmation) or surfaces.close is refused, record error.code and error.requestId in your logs, and show the requestId to the person next to the error text, so a complaint reaches WiseTeam support with it. WiseTeam logs every refusal of these two calls under the same requestId, together with the code (confirmation_required, confirmation_expired, validation_failed, not_found, turn_in_progress, ...), and finds the exact reason by it. Without the requestId, "the button showed an error" cannot be matched to a cause. The log line carries no token, user id, message text or form values. requestId is null when the request never reached WiseTeam (network_error, aborted).

Disconnect

workspace.disconnect(); // closes the socket, stops timers, rejects further calls

Versioning

The package major version follows the API path version (/api/external/v1 -> 1.x).