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.
| 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) |
┌─────────────────────────────────────────────────────────────────────────┐
│ 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. │
└─────────────────────────────────────────────────────────────────────────┘
| 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 |
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 dropped16+ 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.tsgenerate-thread-name.ts(Inngest),AgentEventEmitter.emitAgentStatusEvent
permission-helpers.ts resolves threads through projectResolvers which calls resolveToProject() -- returns null for workspace threads, causing a 404. All thread CRUD routes are blocked.
GET /threads only accepts projectId. No workspaceId filter. No getThreadsForWorkspace() service method.
- Starter prompts skip workspace threads (acceptable -- no fixed project context)
POST /:threadId/shell/wakehard-fails on workspace threads (should return clear error)generate-thread-name.tsskips workspace threads (should auto-name them too)
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 (
projectIdpresent):publishToProjectUsers(projectId, { ...event, _threadMeta })-- unchanged from today - Workspace thread (
workspaceIdonly): 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
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.tsapps/api/src/services/thread.service.ts
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.tsapps/api/src/routes/threads.ts
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.
apps/api/src/agents/obvious-v2/state/events.ts--emitAgentStatusEvent()usespublishThreadEvent()apps/api/src/inngest/generate-thread-name.ts--publishThreadUpdateEvent()usespublishThreadEvent()so workspace threads get auto-named
| 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 |
# 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 --changedManual verification:
- Create a workspace thread:
POST /threadswith{ workspaceId: "ws_xxx", source: "chat" }(no projectId) - List workspace threads:
GET /threads?workspaceId=ws_xxx - Send a message on workspace thread -- event should arrive on owner's SSE stream
- Thread CRUD (GET, PUT, DELETE, archive, unarchive) on workspace threads -- no 404s
- Agent execution on workspace thread --
scope: 'workspace', workspace discovery tools available - Verify existing project chat is completely unaffected