docs
TypeScript SDK

Getting Started

Render and steer a real-time session from a deployed uRun app

A uRun Python function runs on a remote GPU and produces media in real time. The TypeScript SDK is how you consume it in the browser: reference the deployed app, invoke a function, and render the session's streams. This guide builds a React video player that connects to a deployed function and steers it live.

Install

npm install @urun-sh/react @urun-sh/core
npm install @urun-sh/core

@urun-sh/react supports React 18 and 19. You need your baseUrl (e.g. https://urun.sh) and orgId from your uRun console.

Package names

The browser client is @urun-sh/core (+ @urun-sh/react). It is distinct from the Python library urun and the CLI urun-cli. Do not confuse @urun-sh/core with the monorepo name.

Authentication

Browser clients never carry a server API key. They authenticate with your orgId plus a short-lived token, validated server-side.

Never ship secrets to the browser

Do not pass a long-lived API key or the urun_… deploy key to a browser client — every path below keeps it server-side.

The onramp: a token route in your app

The fastest way from zero to a live session: embed a token-vending endpoint in your Next.js app. Your org API key stays server-side in URUN_API_KEY; the route mints short-lived scoped client tokens with createClientToken (from @urun-sh/core — a server-side call, never imported from browser code); the SDK fetches them through the normal getAccessToken option. Minutes to a first session, no identity provider required.

// app/api/urun-token/route.ts — the onramp: the API key never leaves the server
import { createClientToken } from '@urun-sh/core'

export async function POST() {
  const { token, expiresAt, orgId } = await createClientToken(process.env.URUN_API_KEY!, {
    allowedFunctions: ['my-app/generate'],       // only these app/functions
    allowedOrigins: ['https://myapp.example'],   // only presented from these Origins
    expiresIn: 60,                               // seconds; gates STARTING sessions only
    maxSessionS: 600,                            // per-session length cap
  })
  return Response.json({ token, expiresAt, orgId })
}
// browser — the SDK fetches tokens from your route
const app = App('my-app', {
  baseUrl: 'https://urun.sh',
  orgId: 'your-org-id',
  getAccessToken: async () => {
    const res = await fetch('/api/urun-token', { method: 'POST' })
    const body = (await res.json()) as { token: string }
    return body.token
  },
})

Constraints are enforced server-side at session admission: the token can only start the allowlisted functions, from the allowlisted origins, within the session-length cap. Token expiry gates starting sessions only — a live session is never killed by its token expiring. Pass a stable subject (e.g. your user id) to coalesce one user's tabs into one session; by default every token gets a random subject, so distinct users never share a session.

What the onramp does and doesn't cover

Onramp tokens are org-scoped credentials your server mints for anyone it answers — right for prototypes, demos, and internal tools. If your product has real end users, put your identity provider in front: see Production auth best practices for WorkOS AuthKit and customer-JWT (trusted JWKS) setup, per-user identity, and key rotation.

Production auth (external identity provider)

In production, browser users authenticate against your identity provider and uRun verifies their short-lived JWTs directly:

  • WorkOS AuthKit (default in production) — the browser obtains WorkOS access tokens; uRun validates them against your org's provisioned WorkOS JWKS. The React bridges (UrunWorkOSProvider) wire it in a few lines.
  • Customer JWT — you bring your own JWTs, validated against a JWKS you register via urun auth trust-jwk (or Settings → Frontend Auth in the console). Enable with NEXT_PUBLIC_AUTH_MODE=jwt.

Full setup — issuer/JWKS trust, per-user identity vs org tokens, key rotation: Production auth best practices.

React: render a stream

Wrap your tree in an auth bridge and UrunProvider, then call the function with useApp().

'use client'

import '@urun-sh/react/styles.css'
import { UrunProvider, useApp } from '@urun-sh/react'
import { UrunWorkOSProvider } from '@urun-sh/react/workos'        // Next.js: '@urun-sh/react/next-workos'
import { useEffect, useRef, useState } from 'react'

function VideoPlayer() {
  const app = useApp()
  const videoRef = useRef<HTMLVideoElement>(null)
  const [text, setText] = useState('a cat walking in a garden')

  // Render-safe: the same args reuse the same session across re-renders.
  const session = app.generate({ prompt: text })
  const video = session.stream('video')

  useEffect(() => {
    if (video.track && videoRef.current) {
      videoRef.current.srcObject = new MediaStream([video.track])
    }
  }, [video.track])

  return (
    <div>
      <video ref={videoRef} autoPlay muted className="w-full max-w-2xl rounded-lg" />
      <div className="flex gap-2 mt-4">
        <input value={text} onChange={(e) => setText(e.target.value)} />
        <button onClick={() => session.doc('control').set({ prompt: { text } })}>
          Generate
        </button>
      </div>
    </div>
  )
}

export default function Home() {
  return (
    <UrunWorkOSProvider clientId="client_...">
      <UrunProvider
        baseUrl="https://urun.sh"
        orgId="your-org-id"
        authProvider="workos"
        appId="my-app"
      >
        <VideoPlayer />
      </UrunProvider>
    </UrunWorkOSProvider>
  )
}

What happens:

  1. UrunProvider configures the app proxy (baseUrl, orgId, authProvider, appId); the WorkOS bridge supplies access tokens.
  2. useApp() returns the proxy. app.generate({ prompt }) invokes the deployed Python generate function and returns a render-safe Session.
  3. session.stream('video') is the named media stream. Its .track is the inbound MediaStreamTrack, which you assign to a <video> via new MediaStream([track]).
  4. session.doc('control').set(...) writes the synced control doc; on the Python side ctx.doc("control") reads the same doc — a live inline read for cheap config, doc.bind for structural change — and steers generation. No restart, no REST endpoint.

Render-safe sessions

useApp() memoizes by function name + args: calling app.generate(args) with the same args during re-renders reuses one session. session.stream(name) and session.doc(name) are likewise stable.

Vanilla: the same flow without React

import { App } from '@urun-sh/core'

const app = App('my-app', {
  baseUrl: 'https://urun.sh',
  orgId: 'your-org-id',
  jwt: await getCurrentUserJwt(),   // advanced/manual path; React WorkOS resolves this for you
  authProvider: 'workos',
})

const session = app.generate({ prompt: 'a cat walking' })

const video = session.stream('video')
video.on('track', (track) => {
  if (track) videoEl.srcObject = new MediaStream([track])
})

session.doc('control').set({ prompt: { text: 'a dog running' } })

Session phases

A session moves through nine honest, renderable phases:

idle → queued | unavailable | provisioning → connecting → live → ended | expired
                                                        ↘ error

While waiting, phase.queue carries { position, depth }, phase.runtime reports honest runtime readiness (unavailable | starting | busy + a reason), and phase.wakingSince timestamps the wait so you can render "waking (Ns)". On error, phase.error carries { reason, code?, kind? }.

import { describeSessionPhase, whenLive } from '@urun-sh/core'

session.onPhase((p) => statusEl.textContent = describeSessionPhase(p))

await whenLive(session)          // resolve at 'live'; reject with a typed SessionFailedError
await video.attachVideo(localTrack)   // only publish once live

See Session lifecycle for the full phase model, scale-to-zero waking UX, session expiry, and idle handling; Transport for reconnection and viewer attach.

Media components

@urun-sh/react ships bare-name media components over the session's named streams so you rarely wire tracks by hand — send with <Camera> / <Mic>, show with <Video> / <Image> / <Audio>:

  • Session — provides one session to every media component beneath it, so each block needs nothing but a stream name (every component also takes an explicit session prop).
  • Video — live video display for a named inbound stream (persistent srcObject, iOS-safe attributes, honest empty state).
  • Image — latest-image display over the platform image lane (H.264 intra frames on a video stream; default stream "image").
  • Audio — gesture-safe playback of a session's inbound audio (handles the browser autoplay dance); sound-only unless controls.
  • Voice — a full-duplex voice loop: playback plus mic capture/attach over one named stream.
  • Camera — camera capture and publish: viewless by default, self-view via visible, declarative front/back facing with flip via replaceTrack.
  • Mic — viewless mic publish (the capture-only half of Voice); visible renders a level meter.
  • useSessionTrack(session, name) — the latest inbound MediaStreamTrack | null for a named stream, as React state.
  • VideoPlayer — a video.js-backed player for VOD playback of a recording URL (src). video.js is an optional peer, so it lives behind a subpath: install video.js and import from @urun-sh/react/video — it is deliberately not exported from the package root.

For live rendering, <Video> (or useSessionTrack + a <video> element) is all you need — VideoPlayer is only for apps that want player chrome over DVR recordings.

One copy-pasteable recipe for each of these — send camera (phone front/back), send mic, viewless sending, output video/audio/images — is on Inputs & outputs.

Next steps

On this page