@wiseteam/workspace-sdk
v1.4.0
Published
Client SDK for the WiseTeam Workspace external API v1: rooms, messages, attachments, runtime surfaces and live events.
Maintainers
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
WebSocketimplementation in Node). - Every type is bundled:
WorkspaceRoom,WorkspaceMessage,WorkspaceEvent,RuntimeSurface,RuntimeBlockand 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-sdkConnect
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 rulessurfaceFirst 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 asksPOST .../events/ticketfor a one-time 30-second ticket and passes it only inSec-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.createdis delivered once per room. If the room already came back in themessages.sendresponse, the socket event is delivered asroom.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:
rooms.list()replaces the room list completely.- For every open room (one you called
messages.listfor) it drops messages still instreaming(their partial text cannot be restored), reads the tail after the last finished message and replaces the room's surfaces. - Duplicates are removed by
message.id,surface.idandroom.id. - Team status is reconciled with the new room list. A room whose status is not
workingcannot have a running team, so a staleworking/warming(left behind when the finalteam.status: idleframe was lost) becomes{ state: "idle", responders: [] }and is emitted as ateam.statusevent. A room that is stillworkingkeeps its last status and responders. A room missing from the list loses its entry. Ateam.statusthat arrives while the resync is in flight is newer than the snapshot and is never overwritten.turn.completedalone does not change team status. - It then emits
{ type: "resync" }so you can re-render.
Frames carry a per-room sequence and a streamEpoch:
- a new
streamEpochresets all cursors and triggers a resync; - a
sequencenot greater than the last one seen in the same epoch triggers a resync (a repeatedmessage.deltais not applied, so text is never doubled); - a gap in
sequencetriggers 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 whateverpresentationsays. replace(default): the surface comes to the foreground and supersedes surfaces shown earlier from the same source (source.kindandsource.id). Superseded surfaces are not closed. They stay insurfaces.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/limitTokensare tokens;percentisround(usedTokens / limitTokens * 100), capped at 100.measuredAtis the end of the turn the snapshot was taken at; the value does not change between turns. Re-read the room (rooms.getorrooms.list) afterturn.completedto pick up the new value.compactedAtis the last time the provider compacted (summarised) this employee's context. After it,usedTokensdrops 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 callsVersioning
The package major version follows the API path version (/api/external/v1 ->
1.x).
