Embed the simulator
<RemoteControl /> from @limrun/ui streams a Limrun instance into a <div> in your web app. It opens a WebSocket + WebRTC connection to the instance, paints the screen, forwards touch and keyboard input, and exposes imperative control through a React ref.
This is the path platform integrators take: companies whose end users need a live mobile device on the page. Cloud coding agents that work on iOS, iPadOS, watchOS, or Android. Mobile preview tools. Internal QA dashboards. Same component, same wiring, on every one of those.

The Limrun console's Playground uses this exact component for its own streaming view, so what you ship to your users looks the same.
Architecture overview for platforms
┌─ End user's browser ────────────┐ ┌─ Your backend ──────────────────┐
│ │ │ │
│ <RemoteControl │ │ POST /sessions/start ─┐ │
│ url={webrtcUrl} │ │ │ │
│ token={token} │◀───┤ new Limrun({ apiKey })│ │
│ /> │ │ .iosInstances.create(…) │
└────────────────┬────────────────┘ └────────────────┬────────────────┘
│ WebSocket + WebRTC, │ HTTP, API key
│ token in query string ▼
│ Limrun control plane
▼ │
Limrun instance ◀─────────────────────────────┘
(status.endpointWebSocketUrl, status.token)Three parts of the system:
- End user's browser. Renders
<RemoteControl />, sees only a per-instance WebSocket URL and a per-instance token. Never sees your API key. - Your backend. Holds your API key, creates the instance via the Limrun SDK, returns
{ webrtcUrl, token }to the browser, and handles teardown. - Limrun control plane. Provisions the instance and returns a token scoped to it.
Authentication
The client browser never sees your API key, it uses a WebSocket URL and a token scoped to just the one instance. This token works only as long as that instance is running, and can't be used to access anything else.
Prerequisites
Before you embed, you need:
- A Limrun API key, kept server-side. Sign up at console.limrun.com, open Settings → API Keys, and create a token. See Authentication for the full flow.
- A way to create an instance from your backend. Follow Run an iOS simulator (covers iPhone, iPad, Apple Watch) or Run an Android emulator.
- A React 18+ frontend. Vite, Next.js, Create React App, Remix, etc.
The single contract between your backend and <RemoteControl /> is the pair endpointWebSocketUrl + token from instance.status. Every spec field, region clue, label pattern, and lifecycle knob lives on the per-platform pages above.
If your platform also builds your customers' apps in the cloud (not just runs them), Build with remote Xcode is the companion page for compiling iOS apps on a remote Mac.
Backend provisioning
A minimal session endpoint creates an instance for an end user and returns the two strings the frontend needs. The same shape works in Fastify, Next.js route handlers, Go's net/http, or Python's FastAPI; only the framework wrapper changes.
import express from 'express';
import cors from 'cors';
import Limrun from '@limrun/api';
const lim = new Limrun({ apiKey: process.env.LIM_API_KEY });
const app = express();
app.use(cors(), express.json());
app.post('/sessions/start', async (req, res) => {
const clientIp = clientIpFrom(req);
const instance = await lim.iosInstances.create({
wait: true,
reuseIfExists: true,
metadata: {
labels: {
tenant: req.body.tenant,
user: req.body.user,
managed_by: 'platform-embed',
},
},
spec: {
model: 'iphone', // 'iphone' | 'ipad' | 'watch'
inactivityTimeout: '15m',
...(clientIp && { clues: [{ kind: 'ClientIP', clientIp }] }),
},
});
res.json({
id: instance.metadata.id,
webrtcUrl: instance.status.endpointWebSocketUrl,
token: instance.status.token,
});
});
app.post('/sessions/stop', async (req, res) => {
await lim.iosInstances.delete(req.body.id);
res.sendStatus(204);
});
function clientIpFrom(req: express.Request): string | undefined {
const xff = req.headers['x-forwarded-for'];
const first = Array.isArray(xff) ? xff[0] : xff?.split(',')[0]?.trim();
return first ?? req.socket.remoteAddress ?? undefined;
}
app.listen(3001);package main
import (
"encoding/json"
"net/http"
"os"
"strings"
limrun "github.com/limrun-inc/go-sdk"
"github.com/limrun-inc/go-sdk/option"
"github.com/limrun-inc/go-sdk/packages/param"
)
var lim = limrun.NewClient(option.WithAPIKey(os.Getenv("LIM_API_KEY")))
type startReq struct {
Tenant string `json:"tenant"`
User string `json:"user"`
}
func startHandler(w http.ResponseWriter, r *http.Request) {
var body startReq
json.NewDecoder(r.Body).Decode(&body)
clientIP := clientIPFrom(r)
inst, err := lim.IosInstances.New(r.Context(), limrun.IosInstanceNewParams{
Wait: param.NewOpt(true),
ReuseIfExists: param.NewOpt(true),
Metadata: limrun.IosInstanceNewParamsMetadata{
Labels: map[string]string{
"tenant": body.Tenant,
"user": body.User,
"managed_by": "platform-embed",
},
},
Spec: limrun.IosInstanceNewParamsSpec{
InactivityTimeout: param.NewOpt("15m"),
Clues: []limrun.IosInstanceNewParamsSpecClue{{
Kind: "ClientIP",
ClientIP: param.NewOpt(clientIP),
}},
},
})
if err != nil { http.Error(w, err.Error(), 500); return }
json.NewEncoder(w).Encode(map[string]string{
"id": inst.Metadata.ID,
"webrtcUrl": inst.Status.EndpointWebSocketURL,
"token": inst.Status.Token,
})
}
func clientIPFrom(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
return strings.TrimSpace(strings.Split(xff, ",")[0])
}
host, _, _ := strings.Cut(r.RemoteAddr, ":")
return host
}
func main() {
http.HandleFunc("/sessions/start", startHandler)
http.ListenAndServe(":3001", nil)
}from fastapi import FastAPI, Request
from limrun_api import Limrun
import os
lim = Limrun(api_key=os.environ["LIM_API_KEY"])
app = FastAPI()
@app.post("/sessions/start")
async def start(req: Request):
body = await req.json()
client_ip = (req.headers.get("x-forwarded-for") or req.client.host).split(",")[0].strip()
instance = lim.ios_instances.create(
wait=True,
reuse_if_exists=True,
metadata={
"labels": {
"tenant": body.get("tenant"),
"user": body.get("user"),
"managed_by": "platform-embed",
},
},
spec={
"model": "iphone",
"inactivity_timeout": "15m",
"clues": [{"kind": "ClientIP", "client_ip": client_ip}] if client_ip else [],
},
)
return {
"id": instance.metadata.id,
"webrtcUrl": instance.status.endpoint_web_socket_url,
"token": instance.status.token,
}
@app.post("/sessions/stop")
async def stop(req: Request):
body = await req.json()
lim.ios_instances.delete(body["id"])
return {"ok": True}A few details worth calling out. Full coverage of each lives on Run an iOS simulator and Run an Android emulator.
- Scheduling clues. Network latency between client browser and Limrun's hosting region can be minised by hinting Limrun's scheduler to provision the instance closer to the end user's location. To do this, forward the end user's IP (
X-Forwarded-Fororreq.socket.remoteAddress) as aClientIPclue so the instance lands near them. - Labels for tenant scope and reuse. Labels are free-form key-value pairs which can be used to organise multiple tenants or reuse hot instances. If
reuseIfExistsis set totrue, scheduler returns existing hot instance whoseregionandlabelsmatch the current create call, so a user reopening their tab lands back on the same warm instance. - Pre-install the customer's app. Reference an uploaded build (or an App Store catalog entry like Expo Go) via
spec.initialAssets, and the embedded simulator is launched with the app pre-installed during provisioning. See Asset Storage. - Session lifecycle.
inactivityTimeoutdefaults to3m, almost always too short for an embedded UI; set 10 to 30 minutes.hardTimeoutis a forced-termination safety cap, default'0'(no cap). Always wire up an explicitdeleteon tab close.
Install @limrun/ui
Add the UI package to your frontend. It ships ES modules and TypeScript types; styles are bundled into the build.
# Pick your package manager
npm install @limrun/ui
pnpm add @limrun/ui
bun add @limrun/uiWorks in any React 18+ tree (Vite, Next.js, Create React App, Remix). <RemoteControl /> is a single forward-refed component with no providers, no context, and no global state.
Render <RemoteControl />
Ask your backend for a session, render the component, post a stop when the user leaves.
import { RemoteControl, type RemoteControlHandle } from '@limrun/ui';
import { useEffect, useRef, useState } from 'react';
type Session = { id: string; webrtcUrl: string; token: string };
export function Simulator() {
const [session, setSession] = useState<Session | null>(null);
const ctrlRef = useRef<RemoteControlHandle>(null);
useEffect(() => {
let cancelled = false;
let sessionId: string | undefined;
fetch('/sessions/start', { method: 'POST' })
.then((r) => r.json())
.then((s: Session) => {
if (cancelled) return;
sessionId = s.id;
setSession(s);
});
return () => {
cancelled = true;
if (sessionId) {
fetch('/sessions/stop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: sessionId }),
keepalive: true,
});
}
};
}, []);
if (!session) return <div>Booting simulator…</div>;
return (
<RemoteControl
ref={ctrlRef}
url={session.webrtcUrl}
token={session.token}
sessionId={`tab-${Date.now()}`}
/>
);
}keepalive: true on the cleanup fetch lets the request complete even if the tab is closing, so the instance terminates promptly instead of waiting for inactivityTimeout.
Component props
| Prop | Type | Default | Use |
|---|---|---|---|
url | string | required | WebSocket URL from the instance. Pass instance.status.endpointWebSocketUrl. |
token | string | required | Per-instance bearer. Pass instance.status.token. The component appends it to the connection URL as ?token=…. |
sessionId | string | random | A unique signaling-session identifier. Prevents collisions when multiple browser tabs resolve to the same reusable instance. Use a fresh value per tab (e.g. tab-${Date.now()}). |
openUrl | string | none | A URL or deep link to open on the device once the control channel opens (e.g. https://example.com, myapp://orders/42, exp://… for Expo Go). |
showFrame | boolean | true | Draw the device bezel around the video. See iPad and Apple Watch; only iPhone and Pixel models ship native bezel art. |
autoReconnect | boolean | false | If a working session's connection drops, reconnect automatically instead of showing the Retry button. |
className | string | none | Extra class names applied on top of the component's defaults. |
Pre-wired interactions
The component translates browser events into device input on its own. No wiring required from your side.
| Browser input | Device input |
|---|---|
| Mouse click / tap | Tap |
| Mouse drag / single-finger touch drag | Swipe |
| Alt + drag | Two-finger pinch (mirrored around the screen center) |
| Two-finger touch on a touchscreen | Two-finger pinch / pan |
Letters, numbers, arrows, Enter, Backspace, modifiers | Forwarded as device key events |
Cmd + V / Ctrl + V | Reads browser clipboard, pastes into the focused field on the device |
Cmd + M / Ctrl + M | Sends the Menu key |
Keyboard events only forward while the embedded <video> element is focused. The component focuses the video on tap.
iOS extended touch area
iOS treats the home-indicator strip as part of the gesture surface. To make swipe-up-from-home, app-switcher, and pull-from-bottom gestures work the same way they do on a real device, <RemoteControl /> exposes a 60-pixel extended touch area below the visible screen. A drag started inside that strip and moved up enters the iOS gesture system the way it would on hardware.
Reconnect behavior
<RemoteControl /> runs its own retry budget when a connection fails:
- During the initial connect, up to 3 attempts with a 1 s delay between them; if the control channel doesn't open within 15 s, the current attempt counts as failed.
- If all 3 initial attempts fail, the component renders a Retry button and stops trying. The user clicking it (or your code calling
ctrlRef.current.reconnect()) starts over. - If a connection succeeds, the user uses the simulator for a while, and then the connection drops:
- With
autoReconnect={false}(default), the Retry button is shown. - With
autoReconnect={true}, the component reconnects in the background.
- With
autoReconnect={true} is the right default for long-running embedded sessions where transient network blips are expected. The Retry button is more honest for short sessions where the user benefits from knowing the connection dropped.
A separate keep-alive ping fires every 10 s while the page is visible, and pauses while the tab is hidden (so an idle background tab doesn't waste your inactivityTimeout budget).
Configurable actions using <RemoteControlHandle>
Pass a ref to call methods imperatively from the React side. The handle is populated once the WebSocket and control channel are open. Five methods, no more:
| Method | Returns | Notes |
|---|---|---|
openUrl(url: string) | void | Sends a URL/deep-link message over the WebSocket. The component URL-decodes the string before sending. |
screenshot() | Promise<{ dataUri: string }> | Rejects after 30 s if no reply. |
terminateApp(bundleId: string) | Promise<void> | Rejects after 30 s if no reply. iOS only today. |
sendKeyEvent({ type, code, shiftKey?, altKey?, ctrlKey?, metaKey? }) | void | type is 'keydown' | 'keyup', code is a DOM KeyboardEvent.code value (e.g. 'KeyA', 'Enter', 'ArrowDown'). |
reconnect() | void | Tears down the current WebSocket/RTC connection and starts a fresh attempt with the same url/token. |
const ctrlRef = useRef<RemoteControlHandle>(null);
// Open a deep link or URL on the device
ctrlRef.current?.openUrl('myapp://orders/42');
// Capture a screenshot (data URI, 30 s timeout)
const { dataUri } = await ctrlRef.current!.screenshot();
// Kill an app by bundle ID (iOS; 30 s timeout)
await ctrlRef.current!.terminateApp('com.example.MyApp');
// Send a single keyboard event
ctrlRef.current?.sendKeyEvent({ type: 'keydown', code: 'Enter' });
// Tear down the current connection and start a fresh attempt
ctrlRef.current?.reconnect();These are browser-side imperatives. If you need richer backend-side control of the same device (taps by accessibility selector, element-tree inspection, video recording, reverse tunnels, soft-resets), drive the instance from your server with Ios.createInstanceClient or the Android equivalent. The two paths are fully compatible; you can run both at once.
iPad and Apple Watch
showFrame ships native bezel art for iPhone (iOS), Pixel 9 (Android phone), and Pixel Tablet (Android tablet). iPad and Apple Watch don't have a dedicated frame today; render them frameless:
<RemoteControl
url={session.webrtcUrl}
token={session.token}
showFrame={false}
/>The video stream still carries the correct aspect ratio and resolution; you can wrap the component in your own chrome if you want a frame. To pick the model on the backend side, set spec.model: 'ipad' or spec.model: 'watch' on the iOS create call.
End-to-end example
A self-contained backend + frontend pair. Backend creates an instance (with Expo Go pre-installed and a client-IP scheduling clue), frontend renders it and tears down on unmount. Drop these two files into a fresh project and you have a working embedded simulator.
import express, { Request, Response } from 'express';
import cors from 'cors';
import Limrun from '@limrun/api';
const lim = new Limrun({ apiKey: process.env.LIM_API_KEY! });
const app = express();
app.use(cors(), express.json());
app.post('/sessions/start', async (req: Request, res: Response) => {
try {
const { tenant = 'demo', user = 'anonymous' } = req.body ?? {};
const xff = req.headers['x-forwarded-for'];
const firstHop = Array.isArray(xff)
? xff[0]
: xff?.split(',')[0]?.trim();
const clientIp = firstHop ?? req.socket.remoteAddress ?? undefined;
const instance = await lim.iosInstances.create({
wait: true,
reuseIfExists: true,
metadata: {
labels: { tenant, user, managed_by: 'platform-embed' },
},
spec: {
model: 'iphone',
inactivityTimeout: '15m',
hardTimeout: '2h',
...(clientIp && clientIp !== '::1' && clientIp !== '127.0.0.1'
? { clues: [{ kind: 'ClientIP', clientIp }] }
: {}),
initialAssets: [
{
kind: 'App',
source: 'AssetName',
assetName: 'appstore/Expo-Go-55-iOS-latest.tar.gz',
launchMode: 'ForegroundIfRunning',
},
],
},
});
res.json({
id: instance.metadata.id,
webrtcUrl: instance.status.endpointWebSocketUrl,
token: instance.status.token,
});
} catch (err) {
res.status(500).json({
error: err instanceof Error ? err.message : String(err),
});
}
});
app.post('/sessions/stop', async (req: Request, res: Response) => {
try {
await lim.iosInstances.delete(req.body.id);
res.sendStatus(204);
} catch (err) {
res.status(500).json({
error: err instanceof Error ? err.message : String(err),
});
}
});
app.listen(3001, () => console.log('Backend listening on :3001'));import { RemoteControl, type RemoteControlHandle } from '@limrun/ui';
import { useEffect, useRef, useState } from 'react';
type Session = { id: string; webrtcUrl: string; token: string };
export function Simulator({ tenant, user }: { tenant: string; user: string }) {
const [session, setSession] = useState<Session | null>(null);
const [error, setError] = useState<string | null>(null);
const ctrlRef = useRef<RemoteControlHandle>(null);
useEffect(() => {
let cancelled = false;
let sessionId: string | undefined;
(async () => {
try {
const res = await fetch('/sessions/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tenant, user }),
});
if (!res.ok) throw new Error((await res.json()).error ?? res.statusText);
const s: Session = await res.json();
if (cancelled) {
fetch('/sessions/stop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: s.id }),
keepalive: true,
});
return;
}
sessionId = s.id;
setSession(s);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
}
})();
return () => {
cancelled = true;
if (sessionId) {
fetch('/sessions/stop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: sessionId }),
keepalive: true,
});
}
};
}, [tenant, user]);
if (error) return <div role="alert">Failed to start session: {error}</div>;
if (!session) return <div>Booting simulator…</div>;
return (
<div style={{ display: 'flex', gap: 12 }}>
<RemoteControl
ref={ctrlRef}
url={session.webrtcUrl}
token={session.token}
sessionId={`tab-${Date.now()}`}
autoReconnect
openUrl="exp://exp.host/@anonymous/my-snack"
/>
<button
onClick={async () => {
const shot = await ctrlRef.current?.screenshot();
if (shot) window.open(shot.dataUri);
}}
>
Screenshot
</button>
</div>
);
}A more elaborate version of the same pattern lives in typescript-sdk/examples/fullstack, with a UI for switching platforms and iOS models.
Next steps
Asset Storage
Upload customer builds and reference them in spec.initialAssets so the embedded simulator opens on their app.
Run an iOS Simulator
Backend-side device control: tap by accessibility selector, element-tree, recording, reverse tunnels.
Run an Android Emulator
Same embed wiring, Android side. ADB tunnel, scrcpy, Appium, OS-version clues.
Build with remote Xcode
If your customers also compile iOS apps in your product, run xcodebuild on Limrun and pipe the result into the same simulator.
Was this guide helpful?