Get started
Build a studio

Collaborate live

Collaborate live

Two things drive a studio besides the person in front of it: a collaborator editing the same itinerary, and a model asked to change it. Both arrive over Server-Sent Events and reconcile into the same stores. Both dispatch through the same tool bus a click does.

Opt-in subpaths

Open the stream

@mountaya/client/events opens the streams and decodes the frames. The generated *Events operations cannot serve one: two of them resolve the whole body before returning, which on a feed with no end never returns at all.

import { createAgentTransport, createCollabTransport } from "@mountaya/client/events";

const collabTransport = createCollabTransport(mountaya.transport);
const agentTransport = createAgentTransport(mountaya.transport);

The URL is resolved through the client rather than concatenated. A stream reaches the same base a request does, trailing slash and path prefix included.

Reconcile what a peer changes

import { CollabEngine } from "@mountaya/elements/collab";
import { studioCollabTools } from "@mountaya/studio";

const session = new CollabEngine({
  bus: collabBus,
  ctx: studioContext,
  resource: { id: itineraryId, kind: "itineraries" },
  tools: studioCollabTools,
  transport: collabTransport,
});

The session opens the resource's stream, reconciles every durable change through the bus, and keeps the presence and lock rosters. The stream acts as a member, so the transport's client needs a session token minted for the user it names, and opening one under a key with no token is refused before anything is sent.

A stream ends with the token it was opened with: when the token expires or the key is revoked, the API writes a reconnect event and closes it. The session reopens it at once, from the last event id, with the token the client holds then, and replays what changed in between, so a token refreshed on its timer costs no change. A 401 on that reopen is the credential gone, and the session stops. An agent run's stream reopens the same way, and the run goes on while it is closed.

A member who only watches sends nothing: the API refuses every signal from a viewer, a selection or a cursor as much as a lock or a draft. Pass canEdit, a function answering whether the local user holds a write role on the resource, and the session reads it before each signal. While it answers false, no selection or draft goes out and a lock asked for is refused locally with MountayaElementsForbiddenError, which the studio reads as the API's own 403. Left out, every signal is sent and a viewer's are refused by the API. Pass it to MountayaStudio as collabEngine, and a zone's session as zoneCollabEngine. CollabListEngine is the sibling for an organization's itinerary list, which reconciles changes and announces no presence.

tools is yours because a session reads a protocol: it dispatches a local tool for every frame, and which tool answers "a roster arrived" belongs to whoever registered it. studioCollabTools is the studio's answer. The contract types every frame, and each event decodes the instants it declares, as a response decodes its operation's: a change frame's occurred_at arrives as a Date.

Show who is here and what they hold

PresenceRoster draws the avatars for everyone else in a resource, capped, with a +N beyond the cap. It reads the presence slice for the resource you give it. An itinerary open beside a zone shows two rosters that stay apart. It never draws the local reader. Each avatar paints in its collaborator's palette colour, the one their cursor wears on the map.

PeerLockTooltip wraps a control a peer currently holds and says who holds it on hover. LockedActionsButton is the disabled kebab a row swaps in for its own menu. It names the holder in its accessible name, not in the tooltip alone. usePeerLockLabels is the hook behind both, for a lock treatment of your own: it resolves the directory once for a whole list rather than once per contested row.

Each button pushes one frame into createCollabScriptedTransport, and the session decodes it exactly as it decodes a socket.

Give peers names

None of those surfaces knows a person's name. Who a user id belongs to is read from a member directory you own, behind whatever authorization your product applies. Pass resolvePeerName on the provider's behavior, a factory over the organization that answers a PeerNameResolver.

import type { PeerNameResolver } from "@mountaya/elements/collab";
import { emailLocalPart } from "@mountaya/elements/collab";

const resolvePeerName =
  (organizationId: string | null): PeerNameResolver =>
  (userId) => {
    const member = directoryFor(organizationId).find((entry) => entry.id === userId);

    return member ? (member.name ?? emailLocalPart(member.email)) : null;
  };

It is a factory because a roster is per organization. The studio does not know which one it is looking at until it has read its store. Without it every peer is drawn with their initials and every lock reads "A collaborator is editing this". Nothing throws and nothing waits.

Run the co-pilot on the same bus

import { AgentEngine } from "@mountaya/elements/agent";
import { recoveryFor } from "@mountaya/studio/agent";

const run = new AgentEngine({
  bus: agentBus,
  confirmServerTool: askTheReader,
  ctx: studioContext,
  observer: watchTheRun,
  recoveryFor,
  transport: agentTransport,
});

confirmServerTool is required: a run that reshapes an itinerary asks before it does. observer is how the transcript is built, since the session reports frames and renders nothing. recoveryFor suggests what a failed call tries next, and a stateDelta of your own reports what your state did while a tool ran. Both are optional, and a host driving its own tools reports the result bare.

PLAYBOOK and SURFACES ship generated from Mountaya's own guidance, so a run gets the routing Mountaya's co-pilot gets, and selectSurfaces and surfaceTools read them for a policy of your own. AgentThreadsEngine runs the studio's own co-pilot over them.

A run started for an organization the signed-in reader does not belong to answers 404, which the conversation reports as no access rather than as a failure to retry.

Neither session opens a connection of its own, chooses an organization, or decides who may start a run. Both take a scripted transport for offline tests: createCollabScriptedTransport and its agent twin.

Next steps