diff --git a/frontend/.gitkeep b/frontend/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..019aca7
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ PodMan
+
+
+
+
+
+
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..0ae9d92
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "@podman/frontend",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc --noEmit && vite build",
+ "preview": "vite preview",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@podman/shared": "workspace:*",
+ "livekit-client": "^2.20.0",
+ "react": "^19.2.7",
+ "react-dom": "^19.2.7"
+ },
+ "devDependencies": {
+ "@tailwindcss/vite": "^4.3.1",
+ "@types/react": "^19.2.17",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.3",
+ "tailwindcss": "^4.3.1",
+ "vite": "^8.1.0",
+ "vite-plugin-pwa": "^1.3.0"
+ }
+}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
new file mode 100644
index 0000000..f8adf28
--- /dev/null
+++ b/frontend/src/App.tsx
@@ -0,0 +1,65 @@
+import { useState } from 'react';
+import type { Room } from 'livekit-client';
+import { joinPod } from './lib/pod.js';
+
+export default function App() {
+ const [podId, setPodId] = useState('demo-pod');
+ const [name, setName] = useState('');
+ const [room, setRoom] = useState(null);
+ const [error, setError] = useState(null);
+ const [connecting, setConnecting] = useState(false);
+
+ async function handleJoin() {
+ setError(null);
+ setConnecting(true);
+ try {
+ const identity = `${name || 'engineer'}-${Math.random().toString(36).slice(2, 7)}`;
+ setRoom(await joinPod(podId, identity, name || identity));
+ } catch (err) {
+ setError((err as Error).message);
+ } finally {
+ setConnecting(false);
+ }
+ }
+
+ return (
+
+
+
+ {room ? (
+
+ Connected to “{podId}”.
+ Sharing screen + mic. PodMan is watching.
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
new file mode 100644
index 0000000..7993442
--- /dev/null
+++ b/frontend/src/index.css
@@ -0,0 +1,16 @@
+@import 'tailwindcss';
+
+:root {
+ color-scheme: dark;
+}
+
+body {
+ margin: 0;
+ background: #0b0f17;
+ color: #e7eaf0;
+ font-family:
+ ui-sans-serif,
+ system-ui,
+ -apple-system,
+ sans-serif;
+}
diff --git a/frontend/src/lib/pod.ts b/frontend/src/lib/pod.ts
new file mode 100644
index 0000000..22f714e
--- /dev/null
+++ b/frontend/src/lib/pod.ts
@@ -0,0 +1,38 @@
+import { Room, RoomEvent } from 'livekit-client';
+
+const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8787';
+
+/** Ask the backend for a LiveKit token to join a pod. */
+export async function fetchPodToken(
+ podId: string,
+ identity: string,
+ name: string,
+): Promise<{ token: string; url: string }> {
+ const res = await fetch(`${BACKEND_URL}/pods/${encodeURIComponent(podId)}/token`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ identity, name }),
+ });
+ if (!res.ok) throw new Error(`token request failed: ${res.status}`);
+ return res.json();
+}
+
+/**
+ * Join a pod: connect to the LiveKit room and publish screen + mic so PodMan
+ * can watch. Returns the connected Room.
+ */
+export async function joinPod(podId: string, identity: string, name: string): Promise {
+ const { token, url } = await fetchPodToken(podId, identity, name);
+ const room = new Room({ adaptiveStream: true, dynacast: true });
+ room.on(RoomEvent.Disconnected, () => console.log('[podman] disconnected'));
+
+ await room.connect(url, token);
+
+ const screen = await navigator.mediaDevices.getDisplayMedia({ video: true });
+ for (const track of screen.getTracks()) {
+ await room.localParticipant.publishTrack(track);
+ }
+ await room.localParticipant.setMicrophoneEnabled(true);
+
+ return room;
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 0000000..251d265
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import App from './App.js';
+import './index.css';
+
+createRoot(document.getElementById('root')!).render(
+
+
+ ,
+);
diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts
new file mode 100644
index 0000000..2ce65b7
--- /dev/null
+++ b/frontend/src/vite-env.d.ts
@@ -0,0 +1,11 @@
+///
+///
+
+interface ImportMetaEnv {
+ readonly VITE_BACKEND_URL: string;
+ readonly VITE_LIVEKIT_URL: string;
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv;
+}
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..66a33a9
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../tsconfig.base.json",
+ "compilerOptions": {
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
+ "jsx": "react-jsx",
+ "noEmit": true
+ },
+ "include": ["src", "vite.config.ts"]
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..f953048
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,25 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+import tailwindcss from '@tailwindcss/vite';
+import { VitePWA } from 'vite-plugin-pwa';
+
+export default defineConfig({
+ plugins: [
+ react(),
+ tailwindcss(),
+ VitePWA({
+ registerType: 'autoUpdate',
+ manifest: {
+ name: 'PodMan',
+ short_name: 'PodMan',
+ description: 'Ambient AI teammate that prevents merge collisions before push',
+ theme_color: '#0b0f17',
+ background_color: '#0b0f17',
+ display: 'standalone',
+ },
+ }),
+ ],
+ server: {
+ port: 5173,
+ },
+});