diff --git a/.github/workflows/e2e-tests-full.yml b/.github/workflows/e2e-tests-full.yml
index 50528a8b3..26df33b74 100644
--- a/.github/workflows/e2e-tests-full.yml
+++ b/.github/workflows/e2e-tests-full.yml
@@ -79,3 +79,19 @@ jobs:
           GEMINI_API_KEY: ${{ env.E2E_GEMINI_API_KEY }}
           CDK_TARBALL: ${{ env.CDK_TARBALL }}
         run: npm run test:e2e
+      - name: Install Playwright browsers
+        run: npx playwright install chromium --with-deps
+      - name: Run browser tests
+        env:
+          AWS_ACCOUNT_ID: ${{ steps.aws.outputs.account_id }}
+          AWS_REGION: ${{ inputs.aws_region || 'us-east-1' }}
+          PLAYWRIGHT_TRACE: 'off'
+        run: npm run test:browser
+      - name: Print browser test debug info
+        if: failure()
+        run: |
+          echo "=== Dev server PTY output ==="
+          cat test-results/agentcore-dev-pty.log 2>/dev/null || echo "(no pty log)"
+          echo ""
+          echo "=== Error contexts ==="
+          find test-results -name 'error-context.md' -exec echo "--- {} ---" \; -exec cat {} \; 2>/dev/null || echo "(no error contexts)"
diff --git a/.gitignore b/.gitignore
index 3b00d92ae..d22e52199 100644
--- a/.gitignore
+++ b/.gitignore
@@ -67,3 +67,8 @@ ProtocolTesting/
 
 # Auto-cloned CDK constructs (from scripts/bundle.mjs)
 .cdk-constructs-clone/
+
+# Browser tests
+browser-tests/.browser-test-env
+browser-tests/test-results/
+browser-tests/playwright-report/
diff --git a/browser-tests/constants.ts b/browser-tests/constants.ts
new file mode 100644
index 000000000..7ef4d4848
--- /dev/null
+++ b/browser-tests/constants.ts
@@ -0,0 +1,3 @@
+import { join } from 'node:path';
+
+export const ENV_FILE = join(__dirname, '.browser-test-env');
diff --git a/browser-tests/fixtures.ts b/browser-tests/fixtures.ts
new file mode 100644
index 000000000..b6aa7b601
--- /dev/null
+++ b/browser-tests/fixtures.ts
@@ -0,0 +1,56 @@
+import { ENV_FILE } from './constants';
+import { type Page, test as base, expect } from '@playwright/test';
+import { readFileSync } from 'node:fs';
+
+interface BrowserTestEnv {
+  projectPath: string;
+  port: number;
+  projectName: string;
+}
+
+function readTestEnv(): BrowserTestEnv {
+  const raw = readFileSync(ENV_FILE, 'utf-8');
+  const parsed: Record<string, string> = {};
+  for (const line of raw.split('\n')) {
+    const match = line.match(/^(\w+)=(.+)$/);
+    if (match) parsed[match[1]!] = match[2]!;
+  }
+  return {
+    projectPath: parsed.PROJECT_PATH!,
+    port: Number(parsed.PORT),
+    projectName: parsed.PROJECT_NAME!,
+  };
+}
+
+export const test = base.extend<{ testEnv: BrowserTestEnv }>({
+  testEnv: async ({}, use) => {
+    await use(readTestEnv());
+  },
+});
+
+/**
+ * Send a chat message and wait for the agent to finish responding.
+ * Returns the assistant message locator.
+ */
+export async function sendMessage(page: Page, text: string) {
+  const chatInput = page.getByTestId('chat-input');
+  await expect(chatInput).toBeEnabled({ timeout: 60_000 });
+
+  const messageList = page.getByTestId('message-list');
+  const existingCount = await messageList.getByTestId(/^chat-message-/).count();
+
+  await chatInput.fill(text);
+  await page.getByRole('button', { name: 'Send message' }).click();
+
+  const assistantMessage = messageList.getByTestId(`chat-message-${existingCount + 1}`);
+  await expect(assistantMessage).toBeVisible({ timeout: 60_000 });
+  await expect(assistantMessage).not.toContainText('ECONNREFUSED');
+
+  // Wait for streaming to complete so the agent is idle for subsequent tests.
+  await chatInput.fill('.');
+  await expect(page.getByRole('button', { name: 'Send message' })).toBeEnabled({ timeout: 30_000 });
+
+  return assistantMessage;
+}
+
+export { expect };
diff --git a/browser-tests/global-setup.ts b/browser-tests/global-setup.ts
new file mode 100644
index 000000000..462cfa260
--- /dev/null
+++ b/browser-tests/global-setup.ts
@@ -0,0 +1,144 @@
+import { ENV_FILE } from './constants';
+import * as pty from 'node-pty';
+import { type ExecSyncOptions, execSync } from 'node:child_process';
+import { randomUUID } from 'node:crypto';
+import { createWriteStream, mkdirSync, writeFileSync } from 'node:fs';
+import { createConnection } from 'node:net';
+import { tmpdir } from 'node:os';
+import { join, resolve } from 'node:path';
+
+const CLI_PATH = join(__dirname, '..', 'dist', 'cli', 'index.mjs');
+const PTY_LOG = join(__dirname, 'test-results', 'agentcore-dev-pty.log');
+
+function hasAwsCredentials(): boolean {
+  try {
+    execSync('aws sts get-caller-identity', { stdio: 'ignore' });
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+function hasCommand(cmd: string): boolean {
+  try {
+    execSync(`which ${cmd}`, { stdio: 'ignore' });
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+async function waitForServerReady(port: number, timeoutMs = 90000): Promise<boolean> {
+  const start = Date.now();
+  while (Date.now() - start < timeoutMs) {
+    const listening = await new Promise<boolean>(resolve => {
+      const socket = createConnection({ port, host: '127.0.0.1' }, () => {
+        socket.destroy();
+        resolve(true);
+      });
+      socket.on('error', () => {
+        socket.destroy();
+        resolve(false);
+      });
+    });
+    if (listening) return true;
+    await new Promise(resolve => setTimeout(resolve, 500));
+  }
+  return false;
+}
+
+export default async function globalSetup() {
+  const missing: string[] = [];
+  if (!hasAwsCredentials()) missing.push('AWS credentials (run `aws sts get-caller-identity`)');
+  if (!hasCommand('uv')) missing.push('`uv` on PATH');
+
+  if (missing.length > 0) {
+    if (process.env.CI) {
+      throw new Error(`Browser tests require: ${missing.join(', ')}`);
+    }
+    console.log(`\nSkipping browser tests — missing: ${missing.join(', ')}\n`);
+    process.exit(0);
+  }
+
+  const testDir = join(tmpdir(), `agentcore-browser-test-${randomUUID()}`);
+  mkdirSync(testDir, { recursive: true });
+
+  const projectName = `BrTest${String(Date.now()).slice(-8)}`;
+
+  console.log(`\nCreating test project "${projectName}" in ${testDir}`);
+
+  const cleanEnv = { ...process.env };
+  delete cleanEnv.INIT_CWD;
+
+  const execOpts: ExecSyncOptions = { cwd: testDir, stdio: 'pipe', env: cleanEnv };
+
+  let createRaw: string;
+  try {
+    createRaw = execSync(
+      `node ${CLI_PATH} create --name ${projectName} --language Python --framework Strands --model-provider Bedrock --memory none --json`,
+      execOpts
+    ).toString();
+  } catch (err: unknown) {
+    const e = err as { stderr?: Buffer; stdout?: Buffer; status?: number };
+    const stderr = e.stderr?.toString() ?? '';
+    const stdout = e.stdout?.toString() ?? '';
+    throw new Error(`agentcore create failed (exit ${e.status}):\nstdout: ${stdout}\nstderr: ${stderr}`);
+  }
+
+  // eslint-disable-next-line no-control-regex
+  const createResult = createRaw.replace(/\x1B\[\??\d*[a-zA-Z]/g, '').trim();
+  const parsed = JSON.parse(createResult.split('\n').pop()!);
+  const projectPath: string = resolve(testDir, parsed.projectPath);
+
+  console.log(`Project created at ${projectPath}`);
+  console.log(`Starting agentcore dev...`);
+
+  const env = { ...process.env };
+  delete env.INIT_CWD;
+  if (env.AGENT_INSPECTOR_PATH) {
+    env.AGENT_INSPECTOR_PATH = resolve(env.AGENT_INSPECTOR_PATH);
+  }
+
+  const ptyProcess = pty.spawn('node', [CLI_PATH, 'dev'], {
+    cwd: projectPath,
+    env,
+    cols: 80,
+    rows: 24,
+  });
+
+  mkdirSync(join(__dirname, 'test-results'), { recursive: true });
+  // eslint-disable-next-line no-control-regex
+  const stripAnsi = (s: string) => s.replace(/\x1B\[\??[\d;]*[a-zA-Z]/g, '');
+  const ptyLog = createWriteStream(PTY_LOG);
+
+  let serverOutput = '';
+  const webUIPort = await new Promise<number>((resolvePort, reject) => {
+    const timeout = setTimeout(() => {
+      ptyProcess.kill();
+      reject(new Error(`agentcore dev failed to start within timeout.\nOutput: ${serverOutput}`));
+    }, 90000);
+
+    ptyProcess.onData((data: string) => {
+      serverOutput += data;
+      ptyLog.write(stripAnsi(data));
+      const match = serverOutput.match(/Chat UI: http:\/\/localhost:(\d+)/);
+      if (match) {
+        clearTimeout(timeout);
+        resolvePort(parseInt(match[1]!, 10));
+      }
+    });
+  });
+
+  const ready = await waitForServerReady(webUIPort);
+  if (!ready) {
+    ptyProcess.kill();
+    throw new Error(`Web UI reported port ${webUIPort} but it is not responding.\nOutput: ${serverOutput}`);
+  }
+
+  console.log(`Dev server ready on port ${webUIPort}`);
+
+  writeFileSync(
+    ENV_FILE,
+    `PROJECT_PATH=${projectPath}\nPORT=${webUIPort}\nTEST_DIR=${testDir}\nSERVER_PID=${ptyProcess.pid}\nPROJECT_NAME=${projectName}\n`
+  );
+}
diff --git a/browser-tests/global-teardown.ts b/browser-tests/global-teardown.ts
new file mode 100644
index 000000000..be3ad64bf
--- /dev/null
+++ b/browser-tests/global-teardown.ts
@@ -0,0 +1,39 @@
+import { ENV_FILE } from './constants';
+import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, unlinkSync } from 'node:fs';
+import { join } from 'node:path';
+
+export default async function globalTeardown() {
+  if (!existsSync(ENV_FILE)) return;
+
+  const raw = readFileSync(ENV_FILE, 'utf-8');
+
+  const serverPid = raw.match(/^SERVER_PID=(.+)$/m)?.[1];
+  if (serverPid) {
+    try {
+      process.kill(Number(serverPid), 'SIGTERM');
+      console.log(`\nStopped dev server (PID ${serverPid})`);
+    } catch {
+      // Process already exited
+    }
+    await new Promise<void>(resolve => setTimeout(resolve, 2000));
+  }
+
+  const projectPath = raw.match(/^PROJECT_PATH=(.+)$/m)?.[1];
+  const testDir = raw.match(/^TEST_DIR=(.+)$/m)?.[1];
+
+  if (projectPath) {
+    const logsDir = join(projectPath, 'agentcore', '.cli', 'logs');
+    const outputDir = join(__dirname, 'test-results', 'dev-server-logs');
+    if (existsSync(logsDir)) {
+      mkdirSync(outputDir, { recursive: true });
+      cpSync(logsDir, outputDir, { recursive: true });
+    }
+  }
+
+  if (testDir && existsSync(testDir)) {
+    console.log(`Cleaning up ${testDir}`);
+    rmSync(testDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 });
+  }
+
+  unlinkSync(ENV_FILE);
+}
diff --git a/browser-tests/playwright.config.ts b/browser-tests/playwright.config.ts
new file mode 100644
index 000000000..988b0004f
--- /dev/null
+++ b/browser-tests/playwright.config.ts
@@ -0,0 +1,33 @@
+import { ENV_FILE } from './constants';
+import { defineConfig, devices } from '@playwright/test';
+import { readFileSync } from 'node:fs';
+
+function getPort(): number {
+  try {
+    const raw = readFileSync(ENV_FILE, 'utf-8');
+    const match = raw.match(/^PORT=(\d+)$/m);
+    if (match) return parseInt(match[1]!, 10);
+  } catch {}
+  return 8081;
+}
+
+export default defineConfig({
+  testDir: './tests',
+  fullyParallel: false,
+  workers: 1,
+  timeout: 120_000,
+  retries: process.env.CI ? 1 : 0,
+  outputDir: './test-results',
+  reporter: [['html', { open: 'never', outputFolder: './playwright-report' }]],
+
+  globalSetup: './global-setup.ts',
+  globalTeardown: './global-teardown.ts',
+
+  use: {
+    baseURL: `http://localhost:${getPort()}`,
+    trace: process.env.PLAYWRIGHT_TRACE === 'off' ? 'off' : 'retain-on-failure',
+    screenshot: 'only-on-failure',
+  },
+
+  projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
+});
diff --git a/browser-tests/tests/chat-invocation.test.ts b/browser-tests/tests/chat-invocation.test.ts
new file mode 100644
index 000000000..fb82b39a0
--- /dev/null
+++ b/browser-tests/tests/chat-invocation.test.ts
@@ -0,0 +1,10 @@
+import { expect, sendMessage, test } from '../fixtures';
+
+test.describe('Chat invocation', () => {
+  test('send a message and receive a response', async ({ page }) => {
+    await page.goto('/');
+
+    const assistantMessage = await sendMessage(page, 'What is 2 plus 2? Reply with just the number.');
+    await expect(assistantMessage).not.toBeEmpty();
+  });
+});
diff --git a/browser-tests/tests/inspector-loads.test.ts b/browser-tests/tests/inspector-loads.test.ts
new file mode 100644
index 000000000..649a3edb4
--- /dev/null
+++ b/browser-tests/tests/inspector-loads.test.ts
@@ -0,0 +1,13 @@
+import { expect, test } from '../fixtures';
+
+test.describe('Inspector loads', () => {
+  test('page renders and shows the agent', async ({ page, testEnv }) => {
+    await page.goto('/');
+
+    await expect(page.locator('header')).toBeVisible();
+
+    const agentStatus = page.getByTestId('agent-status');
+    await expect(agentStatus).toBeVisible({ timeout: 30_000 });
+    await expect(agentStatus).toContainText(testEnv.projectName);
+  });
+});
diff --git a/browser-tests/tests/resources.test.ts b/browser-tests/tests/resources.test.ts
new file mode 100644
index 000000000..87e06c1c5
--- /dev/null
+++ b/browser-tests/tests/resources.test.ts
@@ -0,0 +1,19 @@
+import { expect, test } from '../fixtures';
+
+test.describe('Resources', () => {
+  test('resource panel shows the agent', async ({ page, testEnv }) => {
+    await page.goto('/');
+
+    const resourcePanel = page.getByTestId('resource-panel');
+    await expect(resourcePanel).toBeVisible({ timeout: 10_000 });
+
+    const resourcesTab = resourcePanel.getByRole('tab', { name: 'Resources' });
+    await resourcesTab.click();
+
+    const agentNode = resourcePanel.getByRole('button', { name: new RegExp(`agent: ${testEnv.projectName}`, 'i') });
+    await expect(agentNode).toBeVisible({ timeout: 10_000 });
+
+    await page.getByRole('button', { name: 'Toggle resource panel' }).click();
+    await expect(resourcePanel).not.toBeVisible();
+  });
+});
diff --git a/browser-tests/tests/start-agent.test.ts b/browser-tests/tests/start-agent.test.ts
new file mode 100644
index 000000000..a18c39747
--- /dev/null
+++ b/browser-tests/tests/start-agent.test.ts
@@ -0,0 +1,16 @@
+import { expect, test } from '../fixtures';
+
+test.describe('Start agent', () => {
+  test('agent starts and shows running status', async ({ page }) => {
+    await page.goto('/');
+
+    const agentStatus = page.getByTestId('agent-status');
+    await expect(agentStatus).toBeVisible({ timeout: 30_000 });
+
+    const chatInput = page.getByTestId('chat-input');
+    await expect(chatInput).toBeVisible({ timeout: 60_000 });
+    await expect(chatInput).toBeEnabled({ timeout: 60_000 });
+
+    await expect(page.getByText('Error')).not.toBeVisible();
+  });
+});
diff --git a/browser-tests/tests/traces.test.ts b/browser-tests/tests/traces.test.ts
new file mode 100644
index 000000000..77c1b5ad4
--- /dev/null
+++ b/browser-tests/tests/traces.test.ts
@@ -0,0 +1,22 @@
+import { expect, sendMessage, test } from '../fixtures';
+
+test.describe('Traces', () => {
+  test('traces panel shows trace after invocation', async ({ page }) => {
+    await page.goto('/');
+
+    await sendMessage(page, 'Say hello');
+
+    await page.getByRole('tab', { name: 'Traces' }).click();
+
+    const traceList = page.getByTestId('trace-list');
+    await expect(traceList).toBeVisible({ timeout: 30_000 });
+
+    const traceButton = traceList.getByRole('button').first();
+    await expect(traceButton).toBeVisible({ timeout: 30_000 });
+
+    await traceButton.click();
+
+    const spanRow = page.locator('[role="button"]').filter({ hasText: /.+/ });
+    await expect(spanRow.first()).toBeVisible({ timeout: 10_000 });
+  });
+});
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 30889b17a..700ab3aae 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -3,11 +3,12 @@
 ## Quick Start
 
 ```bash
-npm test           # Run unit tests
-npm run test:watch # Run tests in watch mode
-npm run test:integ # Run integration tests
-npm run test:tui   # Run TUI integration tests (builds first)
-npm run test:all   # Run all tests (unit + integ)
+npm test              # Run unit tests
+npm run test:watch    # Run tests in watch mode
+npm run test:integ    # Run integration tests
+npm run test:tui      # Run TUI integration tests (builds first)
+npm run test:browser  # Run browser tests (requires AWS creds, uv, agentcore)
+npm run test:all      # Run all tests (unit + integ)
 ```
 
 ## Test Organization
@@ -347,6 +348,63 @@ it('provides diagnostics in LaunchError', async () => {
 });
 ```
 
+## Browser Tests
+
+Browser tests use Playwright to test the web UI (agent inspector) served by `agentcore dev`.
+
+### Prerequisites
+
+- AWS credentials configured (`aws sts get-caller-identity` must succeed)
+- `uv` on PATH
+- Local build (`npm run build`)
+- Playwright browsers installed: `npx playwright install chromium`
+
+### Running
+
+```bash
+npm run test:browser
+```
+
+Test results and the HTML report are written to `browser-tests/test-results/` and `browser-tests/playwright-report/`
+respectively. To view the report:
+
+```bash
+npx playwright show-report browser-tests/playwright-report
+```
+
+By default, tests run against the `@aws/agent-inspector` package from npm (in `node_modules`).
+
+### Testing against a local agent-inspector build
+
+To test with a local checkout of the agent-inspector (e.g. when developing new UI features or adding test IDs):
+
+1. Clone `agent-inspector` as a sibling directory and build it
+2. Run with `AGENT_INSPECTOR_PATH`:
+
+```bash
+AGENT_INSPECTOR_PATH=../agent-inspector/dist-assets npm run test:browser
+```
+
+### Test structure
+
+```
+browser-tests/
+├── playwright.config.ts  # Playwright configuration
+├── global-setup.ts       # Creates test project, starts agentcore dev
+├── global-teardown.ts    # Stops dev server, cleans up temp files
+├── constants.ts          # Shared constants (env file path)
+├── fixtures.ts           # Custom test fixtures (testEnv with port, project path)
+└── tests/                # Test files
+    ├── chat-invocation.test.ts
+    ├── inspector-loads.test.ts
+    ├── resources.test.ts
+    ├── start-agent.test.ts
+    └── traces.test.ts
+```
+
+The global setup creates a temporary project via `agentcore create`, starts `agentcore dev`, and writes connection
+details to an env file. Tests read the env file via the `testEnv` fixture.
+
 ## Configuration
 
 Test configuration is in `vitest.config.ts` using Vitest projects:
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 72990753f..5111978cb 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -143,7 +143,7 @@ export default tseslint.config(
   prettier,
   // Relaxed rules for test files
   {
-    files: ['**/*.test.ts', '**/*.test.tsx', '**/test-utils/**', 'integ-tests/**'],
+    files: ['**/*.test.ts', '**/*.test.tsx', '**/test-utils/**', 'integ-tests/**', 'browser-tests/**'],
     rules: {
       'partition/no-hardcoded-arn-partition': 'off',
       'partition/no-hardcoded-endpoint-tld': 'off',
@@ -154,6 +154,10 @@ export default tseslint.config(
       '@typescript-eslint/no-unsafe-return': 'off',
       '@typescript-eslint/no-explicit-any': 'off',
       '@typescript-eslint/prefer-nullish-coalescing': 'off',
+      '@typescript-eslint/prefer-regexp-exec': 'off',
+      'no-empty-pattern': 'off',
+      'no-empty': 'off',
+      'react-hooks/rules-of-hooks': 'off',
     },
   },
   {
diff --git a/package-lock.json b/package-lock.json
index e7aada6c1..3abf38dc0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -48,6 +48,7 @@
         "@aws-sdk/client-cognito-identity-provider": "^3.1018.0",
         "@eslint/js": "^9.39.2",
         "@modelcontextprotocol/sdk": "^1.0.0",
+        "@playwright/test": "^1.59.1",
         "@secretlint/secretlint-rule-preset-recommend": "^11.3.0",
         "@trivago/prettier-plugin-sort-imports": "^6.0.2",
         "@types/js-yaml": "^4.0.9",
@@ -4228,6 +4229,22 @@
         "url": "https://opencollective.com/pkgr"
       }
     },
+    "node_modules/@playwright/test": {
+      "version": "1.59.1",
+      "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
+      "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "playwright": "1.59.1"
+      },
+      "bin": {
+        "playwright": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
     "node_modules/@protobufjs/aspromise": {
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
@@ -13337,6 +13354,53 @@
         "node": ">=16.20.0"
       }
     },
+    "node_modules/playwright": {
+      "version": "1.59.1",
+      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
+      "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "playwright-core": "1.59.1"
+      },
+      "bin": {
+        "playwright": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "fsevents": "2.3.2"
+      }
+    },
+    "node_modules/playwright-core": {
+      "version": "1.59.1",
+      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
+      "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "playwright-core": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/playwright/node_modules/fsevents": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+      "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
     "node_modules/pluralize": {
       "version": "8.0.0",
       "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
diff --git a/package.json b/package.json
index 01a066d7e..569244245 100644
--- a/package.json
+++ b/package.json
@@ -69,6 +69,7 @@
     "test:e2e": "vitest run --project e2e",
     "test:update-snapshots": "vitest run --project unit --update",
     "test:tui": "npm run build:harness && vitest run --project tui",
+    "test:browser": "npx playwright test --config browser-tests/playwright.config.ts",
     "bundle": "node scripts/bundle.mjs"
   },
   "dependencies": {
@@ -111,6 +112,7 @@
     "@aws-sdk/client-cognito-identity-provider": "^3.1018.0",
     "@eslint/js": "^9.39.2",
     "@modelcontextprotocol/sdk": "^1.0.0",
+    "@playwright/test": "^1.59.1",
     "@secretlint/secretlint-rule-preset-recommend": "^11.3.0",
     "@trivago/prettier-plugin-sort-imports": "^6.0.2",
     "@types/js-yaml": "^4.0.9",
diff --git a/src/cli/operations/dev/web-ui/__tests__/resolve-ui-dist-dir.test.ts b/src/cli/operations/dev/web-ui/__tests__/resolve-ui-dist-dir.test.ts
new file mode 100644
index 000000000..710281293
--- /dev/null
+++ b/src/cli/operations/dev/web-ui/__tests__/resolve-ui-dist-dir.test.ts
@@ -0,0 +1,62 @@
+import { resolveUIDistDir } from '../web-server.js';
+import fs from 'node:fs';
+import path from 'node:path';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('node:fs');
+
+const existsSync = vi.mocked(fs.existsSync);
+
+describe('resolveUIDistDir', () => {
+  const originalEnv = process.env;
+
+  beforeEach(() => {
+    process.env = { ...originalEnv };
+    delete process.env.AGENT_INSPECTOR_PATH;
+    existsSync.mockReturnValue(false);
+  });
+
+  afterEach(() => {
+    process.env = originalEnv;
+    vi.restoreAllMocks();
+  });
+
+  it('returns null when no candidate has index.html', () => {
+    expect(resolveUIDistDir()).toBeNull();
+  });
+
+  it('returns AGENT_INSPECTOR_PATH when env var is set and dir has index.html', () => {
+    const customPath = '/custom/inspector/dist';
+    process.env.AGENT_INSPECTOR_PATH = customPath;
+
+    existsSync.mockImplementation(p => p === path.join(customPath, 'index.html'));
+
+    expect(resolveUIDistDir()).toBe(customPath);
+  });
+
+  it('skips AGENT_INSPECTOR_PATH when env var is set but dir lacks index.html', () => {
+    process.env.AGENT_INSPECTOR_PATH = '/missing/inspector';
+    existsSync.mockReturnValue(false);
+
+    expect(resolveUIDistDir()).toBeNull();
+  });
+
+  it('returns the first candidate that has index.html', () => {
+    existsSync.mockImplementation(p => {
+      return String(p).endsWith(path.join('agent-inspector', 'index.html'));
+    });
+
+    const result = resolveUIDistDir();
+    expect(result).not.toBeNull();
+    expect(result!).toMatch(/agent-inspector$/);
+  });
+
+  it('prefers AGENT_INSPECTOR_PATH over bundled candidates', () => {
+    const customPath = '/custom/path';
+    process.env.AGENT_INSPECTOR_PATH = customPath;
+
+    existsSync.mockReturnValue(true);
+
+    expect(resolveUIDistDir()).toBe(customPath);
+  });
+});
diff --git a/src/cli/operations/dev/web-ui/handlers/start.ts b/src/cli/operations/dev/web-ui/handlers/start.ts
index 7fea6b742..41857bda1 100644
--- a/src/cli/operations/dev/web-ui/handlers/start.ts
+++ b/src/cli/operations/dev/web-ui/handlers/start.ts
@@ -170,10 +170,9 @@ async function doStartAgent(
     return { success: false, name: agentName, port: 0, error: errorMsg };
   }
 
-  ctx.runningAgents.set(agentName, { server: agentServer, port: agentPort, protocol: config.protocol });
-
-  // Wait for the server to actually accept connections before telling the
-  // frontend it's ready — otherwise immediate invocations get ECONNREFUSED.
+  // Wait for the server to accept connections before adding to runningAgents.
+  // runningAgents gates /api/status, so adding early lets the frontend send
+  // invocations before the server is ready.
   const ready = await waitForServerReady(agentPort);
   if (!ready) {
     const errorMsg =
@@ -182,5 +181,6 @@ async function doStartAgent(
     return { success: false, name: agentName, port: 0, error: errorMsg };
   }
 
+  ctx.runningAgents.set(agentName, { server: agentServer, port: agentPort, protocol: config.protocol });
   return { success: true, name: agentName, port: agentPort };
 }
diff --git a/src/cli/operations/dev/web-ui/web-server.ts b/src/cli/operations/dev/web-ui/web-server.ts
index 8632fedb7..c3f9c6f36 100644
--- a/src/cli/operations/dev/web-ui/web-server.ts
+++ b/src/cli/operations/dev/web-ui/web-server.ts
@@ -32,16 +32,17 @@ const CSP_HEADER =
   "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'self' data:";
 
 /** Resolve the frontend dist directory. Returns null if not found. */
-function resolveUIDistDir(): string | null {
+export function resolveUIDistDir(): string | null {
   const thisDir = path.dirname(fileURLToPath(import.meta.url));
   const candidates = [
+    process.env.AGENT_INSPECTOR_PATH,
     // Bundled CLI: dist/cli/index.mjs → dist/agent-inspector/
     path.resolve(thisDir, '..', 'agent-inspector'),
     // npm package: @aws/agent-inspector/dist-assets/
     path.resolve(thisDir, '..', '..', '..', '..', '..', 'node_modules', '@aws', 'agent-inspector', 'dist-assets'),
     // Dev via tsx: src/cli/operations/dev/web-ui/ → src/assets/agent-inspector/
     path.resolve(thisDir, '..', '..', '..', '..', 'assets', 'agent-inspector'),
-  ];
+  ].filter((c): c is string => !!c);
   for (const dir of candidates) {
     if (fs.existsSync(path.join(dir, 'index.html'))) return dir;
   }
diff --git a/tsconfig.json b/tsconfig.json
index ea8523c03..eedb71730 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -27,6 +27,7 @@
     "src/**/*",
     "integ-tests/**/*",
     "e2e-tests/**/*",
+    "browser-tests/**/*",
     "scripts/**/*",
     "vitest.config.ts",
     "vitest.integ.config.ts",
