Skip to content

Instantly share code, notes, and snippets.

@matthew-gerstman
Last active February 6, 2026 14:30
Show Gist options
  • Select an option

  • Save matthew-gerstman/90c8d16d7320d52822460ea20bbd07f1 to your computer and use it in GitHub Desktop.

Select an option

Save matthew-gerstman/90c8d16d7320d52822460ea20bbd07f1 to your computer and use it in GitHub Desktop.
Workspace-Scoped Chat: Backend Architecture Plan

Workspace-Scoped Chat: Backend Architecture Plan

Goal

Add workspace-scoped chat as a new capability alongside the existing project-scoped chat. This is purely additive -- project chat continues to work exactly as it does today. Workspace chat introduces a general-purpose assistant that operates without a fixed project context.

Two Chat Scopes

Project Chat (existing) Workspace Chat (new)
Thread has projectId set workspaceId set, projectId null
Agent scope 'project' -- operates within one project 'workspace' -- can discover/create/switch projects
Tools available All project tools (sheets, artifacts, folders, etc.) Workspace discovery tools + project tools after set-active-project
Visibility Thread owner + project owner + collaborators with showHistory Thread owner only (private)
Event publishing Broadcast to project members via publishToProjectUsers Publish to thread owner's USER channel only
Starter prompts Supported (project-context-specific) Not supported (no fixed context)
Checkpoints Supported Not applicable (no project to checkpoint)

Architecture Diagram

┌─────────────────────────────────────────────────────────────────────────┐
│                           DASHBOARD (Frontend)                          │
│                                                                         │
│   ┌─────────────────────┐          ┌──────────────────────┐            │
│   │   Project Chat UI   │          │  Workspace Chat UI   │  (new)     │
│   │  /projects/:id/chat │          │  /workspace/chat     │            │
│   └────────┬────────────┘          └──────────┬───────────┘            │
│            │                                   │                        │
│            └──────────┬───────────────────────┘                        │
│                       │                                                 │
│              SSE subscription to USER channel                           │
│              (both scopes use same channel)                             │
└───────────────────────┼─────────────────────────────────────────────────┘
                        │
                        ▼
┌─────────────────────────────────────────────────────────────────────────┐
│                          API ROUTES (Elysia)                            │
│                                                                         │
│   POST /threads ─────────────────────────────────────────────────────── │
│   │  body: { projectId?, workspaceId?, ... }                            │
│   │  Validates: must have projectId OR workspaceId                      │
│   │  ✅ Already works for both scopes                                   │
│   │                                                                     │
│   GET /threads ──────────────────────────────────────────────────────── │
│   │  query: { projectId?, workspaceId? }                                │
│   │  ⚠️  workspaceId filter missing today                              │
│   │                                                                     │
│   GET/PUT/DELETE /threads/:id ───────────────────────────────────────── │
│   │  ❌ Permission middleware 404s workspace threads                    │
│   │                                                                     │
│   POST /threads/:id/messages ────────────────────────────────────────── │
│   │  ❌ Event publishing silently drops workspace thread events         │
│   │                                                                     │
└───┼─────────────────────────────────────────────────────────────────────┘
    │
    ▼
┌─────────────────────────────────────────────────────────────────────────┐
│                      PERMISSION MIDDLEWARE                               │
│                      (permission-helpers.ts)                             │
│                                                                         │
│   Current flow for threads:                                             │
│   ┌──────────────┐     ┌─────────────────┐     ┌───────────────┐       │
│   │ thread route  │────▶│ resolveToProject │────▶│ check project │       │
│   │ (any CRUD)    │     │ (returns null    │     │ permissions   │       │
│   └──────────────┘     │  for workspace)  │     └───────┬───────┘       │
│                         └────────┬────────┘             │               │
│                                  │ null                  │               │
│                                  ▼                       │               │
│                         ❌ throw 404                     │               │
│                                                          │               │
│   Proposed flow (mirrors existing threadFolder pattern):                 │
│   ┌──────────────┐     ┌─────────────────┐                              │
│   │ thread route  │────▶│ resolveToScope   │                             │
│   └──────────────┘     └────────┬────────┘                              │
│                                  │                                       │
│                    ┌─────────────┼──────────────┐                       │
│                    ▼                             ▼                       │
│           has projectId?                has workspaceId?                 │
│           ┌───────────────┐            ┌──────────────────┐             │
│           │ check project │            │ check workspace  │             │
│           │ permissions   │            │ membership       │             │
│           └───────────────┘            └──────────────────┘             │
└─────────────────────────────────────────────────────────────────────────┘
    │
    ▼
┌─────────────────────────────────────────────────────────────────────────┐
│                         SERVICE LAYER                                    │
│                                                                         │
│   ┌─────────────────────────────────────────────────────────┐           │
│   │                   thread.service.ts                      │           │
│   │                                                          │           │
│   │  create()        ── ✅ works for both scopes             │           │
│   │  findMany()      ── ⚠️  needs workspaceId filter         │           │
│   │  findOne()       ── ✅ works (blocked by middleware)     │           │
│   │  update()        ── ❌ events dropped for workspace      │           │
│   │  softDelete()    ── ❌ events dropped for workspace      │           │
│   │  archive()       ── ❌ events dropped for workspace      │           │
│   │  resolveToProject() ── returns null for workspace        │           │
│   │  resolveToScope()   ── 🆕 new method needed             │           │
│   │  getThreadsForWorkspace() ── 🆕 new method needed       │           │
│   └──────────────────────────────────────────────────────────┘           │
│                                                                         │
│   ┌─────────────────────────────────────────────────────────┐           │
│   │                  message.service.ts                       │           │
│   │                                                          │           │
│   │  create()            ── ❌ events dropped for workspace  │           │
│   │  update()            ── ❌ events dropped for workspace  │           │
│   │  softDelete()        ── ❌ events dropped for workspace  │           │
│   │  truncateFrom()      ── ❌ events dropped for workspace  │           │
│   │  restoreMessages()   ── ❌ events dropped for workspace  │           │
│   └──────────────────────────────────────────────────────────┘           │
│                                                                         │
│   ┌─────────────────────────────────────────────────────────┐           │
│   │              Other affected services                     │           │
│   │                                                          │           │
│   │  thread-todo.service.ts      ── ❌ events dropped        │           │
│   │  compacted-message.service   ── ❌ events dropped        │           │
│   │  exchange-compaction.service ── ❌ events dropped        │           │
│   │  objective-tracking.service  ── ❌ events dropped        │           │
│   └──────────────────────────────────────────────────────────┘           │
└───┬─────────────────────────────────────────────────────────────────────┘
    │
    ▼
┌─────────────────────────────────────────────────────────────────────────┐
│                    EVENT PUBLISHING (Redis + SSE)                        │
│                    (events.service.ts)                                   │
│                                                                         │
│   Current pattern (all 16+ call sites):                                 │
│   ┌──────────────┐     ┌─────────────────────┐     ┌────────────┐      │
│   │ get threadMeta│────▶│ if (projectId)       │────▶│ publish to │      │
│   │ from threadId │     │   publish to project │     │ USER chans │      │
│   └──────────────┘     │ else                  │     └────────────┘      │
│                         │   ❌ drop event       │                        │
│                         └─────────────────────┘                         │
│                                                                         │
│   Proposed: unified publishThreadEvent() utility                        │
│   ┌──────────────┐     ┌──────────────────────┐     ┌────────────┐     │
│   │ get thread   │────▶│ if (projectId)        │────▶│ publish to │     │
│   │ event context│     │   publishToProject... │     │ project    │     │
│   │ (project +   │     │ if (workspaceId only) │     │ members    │     │
│   │  workspace + │     │   publish to owner's  │────▶│ USER chans │     │
│   │  userId)     │     │   USER channel        │     └────────────┘     │
│   └──────────────┘     └──────────────────────┘                         │
│                                                                         │
│   Existing methods (both already implemented):                          │
│   • publishToProjectUsers(projectId, event) ── fans out to members     │
│   • publishToWorkspaceUsers(wsId, event)    ── fans out to members     │
│   • Redis USER channels                     ── per-user SSE delivery   │
└─────────────────────────────────────────────────────────────────────────┘
    │
    ▼
┌─────────────────────────────────────────────────────────────────────────┐
│                      AGENT EXECUTION (Inngest)                          │
│                                                                         │
│   ┌──────────────────────────────────────────────────────────┐          │
│   │               obvious-agent-execution.ts                  │          │
│   │                                                          │          │
│   │  1. Prepare execution                                    │          │
│   │  2. Credit check (resolves workspace ── ✅ works)        │          │
│   │  3. Build config ── deriveScope() ── ✅ works            │          │
│   │  4. Build tool context ── ✅ carries both IDs            │          │
│   │  5. Execute agent steps                                  │          │
│   │  6. AgentEventEmitter ── ❌ drops workspace events       │          │
│   └──────────────────────────────────────────────────────────┘          │
│                                                                         │
│   ┌──────────────────────────────────────────────────────────┐          │
│   │               Tool Registry (registry.ts)                 │          │
│   │                                                          │          │
│   │  Scope filtering ── ✅ already works:                    │          │
│   │  • project scope: all project tools, no workspace tools  │          │
│   │  • workspace scope: workspace discovery + project tools  │          │
│   │    (agent calls set-active-project first)                │          │
│   └──────────────────────────────────────────────────────────┘          │
│                                                                         │
│   ┌──────────────────────────────────────────────────────────┐          │
│   │            Other Inngest Functions                         │          │
│   │                                                          │          │
│   │  generate-thread-name.ts ── ❌ skips workspace threads   │          │
│   │  thread-starter-prompt.ts ── skips workspace (OK for MVP)│          │
│   │  thread-check-and-compact ── ✅ works (threadId-based)   │          │
│   └──────────────────────────────────────────────────────────┘          │
└─────────────────────────────────────────────────────────────────────────┘
    │
    ▼
┌─────────────────────────────────────────────────────────────────────────┐
│                         DATABASE (PostgreSQL + Drizzle)                  │
│                                                                         │
│   threads                                                               │
│   ├── id (text, unique)                                                 │
│   ├── userId (text, FK → users) ── thread owner                        │
│   ├── projectId (text, nullable) ── set for project-scoped threads     │
│   ├── workspaceId (text, nullable) ── set for workspace-scoped threads │
│   ├── agentStatus, objectiveStatus ── agent execution state            │
│   ├── estimatedTokens ── compaction tracking                           │
│   ├── idx_threads_project_id ✅                                        │
│   ├── idx_threads_workspace_id ✅                                      │
│   └── idx_threads_project_deleted_updated ✅                           │
│                                                                         │
│   messages ── scoped through threadId FK (no direct project/workspace) │
│   compactedMessages ── scoped through threadId FK                      │
│   threadTodos ── scoped through threadId FK                            │
│   threadFolders ── has both workspaceId (NOT NULL) + projectId (nullable)│
│   agentExecutions ── scoped through threadId FK                        │
│   creditLedger ── has workspaceId (NOT NULL) + optional threadId       │
│                                                                         │
│   ✅ Schema already supports both scopes. No migrations needed.        │
└─────────────────────────────────────────────────────────────────────────┘

What Already Works (No Changes Needed)

Layer Detail
DB schema threads has both projectId and workspaceId with indexes
Thread creation API POST /threads accepts either scope, validates at least one
Agent scope derivation deriveScope() in types.ts returns 'project' | 'workspace'
Tool filtering Registry excludes workspace tools in project scope, includes them in workspace scope
SharedToolContext Carries both projectId? and workspaceId? to all tools
Credit resolution resolveWorkspaceId() resolves from any of workspaceId/projectId/threadId
Thread folders Already support workspace-only scope
publishToWorkspaceUsers() Exists in events.service.ts
SSE subscription Clients subscribe to USER channel; events forwarded automatically
Thread compaction Works on threadId, scope-agnostic

What's Broken/Missing

1. Event Publishing Drops Workspace Events (CRITICAL)

Every service follows this pattern -- workspace threads get zero realtime updates:

const { projectId, threadMeta } = await getThreadMetaFromThreadId(threadId)
if (projectId) {  // <-- false for workspace threads
  await eventsService.publishToProjectUsers(projectId, { ... })
}
// workspace threads: event silently dropped

16+ call sites across 8 files:

  • thread.service.ts -- 7 sites (create, update, updateThreadStatus, clearContextLimit, softDelete, archive, unarchive)
  • message.service.ts -- 8 sites (create, update, setTurnId, updateToolCallResult, clearErrors, softDelete, truncateFrom, restoreMessages)
  • thread-todo.service.ts, compacted-message.service.ts, exchange-compaction.service.ts, objective-tracking.service.ts
  • generate-thread-name.ts (Inngest), AgentEventEmitter.emitAgentStatusEvent

2. Permission Middleware 404s Workspace Threads (CRITICAL)

permission-helpers.ts resolves threads through projectResolvers which calls resolveToProject() -- returns null for workspace threads, causing a 404. All thread CRUD routes are blocked.

3. Thread Listing Has No Workspace Filter

GET /threads only accepts projectId. No workspaceId filter. No getThreadsForWorkspace() service method.

4. Minor Issues

  • Starter prompts skip workspace threads (acceptable -- no fixed project context)
  • POST /:threadId/shell/wake hard-fails on workspace threads (should return clear error)
  • generate-thread-name.ts skips workspace threads (should auto-name them too)

Implementation

Commit 1: refactor: Extract publishThreadEvent utility

New file: apps/api/src/utils/thread-event-publisher.ts

interface ThreadEventContext {
  projectId: string | null
  workspaceId: string | null
  userId: string           // thread owner
  threadMeta?: ThreadMeta  // only for project threads
}

async function getThreadEventContext(threadId: string): Promise<ThreadEventContext>

async function publishThreadEvent(options: {
  threadId: string
  event: Omit<EventData, 'topic'>
  tx?: unknown
}): Promise<void>

Routing logic:

  • Project thread (projectId present): publishToProjectUsers(projectId, { ...event, _threadMeta }) -- unchanged from today
  • Workspace thread (workspaceId only): publish directly to thread owner's USER channel -- owner-only, private

Modify: apps/api/src/utils/thread-metadata.ts Update getThreadMetaFromThreadId() to also return workspaceId and userId.

Files:

  • New: apps/api/src/utils/thread-event-publisher.ts
  • Modify: apps/api/src/utils/thread-metadata.ts

Commit 2: fix: Handle workspace threads in permission middleware

Modify: apps/api/src/auth/permission-helpers.ts

Follow the existing threadFolder pattern (lines 104-136). Remove thread from projectResolvers and add hybrid resolution:

if (config.resource === 'thread') {
  const scope = await threadService.resolveToScope(sourceResourceId)
  if (!scope) throw new ApiError('Thread not found', 404)

  if (scope.projectId) {
    // Existing: check project permissions
    return permissionsService.resolvePermission({ ... })
  }

  // Workspace thread: check workspace membership
  const hasAccess = await workspaceService.hasWorkspaceAccess(actorId, scope.workspaceId)
  if (!hasAccess) throwAccessDenied('workspace')
  return { hasAccess: true, source: 'direct' }
}

Modify: apps/api/src/services/thread.service.ts

Add resolveToScope():

async resolveToScope(threadId: string): Promise<{
  projectId: string | null
  workspaceId: string | null
  userId: string
} | null>

Files:

  • apps/api/src/auth/permission-helpers.ts
  • apps/api/src/services/thread.service.ts

Commit 3: feat: Add workspace thread listing

Modify: apps/api/src/services/thread.service.ts

Add getThreadsForWorkspace(workspaceId, userId) -- filters workspaceId = ? AND userId = ? AND projectId IS NULL AND deletedAt IS NULL, enriches with message stats.

Modify: apps/api/src/routes/threads.ts

Add workspaceId to GET /threads query params with workspace permission check.

Files:

  • apps/api/src/services/thread.service.ts
  • apps/api/src/routes/threads.ts

Commits 4-6: Migrate event publishing

Replace all if (projectId) { publishToProjectUsers(...) } patterns with publishThreadEvent():

  • Commit 4: thread.service.ts (7 sites)
  • Commit 5: message.service.ts (8 sites)
  • Commit 6: thread-todo.service.ts, compacted-message.service.ts, exchange-compaction.service.ts, objective-tracking.service.ts

Each is a mechanical replacement. Project chat behavior is completely unchanged -- publishThreadEvent delegates to the same publishToProjectUsers path when projectId is present.


Commit 7: fix: Workspace support in AgentEventEmitter and Inngest

  • apps/api/src/agents/obvious-v2/state/events.ts -- emitAgentStatusEvent() uses publishThreadEvent()
  • apps/api/src/inngest/generate-thread-name.ts -- publishThreadUpdateEvent() uses publishThreadEvent() so workspace threads get auto-named

Out of Scope (Acceptable for Now)

Item Why
Workspace starter prompts Workspace chat is a general assistant; starter prompts need project context
Workspace thread sharing Only thread owner sees their threads; no showHistory equivalent needed yet
Agent tools requiring projectId These correctly tell the agent to set-active-project first; workspace scope preamble already handles this
Checkpoints for workspace threads Checkpoints are project-specific (projectId NOT NULL)
ObjectiveBatches for workspace Project-specific batch tracking
Frontend/dashboard changes Separate effort after backend is solid
Migration of existing threads Not needed; existing project threads stay as-is

Verification

# Unit tests
bun obvious test --files apps/api/src/services/thread.service.test.ts
bun obvious test --files apps/api/src/services/message.service.test.ts
bun obvious test --files apps/api/src/auth/permission-helpers.test.ts

# Typecheck & lint
bun obvious typecheck --changed
bun obvious lint --changed

Manual verification:

  1. Create a workspace thread: POST /threads with { workspaceId: "ws_xxx", source: "chat" } (no projectId)
  2. List workspace threads: GET /threads?workspaceId=ws_xxx
  3. Send a message on workspace thread -- event should arrive on owner's SSE stream
  4. Thread CRUD (GET, PUT, DELETE, archive, unarchive) on workspace threads -- no 404s
  5. Agent execution on workspace thread -- scope: 'workspace', workspace discovery tools available
  6. Verify existing project chat is completely unaffected
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment