@stream-io/calypso
v0.3.0
Published
JavaScript client for the Stream acceleration backend: agents that run there, and the conversations they hold.
Readme
@stream-io/calypso
The JavaScript client for the Stream acceleration backend. One package for both ends: a Node process that runs agents, and a browser that holds a conversation with one.
Nothing here does inference or touches media. The backend joins the call, hears the caller, answers and speaks. What arrives here are the events saying so, and what stays here is function calling — because the functions are here.
npm install @stream-io/calypsoNode 22 or newer, or any browser. No runtime dependencies: fetch, WebSocket and Web
Crypto are all taken from the runtime.
The three ways to say who is calling
Which one a deployment uses is a property of the deployment rather than a choice.
| Credential | Who it is | Reaches |
| --- | --- | --- |
| customerId | A router with nothing in front of it, which is a laptop | everything |
| customerId + setUser | A page on that laptop | the conversations that user owns |
| apiKey + apiSecret | A process you run | everything |
| apiKey + token | A browser | the conversations that user owns |
import { Client } from "@stream-io/calypso";
// On your own server, with STREAM_API_KEY and STREAM_API_SECRET in the environment.
const api = new Client();
// In a browser, with a token your backend minted for this user.
const api = new Client({ apiKey: "vak_live_…", token: () => fetch("/api/token").then((r) => r.text()) });Search is the one routed modality a page may reach: api.router({ config: "healthcare" }).search("what changed in the pricing page").
url falls back to STREAM_ACCELERATION_URL, then Stream's hosted router, so it is only
passed for a self-hosted or local one. The hosted router is us-east4's; for another region,
pass region: "europe-west4" or set STREAM_ACCELERATION_REGION instead. customerId, apiKey and apiSecret fall back to
STREAM_ACCELERATION_CUSTOMER_ID, STREAM_API_KEY and STREAM_API_SECRET.
The hosted router is reached through Stream's authenticating proxy, which wants the
credential spelled its own way; that is on by default for it. A self-hosted deployment
behind the same proxy passes authenticate: true, or sets STREAM_ACCELERATION_AUTHENTICATE:
const api = new Client({ url: "https://agents.example.com", authenticate: true });A router running locally with nothing in front of it is reached by customer id:
const api = new Client({ url: "http://localhost:8080", customerId: "examples" });A page there names its user with setUser, and is then that user rather than the app's
backend: it lists only their conversations, and serverSide is false. A server that passes
userId instead stays the backend, acting for that user.
It is a switch rather than an extra header or two because the two spellings contradict each
other: a router reached directly needs Stream-Auth-Type: server before it admits a
backend, and that is the one thing the proxy refuses. Opt in rather than let the presence of
a credential decide, because a Stream key and secret are in the environment for plenty of
reasons that have nothing to do with this router.
A page that signs somebody out calls api.clearUser(), and a guest from api.guestUser() is
acted as with api.setUser(guest), which takes the token it was minted with.
A secret belongs on a server. A browser holding one could rewrite every agent in the app, which is why the browser passes a token instead — and why the operations that configure an agent answer a browser with a 403 rather than trusting it.
Resources, typed from the spec
The API is grouped by resource, with the request and response types generated from
acceleration/api/openapi.yaml:
const simulation = await api.simulations.create({ name, config_id, scenario, assertion });
let run = await api.simulations.run(simulation.id);
run = await api.simulations.runs.get(run.id);
await api.memories.truncate("user-123");A failure raises RouterError, carrying the status, the operation and what the router said
went wrong. Branch on code (not_configured, validation_failed, ...) or type, not on the
message, and expect codes you do not know; quote requestId to support, since a 500 says only
"something went wrong".
A conversation somebody comes back to
A text conversation is kept in Stream Chat, so what was said outlives the session that heard it.
const support = api.agent("support");
const session = await support.sessions.create({ agent_id: "ana-support" });
// Keep session.id. It is the way back to what was said.
await session.responses.create("Where is my order?");
// Later: ana's conversations still running, without paging through every one that ended.
const live = await support.sessions.query({ agentId: "ana-support", state: "live" });
// This month's, labelled for the trip, the most recently changed first.
const trip = await support.sessions.query({
createdSince: new Date("2026-11-01"),
custom: { topic: "lisbon" },
sort: "updated_at",
});
// Carry one on, running or ended, by the id of the session it was held in.
const again = await support.sessions.resume(session.id);The channel is the backend's to name, so a conversation is carried on by its session id
rather than by its channel. A session id may also be one the caller picks, up to 64 of
A-Za-z0-9_-, passed as id when it is created.
timezone is the person's IANA zone, such as Europe/Lisbon, which the agent tells the date
and the hour in. Opened from a page and left out, it is the zone the browser reports for the
device, when that reads as a zone name; anywhere else nothing is read from the machine, and
timezone: "" sends none anywhere, leaving the user's stored zone to decide.
memory: {} has the session recall what the agent learned about the user in earlier ones, and
keep what it learns in this one, with filter and app_id to narrow it. From a page it is
remembered for the user setUser named, whatever user_id it was handed, since a device may
only name its own; a backend names whichever of its users it means, or the one it acts for.
session.close() stops a conversation and keeps everything it recorded and remembered, so
a conversation in writing is usually left running. session.delete(), or
sessions.delete(id) without a handle, deletes it with its turns and what it taught
memory. A conversation this process is not holding is renamed with sessions.update(id, …),
put on its call and taken off with sessions.startVoice(id) and sessions.stopVoice(id),
stopped with sessions.close(id) and forked with sessions.fork(id, …).
What the agent holds for later
api.scheduledTasks is the reminders and later work agents hold for the user setUser named:
what a person does with a reminder they already have. The agent makes one in a conversation,
and the app's backend can make one in their name; a device cannot.
const page = await api.scheduledTasks.list({ state: "active" });
await api.scheduledTasks.update(page.items[0].id, { next: { in: "PT15M" } }); // snooze
await api.scheduledTasks.pause(id);
await api.scheduledTasks.resume(id);
const runs = await api.scheduledTasks.runs(id);
await api.scheduledTasks.delete(id);An agent
import { Agent } from "@stream-io/calypso";
const agent = new Agent({
name: "John",
instructions: "You are a friendly assistant. Keep replies short.",
pipeline: { llm: "llm-fast", stt: "en-low-latency", tts: "sonic_36" },
});
agent.tools.register<{ city: string }>({
name: "get_weather",
description: "Get the current weather for a city",
parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
run: async ({ city }) => fetchWeather(city),
});
const session = await agent.join();
console.log(await agent.monitorURL(session));
for await (const event of session.events()) {
console.log(event.kind, event.text);
}join opens a session with voice on, and the backend joins the session's own call,
agent:<session id>. chat holds the same conversation in writing instead — the same
instructions, the same skills, the same knowledge, with nothing transcribed and nothing
spoken. session.voice.stop() carries a conversation on in writing and
session.voice.start() puts the agent back on the call. Instructions reach the backend
with agent.sync(): a session runs on the stored config's.
new Agent({ timezone: "Europe/Lisbon" }) is the zone the agent's sessions are in, and join,
chat, answer, reply, startCall and waitForCall each take a timezone for the person on
that call, over the agent's. Neither set sends none.
The model asks for a tool over the session socket, this process runs it, and the answer goes back the same way. A tool that throws is reported to the model, because the model is mid-sentence waiting for it and can only say something useful if it is told.
parameters can also be a Zod 4 schema, or any schema that implements
Standard JSON Schema, such as ArkType's or Valibot's:
import { z } from "zod";
agent.tools.register({
name: "book_flight",
description: "Book a flight for the caller",
parameters: z.object({
flight_id: z.string().regex(/^[A-Z]{2}\d{1,4}$/).describe("e.g. LH400"),
seats: z.number().int().min(1).max(9),
cabin: z.enum(["economy", "business", "first"]).default("economy"),
}),
run: async ({ flight_id, seats, cabin }) => bookings.book(flight_id, seats, cabin),
});The model is told the JSON Schema the Zod schema describes, and each call's arguments are
parsed by it before run, which is handed what it parsed, typed from the schema. Arguments it
refuses fail the call with what was wrong, such as seats: Too big: expected number to be
<=9, and the model can try again. A rule JSON Schema cannot say, such as a refine, still
holds; the model is just not told it. A JSON Schema written out is told to the model as it is,
and its arguments reach run unchecked. The package does not depend on Zod.
A tool can say how the backend treats its calls, and read the call it is running for:
agent.tools.register<{ flight_id: string }>({
name: "book_flight",
description: "Book a flight for the caller",
parameters: { type: "object", properties: { flight_id: { type: "string" } }, required: ["flight_id"] },
approval: { title: "Book flight {flight_id}?" },
timeoutMs: 20_000,
run: async ({ flight_id }, signal, toolCallContext) => {
toolCallContext.update("checking seats");
const writing = await toolCallContext.disallowInterruptions();
return bookings.book(flight_id, {
user: toolCallContext.user.id,
key: toolCallContext.id,
signal: writing,
});
},
});interruptible: truelets the caller talking over the agent cancel a call in flight. By default it keeps running and the model is told it is still going.timeoutMsis how long one call may take, in place of the session's. A call that takes longer is aborted and fails.approvalis asked before each call runs. In writing the call waits insession.approvalsuntilsession.decide(id, { allowed })answers it; a page follows the list insession.conversation, which says when it changes, a cancel by the model included. On a voice call the agent asks the caller out loud, with each{name}in the title filled in from the call's arguments, and runs the call on a yes within 15 seconds, withtoolCallContext.approvedset. A{name}that is not inparametersis refused when the tool is registered.
toolCallContext is the call being answered: its id, which a call sent again keeps, so it is
the key to make a side effect happen once; sessionId, turnId, the user whose turn asked,
and the conversation. update(text) says how the call is going: a voice agent tells the
caller in its own words once nobody is talking. say(text) has it say text as written, which
a speech-to-speech model may paraphrase. disallowInterruptions() keeps the call going from
then on, for a write that must not stop halfway, and resolves with the signal to do it on, which
only the timeout and the socket ending abort. When the call had already been stopped, that
signal is already aborted with a CallNotHeldError. A call the router addresses to another
watcher of the session (run: false) is left for whoever runs it.
A function shows the model a picture by returning a ToolOutput: its text, and images
as URLs or data URIs, which the model sees with that call's result.
run: async () => new ToolOutput({ text: "The desk, just now.", images: [{ url: dataUri }] }),Agent dispatch
A caller reached a Stream call over SIP, or somebody wrote in a channel, and the router found out by webhook. The agent, though, runs in your process. So the worker connects out and waits, and the router pushes work down the connection as it arrives — nothing you run has to be publicly reachable.
import { Agent, Dispatch } from "@stream-io/calypso";
const dispatch = new Dispatch({ capacity: 4 });
dispatch.onCall(async (call) => {
const agent = new Agent({ name: "John", pipeline: { config: "support" } });
const session = await agent.answer(call);
await session.wait();
});
dispatch.onMessage(async (message) => {
if (message.sessionId) {
await dispatch.answer(message);
return;
}
const session = await dispatch.sessionFor(message, () => new Agent({ name: "John" }));
const images = (message.images ?? []).map(({ url }) => ({ url }));
await session.responses.create(message.text, { images });
});
await dispatch.run();capacity is a promise about what this process can answer: the router passes over a full
worker rather than queueing behind it. Several workers can wait at once, and the work is
shared between them. Each call and message is reported done to the router when its handler
returns, with the error when it throws, which is what frees the room it took.
A message usually arrives here only when no agent is running on its channel — one written to
an agent that is already running is answered by the router from that session, because that
agent is the one that knows what has been said. sessionFor keeps one conversation per
channel for the same reason. The exception is an agent whose agent.yaml says
dispatch: {text: enabled}: what its end users write comes here with the sessionId it was
written to and a requestId, unanswered, and dispatch.answer(message) has the model answer
it, with this worker's credential acting for the user who wrote it. The photos sent with a
message come in images, by URL, and dispatch.answer shows them to the model.
A worker can also host tools for every session opened under an agent id, including one a browser opened, where the session's own process cannot reach what the tool needs:
const agent = client.agent("my-agent");
agent.tools.register({ name: "lookup", description: "Look up an order", run: lookup });
dispatch.host(agent, { toolTimeoutMs: 60_000 });The router offers them to each session naming the agent and sends every call here. A hosted
tool takes the same interruptible, timeoutMs and approval (asked only on a voice
call), is handed its toolCallContext, and is aborted when the router gives up on it. run
works with only hosted tools and no handler, and throws HostingRefusedError if the router
refuses them.
Server side only. A worker is offered other people's callers, so anything that can open that socket could answer for the whole app.
Placing a call outward is the other direction of the same thing:
const session = await agent.startCall("+15551234567", "+15557654321");The agent is told it is navigating, so recordings are let finish and menus are answered
rather than talked over. Every caller to an attached number gets a session of their own:
agent.answer(call) joins the one the router names, and agent.waitForCall(number) waits
for the next one without a worker of your own.
An agent written down as a directory
agents/jean/
agent.yaml required: the name and what it runs on (llm, stt, tts, greeting, harness, tags, ...)
instructions.md
guardrail.md
skills/think.md
skills/pdf/SKILL.md a skill as a folder: SKILL.md plus the text files it brings
skills/pdf/scripts/fill.py
knowledge/pricing.md
knowledge/urls.yaml pages to read, each optionally with refresh_hours
simulations/lunch.yaml
.agent_sync written by sync: the fingerprint last synced and whenimport { Agent } from "@stream-io/calypso";
import { loadFolder } from "@stream-io/calypso/node";
const agent = new Agent({ folder: await loadFolder("agents/jean") });
await agent.sync();sync stores it as a config a session can then be created from by name, in one request
carrying the files, the pages and what agent.yaml declares. A key agent.yaml does not
know is refused. The request carries a fingerprint of everything in it and .agent_sync
records it, so syncing on every startup only reads the config back when nothing has changed.
A setting left out leaves whatever is stored — a model chosen in the dashboard survives a
sync that says nothing about it. The harness (harness, subagent, sandbox and skills) is
stored on the config this way too, never on a session: a session runs its config's.
region: eu (or us) is where the agent's work is done: every model it runs, its knowledge
base and its sandbox are there and nowhere else. Left out, the stored region stays, and a new
agent is in us. The router refuses a sync naming a model with no instance in the region,
naming each, and one whose knowledge base is already kept in another region.
Each file in simulations/ is a list of name, scenario and assertion, with mode,
variations, max_turns, caller_*, judge_target and tags optional. With a
simulations/ directory the config's simulations become exactly what it declares, so an
empty one deletes them; without one, the stored ones are left alone.
What is written in code wins over what the directory says, so a directory is a starting point rather than an override.
loadFolder needs a filesystem, which is why it is the one thing in the /node entry
point rather than the main one. It hands sync the stamp to read and write, so sync
itself touches no filesystem.
Talking to an agent from a browser
VoiceSession puts the agent on a call and this page on the same call. It lives in the
/video entry point, which is what brings @stream-io/video-client in:
import { VoiceSession } from "@stream-io/calypso/video";
const voice = await VoiceSession.start(api.agent("my-agent"));
await voice.join(); // the microphone on; { camera: true } for the back lens
const { call, client, muted, status } = voice.get();
voice.subscribe(() => render(voice.get()));
await voice.setMuted(true);
await voice.leave(); // and closes the session start() made; end() always doesstart opens a session with voice on, so the agent is on the call before this page joins.
join creates the call rather than only joining it, since which of the two gets there first
is a race, and rejects when it fails, for example when the person denies the microphone.
Asking twice joins once. VoiceSession.of(session) joins a session that already has voice
on, one switched over with session.voice.start(); leaving it leaves the session running.
VoiceSession.attach(api, sessionId) does the same from the session's id alone, with a watcher
of its own that runs no tools, as the phones do.
status is what the agent is doing in the states VoiceGlow and VoiceWave in
stream-chat-react/ai-components take: connecting until joined, speaking while the agent
is heard on the call, listening, thinking and idle. Speaking comes from the call
because a reply is written before it is spoken. Playing the agent is the page's:
<ParticipantsAudio /> from @stream-io/video-react-sdk, or call.bindAudioElement per
remote participant. Start from a click, since a browser plays audio and asks for the
microphone only after one. In React, get and subscribe are bound, so they go straight
into useSyncExternalStore(voice.subscribe, voice.get).
With the camera on, the agent can look through it: the page keeps a frame twice a second, and
the session answers the router's get_video_frames with the ones taken when the question was
put. A session with no camera on leaves that request to whoever else on the call owns it.
What the agent is doing is on every session, voice or not, as session.state: idle,
listening, responding, working (with the skills thinking) and ended. It is fed from
the socket beside events(), so it takes nothing from a loop reading them, and has the same
get and subscribe. It is not the session's lifecycle, which is session.created.state.
session.conversation is the rest of what a page shows, fed the same way: turns, what this
device typed, what was transcribed on the call and the agent's replies as they were written;
failure, what the agent last reported going wrong, such as speech recognition on a call,
with failureContext naming the part (stt, llm, sts, tts) and failureAt when, until
the agent next hears, answers or speaks;
approvals, the calls waiting for the person; and connected, with socketError saying why
the socket stopped when it did not end normally. A failed join, mute or camera rejects and is
kept as failure on the voice session's snapshot too.
const { turns, approvals, failure } = useSyncExternalStore(
session.conversation.subscribe,
session.conversation.get,
);When a call of one of the agent's connectors waits for the person to sign in, its step in the
Chat channel is awaiting_authorization, with the router's sign-in page and a handoff token.
A page opens it in a popup from a page of the session's return_url origin and posts it the
token; Not now is session.declineLogin(toolCallId), and the router ends the call and tells
the agent. A connector_scope_required event, the step-up a call needs, reads as
connectorScopeRequiredOf(event).
A reply as it is heard
The Chat channel has the agent's reply as fast as the model writes it, seconds ahead of the
voice. session.speech says how much of each reply has been heard on the call, so a page can
show a message as it is said:
import type { LocalMessage } from "stream-chat";
function shownText(message: LocalMessage, now = performance.now()) {
const text = message.text ?? "";
const heard = session.speech.heard(message, now);
// Characters are Unicode code points, as the session socket counts them.
return heard ? Array.from(text).slice(0, heard.chars).join("") : text;
}
session.speech.subscribe(() => requestAnimationFrame(render));Every message the agent said carries the turn_id the session socket's speaking, spoke
and interrupted name (on message.turn_id in stream-chat 9, message.custom.turn_id in
10). heard takes the message and reads either, or the turn id itself, and with now is
{ chars, done }: the code points of the message heard so far, exact once the playing
utterance's spoke has said how long it is and estimated from the voice's measured rate
(about 15 a second until then), and done once the turn's last known utterance has played out
or the caller talked over it. The voice is not sent the message's own words (a direction it
acts, "last four digits, 1234" for "last four 1234", a sentence held back), so the router says
where each utterance is in the message (message_start and message_end on speaking,
spoke and interrupted), and each is revealed across its own place there; a router that
does not say is counted in the voice's characters. A cut freezes the turn at what the router says was heard of the utterance it cut
(heard_ms on interrupted), or at the estimate when it does not say. An utterance reported
before the track got to it, behind speech still queued, starts when that has played
(delay_ms on speaking). A voice that takes a sentence at a time can still start another
after a pause, so show a reply in full once done is true and its message is no longer
generating.
A reply's message usually arrives before its audio starts, and Chat can even be faster than the
session socket. While the agent is on the call, a turn is { chars: 0, done: false } until its
speaking once it is known to be coming: its text has begun on the socket (said,
response_delta, or a responded with words), or heard was asked about a message of the
agent's (source: "agent" or ai_generated) that is still generating. It is let go of, and
undefined from then on, once it turns out to say nothing (an empty responded, or a turn
that published no audio) or when SPEECH_GRACE_MS (5 s) pass without a speaking; speech that
comes later does not take it back, since the page has shown it in full. The agent leaving the
call (left), voice.stop() and the session ending stop anything being held, so a reply in
writing afterwards shows at once. A turn let go of, a message with no turn and a router too
old to send speaking are all undefined: show those as they are. A reply cut off before any
of it was heard stays at { chars: 0, done: true }.
now is on the clock of performance.now(), which requestAnimationFrame hands its callback
too, and heard is cheap enough to call on every frame. subscribe is told whenever what
heard says of a turn changes other than with the clock; get says where the voice last was.
The store is fed from the frames the session reads, beside events() rather than through it,
so it needs no loop and takes nothing from yours. It keeps the last 20 turns.
Going back, and branching off
const items = await session.responses.items.all();
await session.responses.rewind(items[2]); // carry on as though nothing after it was said
const branch = await session.fork({ response_id: items[2].response_id, title: "asked again" });rewind takes a response, a response's id, or any item of one. The model forgets the later
turns, and they drop out of what responses reads back. A text conversation is kept in
Stream Chat unless it is incognito, and so cannot be rewound, since the channel would still
hold the later turns: fork it at the
response instead, which starts a new session carrying the history only that far. Both work
from a browser as well as a server.
Sockets
Socket is the hand-written half of this SDK. OpenAPI stops at the upgrade, so the session
events socket, the dispatch socket and the four modality sockets are written rather than
generated.
import { Socket } from "@stream-io/calypso";
const socket = await Socket.open(api.backend, await api.backend.socketURL("/v1/stt/stream"));
socket.send({ type: "start", options: { language: "en" } });
socket.sendAudio(pcm);
for await (const message of socket.messages()) {
if (message instanceof Uint8Array) {
play(message);
} else {
console.log(message.type);
}
}Credentials go in the query string, because a browser WebSocket carries no headers of its
own. A socket never says it is a backend: Stream-Auth-Type: server has no query
counterpart on purpose, and the proxy is only ever told stream-auth-type=jwt, which is how
it reads a user's token.
Working on this package
npm install
npm run types # regenerate src/generated/api.ts from the spec
npm test # typecheck, then the suite
npm run test:local # against a router running on this machine
npm run test:qa # against the hosted deployments
npm run test:integration # device posture and one Stream connection, see below
npm run build # dist/, what gets published
npm run example build/examples/voice.jsThe generated types are committed, so installing the package needs no code generation, and
npm run types -- --check is what CI uses to catch a spec that has moved without them.
acceleration/api/openapi.yaml is the source of truth: after editing it, regenerate every
client — see acceleration/README.md.
The tests run against a real HTTP server and a real WebSocket upgrade rather than a stubbed
fetch, so what they exercise is the request this SDK actually puts on the wire.
npm test is hermetic and is what CI runs. The two live suites are not part of it and are
not in CI: they talk to a deployment, and they skip rather than fail when there is none to
talk to — a laptop with no router up, or a checkout with no credentials, is not a broken
build. test:local wants a router on LOCAL_ACCELERATION_URL, default
http://127.0.0.1:8098. test:qa wants STREAM_API_KEY and STREAM_API_SECRET, and
reaches QA_ACCELERATION_URL, which it skips without, and QA_DOCS_SEARCH_URL. They are the only place
the claims above are checked against a real backend rather than against a test one, which
is worth running before trusting any of them: three of them are todo there today, each
naming what the deployment does instead.
test:integration checks the two promises a client SDK makes. A device holding a user's token
is refused every server-side operation (minting a call or chat token, claiming a guest,
configs, sync, simulations, memories, the dispatch socket) with a 403 server_side_only. And
however many sessions ask, there is one Stream Chat and one Stream Video socket for the user,
none at all beside a StreamChat the app handed over with client.use, nobody left online
after client.disconnect(), and no push device but the app's own. Sockets are counted off
Node's diagnostics channels, so they are the connections that actually left the process.
The posture half needs a router that verifies credentials, which the Docker one in proxy mode
does not. Mint a key against its Postgres and run a second router in api_key mode beside it:
cd acceleration && set -a && source ../.env && set +a
export ROUTER_POSTGRES_DSN='postgres://postgres:postgres@localhost:55432/model_router?sslmode=disable' ROUTER_REDIS_ADDR=localhost:56379
go run ./cmd/router keys create --app-name sdk-suite # prints the key and secret once
ROUTER_ADDR=:8099 ROUTER_AUTH_MODE=api_key ROUTER_PUBLIC_URL=http://localhost:8099 go run ./cmd/routerThen INTEGRATION_ROUTER_KEY=vak_live_… INTEGRATION_ROUTER_SECRET=vas_live_… npm run
test:integration (INTEGRATION_ROUTER_URL defaults to http://localhost:8099). The Stream
half wants the Docker router on STREAM_ACCELERATION_URL with STREAM_ACCELERATION_CUSTOMER_ID,
STREAM_API_KEY and STREAM_API_SECRET, all read from .env. Each half skips without its own.
Run it from a shell without .env sourced, or npm test reads the same variables and fails.
They hold real conversations, so they spend the app's daily token allowance, and run often enough they will spend all of it. A test that finds it gone skips saying so rather than reporting the deployment broken — worth recognising in the output, because everything else still passes and only the one test that needed an answer steps aside.
Releasing
The Release JS SDK workflow publishes to npm, run by hand from the accelerate branch.
semantic-release (release.config.mjs) reads the commits since the last sdk-js-v* tag and
counts only those scoped sdk-js: feat(sdk-js): moves the minor, fix(sdk-js): and
perf(sdk-js): the patch, and while the package is 0.x a breaking change moves the minor too.
A commit with any other scope, or none, ships nothing, whatever it touches. The workflow runs
as a dry run unless that box is unticked, printing the version and notes it would publish.
It publishes with npm trusted publishing, so there is no token, and npm only lets a package trust a workflow once the package exists. The first version is therefore published by hand:
npm publish # 0.2.0, as package.json says
git tag sdk-js-v0.2.0 && git push origin sdk-js-v0.2.0then on npmjs.com, under the package's settings, add GitHub Actions as a trusted publisher:
GetStream/Vision-Agents, workflow release-sdk-js.yml. From then on the version in
package.json is not maintained: semantic-release writes the next one into the package it
publishes and nothing is committed back.
