diff --git a/README.md b/README.md
index d469d666..4cd85fa1 100644
--- a/README.md
+++ b/README.md
@@ -95,6 +95,7 @@ For contribution workflow and code style, see [CONTRIBUTING](CONTRIBUTING.md) if
- **API** โ See `apps/api/README.md` for setup, env vars, and running the server.
- **UI** โ See `apps/web/README.md` for front-end setup and scripts.
+- **Agent workflows** โ See `proposals/agent-workflows.md` for the proposed autonomous agent architecture and rollout plan.
---
diff --git a/apps/api/internal/handler/agent.go b/apps/api/internal/handler/agent.go
new file mode 100644
index 00000000..d815a247
--- /dev/null
+++ b/apps/api/internal/handler/agent.go
@@ -0,0 +1,345 @@
+package handler
+
+import (
+ "errors"
+ "net/http"
+
+ "github.com/Devlaner/devlane/api/internal/middleware"
+ "github.com/Devlaner/devlane/api/internal/model"
+ "github.com/Devlaner/devlane/api/internal/service"
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+)
+
+type AgentHandler struct {
+ Agent *service.AgentService
+}
+
+type agentToolPermissionBody struct {
+ Tool string `json:"tool"`
+ Scope string `json:"scope"`
+ Config map[string]interface{} `json:"config"`
+}
+
+type agentCreateBody struct {
+ ProjectID *string `json:"project_id"`
+ Name string `json:"name" binding:"required"`
+ Description string `json:"description"`
+ Avatar string `json:"avatar"`
+ Instructions string `json:"instructions"`
+ Model string `json:"model"`
+ Enabled *bool `json:"enabled"`
+ AutonomyLevel string `json:"autonomy_level"`
+ ToolPermissions []agentToolPermissionBody `json:"tool_permissions"`
+}
+
+type agentUpdateBody struct {
+ Name *string `json:"name"`
+ Description *string `json:"description"`
+ Avatar *string `json:"avatar"`
+ Instructions *string `json:"instructions"`
+ Model *string `json:"model"`
+ Enabled *bool `json:"enabled"`
+ AutonomyLevel *string `json:"autonomy_level"`
+ ToolPermissions *[]agentToolPermissionBody `json:"tool_permissions"`
+}
+
+func parseUUIDParam(c *gin.Context, param, label string) (uuid.UUID, bool) {
+ id, err := uuid.Parse(c.Param(param))
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid " + label})
+ return uuid.Nil, false
+ }
+ return id, true
+}
+
+func agentPermissionsFromBody(body []agentToolPermissionBody) []service.AgentToolPermissionParams {
+ out := make([]service.AgentToolPermissionParams, 0, len(body))
+ for _, p := range body {
+ cfg := model.JSONMap{}
+ if p.Config != nil {
+ cfg = model.JSONMap(p.Config)
+ }
+ out = append(out, service.AgentToolPermissionParams{
+ Tool: p.Tool,
+ Scope: p.Scope,
+ Config: cfg,
+ })
+ }
+ return out
+}
+
+func writeAgentError(c *gin.Context, err error, fallback string) {
+ switch {
+ case errors.Is(err, service.ErrProjectForbidden),
+ errors.Is(err, service.ErrProjectNotFound),
+ errors.Is(err, service.ErrIssueNotFound),
+ errors.Is(err, service.ErrAgentNotFound):
+ c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
+ case errors.Is(err, service.ErrAgentForbidden):
+ c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"})
+ case errors.Is(err, service.ErrAgentNameRequired),
+ errors.Is(err, service.ErrAgentInvalidAutonomyLevel),
+ errors.Is(err, service.ErrAgentInvalidTool),
+ errors.Is(err, service.ErrAgentUnavailable):
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ default:
+ c.JSON(http.StatusInternalServerError, gin.H{"error": fallback})
+ }
+}
+
+func (h *AgentHandler) List(c *gin.Context) {
+ user := middleware.GetUser(c)
+ if user == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
+ return
+ }
+ var projectID *uuid.UUID
+ if c.Param("projectId") != "" {
+ id, ok := parseUUIDParam(c, "projectId", "project ID")
+ if !ok {
+ return
+ }
+ projectID = &id
+ }
+ list, err := h.Agent.ListAgents(c.Request.Context(), c.Param("slug"), projectID, user.ID)
+ if err != nil {
+ writeAgentError(c, err, "Failed to list agents")
+ return
+ }
+ c.JSON(http.StatusOK, list)
+}
+
+func (h *AgentHandler) Create(c *gin.Context) {
+ user := middleware.GetUser(c)
+ if user == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
+ return
+ }
+ var body agentCreateBody
+ if err := c.ShouldBindJSON(&body); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()})
+ return
+ }
+ var projectID *uuid.UUID
+ if c.Param("projectId") != "" {
+ id, ok := parseUUIDParam(c, "projectId", "project ID")
+ if !ok {
+ return
+ }
+ projectID = &id
+ } else if body.ProjectID != nil && *body.ProjectID != "" {
+ id, err := uuid.Parse(*body.ProjectID)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project_id"})
+ return
+ }
+ projectID = &id
+ }
+ agent, err := h.Agent.CreateAgent(c.Request.Context(), c.Param("slug"), user.ID, service.AgentCreateParams{
+ ProjectID: projectID,
+ Name: body.Name,
+ Description: body.Description,
+ Avatar: body.Avatar,
+ Instructions: body.Instructions,
+ Model: body.Model,
+ Enabled: body.Enabled,
+ AutonomyLevel: body.AutonomyLevel,
+ ToolPermissions: agentPermissionsFromBody(body.ToolPermissions),
+ })
+ if err != nil {
+ writeAgentError(c, err, "Failed to create agent")
+ return
+ }
+ c.JSON(http.StatusCreated, agent)
+}
+
+func (h *AgentHandler) Get(c *gin.Context) {
+ user := middleware.GetUser(c)
+ if user == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
+ return
+ }
+ agentID, ok := parseUUIDParam(c, "agentId", "agent ID")
+ if !ok {
+ return
+ }
+ agent, err := h.Agent.GetAgent(c.Request.Context(), c.Param("slug"), agentID, user.ID)
+ if err != nil {
+ writeAgentError(c, err, "Failed to get agent")
+ return
+ }
+ c.JSON(http.StatusOK, agent)
+}
+
+func (h *AgentHandler) Update(c *gin.Context) {
+ user := middleware.GetUser(c)
+ if user == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
+ return
+ }
+ agentID, ok := parseUUIDParam(c, "agentId", "agent ID")
+ if !ok {
+ return
+ }
+ var body agentUpdateBody
+ if err := c.ShouldBindJSON(&body); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()})
+ return
+ }
+ params := service.AgentUpdateParams{
+ Name: body.Name,
+ Description: body.Description,
+ Avatar: body.Avatar,
+ Instructions: body.Instructions,
+ Model: body.Model,
+ Enabled: body.Enabled,
+ AutonomyLevel: body.AutonomyLevel,
+ }
+ if body.ToolPermissions != nil {
+ params.ReplaceToolPerms = true
+ params.ToolPermissions = agentPermissionsFromBody(*body.ToolPermissions)
+ }
+ agent, err := h.Agent.UpdateAgent(c.Request.Context(), c.Param("slug"), agentID, user.ID, params)
+ if err != nil {
+ writeAgentError(c, err, "Failed to update agent")
+ return
+ }
+ c.JSON(http.StatusOK, agent)
+}
+
+func (h *AgentHandler) Delete(c *gin.Context) {
+ user := middleware.GetUser(c)
+ if user == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
+ return
+ }
+ agentID, ok := parseUUIDParam(c, "agentId", "agent ID")
+ if !ok {
+ return
+ }
+ if err := h.Agent.DeleteAgent(c.Request.Context(), c.Param("slug"), agentID, user.ID); err != nil {
+ writeAgentError(c, err, "Failed to delete agent")
+ return
+ }
+ c.Status(http.StatusNoContent)
+}
+
+func (h *AgentHandler) ListIssueAssignments(c *gin.Context) {
+ user := middleware.GetUser(c)
+ if user == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
+ return
+ }
+ projectID, ok := parseUUIDParam(c, "projectId", "project ID")
+ if !ok {
+ return
+ }
+ issueID, ok := parseUUIDParam(c, "pk", "issue ID")
+ if !ok {
+ return
+ }
+ list, err := h.Agent.ListIssueAssignments(c.Request.Context(), c.Param("slug"), projectID, issueID, user.ID)
+ if err != nil {
+ writeAgentError(c, err, "Failed to list agent assignments")
+ return
+ }
+ c.JSON(http.StatusOK, list)
+}
+
+func (h *AgentHandler) AssignIssue(c *gin.Context) {
+ user := middleware.GetUser(c)
+ if user == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
+ return
+ }
+ projectID, ok := parseUUIDParam(c, "projectId", "project ID")
+ if !ok {
+ return
+ }
+ issueID, ok := parseUUIDParam(c, "pk", "issue ID")
+ if !ok {
+ return
+ }
+ var body struct {
+ AgentID string `json:"agent_id" binding:"required"`
+ Reason string `json:"reason"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()})
+ return
+ }
+ agentID, err := uuid.Parse(body.AgentID)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid agent_id"})
+ return
+ }
+ assignment, err := h.Agent.AssignIssue(c.Request.Context(), c.Param("slug"), projectID, issueID, agentID, user.ID, body.Reason)
+ if err != nil {
+ writeAgentError(c, err, "Failed to assign agent")
+ return
+ }
+ c.JSON(http.StatusCreated, assignment)
+}
+
+func (h *AgentHandler) ListIssueRuns(c *gin.Context) {
+ user := middleware.GetUser(c)
+ if user == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
+ return
+ }
+ projectID, ok := parseUUIDParam(c, "projectId", "project ID")
+ if !ok {
+ return
+ }
+ issueID, ok := parseUUIDParam(c, "pk", "issue ID")
+ if !ok {
+ return
+ }
+ list, err := h.Agent.ListIssueRuns(c.Request.Context(), c.Param("slug"), projectID, issueID, user.ID)
+ if err != nil {
+ writeAgentError(c, err, "Failed to list agent runs")
+ return
+ }
+ c.JSON(http.StatusOK, list)
+}
+
+func (h *AgentHandler) CreateIssueRun(c *gin.Context) {
+ user := middleware.GetUser(c)
+ if user == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
+ return
+ }
+ projectID, ok := parseUUIDParam(c, "projectId", "project ID")
+ if !ok {
+ return
+ }
+ issueID, ok := parseUUIDParam(c, "pk", "issue ID")
+ if !ok {
+ return
+ }
+ var body struct {
+ AgentID string `json:"agent_id" binding:"required"`
+ Trigger string `json:"trigger"`
+ Input map[string]interface{} `json:"input"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()})
+ return
+ }
+ agentID, err := uuid.Parse(body.AgentID)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid agent_id"})
+ return
+ }
+ input := model.JSONMap{}
+ if body.Input != nil {
+ input = model.JSONMap(body.Input)
+ }
+ run, err := h.Agent.CreateIssueRun(c.Request.Context(), c.Param("slug"), projectID, issueID, agentID, user.ID, body.Trigger, input)
+ if err != nil {
+ writeAgentError(c, err, "Failed to create agent run")
+ return
+ }
+ c.JSON(http.StatusCreated, run)
+}
diff --git a/apps/api/internal/handler/agent_test.go b/apps/api/internal/handler/agent_test.go
new file mode 100644
index 00000000..e49af930
--- /dev/null
+++ b/apps/api/internal/handler/agent_test.go
@@ -0,0 +1,180 @@
+package handler_test
+
+import (
+ "net/http"
+ "testing"
+
+ "github.com/Devlaner/devlane/api/internal/testutil"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func agentBase(slug string) string {
+ return "/api/workspaces/" + slug + "/agents/"
+}
+
+func projectAgentBase(slug, projectID string) string {
+ return "/api/workspaces/" + slug + "/projects/" + projectID + "/agents/"
+}
+
+func issueAgentBase(slug, projectID, issueID string) string {
+ return "/api/workspaces/" + slug + "/projects/" + projectID + "/issues/" + issueID + "/"
+}
+
+func createTestAgent(t *testing.T, ts *testutil.TestServer, w testutil.SeededWorld, name string) string {
+ t.Helper()
+ rr := ts.POST(agentBase(w.Workspace.Slug), map[string]any{
+ "name": name,
+ "description": "Keeps issue work tidy",
+ "instructions": "Summarize the issue and propose next steps.",
+ "autonomy_level": "comment",
+ "tool_permissions": []map[string]any{
+ {"tool": "issue.read", "scope": "workspace"},
+ {"tool": "issue.comment", "scope": "workspace"},
+ },
+ }, w.Session)
+ require.Equal(t, http.StatusCreated, rr.Code, "body=%s", rr.Body.String())
+ id, _ := testutil.MustJSONMap(t, rr)["id"].(string)
+ require.NotEmpty(t, id)
+ return id
+}
+
+func TestAgent_RequiresAuth(t *testing.T) {
+ ts := testutil.NewTestServer(t)
+ rr := ts.GET(agentBase("x"), "")
+ require.Equal(t, http.StatusUnauthorized, rr.Code)
+}
+
+func TestAgent_CRUD(t *testing.T) {
+ ts := testutil.NewTestServer(t)
+ w := testutil.SeedWorld(t, ts.DB)
+ base := agentBase(w.Workspace.Slug)
+
+ agentID := createTestAgent(t, ts, w, "Bug Triage")
+
+ rrList := ts.GET(base, w.Session)
+ require.Equal(t, http.StatusOK, rrList.Code, "body=%s", rrList.Body.String())
+ agents := testutil.DecodeJSON[[]map[string]any](t, rrList)
+ require.Len(t, agents, 1)
+ assert.Equal(t, "Bug Triage", agents[0]["name"])
+ require.Len(t, agents[0]["tool_permissions"], 2)
+
+ rrGet := ts.GET(base+agentID+"/", w.Session)
+ require.Equal(t, http.StatusOK, rrGet.Code, "body=%s", rrGet.Body.String())
+ assert.Equal(t, "comment", testutil.MustJSONMap(t, rrGet)["autonomy_level"])
+
+ enabled := false
+ rrPatch := ts.PATCH(base+agentID+"/", map[string]any{
+ "name": "Bug Triage v2",
+ "enabled": enabled,
+ "autonomy_level": "suggest",
+ "tool_permissions": []map[string]any{{"tool": "issue.read", "scope": "workspace"}},
+ }, w.Session)
+ require.Equal(t, http.StatusOK, rrPatch.Code, "body=%s", rrPatch.Body.String())
+ updated := testutil.MustJSONMap(t, rrPatch)
+ assert.Equal(t, "Bug Triage v2", updated["name"])
+ assert.Equal(t, false, updated["enabled"])
+ assert.Equal(t, "suggest", updated["autonomy_level"])
+ require.Len(t, updated["tool_permissions"], 1)
+
+ rrDelete := ts.DELETE(base+agentID+"/", w.Session)
+ require.Equal(t, http.StatusNoContent, rrDelete.Code, "body=%s", rrDelete.Body.String())
+
+ rrMissing := ts.GET(base+agentID+"/", w.Session)
+ require.Equal(t, http.StatusNotFound, rrMissing.Code)
+}
+
+func TestAgent_ProjectRosterIncludesWorkspaceAgents(t *testing.T) {
+ ts := testutil.NewTestServer(t)
+ w := testutil.SeedWorld(t, ts.DB)
+ createTestAgent(t, ts, w, "Workspace Agent")
+
+ rrProject := ts.POST(projectAgentBase(w.Workspace.Slug, w.Project.ID.String()), map[string]any{
+ "name": "Project Agent",
+ "autonomy_level": "suggest",
+ }, w.Session)
+ require.Equal(t, http.StatusCreated, rrProject.Code, "body=%s", rrProject.Body.String())
+ assert.Equal(t, w.Project.ID.String(), testutil.MustJSONMap(t, rrProject)["project_id"])
+
+ rrList := ts.GET(projectAgentBase(w.Workspace.Slug, w.Project.ID.String()), w.Session)
+ require.Equal(t, http.StatusOK, rrList.Code, "body=%s", rrList.Body.String())
+ agents := testutil.DecodeJSON[[]map[string]any](t, rrList)
+ require.Len(t, agents, 2)
+ assert.Equal(t, "Workspace Agent", agents[0]["name"])
+ assert.Equal(t, "Project Agent", agents[1]["name"])
+}
+
+func TestAgent_CreateRequiresWorkspaceAdmin(t *testing.T) {
+ ts := testutil.NewTestServer(t)
+ w := testutil.SeedWorld(t, ts.DB)
+ member := testutil.CreateUser(t, ts.DB)
+ testutil.AddWorkspaceMember(t, ts.DB, w.Workspace.ID, member.ID, testutil.RoleMember)
+ memberSession := testutil.LoginAs(t, ts.DB, member)
+
+ rr := ts.POST(agentBase(w.Workspace.Slug), map[string]any{"name": "Docs Writer"}, memberSession)
+ require.Equal(t, http.StatusForbidden, rr.Code, "body=%s", rr.Body.String())
+}
+
+func TestAgent_IssueAssignmentAndRun(t *testing.T) {
+ ts := testutil.NewTestServer(t)
+ w := testutil.SeedWorld(t, ts.DB)
+ agentID := createTestAgent(t, ts, w, "Spec Breaker")
+ issue := testutil.CreateIssue(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID)
+ base := issueAgentBase(w.Workspace.Slug, w.Project.ID.String(), issue.ID.String())
+
+ rrAssign := ts.POST(base+"agent-assignments/", map[string]any{
+ "agent_id": agentID,
+ "reason": "Break this into child tasks",
+ }, w.Session)
+ require.Equal(t, http.StatusCreated, rrAssign.Code, "body=%s", rrAssign.Body.String())
+ assignment := testutil.MustJSONMap(t, rrAssign)
+ assert.Equal(t, agentID, assignment["agent_id"])
+ assert.Equal(t, "active", assignment["status"])
+
+ rrAssignments := ts.GET(base+"agent-assignments/", w.Session)
+ require.Equal(t, http.StatusOK, rrAssignments.Code, "body=%s", rrAssignments.Body.String())
+ require.Len(t, testutil.DecodeJSON[[]map[string]any](t, rrAssignments), 1)
+
+ rrRun := ts.POST(base+"agent-runs/", map[string]any{
+ "agent_id": agentID,
+ "trigger": "manual",
+ "input": map[string]any{
+ "task": "draft_subtasks",
+ },
+ }, w.Session)
+ require.Equal(t, http.StatusCreated, rrRun.Code, "body=%s", rrRun.Body.String())
+ run := testutil.MustJSONMap(t, rrRun)
+ assert.Equal(t, agentID, run["agent_id"])
+ assert.Equal(t, "queued", run["status"])
+ assert.Equal(t, "manual", run["trigger"])
+
+ rrRuns := ts.GET(base+"agent-runs/", w.Session)
+ require.Equal(t, http.StatusOK, rrRuns.Code, "body=%s", rrRuns.Body.String())
+ require.Len(t, testutil.DecodeJSON[[]map[string]any](t, rrRuns), 1)
+
+ rrActivities := ts.GET(base+"activities/", w.Session)
+ require.Equal(t, http.StatusOK, rrActivities.Code, "body=%s", rrActivities.Body.String())
+ activities := testutil.DecodeJSON[[]map[string]any](t, rrActivities)
+ var verbs []string
+ for _, activity := range activities {
+ if verb, ok := activity["verb"].(string); ok {
+ verbs = append(verbs, verb)
+ }
+ }
+ assert.Contains(t, verbs, "agent_assigned")
+ assert.Contains(t, verbs, "agent_run_queued")
+}
+
+func TestAgent_DisabledAgentCannotBeAssigned(t *testing.T) {
+ ts := testutil.NewTestServer(t)
+ w := testutil.SeedWorld(t, ts.DB)
+ agentID := createTestAgent(t, ts, w, "Disabled Agent")
+ rrPatch := ts.PATCH(agentBase(w.Workspace.Slug)+agentID+"/", map[string]any{"enabled": false}, w.Session)
+ require.Equal(t, http.StatusOK, rrPatch.Code, "body=%s", rrPatch.Body.String())
+ issue := testutil.CreateIssue(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID)
+ base := issueAgentBase(w.Workspace.Slug, w.Project.ID.String(), issue.ID.String())
+
+ rrAssign := ts.POST(base+"agent-assignments/", map[string]any{"agent_id": agentID}, w.Session)
+ require.Equal(t, http.StatusBadRequest, rrAssign.Code, "body=%s", rrAssign.Body.String())
+ assert.Contains(t, rrAssign.Body.String(), "agent is not available")
+}
diff --git a/apps/api/internal/model/agent.go b/apps/api/internal/model/agent.go
new file mode 100644
index 00000000..66a2e5e6
--- /dev/null
+++ b/apps/api/internal/model/agent.go
@@ -0,0 +1,155 @@
+package model
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+const (
+ AgentAutonomySuggest = "suggest"
+ AgentAutonomyComment = "comment"
+ AgentAutonomyModifyIssue = "modify_issue"
+ AgentAutonomyGithubDraft = "github_draft"
+ AgentAutonomyGithubReviewed = "github_reviewed"
+
+ AgentAssignmentActive = "active"
+ AgentAssignmentCancelled = "cancelled"
+ AgentAssignmentCompleted = "completed"
+
+ AgentRunQueued = "queued"
+ AgentRunRunning = "running"
+ AgentRunNeedsReview = "needs_review"
+ AgentRunCompleted = "completed"
+ AgentRunFailed = "failed"
+ AgentRunCancelled = "cancelled"
+)
+
+type Agent struct {
+ ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
+ WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"`
+ ProjectID *uuid.UUID `gorm:"type:uuid" json:"project_id,omitempty"`
+ Name string `gorm:"type:varchar(255);not null" json:"name"`
+ Description string `gorm:"type:text" json:"description"`
+ Avatar string `gorm:"type:text" json:"avatar"`
+ Instructions string `gorm:"type:text" json:"instructions"`
+ Model string `gorm:"type:varchar(100)" json:"model"`
+ Enabled bool `gorm:"default:true" json:"enabled"`
+ AutonomyLevel string `gorm:"type:varchar(50);default:suggest" json:"autonomy_level"`
+ ToolPermissions []AgentToolPermission `gorm:"foreignKey:AgentID" json:"tool_permissions,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+ CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"`
+ UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"`
+}
+
+func (Agent) TableName() string { return "agents" }
+
+func (a *Agent) BeforeCreate(tx *gorm.DB) error {
+ if a.ID == uuid.Nil {
+ a.ID = uuid.New()
+ }
+ if a.AutonomyLevel == "" {
+ a.AutonomyLevel = AgentAutonomySuggest
+ }
+ return nil
+}
+
+type AgentToolPermission struct {
+ ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
+ AgentID uuid.UUID `gorm:"type:uuid;not null" json:"agent_id"`
+ Tool string `gorm:"type:varchar(100);not null" json:"tool"`
+ Scope string `gorm:"type:varchar(100);default:workspace" json:"scope"`
+ Config JSONMap `gorm:"type:jsonb;serializer:json;not null;default:'{}'" json:"config,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+}
+
+func (AgentToolPermission) TableName() string { return "agent_tool_permissions" }
+
+func (p *AgentToolPermission) BeforeCreate(tx *gorm.DB) error {
+ if p.ID == uuid.Nil {
+ p.ID = uuid.New()
+ }
+ if p.Scope == "" {
+ p.Scope = "workspace"
+ }
+ if p.Config == nil {
+ p.Config = JSONMap{}
+ }
+ return nil
+}
+
+type AgentIssueAssignment struct {
+ ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
+ IssueID uuid.UUID `gorm:"type:uuid;not null" json:"issue_id"`
+ AgentID uuid.UUID `gorm:"type:uuid;not null" json:"agent_id"`
+ ProjectID uuid.UUID `gorm:"type:uuid;not null" json:"project_id"`
+ WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"`
+ AssignedByID *uuid.UUID `gorm:"type:uuid" json:"assigned_by_id,omitempty"`
+ Reason string `gorm:"type:text" json:"reason"`
+ Status string `gorm:"type:varchar(50);default:active" json:"status"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+}
+
+func (AgentIssueAssignment) TableName() string { return "agent_issue_assignments" }
+
+func (a *AgentIssueAssignment) BeforeCreate(tx *gorm.DB) error {
+ if a.ID == uuid.Nil {
+ a.ID = uuid.New()
+ }
+ if a.Status == "" {
+ a.Status = AgentAssignmentActive
+ }
+ return nil
+}
+
+type AgentRun struct {
+ ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
+ AgentID uuid.UUID `gorm:"type:uuid;not null" json:"agent_id"`
+ IssueID *uuid.UUID `gorm:"type:uuid" json:"issue_id,omitempty"`
+ ProjectID uuid.UUID `gorm:"type:uuid;not null" json:"project_id"`
+ WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"`
+ Trigger string `gorm:"type:varchar(100);default:manual" json:"trigger"`
+ Status string `gorm:"type:varchar(50);default:queued" json:"status"`
+ Input JSONMap `gorm:"type:jsonb;serializer:json;not null;default:'{}'" json:"input,omitempty"`
+ Output JSONMap `gorm:"type:jsonb;serializer:json;not null;default:'{}'" json:"output,omitempty"`
+ Error string `gorm:"type:text" json:"error"`
+ QueuedAt time.Time `gorm:"type:timestamptz" json:"queued_at"`
+ StartedAt *time.Time `gorm:"type:timestamptz" json:"started_at,omitempty"`
+ CompletedAt *time.Time `gorm:"type:timestamptz" json:"completed_at,omitempty"`
+ CancelledAt *time.Time `gorm:"type:timestamptz" json:"cancelled_at,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+ CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"`
+}
+
+func (AgentRun) TableName() string { return "agent_runs" }
+
+func (r *AgentRun) BeforeCreate(tx *gorm.DB) error {
+ if r.ID == uuid.Nil {
+ r.ID = uuid.New()
+ }
+ if r.Trigger == "" {
+ r.Trigger = "manual"
+ }
+ if r.Status == "" {
+ r.Status = AgentRunQueued
+ }
+ if r.Input == nil {
+ r.Input = JSONMap{}
+ }
+ if r.Output == nil {
+ r.Output = JSONMap{}
+ }
+ if r.QueuedAt.IsZero() {
+ r.QueuedAt = time.Now()
+ }
+ return nil
+}
diff --git a/apps/api/internal/router/router.go b/apps/api/internal/router/router.go
index 81e55e4a..8676c3ca 100644
--- a/apps/api/internal/router/router.go
+++ b/apps/api/internal/router/router.go
@@ -68,6 +68,7 @@ func New(cfg Config) *gin.Engine {
stateStore := store.NewStateStore(cfg.DB)
labelStore := store.NewLabelStore(cfg.DB)
issueStore := store.NewIssueStore(cfg.DB)
+ agentStore := store.NewAgentStore(cfg.DB)
cycleStore := store.NewCycleStore(cfg.DB)
moduleStore := store.NewModuleStore(cfg.DB)
issueViewStore := store.NewIssueViewStore(cfg.DB)
@@ -142,6 +143,8 @@ func New(cfg Config) *gin.Engine {
issueActivityStore := store.NewIssueActivityStore(cfg.DB)
issueSvc := service.NewIssueService(issueStore, projectStore, workspaceStore)
issueSvc.SetActivityStore(issueActivityStore)
+ agentSvc := service.NewAgentService(agentStore, projectStore, workspaceStore, issueStore)
+ agentSvc.SetActivityStore(issueActivityStore)
attachmentSvc := service.NewAttachmentService(issueStore, projectStore, workspaceStore, cfg.Minio)
attachmentSvc.SetActivityStore(issueActivityStore)
cycleSvc := service.NewCycleService(cycleStore, projectStore, workspaceStore)
@@ -238,6 +241,7 @@ func New(cfg Config) *gin.Engine {
estimateHandler := &handler.EstimateHandler{Estimate: estimateSvc}
issueHandler := &handler.IssueHandler{Issue: issueSvc}
issueLinkHandler := &handler.IssueLinkHandler{Issue: issueSvc}
+ agentHandler := &handler.AgentHandler{Agent: agentSvc}
attachmentHandler := &handler.AttachmentHandler{Attachment: attachmentSvc}
epicHandler := &handler.EpicHandler{Issue: issueSvc}
cycleHandler := &handler.CycleHandler{Cycle: cycleSvc}
@@ -301,6 +305,11 @@ func New(cfg Config) *gin.Engine {
api.GET("/workspaces/:slug/draft-issues/", issueHandler.ListWorkspaceDrafts)
api.GET("/workspaces/:slug/archived-issues/", issueHandler.ListWorkspaceArchived)
api.GET("/workspaces/:slug/search/", searchHandler.Search)
+ api.GET("/workspaces/:slug/agents/", agentHandler.List)
+ api.POST("/workspaces/:slug/agents/", agentHandler.Create)
+ api.GET("/workspaces/:slug/agents/:agentId/", agentHandler.Get)
+ api.PATCH("/workspaces/:slug/agents/:agentId/", agentHandler.Update)
+ api.DELETE("/workspaces/:slug/agents/:agentId/", agentHandler.Delete)
api.GET("/workspaces/:slug/projects/", projectHandler.List)
api.POST("/workspaces/:slug/projects/", projectHandler.Create)
@@ -319,6 +328,8 @@ func New(cfg Config) *gin.Engine {
api.GET("/workspaces/:slug/projects/:projectId/invitations/:pk/", projectHandler.GetInvite)
api.DELETE("/workspaces/:slug/projects/:projectId/invitations/:pk/", projectHandler.DeleteInvite)
api.POST("/workspaces/:slug/projects/:projectId/invitations/:pk/join/", projectHandler.JoinByInvite)
+ api.GET("/workspaces/:slug/projects/:projectId/agents/", agentHandler.List)
+ api.POST("/workspaces/:slug/projects/:projectId/agents/", agentHandler.Create)
api.GET("/workspaces/:slug/projects/:projectId/states/", stateHandler.List)
api.POST("/workspaces/:slug/projects/:projectId/states/", stateHandler.Create)
@@ -366,6 +377,10 @@ func New(cfg Config) *gin.Engine {
api.DELETE("/workspaces/:slug/projects/:projectId/issues/:pk/archive/", issueHandler.Restore)
api.POST("/workspaces/:slug/projects/:projectId/issues/:pk/convert/", issueHandler.Convert)
api.POST("/workspaces/:slug/projects/:projectId/issues/:pk/move/", issueHandler.Move)
+ api.GET("/workspaces/:slug/projects/:projectId/issues/:pk/agent-assignments/", agentHandler.ListIssueAssignments)
+ api.POST("/workspaces/:slug/projects/:projectId/issues/:pk/agent-assignments/", agentHandler.AssignIssue)
+ api.GET("/workspaces/:slug/projects/:projectId/issues/:pk/agent-runs/", agentHandler.ListIssueRuns)
+ api.POST("/workspaces/:slug/projects/:projectId/issues/:pk/agent-runs/", agentHandler.CreateIssueRun)
api.GET("/workspaces/:slug/projects/:projectId/archived-issues/", issueHandler.ListArchived)
api.POST("/workspaces/:slug/projects/:projectId/issues-bulk/update/", issueHandler.BulkUpdate)
api.POST("/workspaces/:slug/projects/:projectId/issues-bulk/archive/", issueHandler.BulkArchive)
diff --git a/apps/api/internal/service/agent.go b/apps/api/internal/service/agent.go
new file mode 100644
index 00000000..47cbc500
--- /dev/null
+++ b/apps/api/internal/service/agent.go
@@ -0,0 +1,433 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "strings"
+
+ "github.com/Devlaner/devlane/api/internal/model"
+ "github.com/Devlaner/devlane/api/internal/store"
+ "github.com/google/uuid"
+)
+
+var (
+ ErrAgentNotFound = errors.New("agent not found")
+ ErrAgentForbidden = errors.New("agent forbidden")
+ ErrAgentNameRequired = errors.New("agent name is required")
+ ErrAgentInvalidAutonomyLevel = errors.New("invalid agent autonomy level")
+ ErrAgentInvalidTool = errors.New("invalid agent tool permission")
+ ErrAgentUnavailable = errors.New("agent is not available for this issue")
+)
+
+type AgentToolPermissionParams struct {
+ Tool string
+ Scope string
+ Config model.JSONMap
+}
+
+type AgentCreateParams struct {
+ ProjectID *uuid.UUID
+ Name string
+ Description string
+ Avatar string
+ Instructions string
+ Model string
+ Enabled *bool
+ AutonomyLevel string
+ ToolPermissions []AgentToolPermissionParams
+}
+
+type AgentUpdateParams struct {
+ Name *string
+ Description *string
+ Avatar *string
+ Instructions *string
+ Model *string
+ Enabled *bool
+ AutonomyLevel *string
+ ToolPermissions []AgentToolPermissionParams
+ ReplaceToolPerms bool
+}
+
+type AgentService struct {
+ as *store.AgentStore
+ ps *store.ProjectStore
+ ws *store.WorkspaceStore
+ is *store.IssueStore
+ activity *store.IssueActivityStore
+}
+
+func NewAgentService(as *store.AgentStore, ps *store.ProjectStore, ws *store.WorkspaceStore, is *store.IssueStore) *AgentService {
+ return &AgentService{as: as, ps: ps, ws: ws, is: is}
+}
+
+func (s *AgentService) SetActivityStore(a *store.IssueActivityStore) { s.activity = a }
+
+func (s *AgentService) ensureWorkspaceAccess(ctx context.Context, workspaceSlug string, userID uuid.UUID) (*model.Workspace, error) {
+ wrk, err := s.ws.GetBySlug(ctx, workspaceSlug)
+ if err != nil {
+ return nil, ErrProjectForbidden
+ }
+ ok, _ := s.ws.IsMember(ctx, wrk.ID, userID)
+ if !ok {
+ return nil, ErrProjectForbidden
+ }
+ return wrk, nil
+}
+
+func (s *AgentService) ensureWorkspaceAdmin(ctx context.Context, wrk *model.Workspace, userID uuid.UUID) error {
+ m, err := s.ws.GetMember(ctx, wrk.ID, userID)
+ if err != nil || m == nil || m.Role < model.RoleAdmin {
+ return ErrAgentForbidden
+ }
+ return nil
+}
+
+func (s *AgentService) ensureProjectScope(ctx context.Context, workspaceID uuid.UUID, projectID *uuid.UUID) error {
+ if projectID == nil {
+ return nil
+ }
+ inWorkspace, _ := s.ps.IsInWorkspace(ctx, *projectID, workspaceID)
+ if !inWorkspace {
+ return ErrProjectNotFound
+ }
+ return nil
+}
+
+func (s *AgentService) ensureProjectAccess(ctx context.Context, workspaceSlug string, projectID, userID uuid.UUID) (*model.Workspace, error) {
+ wrk, err := s.ensureWorkspaceAccess(ctx, workspaceSlug, userID)
+ if err != nil {
+ return nil, err
+ }
+ inWorkspace, _ := s.ps.IsInWorkspace(ctx, projectID, wrk.ID)
+ if !inWorkspace {
+ return nil, ErrProjectNotFound
+ }
+ return wrk, nil
+}
+
+func (s *AgentService) normalizePermissions(params []AgentToolPermissionParams) ([]model.AgentToolPermission, error) {
+ out := make([]model.AgentToolPermission, 0, len(params))
+ seen := map[string]bool{}
+ for _, p := range params {
+ tool := strings.TrimSpace(p.Tool)
+ scope := strings.TrimSpace(p.Scope)
+ if scope == "" {
+ scope = "workspace"
+ }
+ if !validAgentTools[tool] {
+ return nil, ErrAgentInvalidTool
+ }
+ key := tool + "\x00" + scope
+ if seen[key] {
+ continue
+ }
+ seen[key] = true
+ cfg := p.Config
+ if cfg == nil {
+ cfg = model.JSONMap{}
+ }
+ out = append(out, model.AgentToolPermission{
+ Tool: tool,
+ Scope: scope,
+ Config: cfg,
+ })
+ }
+ return out, nil
+}
+
+var validAgentAutonomyLevels = map[string]bool{
+ model.AgentAutonomySuggest: true,
+ model.AgentAutonomyComment: true,
+ model.AgentAutonomyModifyIssue: true,
+ model.AgentAutonomyGithubDraft: true,
+ model.AgentAutonomyGithubReviewed: true,
+}
+
+var validAgentTools = map[string]bool{
+ "issue.read": true,
+ "issue.comment": true,
+ "issue.update": true,
+ "issue.create_child": true,
+ "project.read": true,
+ "github.read": true,
+ "github.comment": true,
+ "github.draft_pr": true,
+}
+
+func normalizeAutonomyLevel(level string) (string, error) {
+ level = strings.TrimSpace(level)
+ if level == "" {
+ level = model.AgentAutonomySuggest
+ }
+ if !validAgentAutonomyLevels[level] {
+ return "", ErrAgentInvalidAutonomyLevel
+ }
+ return level, nil
+}
+
+func (s *AgentService) ListAgents(ctx context.Context, workspaceSlug string, projectID *uuid.UUID, userID uuid.UUID) ([]model.Agent, error) {
+ wrk, err := s.ensureWorkspaceAccess(ctx, workspaceSlug, userID)
+ if err != nil {
+ return nil, err
+ }
+ if err := s.ensureProjectScope(ctx, wrk.ID, projectID); err != nil {
+ return nil, err
+ }
+ return s.as.ListAgents(ctx, wrk.ID, projectID)
+}
+
+func (s *AgentService) CreateAgent(ctx context.Context, workspaceSlug string, userID uuid.UUID, params AgentCreateParams) (*model.Agent, error) {
+ wrk, err := s.ensureWorkspaceAccess(ctx, workspaceSlug, userID)
+ if err != nil {
+ return nil, err
+ }
+ if err := s.ensureWorkspaceAdmin(ctx, wrk, userID); err != nil {
+ return nil, err
+ }
+ if err := s.ensureProjectScope(ctx, wrk.ID, params.ProjectID); err != nil {
+ return nil, err
+ }
+ name := strings.TrimSpace(params.Name)
+ if name == "" {
+ return nil, ErrAgentNameRequired
+ }
+ level, err := normalizeAutonomyLevel(params.AutonomyLevel)
+ if err != nil {
+ return nil, err
+ }
+ permissions, err := s.normalizePermissions(params.ToolPermissions)
+ if err != nil {
+ return nil, err
+ }
+ enabled := true
+ if params.Enabled != nil {
+ enabled = *params.Enabled
+ }
+ actor := userID
+ a := &model.Agent{
+ WorkspaceID: wrk.ID,
+ ProjectID: params.ProjectID,
+ Name: name,
+ Description: params.Description,
+ Avatar: params.Avatar,
+ Instructions: params.Instructions,
+ Model: params.Model,
+ Enabled: enabled,
+ AutonomyLevel: level,
+ CreatedByID: &actor,
+ UpdatedByID: &actor,
+ }
+ if err := s.as.CreateAgent(ctx, a, permissions); err != nil {
+ return nil, err
+ }
+ return s.as.GetAgentByID(ctx, a.ID)
+}
+
+func (s *AgentService) GetAgent(ctx context.Context, workspaceSlug string, agentID, userID uuid.UUID) (*model.Agent, error) {
+ wrk, err := s.ensureWorkspaceAccess(ctx, workspaceSlug, userID)
+ if err != nil {
+ return nil, err
+ }
+ a, err := s.as.GetAgentByID(ctx, agentID)
+ if err != nil || a.WorkspaceID != wrk.ID {
+ return nil, ErrAgentNotFound
+ }
+ return a, nil
+}
+
+func (s *AgentService) UpdateAgent(ctx context.Context, workspaceSlug string, agentID, userID uuid.UUID, params AgentUpdateParams) (*model.Agent, error) {
+ wrk, err := s.ensureWorkspaceAccess(ctx, workspaceSlug, userID)
+ if err != nil {
+ return nil, err
+ }
+ if err := s.ensureWorkspaceAdmin(ctx, wrk, userID); err != nil {
+ return nil, err
+ }
+ a, err := s.as.GetAgentByID(ctx, agentID)
+ if err != nil || a.WorkspaceID != wrk.ID {
+ return nil, ErrAgentNotFound
+ }
+ if params.Name != nil {
+ name := strings.TrimSpace(*params.Name)
+ if name == "" {
+ return nil, ErrAgentNameRequired
+ }
+ a.Name = name
+ }
+ if params.Description != nil {
+ a.Description = *params.Description
+ }
+ if params.Avatar != nil {
+ a.Avatar = *params.Avatar
+ }
+ if params.Instructions != nil {
+ a.Instructions = *params.Instructions
+ }
+ if params.Model != nil {
+ a.Model = *params.Model
+ }
+ if params.Enabled != nil {
+ a.Enabled = *params.Enabled
+ }
+ if params.AutonomyLevel != nil {
+ level, err := normalizeAutonomyLevel(*params.AutonomyLevel)
+ if err != nil {
+ return nil, err
+ }
+ a.AutonomyLevel = level
+ }
+ var permissions []model.AgentToolPermission
+ if params.ReplaceToolPerms {
+ permissions, err = s.normalizePermissions(params.ToolPermissions)
+ if err != nil {
+ return nil, err
+ }
+ }
+ actor := userID
+ a.UpdatedByID = &actor
+ if err := s.as.UpdateAgent(ctx, a, permissions, params.ReplaceToolPerms); err != nil {
+ return nil, err
+ }
+ return s.as.GetAgentByID(ctx, a.ID)
+}
+
+func (s *AgentService) DeleteAgent(ctx context.Context, workspaceSlug string, agentID, userID uuid.UUID) error {
+ wrk, err := s.ensureWorkspaceAccess(ctx, workspaceSlug, userID)
+ if err != nil {
+ return err
+ }
+ if err := s.ensureWorkspaceAdmin(ctx, wrk, userID); err != nil {
+ return err
+ }
+ a, err := s.as.GetAgentByID(ctx, agentID)
+ if err != nil || a.WorkspaceID != wrk.ID {
+ return ErrAgentNotFound
+ }
+ return s.as.DeleteAgent(ctx, agentID)
+}
+
+func (s *AgentService) resolveIssue(ctx context.Context, workspaceSlug string, projectID, issueID, userID uuid.UUID) (*model.Workspace, *model.Issue, error) {
+ wrk, err := s.ensureProjectAccess(ctx, workspaceSlug, projectID, userID)
+ if err != nil {
+ return nil, nil, err
+ }
+ issue, err := s.is.GetByID(ctx, issueID)
+ if err != nil || issue.ProjectID != projectID || issue.WorkspaceID != wrk.ID {
+ return nil, nil, ErrIssueNotFound
+ }
+ return wrk, issue, nil
+}
+
+func (s *AgentService) resolveAvailableAgent(ctx context.Context, workspaceID, projectID, agentID uuid.UUID) (*model.Agent, error) {
+ a, err := s.as.GetAgentByID(ctx, agentID)
+ if err != nil || a.WorkspaceID != workspaceID {
+ return nil, ErrAgentNotFound
+ }
+ if !a.Enabled {
+ return nil, ErrAgentUnavailable
+ }
+ if a.ProjectID != nil && *a.ProjectID != projectID {
+ return nil, ErrAgentUnavailable
+ }
+ return a, nil
+}
+
+func (s *AgentService) AssignIssue(ctx context.Context, workspaceSlug string, projectID, issueID, agentID, userID uuid.UUID, reason string) (*model.AgentIssueAssignment, error) {
+ _, issue, err := s.resolveIssue(ctx, workspaceSlug, projectID, issueID, userID)
+ if err != nil {
+ return nil, err
+ }
+ a, err := s.resolveAvailableAgent(ctx, issue.WorkspaceID, issue.ProjectID, agentID)
+ if err != nil {
+ return nil, err
+ }
+ actor := userID
+ assignment := &model.AgentIssueAssignment{
+ IssueID: issue.ID,
+ AgentID: a.ID,
+ ProjectID: issue.ProjectID,
+ WorkspaceID: issue.WorkspaceID,
+ AssignedByID: &actor,
+ Reason: reason,
+ Status: model.AgentAssignmentActive,
+ }
+ if err := s.as.CreateOrUpdateAssignment(ctx, assignment); err != nil {
+ return nil, err
+ }
+ s.recordIssueAgentActivity(ctx, issue, userID, "agent_assigned", a.ID, "Assigned to agent "+a.Name)
+ return assignment, nil
+}
+
+func (s *AgentService) ListIssueAssignments(ctx context.Context, workspaceSlug string, projectID, issueID, userID uuid.UUID) ([]model.AgentIssueAssignment, error) {
+ _, issue, err := s.resolveIssue(ctx, workspaceSlug, projectID, issueID, userID)
+ if err != nil {
+ return nil, err
+ }
+ return s.as.ListAssignmentsByIssue(ctx, issue.ID)
+}
+
+func (s *AgentService) CreateIssueRun(ctx context.Context, workspaceSlug string, projectID, issueID, agentID, userID uuid.UUID, trigger string, input model.JSONMap) (*model.AgentRun, error) {
+ _, issue, err := s.resolveIssue(ctx, workspaceSlug, projectID, issueID, userID)
+ if err != nil {
+ return nil, err
+ }
+ a, err := s.resolveAvailableAgent(ctx, issue.WorkspaceID, issue.ProjectID, agentID)
+ if err != nil {
+ return nil, err
+ }
+ if trigger = strings.TrimSpace(trigger); trigger == "" {
+ trigger = "manual"
+ }
+ if input == nil {
+ input = model.JSONMap{}
+ }
+ actor := userID
+ iid := issue.ID
+ run := &model.AgentRun{
+ AgentID: a.ID,
+ IssueID: &iid,
+ ProjectID: issue.ProjectID,
+ WorkspaceID: issue.WorkspaceID,
+ Trigger: trigger,
+ Status: model.AgentRunQueued,
+ Input: input,
+ Output: model.JSONMap{},
+ CreatedByID: &actor,
+ }
+ if err := s.as.CreateRun(ctx, run); err != nil {
+ return nil, err
+ }
+ s.recordIssueAgentActivity(ctx, issue, userID, "agent_run_queued", a.ID, "Queued agent run for "+a.Name)
+ return run, nil
+}
+
+func (s *AgentService) ListIssueRuns(ctx context.Context, workspaceSlug string, projectID, issueID, userID uuid.UUID) ([]model.AgentRun, error) {
+ _, issue, err := s.resolveIssue(ctx, workspaceSlug, projectID, issueID, userID)
+ if err != nil {
+ return nil, err
+ }
+ return s.as.ListRunsByIssue(ctx, issue.ID)
+}
+
+func (s *AgentService) recordIssueAgentActivity(ctx context.Context, issue *model.Issue, userID uuid.UUID, verb string, agentID uuid.UUID, comment string) {
+ if s.activity == nil || issue == nil {
+ return
+ }
+ field := "agent_id"
+ newVal := agentID.String()
+ actor := userID
+ row := &model.IssueActivity{
+ IssueID: &issue.ID,
+ ProjectID: issue.ProjectID,
+ WorkspaceID: issue.WorkspaceID,
+ Verb: verb,
+ Field: &field,
+ NewValue: &newVal,
+ Comment: &comment,
+ CreatedByID: &actor,
+ UpdatedByID: &actor,
+ ActorID: &actor,
+ }
+ _ = s.activity.Create(ctx, row)
+}
diff --git a/apps/api/internal/store/agent.go b/apps/api/internal/store/agent.go
new file mode 100644
index 00000000..49b22393
--- /dev/null
+++ b/apps/api/internal/store/agent.go
@@ -0,0 +1,127 @@
+package store
+
+import (
+ "context"
+
+ "github.com/Devlaner/devlane/api/internal/model"
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+type AgentStore struct{ db *gorm.DB }
+
+func NewAgentStore(db *gorm.DB) *AgentStore { return &AgentStore{db: db} }
+
+func (s *AgentStore) CreateAgent(ctx context.Context, a *model.Agent, permissions []model.AgentToolPermission) error {
+ return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ if err := tx.Omit("ToolPermissions").Create(a).Error; err != nil {
+ return err
+ }
+ for i := range permissions {
+ permissions[i].AgentID = a.ID
+ }
+ if len(permissions) > 0 {
+ if err := tx.Create(&permissions).Error; err != nil {
+ return err
+ }
+ }
+ a.ToolPermissions = permissions
+ return nil
+ })
+}
+
+func (s *AgentStore) ListAgents(ctx context.Context, workspaceID uuid.UUID, projectID *uuid.UUID) ([]model.Agent, error) {
+ var list []model.Agent
+ q := s.db.WithContext(ctx).
+ Preload("ToolPermissions", "deleted_at IS NULL").
+ Where("workspace_id = ? AND deleted_at IS NULL", workspaceID)
+ if projectID != nil {
+ q = q.Where("(project_id IS NULL OR project_id = ?)", *projectID)
+ }
+ err := q.Order("project_id NULLS FIRST, name ASC, created_at ASC").Find(&list).Error
+ return list, err
+}
+
+func (s *AgentStore) GetAgentByID(ctx context.Context, id uuid.UUID) (*model.Agent, error) {
+ var a model.Agent
+ err := s.db.WithContext(ctx).
+ Preload("ToolPermissions", "deleted_at IS NULL").
+ Where("id = ? AND deleted_at IS NULL", id).
+ First(&a).Error
+ if err != nil {
+ return nil, err
+ }
+ return &a, nil
+}
+
+func (s *AgentStore) UpdateAgent(ctx context.Context, a *model.Agent, permissions []model.AgentToolPermission, replacePermissions bool) error {
+ return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ if err := tx.Omit("ToolPermissions").Save(a).Error; err != nil {
+ return err
+ }
+ if !replacePermissions {
+ return nil
+ }
+ if err := tx.Where("agent_id = ?", a.ID).Delete(&model.AgentToolPermission{}).Error; err != nil {
+ return err
+ }
+ for i := range permissions {
+ permissions[i].AgentID = a.ID
+ }
+ if len(permissions) > 0 {
+ if err := tx.Create(&permissions).Error; err != nil {
+ return err
+ }
+ }
+ a.ToolPermissions = permissions
+ return nil
+ })
+}
+
+func (s *AgentStore) DeleteAgent(ctx context.Context, id uuid.UUID) error {
+ return s.db.WithContext(ctx).Where("id = ?", id).Delete(&model.Agent{}).Error
+}
+
+func (s *AgentStore) CreateOrUpdateAssignment(ctx context.Context, assignment *model.AgentIssueAssignment) error {
+ return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ var existing model.AgentIssueAssignment
+ err := tx.Where("issue_id = ? AND agent_id = ? AND deleted_at IS NULL", assignment.IssueID, assignment.AgentID).
+ First(&existing).Error
+ if err == nil {
+ existing.Reason = assignment.Reason
+ existing.Status = assignment.Status
+ existing.AssignedByID = assignment.AssignedByID
+ if err := tx.Save(&existing).Error; err != nil {
+ return err
+ }
+ *assignment = existing
+ return nil
+ }
+ if err != gorm.ErrRecordNotFound {
+ return err
+ }
+ return tx.Create(assignment).Error
+ })
+}
+
+func (s *AgentStore) ListAssignmentsByIssue(ctx context.Context, issueID uuid.UUID) ([]model.AgentIssueAssignment, error) {
+ var list []model.AgentIssueAssignment
+ err := s.db.WithContext(ctx).
+ Where("issue_id = ? AND deleted_at IS NULL", issueID).
+ Order("created_at ASC").
+ Find(&list).Error
+ return list, err
+}
+
+func (s *AgentStore) CreateRun(ctx context.Context, run *model.AgentRun) error {
+ return s.db.WithContext(ctx).Create(run).Error
+}
+
+func (s *AgentStore) ListRunsByIssue(ctx context.Context, issueID uuid.UUID) ([]model.AgentRun, error) {
+ var list []model.AgentRun
+ err := s.db.WithContext(ctx).
+ Where("issue_id = ? AND deleted_at IS NULL", issueID).
+ Order("queued_at DESC, created_at DESC").
+ Find(&list).Error
+ return list, err
+}
diff --git a/apps/api/migrations/000007_agents.down.sql b/apps/api/migrations/000007_agents.down.sql
new file mode 100644
index 00000000..56038de5
--- /dev/null
+++ b/apps/api/migrations/000007_agents.down.sql
@@ -0,0 +1,21 @@
+DROP INDEX IF EXISTS idx_agent_runs_workspace_status;
+DROP INDEX IF EXISTS idx_agent_runs_issue;
+DROP INDEX IF EXISTS idx_agent_runs_agent;
+DROP TABLE IF EXISTS agent_runs;
+
+DROP INDEX IF EXISTS idx_agent_issue_assignments_issue_agent_active;
+DROP INDEX IF EXISTS idx_agent_issue_assignments_workspace;
+DROP INDEX IF EXISTS idx_agent_issue_assignments_agent;
+DROP INDEX IF EXISTS idx_agent_issue_assignments_issue;
+DROP TABLE IF EXISTS agent_issue_assignments;
+
+DROP INDEX IF EXISTS idx_agent_tool_permissions_agent_tool_scope_active;
+DROP INDEX IF EXISTS idx_agent_tool_permissions_agent;
+DROP TABLE IF EXISTS agent_tool_permissions;
+
+DROP INDEX IF EXISTS idx_agents_project_name_active;
+DROP INDEX IF EXISTS idx_agents_workspace_name_active;
+DROP INDEX IF EXISTS idx_agents_deleted_at;
+DROP INDEX IF EXISTS idx_agents_project;
+DROP INDEX IF EXISTS idx_agents_workspace;
+DROP TABLE IF EXISTS agents;
diff --git a/apps/api/migrations/000007_agents.up.sql b/apps/api/migrations/000007_agents.up.sql
new file mode 100644
index 00000000..c709a419
--- /dev/null
+++ b/apps/api/migrations/000007_agents.up.sql
@@ -0,0 +1,85 @@
+CREATE TABLE agents (
+ id UUID PRIMARY KEY,
+ workspace_id UUID NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE,
+ project_id UUID REFERENCES projects (id) ON DELETE CASCADE,
+ name VARCHAR(255) NOT NULL,
+ description TEXT NOT NULL DEFAULT '',
+ avatar TEXT NOT NULL DEFAULT '',
+ instructions TEXT NOT NULL DEFAULT '',
+ model VARCHAR(100) NOT NULL DEFAULT '',
+ enabled BOOLEAN NOT NULL DEFAULT TRUE,
+ autonomy_level VARCHAR(50) NOT NULL DEFAULT 'suggest',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ deleted_at TIMESTAMPTZ,
+ created_by_id UUID REFERENCES users (id) ON DELETE SET NULL,
+ updated_by_id UUID REFERENCES users (id) ON DELETE SET NULL
+);
+CREATE INDEX idx_agents_workspace ON agents (workspace_id);
+CREATE INDEX idx_agents_project ON agents (project_id);
+CREATE INDEX idx_agents_deleted_at ON agents (deleted_at);
+CREATE UNIQUE INDEX idx_agents_workspace_name_active
+ ON agents (workspace_id, lower(name))
+ WHERE project_id IS NULL AND deleted_at IS NULL;
+CREATE UNIQUE INDEX idx_agents_project_name_active
+ ON agents (project_id, lower(name))
+ WHERE project_id IS NOT NULL AND deleted_at IS NULL;
+
+CREATE TABLE agent_tool_permissions (
+ id UUID PRIMARY KEY,
+ agent_id UUID NOT NULL REFERENCES agents (id) ON DELETE CASCADE,
+ tool VARCHAR(100) NOT NULL,
+ scope VARCHAR(100) NOT NULL DEFAULT 'workspace',
+ config JSONB NOT NULL DEFAULT '{}',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ deleted_at TIMESTAMPTZ
+);
+CREATE INDEX idx_agent_tool_permissions_agent ON agent_tool_permissions (agent_id);
+CREATE UNIQUE INDEX idx_agent_tool_permissions_agent_tool_scope_active
+ ON agent_tool_permissions (agent_id, tool, scope)
+ WHERE deleted_at IS NULL;
+
+CREATE TABLE agent_issue_assignments (
+ id UUID PRIMARY KEY,
+ issue_id UUID NOT NULL REFERENCES issues (id) ON DELETE CASCADE,
+ agent_id UUID NOT NULL REFERENCES agents (id) ON DELETE CASCADE,
+ project_id UUID NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
+ workspace_id UUID NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE,
+ assigned_by_id UUID REFERENCES users (id) ON DELETE SET NULL,
+ reason TEXT NOT NULL DEFAULT '',
+ status VARCHAR(50) NOT NULL DEFAULT 'active',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ deleted_at TIMESTAMPTZ
+);
+CREATE INDEX idx_agent_issue_assignments_issue ON agent_issue_assignments (issue_id);
+CREATE INDEX idx_agent_issue_assignments_agent ON agent_issue_assignments (agent_id);
+CREATE INDEX idx_agent_issue_assignments_workspace ON agent_issue_assignments (workspace_id);
+CREATE UNIQUE INDEX idx_agent_issue_assignments_issue_agent_active
+ ON agent_issue_assignments (issue_id, agent_id)
+ WHERE deleted_at IS NULL;
+
+CREATE TABLE agent_runs (
+ id UUID PRIMARY KEY,
+ agent_id UUID NOT NULL REFERENCES agents (id) ON DELETE CASCADE,
+ issue_id UUID REFERENCES issues (id) ON DELETE CASCADE,
+ project_id UUID NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
+ workspace_id UUID NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE,
+ trigger VARCHAR(100) NOT NULL DEFAULT 'manual',
+ status VARCHAR(50) NOT NULL DEFAULT 'queued',
+ input JSONB NOT NULL DEFAULT '{}',
+ output JSONB NOT NULL DEFAULT '{}',
+ error TEXT NOT NULL DEFAULT '',
+ queued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ started_at TIMESTAMPTZ,
+ completed_at TIMESTAMPTZ,
+ cancelled_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ deleted_at TIMESTAMPTZ,
+ created_by_id UUID REFERENCES users (id) ON DELETE SET NULL
+);
+CREATE INDEX idx_agent_runs_agent ON agent_runs (agent_id);
+CREATE INDEX idx_agent_runs_issue ON agent_runs (issue_id);
+CREATE INDEX idx_agent_runs_workspace_status ON agent_runs (workspace_id, status);
diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts
index 78f4c401..414063ef 100644
--- a/apps/web/src/api/types.ts
+++ b/apps/web/src/api/types.ts
@@ -919,3 +919,93 @@ export interface RecordRecentVisitRequest {
entity_identifier?: string | null;
project_id?: string | null;
}
+
+export type AgentAutonomyLevel =
+ | 'suggest'
+ | 'comment'
+ | 'modify_issue'
+ | 'github_draft'
+ | 'github_reviewed';
+
+export type AgentAssignmentStatus = 'active' | 'cancelled' | 'completed';
+export type AgentRunStatus =
+ | 'queued'
+ | 'running'
+ | 'needs_review'
+ | 'completed'
+ | 'failed'
+ | 'cancelled';
+
+export interface AgentToolPermissionApiResponse {
+ id: string;
+ agent_id: string;
+ tool: string;
+ scope: string;
+ config?: Record
+ Configure autonomous teammates, their boundaries, and the tools they can use.
+ {value} {label} No agents yet
+ Create one, give it a job, then assign it from any work item.
+
+ {agent.description || 'No description provided.'}
+ Loading agents... No agents available
+ {assignedAgent.name}
+
+ {autonomyLabel(assignedAgent.autonomy_level)}
+
+ {latestActiveAssignment.reason}
+ Recent runs
+ {runPrompt}
+
+ {agent?.name ?? 'Agent'} ยท {relativeTime(run.queued_at)}
+
+ {error}
+
Administration
Agents
+ {agent.name}
+
+ {autonomyLabel(agent.autonomy_level)}
+
+
+ {project ? project.name : 'All projects'}
+
+