Files
coder/coderd/chat_projects.go
Garrett DelfosseandClaude Opus 5.5 7cd1b2040b feat: add chat projects API (#29242)
- Experiment, behind `chat-projects` (off by default). First of three
stacked PRs: #29301 (memory), #29583 (UI). This PR is API only; the
screenshots below are from the UI in #29583.
- CRUD under
`/api/experimental/organizations/{organization}/chats/projects/{project}`.
`GET /api/experimental/chats/projects` lists the caller's projects
across all organizations.
- `project_id` on chat create and as a chat list filter. A chat joins a
project only at creation; there is no move.
- Projects are identified by ID. Names are display labels (64
characters, description 1024) with no uniqueness constraint. An optional
`icon` URL (usually an emoji image under `/emojis`) is capped at 256
characters and audited.
- A user can own at most 100 projects across all organizations; creating
another answers 409. Creates count and insert under a per-owner advisory
lock so concurrent requests cannot overshoot.
- Projects are private to their `owner_id`. Members may only create
their own, and a chat can only be bound to a project its owner owns. An
unreadable or foreign project on chat create answers 404 so IDs cannot
be probed; a project from another organization answers 400.
- Permissions ride on the `agents-access` role next to chats (after
#30004), so whoever can use Coder Agents can use projects. Owners and
org admins reach every project.

### Examples

Create a project in an organization:

```http
POST /api/experimental/organizations/{organization}/chats/projects
{"name": "Platform", "description": "Infra and deploy work", "icon": "/emojis/1f680.png"}
```

```json
{
  "id": "7c0e4a3e-5b1f-4f7d-9a51-2f3c8f6d2b10",
  "organization_id": "bb640d07-ca8a-4869-b6bc-ae61ebb2fda1",
  "owner_id": "0ed9befc-4911-4ccf-a8e2-559bf72daa94",
  "name": "Platform",
  "description": "Infra and deploy work",
  "icon": "/emojis/1f680.png",
  "created_at": "2026-09-30T15:04:05Z",
  "updated_at": "2026-09-30T15:04:05Z"
}
```

List the caller's projects across organizations with `GET
/api/experimental/chats/projects` (an array of the object above). Update
with `PATCH .../chats/projects/{project}` and any of `name`,
`description`, `icon`; delete with `DELETE`, which detaches its chats.

Create a chat in the project, and list only its chats:

```http
POST /api/v2/chats
{"organization_id": "bb640d07-…", "project_id": "7c0e4a3e-…", "content": [{"type": "text", "text": "Plan the deploy"}]}

GET /api/v2/chats?project_id=7c0e4a3e-…
```

At the limit:

```json
409 {"message": "You can have at most 100 chat projects. Delete a project to create another."}
```

<details>
<summary>Screenshots (UI from #29583)</summary>

![Create
project](https://raw.githubusercontent.com/coder/coder/f0ssel/chat-projects-assets/v3/pr1-01-create-project-dialog.png)
![Sidebar
folders](https://raw.githubusercontent.com/coder/coder/f0ssel/chat-projects-assets/v3/pr1-02-sidebar-folders.png)
![Project
page](https://raw.githubusercontent.com/coder/coder/f0ssel/chat-projects-assets/v3/pr1-03-project-page.png)
![Folder
menu](https://raw.githubusercontent.com/coder/coder/f0ssel/chat-projects-assets/v3/pr1-05-folder-menu.png)
![Another member sees no
projects](https://raw.githubusercontent.com/coder/coder/f0ssel/chat-projects-assets/v3/pr5-member-sees-no-projects.png)

</details>

---
Generated with Coder Agents on behalf of @f0ssel.

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 11:49:21 -04:00

310 lines
10 KiB
Go

package coderd
import (
"context"
"database/sql"
"errors"
"fmt"
"net/http"
"strings"
"unicode/utf8"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
)
// @Summary List chat projects
// @ID list-chat-projects
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Success 200 {array} codersdk.ChatProject
// @Router /api/experimental/chats/projects [get]
// @x-apidocgen {"skip": true}
func (api *API) listChatProjects(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
projects, err := api.Database.GetChatProjectsByOwnerID(ctx, apiKey.UserID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to list chat projects.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, slice.List(projects, db2sdk.ChatProject))
}
// @Summary Create chat project
// @ID create-chat-project
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Produce json
// @Param organization path string true "Organization ID" format(uuid)
// @Param request body codersdk.CreateChatProjectRequest true "Create chat project request"
// @Success 201 {object} codersdk.ChatProject
// @Router /api/experimental/organizations/{organization}/chats/projects [post]
// @x-apidocgen {"skip": true}
func (api *API) postChatProject(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
organization := httpmw.OrganizationParam(r)
var req codersdk.CreateChatProjectRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
req.Name = strings.TrimSpace(req.Name)
req.Icon = strings.TrimSpace(req.Icon)
if req.Name == "" {
writeChatProjectFieldError(ctx, rw, "name", "Name is required.")
return
}
if !validateChatProjectFields(ctx, rw, req.Name, req.Description, req.Icon) {
return
}
if !api.Authorize(r, policy.ActionCreate, database.ChatProject{
OrganizationID: organization.ID,
OwnerID: apiKey.UserID,
}.RBACObject()) {
httpapi.Forbidden(rw)
return
}
aReq, commitAudit := audit.InitRequest[database.ChatProject](rw, &audit.RequestParams{
Audit: *api.Auditor.Load(),
Log: api.Logger,
Request: r,
Action: database.AuditActionCreate,
OrganizationID: organization.ID,
})
defer commitAudit()
var project database.ChatProject
err := api.Database.InTx(func(tx database.Store) error {
// The lock serializes a user's creates so concurrent requests cannot
// both observe room under the cap.
if err := tx.AcquireLock(ctx, database.GenLockID("chat-projects-owner:"+apiKey.UserID.String())); err != nil {
return xerrors.Errorf("lock chat projects: %w", err)
}
// The count spans the user's projects in every organization, and the
// caller was already authorized to create one.
//nolint:gocritic // See above.
count, err := tx.CountChatProjectsByOwnerID(dbauthz.AsSystemRestricted(ctx), apiKey.UserID)
if err != nil {
return xerrors.Errorf("count chat projects: %w", err)
}
if count >= maxChatProjectsPerOwner {
return errChatProjectLimit
}
project, err = tx.InsertChatProject(ctx, database.InsertChatProjectParams{
ID: uuid.NullUUID{},
OrganizationID: organization.ID,
OwnerID: apiKey.UserID,
Name: req.Name,
Description: req.Description,
Icon: req.Icon,
})
return err
}, nil)
if errors.Is(err, errChatProjectLimit) {
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: fmt.Sprintf("You can have at most %d chat projects. Delete a project to create another.", maxChatProjectsPerOwner),
})
return
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to create chat project.",
Detail: err.Error(),
})
return
}
aReq.New = project
httpapi.Write(ctx, rw, http.StatusCreated, db2sdk.ChatProject(project))
}
// @Summary Get chat project
// @ID get-chat-project
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param organization path string true "Organization ID" format(uuid)
// @Param project path string true "Chat project ID" format(uuid)
// @Success 200 {object} codersdk.ChatProject
// @Router /api/experimental/organizations/{organization}/chats/projects/{project} [get]
// @x-apidocgen {"skip": true}
//
//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) getChatProject(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
project := httpmw.ChatProjectParam(r)
if !api.Authorize(r, policy.ActionRead, project.RBACObject()) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.ChatProject(project))
}
// @Summary Update chat project
// @ID update-chat-project
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Produce json
// @Param organization path string true "Organization ID" format(uuid)
// @Param project path string true "Chat project ID" format(uuid)
// @Param request body codersdk.UpdateChatProjectRequest true "Update chat project request"
// @Success 200 {object} codersdk.ChatProject
// @Router /api/experimental/organizations/{organization}/chats/projects/{project} [patch]
// @x-apidocgen {"skip": true}
func (api *API) patchChatProject(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
project := httpmw.ChatProjectParam(r)
if !api.Authorize(r, policy.ActionUpdate, project.RBACObject()) {
httpapi.ResourceNotFound(rw)
return
}
aReq, commitAudit := audit.InitRequest[database.ChatProject](rw, &audit.RequestParams{
Audit: *api.Auditor.Load(),
Log: api.Logger,
Request: r,
Action: database.AuditActionWrite,
OrganizationID: project.OrganizationID,
})
defer commitAudit()
aReq.Old = project
var req codersdk.UpdateChatProjectRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
if req.Name != nil {
*req.Name = strings.TrimSpace(*req.Name)
}
if req.Icon != nil {
*req.Icon = strings.TrimSpace(*req.Icon)
}
if req.Name != nil && *req.Name == "" {
writeChatProjectFieldError(ctx, rw, "name", "Name must not be blank.")
return
}
name := ptr.NilToDefault(req.Name, project.Name)
description := ptr.NilToDefault(req.Description, project.Description)
icon := ptr.NilToDefault(req.Icon, project.Icon)
if !validateChatProjectFields(ctx, rw, name, description, icon) {
return
}
updated, err := api.Database.UpdateChatProjectByID(ctx, database.UpdateChatProjectByIDParams{
ID: project.ID,
Name: name,
Description: description,
Icon: icon,
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to update chat project.",
Detail: err.Error(),
})
return
}
aReq.New = updated
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.ChatProject(updated))
}
// @Summary Delete chat project
// @ID delete-chat-project
// @Security CoderSessionToken
// @Tags Chats
// @Param organization path string true "Organization ID" format(uuid)
// @Param project path string true "Chat project ID" format(uuid)
// @Success 204
// @Router /api/experimental/organizations/{organization}/chats/projects/{project} [delete]
// @x-apidocgen {"skip": true}
func (api *API) deleteChatProject(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
project := httpmw.ChatProjectParam(r)
if !api.Authorize(r, policy.ActionDelete, project.RBACObject()) {
httpapi.ResourceNotFound(rw)
return
}
aReq, commitAudit := audit.InitRequest[database.ChatProject](rw, &audit.RequestParams{
Audit: *api.Auditor.Load(),
Log: api.Logger,
Request: r,
Action: database.AuditActionDelete,
OrganizationID: project.OrganizationID,
})
defer commitAudit()
aReq.Old = project
err := api.Database.DeleteChatProjectByID(ctx, project.ID)
if errors.Is(err, sql.ErrNoRows) || httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to delete chat project.",
Detail: err.Error(),
})
return
}
rw.WriteHeader(http.StatusNoContent)
}
// maxChatProjectsPerOwner caps how many projects one user owns across all
// organizations, which bounds the project list every agents page loads.
const maxChatProjectsPerOwner = 100
var errChatProjectLimit = xerrors.New("chat project limit reached")
const (
// The project name is embedded in every generation's system prompt and
// memory tool descriptions, so it is kept short.
chatProjectNameMaxChars = 64
chatProjectDescriptionMaxChars = 1024
chatProjectIconMaxChars = 256
)
// validateChatProjectFields writes a 400 response and returns false when a
// sanitized field exceeds its limit.
func validateChatProjectFields(ctx context.Context, rw http.ResponseWriter, name, description, icon string) bool {
if utf8.RuneCountInString(name) > chatProjectNameMaxChars {
writeChatProjectFieldError(ctx, rw, "name", fmt.Sprintf("Name must be at most %d characters.", chatProjectNameMaxChars))
return false
}
if utf8.RuneCountInString(description) > chatProjectDescriptionMaxChars {
writeChatProjectFieldError(ctx, rw, "description", fmt.Sprintf("Description must be at most %d characters.", chatProjectDescriptionMaxChars))
return false
}
if utf8.RuneCountInString(icon) > chatProjectIconMaxChars {
writeChatProjectFieldError(ctx, rw, "icon", fmt.Sprintf("Icon must be at most %d characters.", chatProjectIconMaxChars))
return false
}
return true
}
func writeChatProjectFieldError(ctx context.Context, rw http.ResponseWriter, field, detail string) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: detail,
Validations: []codersdk.ValidationError{{Field: field, Detail: detail}},
})
}