# Project export: Doryo

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: Cal Hacks 12.0
- Tagline: Doryo is an AI coworker that joins your team’s chat, collaborating in real time with multiple users to brainstorm, plan, and execute ideas seamlessly in one shared space
- Devpost: https://devpost.com/software/doryo
- GitHub: https://github.com/decoder3064/Cooworker
- Demo: https://github.com/namnbk/CalHacks_Backend_Letta_Agent
- Video: https://www.youtube.com/embed/dtu02_wW0GE?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 3 GitHub contributor(s) — David R. (26 commits), Hoang Duc Nam (15 commits), Reet Kothari (2 commits)

## Devpost submission (written by the team)

### Overview

🧠

### Inspiration

In today’s remote and hybrid world, collaboration often feels fragmented — scattered across chat tools, notes, and task managers. We wanted to reimagine what it means to “work together” when one of your teammates is an intelligent agent. Doriyo (ドリヨ, Japanese for coworker) was born from a simple question: “What if your conversations could think, remember, and act with you?” We envisioned a shared workspace where people can chat naturally, brainstorm ideas, and have an AI coworker that listens, remembers, and takes action when needed. Whether it’s creating a GitHub repo, generating a Notion itinerary, or summarizing a discussion, Doriyo acts as a quiet, capable teammate. 🤖

### What it does

Doriyo is a real-time conversational workspace that brings humans and AI together. 🗣️ Collaborative chat: Create or join workspaces and collaborate in real time. 🧑‍💻 AI coworker (@Doriyo): Mention @ask to query context or @act to perform an action. 🪄 Integrated actions with Composio: Connect tools like GitHub, Notion, and Google Drive. Example: “@act create a Next.js template repo for our project.” → Doriyo commits the scaffold to your GitHub. Example: “@act create a Next.js template repo for our project.” → Doriyo commits the scaffold to your GitHub. 🧠 Memory: Doriyo remembers previous discussions to provide contextually aware responses. Think of it as Slack + Notion + an AI coworker combined into a single workspace. 🧩

### How we built it

Frontend: React + Vite for a fast, modular interface. Firebase Firestore for real-time message synchronization and presence tracking. Firebase Auth for secure login, signup, and workspace invites. Backend: FastAPI (Python) for handling requests, orchestrating actions, and managing session logic. Letta to define and manage each user’s personal AI agent, maintaining context and persistence. Composio for secure integration with third-party tools like GitHub, Notion, and Google Drive. Claude for language understanding, reasoning, and conversational context generation. The result is a seamless full-stack system where multiple users can chat, mention the agent, and see actions happen live — all backed by real-time data sync. 🧱

### Challenges we ran into

Maintaining real-time consistency for multi-user chat sessions using Firestore transactions. Designing a safe and permissioned workflow for actions so only workspace hosts can approve executions. Handling long-term memory and summarization efficiently without overloading the context window. Creating a cohesive UX where the agent feels like part of the team, not just another bot. 🏆

### Accomplishments we're proud of

Built a functional, multi-user AI workspace with persistent context and real-time collaboration. Integrated Letta and Composio to allow the AI to act directly on connected user tools. Achieved smooth, low-latency updates using Firebase Firestore listeners. Designed a clean, modern interface that makes AI feel like a true collaborator. 💡

### What we learned

Building multi-user, multi-agent systems is as much a design problem as a technical one. Clear permission boundaries and confirmations improve user trust and safety. Contextual memory dramatically improves AI usefulness in collaborative settings. Real-time Firestore updates simplify sync logic compared to maintaining custom WebSocket servers. 🚀

### What's next

for Doriyo 🎧 Voice support: Integrate SFU (LiveKit) for voice-based interaction. 🧩 Multi-agent collaboration: Let personal agents cooperate on shared goals. 📄 Automatic summaries: Generate meeting notes and documents from conversations. 🏢 Enterprise support: Role-based access control and organization-level workspaces. 🌐 Public sharing: Export or share interactive workspace transcripts. 🧭 In short Doriyo is your AI coworker — listening, thinking, and acting with your team in real time. Collaboration shouldn't just be about talking; it should be about doing, together.

## README (from the GitHub repository)

Conversational Workspace – Project Template Documentation
A voice/text-first collaborative workspace where multiple users interact with an AI Agent that can listen, answer (@ask), and act (@act) using connected tools (GitHub, Notion, Google Drive/Sheets) via Composio and Letta.

1) MVP Scope
User flows

Auth: Firebase (Email/Password or Google).

Workspace: Create, invite via link, join.

Session/Party: Real-time text chat (WebSockets); (Optional later) voice via SFU.

Agent: @ask (answer with memory), @act (host-only; confirm; execute via Composio).

Settings (post-MVP): Link/unlink external apps; permission delegation.

Non‑goals (MVP)

Full voice stack w/ diarization.

Complex role hierarchies beyond host/member.

Mobile apps.

2) System Architecture
┌──────────────┐      HTTPS / WSS       ┌──────────────────┐
│  Next.js UI  │◄──────────────────────►│   FastAPI Core   │
│ (Firebase)   │                        │  (AuthZ, REST,   │
│              │   WebSocket (chat)     │   WS Gateway)    │
└──────┬───────┘                        └───┬──────────────┘
       │                                   │
       │                                   │Pub/Sub      ┌────────────┐
       │                                   ├────────────►│  Redis     │
       │                                   │             └────────────┘
       │                                   │                 ▲
       │                                   │                 │
       │                        ┌──────────┴─────────┐       │
       │                        │  Worker(s): Agent  │◄──────┘
       │                        │  (Letta + Composio │  Subscribes
       │                        │   tool executors)  │  to session
       │                        └──────────┬─────────┘  channels
       │                                   │
       │                             ┌──────┴───────┐
       │                             │ Postgres +   │
       └────────────────────────────►│  pgvector    │
                                     └──────────────┘

(Optional Voice)
Next.js  ──WebRTC──► SFU (LiveKit/Daily) ──► Bot Subscriber ──► ASR → same WS/Agent pipeline
3) Tech Stack
Frontend: Next.js (App Router), React, Tailwind, TanStack Query.

Auth: Firebase Auth (ID token verified by backend).

Backend: FastAPI (Python 3.11+), uvicorn, SQLAlchemy/SQLModel.

DB: Postgres (Neon/Supabase), pgvector for semantic memory.

Realtime: WebSockets (FastAPI) + Redis pub/sub (Upstash/Elasticache).

Agent: Letta (LLM + tool calling) with retrieval; Composio for integrations.

Workers: RQ or Celery (Redis) for @ask/@act jobs and “sleeper” tasks.

(Optional Voice): LiveKit/Daily SFU; ASR (Whisper server or managed).

4) Repository Layout
root/
  apps/
    web/              # Next.js app
    api/              # FastAPI service
  infra/              # IaC / deploy scripts (optional)
  docs/               # Design docs, OpenAPI, sequences
Frontend (apps/web)

src/
  app/
    (auth)/
    dashboard/
    workspaces/[id]/
    sessions/[id]/
  components/
  lib/
  styles/
Backend (apps/api)

app/
  main.py
  auth/firebase.py
  db/base.py
  db/models.py
  api/routes/
    workspaces.py
    invites.py
    sessions.py
    messages.py
    actions.py
    composio.py
  services/
    agent.py       # Letta orchestration
    actions.py     # Composio executors
    memory.py      # embeddings + retrieval
    ws_gateway.py  # Redis + WS fanout
  workers/
    jobs.py        # ask/act jobs
  schemas/
    dto.py         # pydantic models
5) Environment & Config
Create two .env files: one per app.

apps/web/.env.local

NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_WS_URL=wss://api.example.com
NEXT_PUBLIC_FIREBASE_API_KEY=...
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=...
NEXT_PUBLIC_FIREBASE_PROJECT_ID=...
NEXT_PUBLIC_FIREBASE_APP_ID=...
apps/api/.env

PORT=8080
DATABASE_URL=postgresql+psycopg://user:pass@host/db
REDIS_URL=redis://:pass@host:6379/0
FIREBASE_PROJECT_ID=...
COMPOSIO_API_KEY=...
LETTA_API_KEY=...
EMBEDDINGS_PROVIDER=openai
OPENAI_API_KEY=...
JWT_AUDIENCE=your-firebase-project
ALLOW_ORIGINS=https://app.example.com,https://localhost:3000
Store provider keys in your secrets manager (e.g., Fly.io secrets, Vercel envs).

6) Database Schema (MVP)
-- Users are identified by Firebase UID
create table users (
  id text primary key,
  email text not null,
  display_name text,
  created_at timestamptz default now()
);

create table workspaces (
  id uuid primary key default gen_random_uuid(),
  host_user_id text references users(id) on delete cascade,
  name text not null,
  created_at timestamptz default now()
);

create table workspace_members (
  workspace_id uuid references workspaces(id) on delete cascade,
  user_id text references users(id) on delete cascade,
  role text check (role in ('host','member')) not null,
  primary key (workspace_id, user_id)
);

create table sessions (
  id uuid primary key default gen_random_uuid(),
  workspace_id uuid references workspaces(id) on delete cascade,
  is_active boolean default true,
  created_at timestamptz default now()
);

create table messages (
  id bigserial primary key,
  session_id uuid references sessions(id) on delete cascade,
  sender_user_id text null references users(id), -- null for agent/system
  type text check (type in ('user','agent','system','transcript')) not null,
  text text not null,
  seq bigint not null,
  ts timestamptz default now()
);
create index on messages(session_id, seq);

create table actions (
  id uuid primary key default gen_random_uuid(),
  workspace_id uuid references workspaces(id) on delete cascade,
  session_id uuid references sessions(id) on delete cascade,
  initiator_user_id text references users(id),
  kind text not null, -- e.g. github_template, notion_itinerary, sheet_budget
  status text check (status in ('pending','awaiting_confirm','running','done','error')) not null,
  payload_json jsonb not null,
  result_json jsonb,
  created_at timestamptz default now()
);

-- Composio mapping (we store only IDs, not tokens)
create table connections (
  id uuid primary key default gen_random_uuid(),
  workspace_id uuid references workspaces(id) on delete cascade,
  user_id text references users(id),
  provider text not null,    -- github|notion|google
  account_id text not null,  -- composio connected account id
  label text,
  created_at timestamptz default now()
);

-- Semantic memory
create table memory (
  id bigserial primary key,
  workspace_id uuid references workspaces(id) on delete cascade,
  text text not null,
  embedding vector(1536),
  source_message_id bigint references messages(id),
  ts timestamptz default now()
);
7) Backend Endpoints (OpenAPI sketch)
Auth

GET /me → current Firebase user (after token verify).

Workspaces

POST /workspaces { name } → { id } (host = caller)

GET /workspaces → list memberships

POST /workspaces/{id}/invites → { code, url }

POST /invites/{code}/accept → join

Sessions (Parties)

POST /workspaces/{id}/sessions → { sessionId }

POST /sessions/{id}/end → is_active=false

Messages

GET /sessions/{id}/messages?after_seq=N&limit=100

WS /ws/sessions/{id} (see protocol below)

Actions (@act)

POST /actions { workspace_id, session_id, tool, args, connected_account_id, dry_run } → { action_id }

POST /actions/{id}/confirm (host only)

GET /actions/{id} → status/result

Composio

POST /composio/users (idempotent create; maps Firebase uid → Composio user)

POST /composio/connect-link { provider, user_id } → { url }

GET /composio/accounts?user_id=... → list connected accounts

Webhooks: /composio/webhook (optional) to update connection status

8) WebSocket Protocol (Sessions)
Connect: wss://api/ws/sessions/:id with header Authorization: Bearer <idToken>

Client → Server events

{ "type": "user_msg", "client_event_id": "uuid", "text": "@ask what did we decide?" }
{ "type": "typing", "is_typing": true }
Server → Client events

{ "type": "message", "seq": 123, "sender": {"id":"U1","name":"Reet"}, "role":"user|agen

[README truncated for size]

## Detected evidence (automated analysis)

Indexed codebase: 16 recognized source files, 92 KB.
- CSS (language) — detected in the code
- Firebase (technology) — detected in the code
- HTML (language) — detected in the code
- JavaScript (language) — detected in the code
- React (technology) — detected in the code
- FastAPI (technology) — claimed on Devpost, not found in the code
- Python (language) — claimed on Devpost, not found in the code

## Codebase structure (from repository index)

### Files (22 of 22)

```
.DS_Store
frontend/.env
frontend/.gitignore
frontend/craco.config.js
frontend/package.json
frontend/public/index.html
frontend/public/manifest.json
frontend/public/robots.txt
frontend/README.md
frontend/src/App.css
frontend/src/App.js
frontend/src/App.test.js
frontend/src/firebase.js
frontend/src/index.css
frontend/src/index.js
frontend/src/pages/DashboardPage.js
frontend/src/pages/LoginPage.js
frontend/src/pages/OnboardingPage.js
frontend/src/pages/WorkspacePage.js
frontend/src/reportWebVitals.js
frontend/src/setupTests.js
README.md
```

### Dependencies

- frontend/package.json: @composio/core@^0.2.0, @craco/craco@^7.0.0-alpha.3, buffer@^6.0.3, cra-template@1.3.0, crypto-browserify@^3.12.1, firebase@^12.4.0, path-browserify@^1.0.1, process@^0.11.10, react@^19.2.0, react-dom@^19.2.0, react-router-dom@^6.30.1, react-scripts@^5.0.1, stream-browserify@^3.0.0, vm-browserify@^1.1.2

### Recent commits (newest first)

- Merge pull request #4 from decoder3064/fiona/onboarding-fix
- fix: onboarding page & index.html
- improved ui
- added ui animations
- fixed latency
- fab bug fixed
- final ui
- fixed bugs
- Merge pull request #3 from decoder3064/Workspace-Delete
- Delete workspace
- Fix API call
- fixed refreshing issue, udapted workspace component
- POST API call to backend
- added endpoint for api
- Update onboarding
- Username display fix
- fixed bug with merge pull request
- css rules
- added classes
- Merge pull request #2 from decoder3064/Workspace

## Key source files (fetched from GitHub, selected and truncated for size)

### frontend/package.json

```
{
  "name": "frontend",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "@composio/core": "^0.2.0",
    "buffer": "^6.0.3",
    "cra-template": "1.3.0",
    "crypto-browserify": "^3.12.1",
    "firebase": "^12.4.0",
    "path-browserify": "^1.0.1",
    "process": "^0.11.10",
    "react": "^19.2.0",
    "react-dom": "^19.2.0",
    "react-router-dom": "^6.30.1",
    "stream-browserify": "^3.0.0",
    "vm-browserify": "^1.1.2"
  },
  "scripts": {
    "start": "craco start",
    "build": "craco build",
    "test": "craco test",
    "eject": "react-scripts eject"
  },
  "eslintConfig": {
    "extends": [
      "react-app",
      "react-app/jest"
    ]
  },
  "browserslist": {
    "production": [
      ">0.2%",
      "not dead",
      "not op_mini all"
    ],
    "development": [
      "last 1 chrome version",
      "last 1 firefox version",
      "last 1 safari version"
    ]
  },
  "devDependencies": {
    "@craco/craco": "^7.0.0-alpha.3",
    "react-scripts": "^5.0.1"
  }
}

```

### frontend/src/index.js

```javascript
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

```

### frontend/src/App.js

```javascript
import React, { useEffect, useState } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { onAuthStateChanged } from 'firebase/auth';
import { auth } from './firebase';
import LoginPage from './pages/LoginPage';
import OnboardingPage from './pages/OnboardingPage';
import DashboardPage from './pages/DashboardPage';
import WorkspacePage from './pages/WorkspacePage';
import './App.css';

function App() {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true)

    useEffect(() => {
    // Subscribe to auth state changes
    const unsubscribe = onAuthStateChanged(auth, (firebaseUser) => {
      if (firebaseUser) {
        // User is signed in
        setUser({
          id: firebaseUser.uid,
          displayName: firebaseUser.displayName || firebaseUser.email?.split('@')[0],
          email: firebaseUser.email,
        });
      } else {
        // User is signed out
        setUser(null);
      }
      setLoading(false);
    });

    return () => unsubscribe();
  }, []);

  if (loading) {
    return <div style={{ padding: 20 }}>Loading...</div>;
  }


  return (
    <Router future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
      <Routes>
        <Route path="/" element={<LoginPage />} />
        <Route path="/onboarding" element={<OnboardingPage />} />
        <Route 
          path="/dashboard" 
          element={user ? <DashboardPage currentUser={user} /> : <Navigate to="/" />} 
        />
        <Route 
          path="/workspace/:id" 
          element={<WorkspacePage currentUser={user} />}
        />
        <Route path="*" element={<Navigate to="/" />} />
      </Routes>
    </Router>
  );
}

export default App;

```

### frontend/craco.config.js

```javascript
// craco.config.js
const webpack = require('webpack');
module.exports = {
  webpack: {
    configure: (webpackConfig) => {
      webpackConfig.resolve = webpackConfig.resolve || {};
      webpackConfig.resolve.fallback = {
        ...(webpackConfig.resolve.fallback || {}),
        crypto: require.resolve('crypto-browserify'),
        path: require.resolve('path-browserify'),
        stream: require.resolve('stream-browserify'),
        buffer: require.resolve('buffer/'),
        vm: require.resolve('vm-browserify'),
      };
      webpackConfig.plugins = webpackConfig.plugins || [];
      webpackConfig.plugins.push(
        new webpack.ProvidePlugin({
          process: 'process/browser.js',
        })
      );
      return webpackConfig;
    },
  },
};

```

### frontend/src/setupTests.js

```javascript
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

```

### frontend/src/App.test.js

```javascript
import { render, screen } from '@testing-library/react';
import App from './App';

test('renders learn react link', () => {
  render(<App />);
  const linkElement = screen.getByText(/learn react/i);
  expect(linkElement).toBeInTheDocument();
});

```

### frontend/src/reportWebVitals.js

```javascript
const reportWebVitals = onPerfEntry => {
  if (onPerfEntry && onPerfEntry instanceof Function) {
    import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
      getCLS(onPerfEntry);
      getFID(onPerfEntry);
      getFCP(onPerfEntry);
      getLCP(onPerfEntry);
      getTTFB(onPerfEntry);
    });
  }
};

export default reportWebVitals;

```

### frontend/src/index.css

```css
body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
    'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

code {
  font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
    monospace;
}

```

### frontend/public/index.html

```html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="theme-color" content="#000000" />
    <meta
      name="description"
      content="Web site created using create-react-app"
    />
    <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
    <!--
      manifest.json provides metadata used when your web app is installed on a
      user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
    -->
    <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
    <!--
      Notice the use of %PUBLIC_URL% in the tags above.
      It will be replaced with the URL of the `public` folder during the build.
      Only files inside the `public` folder can be referenced from the HTML.

      Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
      work correctly both with client-side routing and a non-root public URL.
      Learn how to configure a non-root public URL by running `npm run build`.
    -->
    <title>Doryo</title>
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
    <!--
      This HTML file is a template.
      If you open it directly in the browser, you will see an empty page.

      You can add webfonts, meta tags, or analytics to this file.
      The build step will place the bundled scripts into the <body> tag.

      To begin the development, run `npm start` or `yarn start`.
      To create a production bundle, use `npm run build` or `yarn build`.
    -->
  </body>
</html>

```

### frontend/src/firebase.js

```javascript
// Firebase core imports
import { initializeApp } from "firebase/app";
import { getAuth, GoogleAuthProvider, signInWithPopup } from "firebase/auth";
import {
  getFirestore,
  doc,
  getDoc,
  setDoc,
  collection,
  getDocs,
  updateDoc,
  serverTimestamp,
  increment,
  deleteDoc,
} from "firebase/firestore";

// Firebase configuration from environment variables
const firebaseConfig = {
  apiKey: process.env.REACT_APP_FIREBASE_API_KEY,
  authDomain: process.env.REACT_APP_FIREBASE_AUTH_DOMAIN,
  projectId: process.env.REACT_APP_FIREBASE_PROJECT_ID,
  storageBucket: process.env.REACT_APP_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: process.env.REACT_APP_FIREBASE_MESSAGING_SENDER_ID,
  appId: process.env.REACT_APP_FIREBASE_APP_ID,
};

// Initialize Firebase
const app = initializeApp(firebaseConfig);
export const auth = getAuth(app);
export const db = getFirestore(app);
export const googleProvider = new GoogleAuthProvider();
googleProvider.setCustomParameters({ prompt: "select_account" });

// Check if a user exists in Firestore
export async function checkUserExists(uid) {
  const userRef = doc(db, "Users", uid);
  const userSnap = await getDoc(userRef);
  return userSnap.exists();
}

// Create a new user in Firestore
export async function createNewUser(uid, userData) {
  const userRef = doc(db, "Users", uid);
  await setDoc(userRef, {
    auth_id: uid,
    display_name: userData.displayName || "",
    email: userData.email || "",
    services: [],
  });
}

// Google sign-in helper
export async function signInWithGoogle() {
  const result = await signInWithPopup(auth, googleProvider);
  return result;
}

async function getUserProfile(uid) {
  const primaryRef = doc(db, "Users", uid);
  let snap = await getDoc(primaryRef);

  if (snap.exists()) {
    return { snap, ref: primaryRef };
  }

  const fallbackRef = doc(db, "users", uid);
  snap = await getDoc(fallbackRef);

  if (!snap.exists()) {
    throw new Error("User profile not found");
  }

  return { snap, ref: fallbackRef };
}

export async function create_workspace(userUUID, workspaceName) {
  const { snap: userSnap } = await getUserProfile(userUUID);
  const userData = userSnap.data() || {};
  const displayName =
    userData.display_name ||
    userData.displayName ||
    userData.name ||
    userData.fullName ||
    userData.email ||
    "Unknown user";

  const workspaceRef = doc(collection(db, "workspaces"));
  const workspaceData = {
    id: workspaceRef.id,
    name: workspaceName,
    hostId: userUUID,
    hostName: displayName,
    participantCount: 1,
    createdAt: serverTimestamp(),
  };

  await setDoc(workspaceRef, workspaceData);

  const participantRef = doc(
    collection(workspaceRef, "participants"),
    userUUID
  );
  await setDoc(participantRef, {
    userId: userUUID,
    displayName,
    role: "host",
    joinedAt: serverTimestamp(),
    workspaceId: workspaceRef.id,
  });

  return workspaceRef.id;
}

export async function join_workspace(userUUID, workspaceID) {
  const workspaceRef = doc(db, "workspaces", workspaceID);
  const workspaceSnap = await getDoc(workspaceRef);

  if (!workspaceSnap.exists()) {
    return false;
  }

  const participantRef = doc(
    collection(workspaceRef, "participants"),
    userUUID
  );
  const participantSnap = await getDoc(participantRef);
  if (participantSnap.exists()) {
    return true;
  }

  const { snap: userSnap } = await getUserProfile(userUUID);
  const userData = userSnap.data() || {};
  const displayName =
    userData.display_name ||
    userData.displayName ||
    userData.name ||
    userData.fullName ||
    userData.email ||
    "Unknown user";

  await setDoc(participantRef, {
    userId: userUUID,
    displayName,
    role: "member",
    joinedAt: serverTimestamp(),
    workspaceId: workspaceID,
  });

  await updateDoc(workspaceRef, {
    participantCount: increment(1),
  });

  return true;
}

export async function get_workspaces(userUUID) {
  if (!userUUID) {
    return [];
  }

  const workspacesRef = collection(db, "workspaces");
  const workspacesSnapshot = await getDocs(workspacesRef);

  if (workspacesSnapshot.empty) {
    return [];
  }

  const participationChecks = workspacesSnapshot.docs.map(async (workspaceDoc) => {
    const workspaceData = workspaceDoc.data() || {};
    const workspaceId = workspaceDoc.id;

    const participantRef = doc(db, "workspaces", workspaceId, "participants", userUUID);
    const participantSnap = await getDoc(participantRef);

    if (!participantSnap.exists()) {
      return null;
    }

    const participantData = participantSnap.data() || {};

    return {
      ...workspaceData,
      id: workspaceId,
      workspaceId,
      currentUserRole: participantData.role || "member",
      currentUserJoinedAt: participantData.joinedAt ?? null,
    };
  });

  const userWorkspacesResults = await Promise.all(participationChecks);
  const userWorkspaces = userWorkspacesResults.filter(Boolean);

  userWorkspaces.sort((a, b) => {
    const aTime = a.createdAt?.toMillis?.() ?? 0;
    const bTime = b.createdAt?.toMillis?.() ?? 0;
    return bTime - aTime;
  });

  return userWorkspaces;
}

async function deleteSubcollectionDocuments(parentRef, subcollectionName) {
  const subcollectionRef = collection(parentRef, subcollectionName);
  const snapshot = await getDocs(subcollectionRef);

  if (snapshot.empty) {
    return;
  }

  await Promise.all(snapshot.docs.map((docSnapshot) => deleteDoc(docSnapshot.ref)));
}

export async function delete_workspace(userUUID, workspaceID) {
  if (!userUUID || !workspaceID) {
    throw new Error("Invalid user or workspace identifier.");
  }

  const workspaceRef = doc(db, "workspaces", workspaceID);
  const workspaceSnap = await getDoc(workspaceRef);

  if (!workspaceSnap.exists()) {
    throw new Error("Workspace not found.");
  }

  const participantRef = doc(collection(workspaceRef, "participants"), userUUID);
  const participantSnap = await getDoc(participantRef);

  if (!participantSnap.exis
[truncated — 406 more characters]
```

[5 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]