diff --git a/.env.example b/.env.example
index eb6f584..157f0a1 100644
--- a/.env.example
+++ b/.env.example
@@ -1,2 +1,3 @@
HR_API_KEY=
+HR_CURSOR_AGENT_ID=
PORT=3000
diff --git a/.gitignore b/.gitignore
index 32bb174..0748d0e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,4 +5,4 @@ node_modules/
.next/
dist/
coverage/
-data/*.json
+**/data/*.json
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index fe01543..b98481a 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,10 +1,10 @@
# Contributing
-Thanks for helping improve Axiom.
+Thanks for helping improve the HarnessRouter demo from the Harvard workshop.
1. Fork the repository and create a focused branch.
2. Keep HarnessRouter credentials in your local `.env` file only.
3. Run `npm test` and `npm run build` before opening a pull request.
4. Explain the user-facing behavior and security impact of your change.
-Please keep pull requests small and avoid committing generated runtime data from `data/`.
+Please keep pull requests small and avoid committing generated runtime data from `demos/*/data/`.
diff --git a/README.md b/README.md
index ab98a17..554b0e1 100644
--- a/README.md
+++ b/README.md
@@ -1,32 +1,70 @@
-# LumaCare
+# HarnessRouter Demo at Harvard
-A compassionate family care companion for parents and caregivers of children receiving cancer care, powered by a purpose-configured HarnessRouter agent.
+Open-source demo repository for the HarnessRouter workshop at Harvard.
-## Run locally
+This repository contains two separate applications. The **Cursor-style coding app is the primary workshop demo**; **LumaCare is a second reference demo** showing how the same HarnessRouter patterns transfer to a different domain.
+
+## Demos
+
+### 1. Cursor-style coding app — primary demo
+
+**Workshop:** Build a Cursor in 3 Steps
+
+A coding-agent interface that demonstrates project-aware tasks, streamed output, persistent sessions, follow-up work, cancellation, and generated-file downloads.
+
+[Open the Cursor-style demo guide](./demos/01-cursor-coding-app/README.md)
```bash
npm install
-npm run dev
+cp .env.example .env
+# Add HR_API_KEY and HR_CURSOR_AGENT_ID to .env
+npm run dev:cursor
+```
+
+Then open [http://localhost:3000](http://localhost:3000).
+
+### 2. LumaCare — secondary reference demo
+
+A family-care companion that uses the same secure streaming and session architecture in a healthcare-support experience.
+
+[Open the LumaCare demo guide](./demos/02-lumacare/README.md)
+
+```bash
+# Add HR_API_KEY to .env; LumaCare's agent mapping is checked in
+npm run dev:lumacare
+```
+
+Then open [http://localhost:3000](http://localhost:3000).
+
+Run one demo at a time because both use port `3000` by default.
+
+## Repository structure
+
+```text
+.
+├── demos/
+│ ├── 01-cursor-coding-app/ # Primary Harvard workshop demo
+│ └── 02-lumacare/ # Secondary reference demo
+├── .env.example # Shared local configuration template
+├── package.json # npm workspace commands
+└── README.md # Repository and demo index
```
-Open `http://localhost:3000`. The server reads `HR_API_KEY` from the ignored `.env` file.
+Each demo owns its frontend, server, tests, agent mapping, and documentation. This keeps the two products independent while making their shared HarnessRouter architecture easy to compare.
-## What is included
+## Prerequisites
-- Calm, responsive family-facing care guidance experience
-- Structured plain-language guides with urgent red flags, care-team questions, and practical support
-- Data-only SSE parsing with immediate recovery-ID persistence
-- Persistent Session Recall and Continue/Revise flows
-- Product-level ownership checks for detail, continue, cancel, preview, and downloads
-- Sandboxed HTML previews and authenticated artifact/archive proxying
-- Demo identity switcher for exercising cross-user isolation
+- Node.js 22 or newer
+- A HarnessRouter API key
+- A HarnessRouter coding-agent ID for the primary demo
## Commands
```bash
-npm test # stream parser and ownership tests
-npm run build # production frontend and server typecheck
-npm run preview # serve the production build on port 3000
+npm run dev:cursor # Run the primary coding demo
+npm run dev:lumacare # Run the secondary LumaCare demo
+npm test # Test both workspaces
+npm run build # Build and type-check both workspaces
```
-The committed feature mapping lives in `config/agents.json`. The HarnessRouter API key never reaches the browser. LumaCare provides supportive guidance only and never replaces the child's oncology team.
+Credentials belong in an ignored `.env` file and never in source control. See each demo's README for its configuration and architecture.
diff --git a/demos/01-cursor-coding-app/.env.example b/demos/01-cursor-coding-app/.env.example
new file mode 100644
index 0000000..157f0a1
--- /dev/null
+++ b/demos/01-cursor-coding-app/.env.example
@@ -0,0 +1,3 @@
+HR_API_KEY=
+HR_CURSOR_AGENT_ID=
+PORT=3000
diff --git a/demos/01-cursor-coding-app/README.md b/demos/01-cursor-coding-app/README.md
new file mode 100644
index 0000000..504b90b
--- /dev/null
+++ b/demos/01-cursor-coding-app/README.md
@@ -0,0 +1,51 @@
+# Cursor-style Coding App
+
+The primary application for the **Build a Cursor in 3 Steps** HarnessRouter workshop at Harvard.
+
+This demo shows how to turn a purpose-configured HarnessRouter coding agent into a product experience with streamed output, recoverable sessions, follow-up tasks, cancellation, and artifact downloads.
+
+## Run it
+
+From the repository root:
+
+1. Install dependencies.
+
+ ```bash
+ npm install
+ ```
+
+2. Copy `.env.example` to the repository root as `.env`, then set both values.
+
+ ```dotenv
+ HR_API_KEY=your_key_here
+ HR_CURSOR_AGENT_ID=your_coding_agent_id
+ PORT=3000
+ ```
+
+3. Start the primary demo.
+
+ ```bash
+ npm run dev:cursor
+ ```
+
+Open [http://localhost:3000](http://localhost:3000).
+
+## Architecture
+
+| Layer | Responsibility |
+| --- | --- |
+| `config/agents.json` | Declares the `coding_assistant` feature; `HR_CURSOR_AGENT_ID` supplies its HarnessRouter agent ID |
+| `server/index.ts` | Keeps credentials server-side and proxies runs, sessions, cancellation, previews, and downloads |
+| `src/api.ts` | Parses data-only server-sent events |
+| `src/App.tsx` | Provides the Cursor-style coding-agent interface and session history |
+| `tests/` | Covers stream parsing and cross-user session ownership |
+
+The demo intentionally does not include a production repository sandbox. Configure the HarnessRouter coding agent with the tools and repository access appropriate to your workshop environment.
+
+## Direct workspace commands
+
+```bash
+npm run test --workspace=@harnessrouter/cursor-coding-app
+npm run build --workspace=@harnessrouter/cursor-coding-app
+npm run preview:cursor
+```
diff --git a/demos/01-cursor-coding-app/config/agents.json b/demos/01-cursor-coding-app/config/agents.json
new file mode 100644
index 0000000..5b5516e
--- /dev/null
+++ b/demos/01-cursor-coding-app/config/agents.json
@@ -0,0 +1,3 @@
+{
+ "coding_assistant": ""
+}
diff --git a/demos/01-cursor-coding-app/index.html b/demos/01-cursor-coding-app/index.html
new file mode 100644
index 0000000..2c8416b
--- /dev/null
+++ b/demos/01-cursor-coding-app/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+ HarnessRouter Code — Cursor-style agent demo
+
+
+
+
+
+
diff --git a/demos/01-cursor-coding-app/package.json b/demos/01-cursor-coding-app/package.json
new file mode 100644
index 0000000..737ce04
--- /dev/null
+++ b/demos/01-cursor-coding-app/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "@harnessrouter/cursor-coding-app",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "tsx server/index.ts",
+ "build": "vite build && tsc -p tsconfig.server.json --noEmit",
+ "preview": "NODE_ENV=production tsx server/index.ts",
+ "test": "node --import tsx --test tests/**/*.test.ts"
+ },
+ "dependencies": {
+ "dotenv": "^16.4.7",
+ "express": "^5.1.0",
+ "lucide-react": "^0.468.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ },
+ "devDependencies": {
+ "@types/express": "^5.0.0",
+ "@types/node": "^22.10.5",
+ "@types/react": "^19.0.3",
+ "@types/react-dom": "^19.0.2",
+ "@vitejs/plugin-react": "^4.3.4",
+ "tsx": "^4.19.2",
+ "typescript": "^5.7.2",
+ "vite": "^6.0.7"
+ }
+}
diff --git a/server/auth.ts b/demos/01-cursor-coding-app/server/auth.ts
similarity index 100%
rename from server/auth.ts
rename to demos/01-cursor-coding-app/server/auth.ts
diff --git a/demos/01-cursor-coding-app/server/index.ts b/demos/01-cursor-coding-app/server/index.ts
new file mode 100644
index 0000000..964a0ba
--- /dev/null
+++ b/demos/01-cursor-coding-app/server/index.ts
@@ -0,0 +1,303 @@
+import dotenv from 'dotenv';
+import crypto from 'node:crypto';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import express, { type Request, type Response } from 'express';
+import { authenticate, DEMO_USERS } from './auth.js';
+import { OwnershipStore } from './store.js';
+import type { RunRecord } from './types.js';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const root = path.resolve(__dirname, '..');
+dotenv.config({ path: path.resolve(root, '../..', '.env') });
+dotenv.config({ path: path.join(root, '.env'), override: true });
+const agentMap = JSON.parse(fs.readFileSync(path.join(root, 'config/agents.json'), 'utf8')) as Record;
+agentMap.coding_assistant = process.env.HR_CURSOR_AGENT_ID || agentMap.coding_assistant;
+const store = new OwnershipStore(path.join(root, 'data/ownership.json'));
+const apiBase = 'https://api.harnessrouter.ai';
+const apiKey = process.env.HR_API_KEY;
+
+if (!apiKey) throw new Error('HR_API_KEY is missing. Add it to the server-only .env file.');
+
+export const app = express();
+app.disable('x-powered-by');
+app.use(express.json({ limit: '2mb' }));
+
+function upstreamHeaders(extra: Record = {}) {
+ return { Authorization: `Bearer ${apiKey}`, ...extra };
+}
+
+async function upstreamJson(urlPath: string, init: RequestInit = {}) {
+ const response = await fetch(`${apiBase}${urlPath}`, {
+ ...init,
+ headers: upstreamHeaders({ ...(init.headers as Record || {}) }),
+ });
+ const data = await response.json().catch(() => ({ detail: 'HarnessRouter returned an unreadable response.' }));
+ if (!response.ok) {
+ const error = new Error(String((data as { detail?: string }).detail || `HarnessRouter request failed (${response.status})`));
+ Object.assign(error, { status: response.status });
+ throw error;
+ }
+ return data;
+}
+
+function errorResponse(res: Response, error: unknown) {
+ const value = error as { status?: number; message?: string };
+ const status = value.status && value.status >= 400 && value.status < 600 ? value.status : 502;
+ res.status(status).json({ error: value.message || 'The coding agent could not be reached.' });
+}
+
+function requireOwnership(req: Request, res: Response): RunRecord | null {
+ const record = store.getAuthorized(String(req.params.sessionId), req.productUser.id);
+ if (!record) {
+ res.status(404).json({ error: 'Session not found' });
+ return null;
+ }
+ return record;
+}
+
+app.get('/api/health', (_req, res) => res.json({ ok: true }));
+
+app.use('/api', authenticate);
+
+app.get('/api/config', (req, res) => {
+ res.json({
+ user: req.productUser,
+ users: Object.values(DEMO_USERS),
+ features: Object.keys(agentMap),
+ agent: { name: 'HarnessRouter Coding Agent', model: 'Configured in HarnessRouter' },
+ });
+});
+
+app.get('/api/sessions', (req, res) => {
+ res.json({ sessions: store.list(req.productUser.id) });
+});
+
+app.post('/api/runs', async (req, res) => {
+ const featureKey = String(req.body.featureKey || '');
+ const input = String(req.body.input || '').trim();
+ const previousResponseId = req.body.previousResponseId ? String(req.body.previousResponseId) : null;
+ const sessionId = req.body.sessionId ? String(req.body.sessionId) : null;
+ const harnessId = agentMap[featureKey];
+
+ if (!harnessId) return res.status(503).json({ error: 'Set HR_CURSOR_AGENT_ID in the root .env file before running the coding demo.' });
+ if (!input || input.length > 20_000) return res.status(400).json({ error: 'Enter a task under 20,000 characters.' });
+ if ((previousResponseId || sessionId) && !(previousResponseId && sessionId)) {
+ return res.status(400).json({ error: 'A continuation requires both recovery identifiers.' });
+ }
+ if (sessionId && !store.getAuthorized(sessionId, req.productUser.id)) {
+ return res.status(404).json({ error: 'Session not found' });
+ }
+
+ const requestBody: Record = { input, stream: true };
+ if (previousResponseId && sessionId) {
+ requestBody.previous_response_id = previousResponseId;
+ requestBody.metadata = { session_id: sessionId };
+ }
+
+ let upstream: globalThis.Response;
+ try {
+ upstream = await fetch(`${apiBase}/${harnessId}/v1/responses`, {
+ method: 'POST',
+ headers: upstreamHeaders({
+ 'Content-Type': 'application/json',
+ 'Idempotency-Key': crypto.randomUUID(),
+ }),
+ body: JSON.stringify(requestBody),
+ });
+ } catch {
+ return res.status(502).json({ error: 'HarnessRouter could not be reached.' });
+ }
+
+ if (!upstream.ok || !upstream.body) {
+ const detail = await upstream.json().catch(() => ({ detail: 'Agent request failed.' })) as { detail?: string };
+ return res.status(upstream.status || 502).json({ error: detail.detail || 'Agent request failed.' });
+ }
+
+ res.status(200);
+ res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
+ res.setHeader('Cache-Control', 'no-cache, no-transform');
+ res.setHeader('Connection', 'keep-alive');
+ res.flushHeaders();
+
+ const reader = upstream.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = '';
+ let recoveredSessionId = sessionId;
+ let recoveredResponseId = previousResponseId;
+
+ const handleFrame = (frame: string) => {
+ const dataLines = frame.split(/\r?\n/).filter((line) => line.startsWith('data:'));
+ if (!dataLines.length) return;
+ const raw = dataLines.map((line) => line.slice(5).trimStart()).join('\n');
+ if (raw === '[DONE]') return;
+ try {
+ const event = JSON.parse(raw) as any;
+ if (event.type === 'response.created') {
+ recoveredResponseId = event.response?.id || recoveredResponseId;
+ recoveredSessionId = event.response?.metadata?.session_id || recoveredSessionId;
+ if (recoveredResponseId && recoveredSessionId) {
+ const now = new Date().toISOString();
+ const existing = store.getAuthorized(recoveredSessionId, req.productUser.id);
+ store.save({
+ sessionId: recoveredSessionId,
+ responseId: recoveredResponseId,
+ userId: req.productUser.id,
+ featureKey,
+ title: existing?.title || (input.length > 62 ? `${input.slice(0, 62)}…` : input),
+ prompt: existing?.prompt || input,
+ createdAt: existing?.createdAt || now,
+ updatedAt: now,
+ status: 'running',
+ });
+ }
+ }
+ const statusMap: Record = {
+ 'response.completed': 'completed',
+ 'response.incomplete': 'incomplete',
+ 'response.failed': event.response?.status === 'cancelled' ? 'cancelled' : 'failed',
+ };
+ if (recoveredSessionId && statusMap[event.type]) {
+ store.update(recoveredSessionId, {
+ responseId: event.response?.id || recoveredResponseId || '',
+ status: statusMap[event.type],
+ });
+ }
+ } catch {
+ // Unknown or malformed upstream frames are forwarded but never crash the stream.
+ }
+ };
+
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ const text = decoder.decode(value, { stream: true });
+ if (!res.closed) res.write(text);
+ buffer += text;
+ let boundary = buffer.search(/\r?\n\r?\n/);
+ while (boundary >= 0) {
+ const frame = buffer.slice(0, boundary);
+ const separator = buffer.slice(boundary).match(/^\r?\n\r?\n/)?.[0].length || 2;
+ buffer = buffer.slice(boundary + separator);
+ handleFrame(frame);
+ boundary = buffer.search(/\r?\n\r?\n/);
+ }
+ }
+ if (buffer.trim()) handleFrame(buffer);
+ } catch {
+ if (!res.closed) {
+ res.write(`data: ${JSON.stringify({ type: 'coding.stream.disconnected', response: { id: recoveredResponseId, metadata: { session_id: recoveredSessionId } } })}\n\n`);
+ }
+ } finally {
+ if (!res.closed) res.end();
+ }
+});
+
+app.get('/api/sessions/:sessionId', async (req, res) => {
+ const record = requireOwnership(req, res);
+ if (!record) return;
+ try {
+ const [session, turns, files] = await Promise.all([
+ upstreamJson(`/v1/sessions/${encodeURIComponent(record.sessionId)}`),
+ upstreamJson(`/v1/sessions/${encodeURIComponent(record.sessionId)}/turns`),
+ upstreamJson(`/v1/sessions/${encodeURIComponent(record.sessionId)}/files?changed=true`),
+ ]);
+ const upstreamStatus = String((session as any).status || '');
+ const mapped = upstreamStatus === 'done' ? 'completed' : upstreamStatus;
+ if (mapped && mapped !== 'running' && mapped !== 'starting') store.update(record.sessionId, { status: mapped });
+ res.json({ record: { ...record, status: mapped || record.status }, session, turns, files });
+ } catch (error) {
+ errorResponse(res, error);
+ }
+});
+
+app.post('/api/sessions/:sessionId/cancel', async (req, res) => {
+ const record = requireOwnership(req, res);
+ if (!record) return;
+ try {
+ const result = await upstreamJson(`/v1/sessions/${encodeURIComponent(record.sessionId)}/cancel`, { method: 'POST' });
+ store.update(record.sessionId, { status: 'cancelled' });
+ res.json(result);
+ } catch (error) {
+ errorResponse(res, error);
+ }
+});
+
+async function getAuthorizedFile(req: Request, res: Response) {
+ const record = requireOwnership(req, res);
+ if (!record) return null;
+ const listing = await upstreamJson(`/v1/sessions/${encodeURIComponent(record.sessionId)}/files`) as any;
+ const file = (listing.files || []).find((item: any) => item.file_id === req.params.fileId);
+ if (!file) {
+ res.status(404).json({ error: 'File not found' });
+ return null;
+ }
+ return { record, file };
+}
+
+app.get('/api/sessions/:sessionId/files/:fileId/preview', async (req, res) => {
+ try {
+ const found = await getAuthorizedFile(req, res);
+ if (!found) return;
+ const officeTypes = new Set([
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ ]);
+ const suffix = officeTypes.has(found.file.media_type) ? '/pdf' : '/content';
+ const upstream = await fetch(`${apiBase}/v1/containers/${encodeURIComponent(found.record.sessionId)}/files/${encodeURIComponent(found.file.file_id)}${suffix}`, { headers: upstreamHeaders() });
+ if (!upstream.ok || !upstream.body) return res.status(upstream.status).json({ error: 'Preview unavailable' });
+ const mediaType = officeTypes.has(found.file.media_type) ? 'application/pdf' : found.file.media_type || 'application/octet-stream';
+ res.setHeader('Content-Type', mediaType);
+ res.setHeader('Content-Disposition', `inline; filename="${path.basename(found.file.path).replace(/["\r\n]/g, '_')}"`);
+ const bytes = Buffer.from(await upstream.arrayBuffer());
+ res.send(bytes);
+ } catch (error) {
+ errorResponse(res, error);
+ }
+});
+
+app.get('/api/sessions/:sessionId/files/:fileId/download', async (req, res) => {
+ try {
+ const found = await getAuthorizedFile(req, res);
+ if (!found) return;
+ const upstream = await fetch(`${apiBase}/v1/containers/${encodeURIComponent(found.record.sessionId)}/files/${encodeURIComponent(found.file.file_id)}/content`, { headers: upstreamHeaders() });
+ if (!upstream.ok || !upstream.body) return res.status(upstream.status).json({ error: 'Download unavailable' });
+ const filename = path.basename(found.file.path).replace(/["\r\n]/g, '_');
+ res.setHeader('Content-Type', found.file.media_type || 'application/octet-stream');
+ res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
+ res.send(Buffer.from(await upstream.arrayBuffer()));
+ } catch (error) {
+ errorResponse(res, error);
+ }
+});
+
+app.get('/api/sessions/:sessionId/archive', async (req, res) => {
+ const record = requireOwnership(req, res);
+ if (!record) return;
+ try {
+ const upstream = await fetch(`${apiBase}/v1/sessions/${encodeURIComponent(record.sessionId)}/files/archive?changed=true`, { headers: upstreamHeaders() });
+ if (!upstream.ok || !upstream.body) return res.status(upstream.status).json({ error: 'Archive unavailable' });
+ res.setHeader('Content-Type', 'application/zip');
+ res.setHeader('Content-Disposition', 'attachment; filename="coding-session-files.zip"');
+ res.send(Buffer.from(await upstream.arrayBuffer()));
+ } catch (error) {
+ errorResponse(res, error);
+ }
+});
+
+const port = Number(process.env.PORT || 3000);
+if (process.env.NODE_ENV === 'production') {
+ app.use(express.static(path.join(root, 'dist')));
+ app.get('*splat', (_req, res) => res.sendFile(path.join(root, 'dist/index.html')));
+} else {
+ const { createServer } = await import('vite');
+ const vite = await createServer({ server: { middlewareMode: true, hmr: false }, appType: 'spa' });
+ app.use(vite.middlewares);
+}
+
+if (process.env.NODE_ENV !== 'test') {
+ app.listen(port, () => console.log(`Cursor-style coding demo is running at http://localhost:${port}`));
+}
diff --git a/server/store.ts b/demos/01-cursor-coding-app/server/store.ts
similarity index 100%
rename from server/store.ts
rename to demos/01-cursor-coding-app/server/store.ts
diff --git a/server/types.ts b/demos/01-cursor-coding-app/server/types.ts
similarity index 100%
rename from server/types.ts
rename to demos/01-cursor-coding-app/server/types.ts
diff --git a/demos/01-cursor-coding-app/src/App.tsx b/demos/01-cursor-coding-app/src/App.tsx
new file mode 100644
index 0000000..45d323a
--- /dev/null
+++ b/demos/01-cursor-coding-app/src/App.tsx
@@ -0,0 +1,134 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import {
+ ArrowUp, Bot, Braces, Check, ChevronRight, CircleStop, Clock3, Code2,
+ Download, FileCode2, Files, GitBranch, History, LoaderCircle, PanelLeft,
+ RefreshCw, Search, Sparkles, TerminalSquare, X,
+} from 'lucide-react';
+import { api, apiHeaders, SSEParser, type StreamEvent } from './api';
+import type { AgentFile, SessionRecord } from './types';
+
+type Config = {
+ user: { id: string; name: string; initials: string };
+ users: { id: string; name: string; initials: string }[];
+ agent: { name: string; model: string };
+};
+
+const starterTasks = [
+ { icon: Search, title: 'Understand a codebase', prompt: 'Inspect this project and explain its architecture, main data flow, and the best place to add a new feature.' },
+ { icon: Braces, title: 'Implement a feature', prompt: 'Add a small, well-tested feature to this project. Explain the plan first, then make the change and verify it.' },
+ { icon: TerminalSquare, title: 'Debug a failure', prompt: 'Investigate the current failing test or build error, identify the root cause, and propose the smallest safe fix.' },
+];
+
+function extractTurnText(payload: any): string {
+ const turns = Array.isArray(payload) ? payload : payload?.turns || payload?.data || [];
+ const values: string[] = [];
+ const walk = (value: any) => {
+ if (!value || typeof value === 'string') return;
+ if (Array.isArray(value)) return value.forEach(walk);
+ if (typeof value === 'object') {
+ if ((value.type === 'output_text' || value.type === 'text') && typeof value.text === 'string') values.push(value.text);
+ Object.values(value).forEach(walk);
+ }
+ };
+ turns.slice(-2).forEach(walk);
+ return values.at(-1) || '';
+}
+
+function AgentOutput({ text }: { text: string }) {
+ return {text.split('\n').map((line, index) => {
+ const clean = line.trim();
+ if (!clean) return
;
+ if (clean.startsWith('```')) return
code
;
+ if (clean.startsWith('## ')) return
{clean.slice(3)} ;
+ if (clean.startsWith('# ')) return
{clean.slice(2)} ;
+ if (/^[-*] /.test(clean)) return
{clean.slice(2).replace(/\*\*/g, '')}
;
+ if (/^\d+\. /.test(clean)) return
{clean.match(/^\d+/)?.[0]} {clean.replace(/^\d+\. /, '').replace(/\*\*/g, '')}
;
+ return
{clean.replace(/\*\*/g, '')}
;
+ })}
;
+}
+
+export default function App() {
+ const [userId, setUserId] = useState(localStorage.getItem('coding-demo-user') || 'alice');
+ const [config, setConfig] = useState(null);
+ const [sessions, setSessions] = useState([]);
+ const [activeSession, setActiveSession] = useState(null);
+ const [files, setFiles] = useState([]);
+ const [input, setInput] = useState('');
+ const [answer, setAnswer] = useState('');
+ const [status, setStatus] = useState<'idle' | 'running' | 'completed' | 'incomplete' | 'failed' | 'cancelled'>('idle');
+ const [error, setError] = useState('');
+ const [historyOpen, setHistoryOpen] = useState(false);
+ const composerRef = useRef(null);
+
+ const loadSessions = useCallback(async () => {
+ const result = await api<{ sessions: SessionRecord[] }>('/api/sessions', userId);
+ setSessions(result.sessions);
+ }, [userId]);
+
+ useEffect(() => {
+ localStorage.setItem('coding-demo-user', userId);
+ setActiveSession(null); setAnswer(''); setFiles([]); setStatus('idle');
+ api('/api/config', userId).then(setConfig).catch((e) => setError(e.message));
+ loadSessions().catch(() => {});
+ }, [userId, loadSessions]);
+
+ const refreshSession = useCallback(async (session: SessionRecord) => {
+ const data = await api(`/api/sessions/${session.sessionId}`, userId);
+ const record = data.record as SessionRecord;
+ setActiveSession(record); setStatus(record.status === 'done' ? 'completed' : record.status as any);
+ setFiles((data.files?.files || []) as AgentFile[]);
+ const recovered = extractTurnText(data.turns); if (recovered) setAnswer(recovered);
+ loadSessions().catch(() => {}); return record;
+ }, [loadSessions, userId]);
+
+ const handleEvent = useCallback((event: StreamEvent, prompt: string) => {
+ if (event.type === 'response.created') setActiveSession({ sessionId: event.response?.metadata?.session_id, responseId: event.response?.id, userId, title: prompt.length > 64 ? `${prompt.slice(0, 64)}…` : prompt, prompt, status: 'running', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() });
+ else if (event.type === 'response.output_text.delta') setAnswer((value) => value + String(event.delta || ''));
+ else if (event.type === 'response.completed' || event.type === 'response.incomplete' || event.type === 'response.failed') setStatus(event.type === 'response.completed' ? 'completed' : event.type === 'response.incomplete' ? 'incomplete' : event.response?.status === 'cancelled' ? 'cancelled' : 'failed');
+ }, [userId]);
+
+ const runTask = async (continuation?: string) => {
+ const prompt = (continuation || input).trim();
+ if (!prompt || status === 'running') return;
+ setError(''); setAnswer(''); setFiles([]); setStatus('running'); if (!continuation) setInput('');
+ try {
+ const body: Record = { featureKey: 'coding_assistant', input: prompt };
+ if (activeSession && continuation) { body.sessionId = activeSession.sessionId; body.previousResponseId = activeSession.responseId; }
+ const response = await fetch('/api/runs', { method: 'POST', headers: apiHeaders(userId, true), body: JSON.stringify(body) });
+ if (!response.ok || !response.body) { const data = await response.json().catch(() => ({})); throw new Error(data.error || 'The coding agent could not start.'); }
+ const parser = new SSEParser(); const reader = response.body.getReader(); const decoder = new TextDecoder();
+ while (true) { const { done, value } = await reader.read(); if (done) break; parser.push(decoder.decode(value, { stream: true })).forEach((event) => handleEvent(event, prompt)); }
+ parser.flush().forEach((event) => handleEvent(event, prompt));
+ setTimeout(() => setActiveSession((current) => { if (current) refreshSession(current).catch(() => {}); return current; }), 700);
+ } catch (e) { setStatus('failed'); setError((e as Error).message); }
+ };
+
+ const openSession = async (session: SessionRecord) => { setHistoryOpen(false); setAnswer(''); setStatus('running'); try { await refreshSession(session); } catch (e) { setError((e as Error).message); setStatus('failed'); } };
+ const cancelRun = async () => { if (!activeSession) return; await api(`/api/sessions/${activeSession.sessionId}/cancel`, userId, { method: 'POST' }); setStatus('cancelled'); };
+ const usePrompt = (prompt: string) => { setInput(prompt); requestAnimationFrame(() => composerRef.current?.focus()); };
+ const downloadFiles = async () => {
+ if (!activeSession) return;
+ const response = await fetch(`/api/sessions/${activeSession.sessionId}/archive`, { headers: apiHeaders(userId) });
+ if (!response.ok) return setError('The session files could not be downloaded.');
+ const url = URL.createObjectURL(await response.blob()); const link = document.createElement('a'); link.href = url; link.download = 'coding-session-files.zip'; link.click(); URL.revokeObjectURL(url);
+ };
+
+ const user = config?.users.find((item) => item.id === userId);
+ return
+
+
HARVARD-WORKSHOP src server tests README.md package.json
setHistoryOpen(true)}> Agent history {sessions.length}
+
+
+
+ Cursor-style coding agent {config?.agent.model || 'HarnessRouter'}
setUserId(e.target.value)} aria-label="Demo developer">{config?.users.map((item) => {item.name} )} setHistoryOpen(true)}> History
+
+ {!answer && status !== 'running' ?
HARNESSROUTER AT HARVARD
Build a Cursor in3 steps. A coding agent that can understand a project, plan changes, stream its work, and preserve the session for follow-up.
{starterTasks.map(({ icon: Icon, title, prompt }) => usePrompt(prompt)}>{title} {prompt} )}
:
HarnessRouter Agent {status === 'running' ? 'Working through the repository…' : 'Task response'}
{files.length > 0 &&
Download files}
{status === 'running' && !answer ? Reading context and planning the change…
: }{status === 'incomplete' && runTask('Continue from the current state and finish the task.')}> Continue }}
+
+ Powered by HarnessRouterEnter to send · Shift+Enter for a new line
+
+ main Workshop demoHarnessRouter connected
+
+ {historyOpen &&
setHistoryOpen(false)}>
e.stopPropagation()}>Agent history Sessions for {user?.name || 'this developer'}
setHistoryOpen(false)} aria-label="Close history"> {sessions.length === 0 ?
Completed tasks will appear here.
: sessions.map((session) =>
openSession(session)}>{session.title} {new Date(session.updatedAt).toLocaleString()}
)}
}
+ {error &&
{error} setError('')}>Dismiss
}
+
;
+}
diff --git a/src/api.ts b/demos/01-cursor-coding-app/src/api.ts
similarity index 100%
rename from src/api.ts
rename to demos/01-cursor-coding-app/src/api.ts
diff --git a/src/main.tsx b/demos/01-cursor-coding-app/src/main.tsx
similarity index 100%
rename from src/main.tsx
rename to demos/01-cursor-coding-app/src/main.tsx
diff --git a/demos/01-cursor-coding-app/src/styles.css b/demos/01-cursor-coding-app/src/styles.css
new file mode 100644
index 0000000..72d20cb
--- /dev/null
+++ b/demos/01-cursor-coding-app/src/styles.css
@@ -0,0 +1,5 @@
+@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Inter:wght@400;500;600;700&display=swap');
+
+:root{font-family:Inter,system-ui,sans-serif;color:#d7d9df;background:#141518;font-synthesis:none}*{box-sizing:border-box}body{margin:0;min-width:320px;min-height:100vh;background:#141518}button,textarea,select{font:inherit}button{color:inherit}button:focus-visible,textarea:focus-visible,select:focus-visible{outline:2px solid #d8ff5f;outline-offset:2px}.code-shell{display:grid;grid-template-columns:48px 230px minmax(0,1fr);min-height:100vh;background:#17181c}.activity-bar{display:flex;flex-direction:column;align-items:center;padding:10px 0;border-right:1px solid #2a2c32;background:#111216}.activity-bar .product-mark{display:grid;place-items:center;width:31px;height:31px;margin-bottom:13px;border-radius:9px;color:#111;background:#d8ff5f}.activity-bar button{display:grid;place-items:center;width:100%;height:46px;border:0;border-left:2px solid transparent;background:none;color:#727681;cursor:pointer}.activity-bar button:hover,.activity-bar button.active{color:#e9ebef;background:#1c1e23}.activity-bar button.active{border-left-color:#d8ff5f}.activity-bar>span{flex:1}.activity-bar .avatar{width:29px;height:29px;margin:8px;border:0;border-radius:8px;background:#343842;color:#fff;font-size:10px;font-weight:700}.explorer{display:flex;flex-direction:column;border-right:1px solid #2a2c32;background:#18191d;color:#b5b8c0}.explorer-title{display:flex;justify-content:space-between;align-items:center;height:42px;padding:0 13px;font-size:10px;letter-spacing:.09em}.explorer>strong{padding:8px 13px;font-size:10px;letter-spacing:.05em;color:#d9dbe0}.file-tree{padding:2px 7px}.file-tree button{display:flex;align-items:center;gap:6px;width:100%;padding:5px 7px;border:0;border-radius:4px;background:none;color:#aeb1b9;text-align:left;font-family:'DM Mono',monospace;font-size:11px}.file-tree button:hover{background:#25272d}.file-tree .folder{color:#d4d6dc}.explorer-bottom{margin-top:auto;padding:8px;border-top:1px solid #292b31}.explorer-bottom button{display:flex;align-items:center;gap:8px;width:100%;padding:8px;border:0;border-radius:5px;background:none;font-size:11px}.explorer-bottom button:hover{background:#24262c}.explorer-bottom span{margin-left:auto;padding:1px 6px;border-radius:8px;background:#30333b}.workspace{display:grid;grid-template-rows:42px minmax(0,1fr) 23px;min-width:0;min-height:100vh}.editor-tabs{display:flex;justify-content:space-between;border-bottom:1px solid #2a2c32;background:#15161a}.tab{display:flex;align-items:center;gap:7px;padding:0 14px;border-right:1px solid #2a2c32;font-size:11px;color:#aaaeb7}.tab.active{border-top:1px solid #d8ff5f;background:#1b1c21;color:#e6e8ed}.workspace-actions{display:flex;align-items:center;gap:7px;padding-right:14px;color:#858994;font-size:10px}.status-dot{width:6px;height:6px;border-radius:50%;background:#6ee7a3}.status-dot.running{background:#d8ff5f;box-shadow:0 0 8px #d8ff5f}.agent-pane{display:grid;grid-template-rows:59px minmax(0,1fr) auto;min-height:0;background:radial-gradient(circle at 50% 25%,#20242a 0,#1a1b20 38%,#17181c 68%)}.agent-header{display:flex;align-items:center;justify-content:space-between;padding:0 20px;border-bottom:1px solid #292b31;background:rgba(24,25,29,.88);backdrop-filter:blur(12px)}.agent-header>div,.response-title>div,.history-head>div{display:flex;align-items:center;gap:10px}.agent-icon,.response-title>div>span{display:grid;place-items:center;width:32px;height:32px;border:1px solid #3b3f48;border-radius:8px;background:#25272d;color:#d8ff5f}.agent-header strong,.agent-header small,.response-title strong,.response-title small,.history-head strong,.history-head small{display:block}.agent-header strong{font-size:12px}.agent-header small,.response-title small,.history-head small{margin-top:2px;color:#7e828d;font-size:10px}.header-actions{gap:7px!important}.header-actions button,.header-actions select,.response-title button{height:29px;border:1px solid #343740;border-radius:6px;background:#212329;color:#aeb2bc;font-size:10px}.header-actions button,.response-title button{display:flex;align-items:center;gap:6px;padding:0 9px;cursor:pointer}.header-actions select{padding:0 7px}.conversation{overflow:auto;padding:48px 30px 24px}.welcome,.response-card{max-width:780px;margin:0 auto}.welcome{text-align:center}.welcome-mark{display:grid;place-items:center;width:48px;height:48px;margin:0 auto 18px;border:1px solid #464b55;border-radius:13px;background:linear-gradient(145deg,#2c3035,#202227);color:#d8ff5f;box-shadow:0 18px 60px #0006}.kicker{margin:0 0 11px;color:#a2ad6d!important;font-family:'DM Mono',monospace;font-size:10px!important;letter-spacing:.14em}.welcome h1{margin:0;color:#f0f1f4;font-size:46px;line-height:1.06;letter-spacing:-.05em}.welcome h1 em{color:#d8ff5f;font-style:normal}.welcome .intro{max-width:570px;margin:18px auto 30px;color:#9296a0;font-size:14px;line-height:1.65}.starter-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;text-align:left}.starter-grid button{position:relative;min-height:148px;padding:16px;border:1px solid #30333a;border-radius:10px;background:#1d1f24;color:#d8dae0;cursor:pointer}.starter-grid button:hover{border-color:#515743;background:#22252a;transform:translateY(-1px)}.starter-grid button>span{display:grid;place-items:center;width:31px;height:31px;margin-bottom:16px;border-radius:7px;background:#292c32;color:#d8ff5f}.starter-grid strong,.starter-grid small{display:block}.starter-grid strong{font-size:12px}.starter-grid small{margin-top:7px;color:#7d818b;font-size:10px;line-height:1.45}.starter-grid>button>svg{position:absolute;right:12px;top:18px;color:#60646d}.response-card{overflow:hidden;border:1px solid #30333a;border-radius:11px;background:#1b1d22}.response-title{display:flex;justify-content:space-between;align-items:center;padding:13px 15px;border-bottom:1px solid #2c2f36}.response-title strong{font-size:11px}.agent-output{padding:21px 24px;color:#cdd0d6;font-size:12px;line-height:1.7}.agent-output h2,.agent-output h3{color:#f0f1f4}.agent-output h2{font-size:18px}.agent-output h3{margin-top:22px;font-size:14px}.agent-output p{margin:8px 0}.output-space{height:6px}.output-row{display:flex;gap:10px;margin:6px 0}.output-row i{width:5px;height:5px;margin-top:8px;border-radius:50%;background:#d8ff5f;flex:0 0 auto}.output-row.numbered i{display:grid;place-items:center;width:18px;height:18px;margin-top:1px;background:#30342e;color:#d8ff5f;font-style:normal;font-size:9px}.code-divider{display:flex;align-items:center;gap:6px;margin:12px -6px 5px;padding:7px 10px;border-radius:5px;background:#111216;color:#858a95;font-family:'DM Mono',monospace;font-size:10px}.thinking{display:flex;align-items:center;gap:9px;padding:35px 24px;color:#969aa4;font-size:12px}.spin{animation:spin 1s linear infinite}.continue{margin:0 24px 20px;padding:7px 10px;border:1px solid #3a3d44;border-radius:6px;background:#25272d;font-size:10px}.composer-wrap{width:min(820px,calc(100% - 48px));margin:0 auto;padding:10px 0 18px}.composer{position:relative;border:1px solid #42464f;border-radius:11px;background:#202227;box-shadow:0 14px 40px #0005}.composer:focus-within{border-color:#697052}.composer textarea{display:block;width:100%;min-height:78px;padding:15px 54px 14px 16px;resize:none;border:0;outline:0;background:transparent;color:#e3e5e9;font-size:12px;line-height:1.55}.composer textarea::placeholder{color:#686c75}.send{position:absolute;right:10px;bottom:10px;display:grid;place-items:center;width:31px;height:31px;border:0;border-radius:7px;background:#d8ff5f;color:#121310;cursor:pointer}.send:disabled{opacity:.35}.send.stop{background:#f17d86;color:#fff}.composer-meta{display:flex;justify-content:space-between;padding:7px 3px 0;color:#656a74;font-size:9px}.composer-meta span{display:flex;align-items:center;gap:4px}.statusbar{display:flex;align-items:center;gap:16px;padding:0 10px;background:#c7eb5b;color:#151611;font-size:9px}.statusbar span{display:flex;align-items:center;gap:4px}.statusbar .grow{flex:1}.modal-backdrop{position:fixed;inset:0;display:grid;place-items:center;background:#090a0dbb;z-index:10}.history-panel{width:min(520px,calc(100% - 30px));max-height:70vh;overflow:hidden;border:1px solid #383b43;border-radius:12px;background:#1c1e23;box-shadow:0 24px 80px #0009}.history-head{display:flex;justify-content:space-between;align-items:center;padding:16px;border-bottom:1px solid #30323a}.history-head>button{border:0;background:none}.history-list{max-height:50vh;overflow:auto;padding:8px}.history-list>button{display:flex;align-items:center;gap:10px;width:100%;padding:11px;border:0;border-radius:7px;background:none;text-align:left}.history-list>button:hover{background:#25272d}.history-list>button div{min-width:0;flex:1}.history-list strong,.history-list small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.history-list strong{font-size:11px}.history-list small{margin-top:4px;color:#747985;font-size:9px}.session-status{display:grid;place-items:center;width:21px;height:21px;border-radius:50%;background:#2d4338;color:#7ee2a8}.empty-history{padding:35px;text-align:center;color:#757a85;font-size:11px}.error-toast{position:fixed;right:18px;bottom:38px;display:flex;align-items:center;gap:15px;max-width:420px;padding:12px 14px;border:1px solid #713a42;border-radius:8px;background:#321f23;color:#ffc9cf;font-size:11px;z-index:20}.error-toast button{border:0;background:none;color:#fff;text-decoration:underline}@keyframes spin{to{transform:rotate(360deg)}}
+
+@media(max-width:800px){.code-shell{grid-template-columns:44px minmax(0,1fr)}.explorer{display:none}.welcome h1{font-size:36px}.starter-grid{grid-template-columns:1fr}.conversation{padding:30px 16px 20px}.agent-header{padding:0 12px}.header-actions button{display:none}.composer-wrap{width:calc(100% - 24px)}.starter-grid button{min-height:112px}.statusbar span:nth-child(2){display:none}}
diff --git a/src/types.ts b/demos/01-cursor-coding-app/src/types.ts
similarity index 100%
rename from src/types.ts
rename to demos/01-cursor-coding-app/src/types.ts
diff --git a/tests/sse.test.ts b/demos/01-cursor-coding-app/tests/sse.test.ts
similarity index 100%
rename from tests/sse.test.ts
rename to demos/01-cursor-coding-app/tests/sse.test.ts
diff --git a/demos/01-cursor-coding-app/tests/store.test.ts b/demos/01-cursor-coding-app/tests/store.test.ts
new file mode 100644
index 0000000..0e84d75
--- /dev/null
+++ b/demos/01-cursor-coding-app/tests/store.test.ts
@@ -0,0 +1,36 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { OwnershipStore } from '../server/store.js';
+
+const record = {
+ sessionId: 'hsess_alice',
+ responseId: 'resp_one',
+ userId: 'alice',
+ featureKey: 'coding_assistant',
+ title: 'Build a demo',
+ prompt: 'Build a demo',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-01T00:00:00.000Z',
+ status: 'running',
+};
+
+test('session ownership allows the creator', () => {
+ const store = new OwnershipStore();
+ store.save(record);
+ assert.equal(store.getAuthorized('hsess_alice', 'alice')?.responseId, 'resp_one');
+});
+
+test('session ownership hides records from another user', () => {
+ const store = new OwnershipStore();
+ store.save(record);
+ assert.equal(store.getAuthorized('hsess_alice', 'bob'), null);
+ assert.equal(store.list('bob').length, 0);
+});
+
+test('status updates preserve ownership', () => {
+ const store = new OwnershipStore();
+ store.save(record);
+ store.update('hsess_alice', { status: 'completed', responseId: 'resp_two' });
+ assert.equal(store.getAuthorized('hsess_alice', 'alice')?.status, 'completed');
+ assert.equal(store.getAuthorized('hsess_alice', 'alice')?.responseId, 'resp_two');
+});
diff --git a/tsconfig.json b/demos/01-cursor-coding-app/tsconfig.json
similarity index 100%
rename from tsconfig.json
rename to demos/01-cursor-coding-app/tsconfig.json
diff --git a/tsconfig.server.json b/demos/01-cursor-coding-app/tsconfig.server.json
similarity index 100%
rename from tsconfig.server.json
rename to demos/01-cursor-coding-app/tsconfig.server.json
diff --git a/vite.config.ts b/demos/01-cursor-coding-app/vite.config.ts
similarity index 100%
rename from vite.config.ts
rename to demos/01-cursor-coding-app/vite.config.ts
diff --git a/demos/02-lumacare/.env.example b/demos/02-lumacare/.env.example
new file mode 100644
index 0000000..eb6f584
--- /dev/null
+++ b/demos/02-lumacare/.env.example
@@ -0,0 +1,2 @@
+HR_API_KEY=
+PORT=3000
diff --git a/demos/02-lumacare/README.md b/demos/02-lumacare/README.md
new file mode 100644
index 0000000..e7b1077
--- /dev/null
+++ b/demos/02-lumacare/README.md
@@ -0,0 +1,43 @@
+# LumaCare
+
+The secondary HarnessRouter reference demo in this repository.
+
+LumaCare is a family-care companion for parents and caregivers of children receiving cancer care. It demonstrates how the same secure streaming, persistence, ownership, and artifact patterns used by the primary coding demo can support a specialized domain experience.
+
+## Run it
+
+From the repository root:
+
+1. Run `npm install` if dependencies are not installed.
+2. Copy `.env.example` to the repository root as `.env` and set `HR_API_KEY`.
+3. Start the demo:
+
+ ```bash
+ npm run dev:lumacare
+ ```
+
+Open [http://localhost:3000](http://localhost:3000).
+
+The checked-in `config/agents.json` maps the `care_companion` feature to its purpose-configured HarnessRouter agent.
+
+## What is included
+
+- Calm, responsive family-facing care guidance
+- Structured plain-language guides and care-team questions
+- Data-only streaming with immediate recovery-ID persistence
+- Session recall and continue/revise flows
+- Ownership checks for session details, cancellation, previews, and downloads
+- Sandboxed HTML previews and authenticated artifact/archive proxying
+- Demo identity switching for cross-user isolation testing
+
+## Safety note
+
+LumaCare provides supportive guidance only and never replaces a child's oncology team. Do not enter identifying patient information in the demo.
+
+## Direct workspace commands
+
+```bash
+npm run test --workspace=@harnessrouter/lumacare
+npm run build --workspace=@harnessrouter/lumacare
+npm run preview:lumacare
+```
diff --git a/config/agents.json b/demos/02-lumacare/config/agents.json
similarity index 100%
rename from config/agents.json
rename to demos/02-lumacare/config/agents.json
diff --git a/index.html b/demos/02-lumacare/index.html
similarity index 100%
rename from index.html
rename to demos/02-lumacare/index.html
diff --git a/demos/02-lumacare/package.json b/demos/02-lumacare/package.json
new file mode 100644
index 0000000..a7b3f32
--- /dev/null
+++ b/demos/02-lumacare/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "@harnessrouter/lumacare",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "tsx server/index.ts",
+ "build": "vite build && tsc -p tsconfig.server.json --noEmit",
+ "preview": "NODE_ENV=production tsx server/index.ts",
+ "test": "node --import tsx --test tests/**/*.test.ts"
+ },
+ "dependencies": {
+ "dotenv": "^16.4.7",
+ "express": "^5.1.0",
+ "lucide-react": "^0.468.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ },
+ "devDependencies": {
+ "@types/express": "^5.0.0",
+ "@types/node": "^22.10.5",
+ "@types/react": "^19.0.3",
+ "@types/react-dom": "^19.0.2",
+ "@vitejs/plugin-react": "^4.3.4",
+ "tsx": "^4.19.2",
+ "typescript": "^5.7.2",
+ "vite": "^6.0.7"
+ }
+}
diff --git a/demos/02-lumacare/server/auth.ts b/demos/02-lumacare/server/auth.ts
new file mode 100644
index 0000000..1c145db
--- /dev/null
+++ b/demos/02-lumacare/server/auth.ts
@@ -0,0 +1,24 @@
+import type { Request, Response, NextFunction } from 'express';
+import type { ProductUser } from './types.js';
+
+export const DEMO_USERS: Record = {
+ alice: { id: 'alice', name: 'Alex Morgan', initials: 'AM' },
+ bob: { id: 'bob', name: 'Sam Lee', initials: 'SL' },
+};
+
+declare global {
+ namespace Express {
+ interface Request { productUser: ProductUser }
+ }
+}
+
+export function authenticate(req: Request, res: Response, next: NextFunction) {
+ const userId = String(req.header('x-demo-user') || 'alice');
+ const user = DEMO_USERS[userId];
+ if (!user) {
+ res.status(401).json({ error: 'Sign in required' });
+ return;
+ }
+ req.productUser = user;
+ next();
+}
diff --git a/server/index.ts b/demos/02-lumacare/server/index.ts
similarity index 98%
rename from server/index.ts
rename to demos/02-lumacare/server/index.ts
index 4c65711..b4dfbed 100644
--- a/server/index.ts
+++ b/demos/02-lumacare/server/index.ts
@@ -1,4 +1,4 @@
-import 'dotenv/config';
+import dotenv from 'dotenv';
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
@@ -10,6 +10,8 @@ import type { RunRecord } from './types.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..');
+dotenv.config({ path: path.resolve(root, '../..', '.env') });
+dotenv.config({ path: path.join(root, '.env'), override: true });
const agentMap = JSON.parse(fs.readFileSync(path.join(root, 'config/agents.json'), 'utf8')) as Record;
const store = new OwnershipStore(path.join(root, 'data/ownership.json'));
const apiBase = 'https://api.harnessrouter.ai';
diff --git a/demos/02-lumacare/server/store.ts b/demos/02-lumacare/server/store.ts
new file mode 100644
index 0000000..8aa4e4c
--- /dev/null
+++ b/demos/02-lumacare/server/store.ts
@@ -0,0 +1,46 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import type { RunRecord, StoreData } from './types.js';
+
+export class OwnershipStore {
+ private data: StoreData = { runs: [] };
+
+ constructor(private readonly filePath: string | null = null) {
+ if (filePath && fs.existsSync(filePath)) {
+ try {
+ this.data = JSON.parse(fs.readFileSync(filePath, 'utf8')) as StoreData;
+ } catch {
+ this.data = { runs: [] };
+ }
+ }
+ }
+
+ private persist() {
+ if (!this.filePath) return;
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
+ fs.writeFileSync(this.filePath, JSON.stringify(this.data, null, 2), { mode: 0o600 });
+ }
+
+ save(record: RunRecord) {
+ const index = this.data.runs.findIndex((run) => run.sessionId === record.sessionId);
+ if (index >= 0) this.data.runs[index] = { ...this.data.runs[index], ...record };
+ else this.data.runs.unshift(record);
+ this.persist();
+ }
+
+ update(sessionId: string, patch: Partial) {
+ const record = this.data.runs.find((run) => run.sessionId === sessionId);
+ if (!record) return;
+ Object.assign(record, patch, { updatedAt: new Date().toISOString() });
+ this.persist();
+ }
+
+ list(userId: string) {
+ return this.data.runs.filter((run) => run.userId === userId);
+ }
+
+ getAuthorized(sessionId: string, userId: string) {
+ const record = this.data.runs.find((run) => run.sessionId === sessionId);
+ return record?.userId === userId ? record : null;
+ }
+}
diff --git a/demos/02-lumacare/server/types.ts b/demos/02-lumacare/server/types.ts
new file mode 100644
index 0000000..470d459
--- /dev/null
+++ b/demos/02-lumacare/server/types.ts
@@ -0,0 +1,19 @@
+export type ProductUser = {
+ id: string;
+ name: string;
+ initials: string;
+};
+
+export type RunRecord = {
+ sessionId: string;
+ responseId: string;
+ userId: string;
+ featureKey: string;
+ title: string;
+ prompt: string;
+ createdAt: string;
+ updatedAt: string;
+ status: string;
+};
+
+export type StoreData = { runs: RunRecord[] };
diff --git a/src/App.tsx b/demos/02-lumacare/src/App.tsx
similarity index 100%
rename from src/App.tsx
rename to demos/02-lumacare/src/App.tsx
diff --git a/demos/02-lumacare/src/api.ts b/demos/02-lumacare/src/api.ts
new file mode 100644
index 0000000..4b35e7d
--- /dev/null
+++ b/demos/02-lumacare/src/api.ts
@@ -0,0 +1,44 @@
+export type StreamEvent = { type: string; [key: string]: any };
+
+export class SSEParser {
+ private buffer = '';
+
+ push(chunk: string): StreamEvent[] {
+ this.buffer += chunk;
+ const events: StreamEvent[] = [];
+ let boundary = this.buffer.search(/\r?\n\r?\n/);
+ while (boundary >= 0) {
+ const frame = this.buffer.slice(0, boundary);
+ const size = this.buffer.slice(boundary).match(/^\r?\n\r?\n/)?.[0].length || 2;
+ this.buffer = this.buffer.slice(boundary + size);
+ const event = this.parseFrame(frame);
+ if (event) events.push(event);
+ boundary = this.buffer.search(/\r?\n\r?\n/);
+ }
+ return events;
+ }
+
+ flush(): StreamEvent[] {
+ const event = this.parseFrame(this.buffer);
+ this.buffer = '';
+ return event ? [event] : [];
+ }
+
+ private parseFrame(frame: string): StreamEvent | null {
+ const data = frame.split(/\r?\n/).filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trimStart()).join('\n');
+ if (!data || data === '[DONE]') return null;
+ try { return JSON.parse(data) as StreamEvent; } catch { return null; }
+ }
+}
+
+export const apiHeaders = (userId: string, json = false) => ({
+ 'x-demo-user': userId,
+ ...(json ? { 'Content-Type': 'application/json' } : {}),
+});
+
+export async function api(path: string, userId: string, init: RequestInit = {}): Promise {
+ const response = await fetch(path, { ...init, headers: { ...apiHeaders(userId, Boolean(init.body)), ...(init.headers || {}) } });
+ const body = await response.json().catch(() => ({}));
+ if (!response.ok) throw new Error(body.error || 'Request failed');
+ return body as T;
+}
diff --git a/demos/02-lumacare/src/main.tsx b/demos/02-lumacare/src/main.tsx
new file mode 100644
index 0000000..5f8b815
--- /dev/null
+++ b/demos/02-lumacare/src/main.tsx
@@ -0,0 +1,8 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import App from './App';
+import './styles.css';
+
+createRoot(document.getElementById('root')!).render(
+ ,
+);
diff --git a/src/styles.css b/demos/02-lumacare/src/styles.css
similarity index 100%
rename from src/styles.css
rename to demos/02-lumacare/src/styles.css
diff --git a/demos/02-lumacare/src/types.ts b/demos/02-lumacare/src/types.ts
new file mode 100644
index 0000000..4fd6e9d
--- /dev/null
+++ b/demos/02-lumacare/src/types.ts
@@ -0,0 +1,19 @@
+export type SessionRecord = {
+ sessionId: string;
+ responseId: string;
+ userId: string;
+ title: string;
+ prompt: string;
+ status: string;
+ createdAt: string;
+ updatedAt: string;
+};
+
+export type AgentFile = {
+ path: string;
+ bytes: number;
+ media_type: string;
+ file_id: string;
+};
+
+export type Activity = { id: string; label: string; detail?: string; state: 'active' | 'done' | 'error' };
diff --git a/demos/02-lumacare/tests/sse.test.ts b/demos/02-lumacare/tests/sse.test.ts
new file mode 100644
index 0000000..1ae84bc
--- /dev/null
+++ b/demos/02-lumacare/tests/sse.test.ts
@@ -0,0 +1,23 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { SSEParser } from '../src/api.js';
+
+test('dispatches using the JSON type in data-only SSE frames', () => {
+ const parser = new SSEParser();
+ const events = parser.push('data: {"type":"response.created","response":{"id":"resp_1","metadata":{"session_id":"hsess_1"}}}\n\n');
+ assert.equal(events[0].type, 'response.created');
+ assert.equal(events[0].response.metadata.session_id, 'hsess_1');
+});
+
+test('buffers split chunks and accepts unknown event types', () => {
+ const parser = new SSEParser();
+ assert.equal(parser.push('data: {"type":"response.future').length, 0);
+ const events = parser.push('.event","sequence_number":4}\n\n');
+ assert.equal(events[0].type, 'response.future.event');
+});
+
+test('ignores malformed frames without stopping later events', () => {
+ const parser = new SSEParser();
+ const events = parser.push('data: not-json\n\ndata: {"type":"response.completed"}\n\n');
+ assert.deepEqual(events.map((event) => event.type), ['response.completed']);
+});
diff --git a/tests/store.test.ts b/demos/02-lumacare/tests/store.test.ts
similarity index 100%
rename from tests/store.test.ts
rename to demos/02-lumacare/tests/store.test.ts
diff --git a/demos/02-lumacare/tsconfig.json b/demos/02-lumacare/tsconfig.json
new file mode 100644
index 0000000..e935337
--- /dev/null
+++ b/demos/02-lumacare/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx"
+ },
+ "include": ["src"]
+}
diff --git a/demos/02-lumacare/tsconfig.server.json b/demos/02-lumacare/tsconfig.server.json
new file mode 100644
index 0000000..1f9b983
--- /dev/null
+++ b/demos/02-lumacare/tsconfig.server.json
@@ -0,0 +1,12 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "types": ["node"]
+ },
+ "include": ["server", "tests"]
+}
diff --git a/demos/02-lumacare/vite.config.ts b/demos/02-lumacare/vite.config.ts
new file mode 100644
index 0000000..31fefb8
--- /dev/null
+++ b/demos/02-lumacare/vite.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+
+export default defineConfig({
+ plugins: [react()],
+ server: { port: 5173 },
+ build: { outDir: 'dist' },
+});
diff --git a/package-lock.json b/package-lock.json
index 200119c..103a714 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,11 +1,39 @@
{
- "name": "lumacare-harnessrouter",
+ "name": "harnessrouter-demo-at-harvard",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "lumacare-harnessrouter",
+ "name": "harnessrouter-demo-at-harvard",
+ "version": "1.0.0",
+ "workspaces": [
+ "demos/*"
+ ]
+ },
+ "demos/01-cursor-coding-app": {
+ "name": "@harnessrouter/cursor-coding-app",
+ "version": "1.0.0",
+ "dependencies": {
+ "dotenv": "^16.4.7",
+ "express": "^5.1.0",
+ "lucide-react": "^0.468.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ },
+ "devDependencies": {
+ "@types/express": "^5.0.0",
+ "@types/node": "^22.10.5",
+ "@types/react": "^19.0.3",
+ "@types/react-dom": "^19.0.2",
+ "@vitejs/plugin-react": "^4.3.4",
+ "tsx": "^4.19.2",
+ "typescript": "^5.7.2",
+ "vite": "^6.0.7"
+ }
+ },
+ "demos/02-lumacare": {
+ "name": "@harnessrouter/lumacare",
"version": "1.0.0",
"dependencies": {
"dotenv": "^16.4.7",
@@ -749,6 +777,14 @@
"node": ">=18"
}
},
+ "node_modules/@harnessrouter/cursor-coding-app": {
+ "resolved": "demos/01-cursor-coding-app",
+ "link": true
+ },
+ "node_modules/@harnessrouter/lumacare": {
+ "resolved": "demos/02-lumacare",
+ "link": true
+ },
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
diff --git a/package.json b/package.json
index 7093ca5..9f4dd3d 100644
--- a/package.json
+++ b/package.json
@@ -1,29 +1,17 @@
{
- "name": "lumacare-harnessrouter",
+ "name": "harnessrouter-demo-at-harvard",
"version": "1.0.0",
"private": true,
- "type": "module",
+ "workspaces": [
+ "demos/*"
+ ],
"scripts": {
- "dev": "tsx server/index.ts",
- "build": "vite build && tsc -p tsconfig.server.json --noEmit",
- "preview": "NODE_ENV=production tsx server/index.ts",
- "test": "node --import tsx --test tests/**/*.test.ts"
- },
- "dependencies": {
- "dotenv": "^16.4.7",
- "express": "^5.1.0",
- "lucide-react": "^0.468.0",
- "react": "^19.0.0",
- "react-dom": "^19.0.0"
- },
- "devDependencies": {
- "@types/express": "^5.0.0",
- "@types/node": "^22.10.5",
- "@types/react": "^19.0.3",
- "@types/react-dom": "^19.0.2",
- "@vitejs/plugin-react": "^4.3.4",
- "tsx": "^4.19.2",
- "typescript": "^5.7.2",
- "vite": "^6.0.7"
+ "dev": "npm run dev:cursor",
+ "dev:cursor": "npm run dev --workspace=@harnessrouter/cursor-coding-app",
+ "dev:lumacare": "npm run dev --workspace=@harnessrouter/lumacare",
+ "test": "npm run test --workspaces --if-present",
+ "build": "npm run build --workspaces --if-present",
+ "preview:cursor": "npm run preview --workspace=@harnessrouter/cursor-coding-app",
+ "preview:lumacare": "npm run preview --workspace=@harnessrouter/lumacare"
}
}