From 65ffaca965d70c7f9ce48782b490ce51216ed881 Mon Sep 17 00:00:00 2001
From: Aditya Work
Date: Wed, 8 Jul 2026 23:39:43 +0200
Subject: [PATCH 1/3] docs: propose autonomous agent workflows
Refs #285.
Co-Authored-By: OpenAI Codex
---
README.md | 1 +
proposals/agent-workflows.md | 273 +++++++++++++++++++++++++++++++++++
2 files changed, 274 insertions(+)
create mode 100644 proposals/agent-workflows.md
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/proposals/agent-workflows.md b/proposals/agent-workflows.md
new file mode 100644
index 00000000..9874d0e2
--- /dev/null
+++ b/proposals/agent-workflows.md
@@ -0,0 +1,273 @@
+# Autonomous agent workflows
+
+Tracked by [GitHub issue #285](https://github.com/Devlaner/devlane/issues/285).
+
+## Purpose
+
+Devlane already has the building blocks for agent-assisted product work:
+
+- work items with assignees, labels, comments, activity, relations, cycles, and modules
+- instance-level AI settings under the `ai` section
+- GitHub App installation, repository sync, PR-to-work-item links, webhook logging, and PR-driven state updates
+- RabbitMQ-backed queues for asynchronous work
+
+This document proposes the product and technical shape for making agents first-class actors that can receive work, run bounded tasks, and report results back into Devlane with a durable audit trail.
+
+## Goals
+
+- Let workspace admins create reusable agents such as Bug Triage, Spec Breaker, PR Reviewer, Test Fixer, Docs Writer, Release Notes, and Coding Agent.
+- Let users assign a work item to an agent without replacing human ownership.
+- Let routing rules assign or recommend agents based on issue type, labels, priority, project, stale state, or GitHub activity.
+- Keep every agent action visible from the work item activity feed.
+- Use explicit tool permissions so agents can start read-only and graduate to supervised writes.
+- Reuse the existing GitHub App integration for repository-aware coding and PR workflows.
+
+## Non-goals
+
+- Do not let agents make unrestricted workspace, repository, or production changes.
+- Do not hide AI-generated work from users.
+- Do not require a separate worker service for the first supervised release.
+- Do not block the initial release on full autonomous code changes.
+
+## Product model
+
+### Agent
+
+An agent is a configurable workspace or project resource.
+
+Suggested fields:
+
+- `id`
+- `workspace_id`
+- `project_id` nullable for workspace-wide agents
+- `name`
+- `description`
+- `avatar`
+- `instructions`
+- `model`
+- `enabled`
+- `autonomy_level`
+- `created_by_id`
+- `updated_by_id`
+
+Suggested autonomy levels:
+
+- `suggest`: draft a recommendation only
+- `comment`: post a visible comment after user confirmation or rule approval
+- `modify_issue`: update issue fields such as labels, estimate, state, or child tasks
+- `github_draft`: create branches and draft PRs only
+- `github_reviewed`: update PR branches after explicit human approval
+
+### Agent tool permission
+
+Tool permissions keep the system auditable and safe.
+
+Suggested fields:
+
+- `agent_id`
+- `tool`
+- `scope`
+- `config`
+
+Initial tools:
+
+- `issue.read`
+- `issue.comment`
+- `issue.update`
+- `issue.create_child`
+- `project.read`
+- `github.read`
+- `github.comment`
+- `github.draft_pr`
+
+### Agent assignment
+
+An agent assignment records that a work item has been delegated to an agent.
+
+Suggested fields:
+
+- `issue_id`
+- `agent_id`
+- `assigned_by_id`
+- `reason`
+- `status`
+
+Agent assignments should coexist with human assignees. Human ownership remains the source of accountability.
+
+### Agent run
+
+An agent run is one execution attempt.
+
+Suggested fields:
+
+- `id`
+- `agent_id`
+- `issue_id`
+- `trigger`
+- `status`
+- `input`
+- `output`
+- `error`
+- `queued_at`
+- `started_at`
+- `completed_at`
+- `cancelled_at`
+- `created_by_id`
+
+Suggested statuses:
+
+- `queued`
+- `running`
+- `needs_review`
+- `completed`
+- `failed`
+- `cancelled`
+
+### Routing rule
+
+Routing rules let admins map work to agents.
+
+Suggested fields:
+
+- `workspace_id`
+- `project_id`
+- `agent_id`
+- `enabled`
+- `conditions`
+- `action`
+
+Example rules:
+
+- Assign Bug Triage when a new work item has the `bug` type.
+- Ask Spec Breaker to draft child tasks when an epic enters Backlog.
+- Ask PR Reviewer to summarize GitHub activity when a linked PR changes.
+- Ask Test Fixer to investigate when a linked PR comment includes a failed check summary.
+
+## Runtime
+
+Add a dedicated queue and task type:
+
+- Queue: `devlane.agents`
+- Task type: `agent_run`
+- Payload: `agent_run_id`
+
+The API should create an `agent_runs` row before publishing the task. The consumer loads the run, resolves the agent permissions, gathers issue/project/GitHub context, executes the allowed action, and persists output before posting any visible activity.
+
+The first implementation can run in the existing API process alongside the current email and webhook consumers. A separate worker binary can be introduced later if run duration or isolation requires it.
+
+## Issue activity
+
+Agent activity should be visible where users already work.
+
+Recommended activity events:
+
+- agent assigned
+- agent run queued
+- agent run started
+- agent proposed changes
+- agent posted comment
+- agent opened draft PR
+- agent run failed
+- agent run cancelled
+
+Agent-generated comments should identify the agent, the trigger, and whether the output was AI generated.
+
+## GitHub integration path
+
+Current GitHub support can already:
+
+- install a GitHub App at workspace level
+- list installation repositories
+- link a repository to a project
+- link PRs to Devlane work items
+- process PR, push, and PR comment webhooks
+- update issue activity and state from PR events
+
+Coding agents need additional GitHub client methods:
+
+- get repository default branch
+- create refs for agent branches
+- read file contents or repository trees
+- create blobs, trees, and commits
+- update refs
+- open draft pull requests
+- comment on PRs with agent run links
+
+Branch naming should include the Devlane issue reference:
+
+```text
+devlane/agent/DEV-123-short-title
+```
+
+Commit messages should include the issue reference and AI disclosure where required by the contribution policy.
+
+## UX entry points
+
+Suggested first surfaces:
+
+- Workspace settings: Agent roster
+- Project settings: Project-specific agent availability
+- Work item detail: Assign to agent action
+- Work item detail: Agent runs panel
+- Command palette: Run agent on current work item
+- GitHub PR sidebar: Agent-authored draft PR status
+
+## Phased delivery
+
+### Phase 1: supervised issue agents
+
+- Add agent roster persistence and settings UI.
+- Add manual assign-to-agent action.
+- Add `agent_runs`.
+- Add queue task and consumer.
+- Support read-only summaries, suggested labels, child task drafts, and comments.
+
+### Phase 2: routing and review
+
+- Add routing rules.
+- Add approval UI for proposed updates.
+- Add issue activity events for agent lifecycle.
+- Add run cancellation and retry.
+
+### Phase 3: GitHub-aware agents
+
+- Add GitHub read context to runs.
+- Summarize linked PRs and push activity.
+- Draft review comments or implementation plans.
+- Post comments to GitHub when permitted.
+
+### Phase 4: coding agents
+
+- Add GitHub branch, commit, and draft PR creation.
+- Require repository sync and explicit `github.draft_pr` permission.
+- Keep PRs draft by default.
+- Require human review before merge or follow-up branch updates.
+
+## Safety guardrails
+
+- Agents must never receive implicit write access.
+- Every tool permission must be explicit and scoped.
+- Agent outputs must be stored before side effects are applied.
+- Mutating runs should be idempotent so queue retries do not duplicate comments, child tasks, or PRs.
+- Long-running jobs need cancellation.
+- Users should see which model and agent produced visible output.
+- Repository writes should default to draft PRs, not direct default-branch commits.
+- Instance admins should be able to disable all agent execution.
+
+## Testing strategy
+
+- Unit tests for routing rule matching.
+- Unit tests for permission checks before every tool call.
+- Handler tests for creating, assigning, listing, cancelling, and retrying runs.
+- Queue tests for idempotent run processing.
+- GitHub client tests against fake API responses for branch and PR creation.
+- UI tests for agent roster, assign-to-agent, and run status rendering.
+
+## Open questions
+
+- Should agents be represented as users, a separate model, or both?
+- Should project admins or only workspace admins create agents?
+- Should agent runs count toward notification preferences?
+- How should agent-generated child tasks be reviewed before creation?
+- Which model defaults should be allowed at instance level?
+- Should coding agents use GitHub REST-only changes or an isolated execution sandbox in a later phase?
From c85e348a8e9a175fecdf48339e8acf5322f81e35 Mon Sep 17 00:00:00 2001
From: Aditya Work
Date: Thu, 9 Jul 2026 00:23:33 +0200
Subject: [PATCH 2/3] feat: add agent workflow backend foundations
Co-Authored-By: OpenAI Codex
---
apps/api/internal/handler/agent.go | 345 ++++++++++++++++
apps/api/internal/handler/agent_test.go | 180 +++++++++
apps/api/internal/model/agent.go | 155 ++++++++
apps/api/internal/router/router.go | 15 +
apps/api/internal/service/agent.go | 433 +++++++++++++++++++++
apps/api/internal/store/agent.go | 127 ++++++
apps/api/migrations/000007_agents.down.sql | 21 +
apps/api/migrations/000007_agents.up.sql | 85 ++++
proposals/agent-workflows.md | 11 +
9 files changed, 1372 insertions(+)
create mode 100644 apps/api/internal/handler/agent.go
create mode 100644 apps/api/internal/handler/agent_test.go
create mode 100644 apps/api/internal/model/agent.go
create mode 100644 apps/api/internal/service/agent.go
create mode 100644 apps/api/internal/store/agent.go
create mode 100644 apps/api/migrations/000007_agents.down.sql
create mode 100644 apps/api/migrations/000007_agents.up.sql
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/proposals/agent-workflows.md b/proposals/agent-workflows.md
index 9874d0e2..2226a0fb 100644
--- a/proposals/agent-workflows.md
+++ b/proposals/agent-workflows.md
@@ -13,6 +13,17 @@ Devlane already has the building blocks for agent-assisted product work:
This document proposes the product and technical shape for making agents first-class actors that can receive work, run bounded tasks, and report results back into Devlane with a durable audit trail.
+## Implementation checkpoint
+
+The initial backend slice adds the core persistence and REST surface for Phase 1:
+
+- workspace/project agent roster records with explicit tool permissions
+- manual work item agent assignments that do not replace human assignees
+- queued agent run records with durable input/output/error fields
+- issue activity events for agent assignment and queued runs
+
+This intentionally stops before autonomous execution. Later phases can attach the queue consumer, model execution, approval UI, and GitHub branch/PR tooling to these records.
+
## Goals
- Let workspace admins create reusable agents such as Bug Triage, Spec Breaker, PR Reviewer, Test Fixer, Docs Writer, Release Notes, and Coding Agent.
From 0fb4ffaca16ff1c449928fcfdab926c70d32e06e Mon Sep 17 00:00:00 2001
From: Aditya Work
Date: Fri, 10 Jul 2026 23:59:47 +0200
Subject: [PATCH 3/3] feat: add agent workflow UI
---
apps/web/src/api/types.ts | 90 ++++
.../components/agents/AgentSettingsPanel.tsx | 457 ++++++++++++++++++
.../src/components/agents/IssueAgentPanel.tsx | 296 ++++++++++++
.../web/src/components/agents/agentOptions.ts | 48 ++
apps/web/src/components/agents/agentUi.tsx | 50 ++
.../src/components/settings/SettingsNav.tsx | 4 +-
.../components/settings/sections-config.tsx | 3 +
apps/web/src/pages/IssueDetailPage.tsx | 9 +
apps/web/src/pages/SettingsPage.tsx | 5 +
apps/web/src/services/agentService.ts | 100 ++++
apps/web/src/services/index.ts | 1 +
proposals/agent-workflows.md | 6 +-
12 files changed, 1065 insertions(+), 4 deletions(-)
create mode 100644 apps/web/src/components/agents/AgentSettingsPanel.tsx
create mode 100644 apps/web/src/components/agents/IssueAgentPanel.tsx
create mode 100644 apps/web/src/components/agents/agentOptions.ts
create mode 100644 apps/web/src/components/agents/agentUi.tsx
create mode 100644 apps/web/src/services/agentService.ts
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;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface AgentApiResponse {
+ id: string;
+ workspace_id: string;
+ project_id?: string | null;
+ name: string;
+ description: string;
+ avatar: string;
+ instructions: string;
+ model: string;
+ enabled: boolean;
+ autonomy_level: AgentAutonomyLevel;
+ tool_permissions: AgentToolPermissionApiResponse[];
+ created_at: string;
+ updated_at: string;
+}
+
+export interface AgentIssueAssignmentApiResponse {
+ id: string;
+ issue_id: string;
+ agent_id: string;
+ project_id: string;
+ workspace_id: string;
+ assigned_by_id?: string | null;
+ reason: string;
+ status: AgentAssignmentStatus;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface AgentRunApiResponse {
+ id: string;
+ agent_id: string;
+ issue_id?: string | null;
+ project_id: string;
+ workspace_id: string;
+ trigger: string;
+ status: AgentRunStatus;
+ input?: Record;
+ output?: Record;
+ error: string;
+ queued_at: string;
+ started_at?: string | null;
+ completed_at?: string | null;
+ cancelled_at?: string | null;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface AgentUpsertRequest {
+ project_id?: string | null;
+ name: string;
+ description?: string;
+ avatar?: string;
+ instructions?: string;
+ model?: string;
+ enabled?: boolean;
+ autonomy_level?: AgentAutonomyLevel;
+ tool_permissions?: Array<{
+ tool: string;
+ scope: string;
+ config: Record;
+ }>;
+}
diff --git a/apps/web/src/components/agents/AgentSettingsPanel.tsx b/apps/web/src/components/agents/AgentSettingsPanel.tsx
new file mode 100644
index 00000000..0d970e96
--- /dev/null
+++ b/apps/web/src/components/agents/AgentSettingsPanel.tsx
@@ -0,0 +1,457 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { Pencil, Plus, Trash2 } from 'lucide-react';
+import type {
+ AgentApiResponse,
+ AgentAutonomyLevel,
+ AgentUpsertRequest,
+ ProjectApiResponse,
+} from '../../api/types';
+import { getApiErrorMessage } from '../../api/client';
+import { agentService } from '../../services/agentService';
+import { Button, Modal, Tooltip } from '../ui';
+import { AgentMark } from './agentUi';
+import { AUTONOMY_OPTIONS, TOOL_OPTIONS, autonomyLabel, toolLabel } from './agentOptions';
+
+interface AgentSettingsPanelProps {
+ workspaceSlug: string;
+ projects: ProjectApiResponse[];
+}
+
+interface AgentFormState {
+ name: string;
+ description: string;
+ instructions: string;
+ model: string;
+ projectId: string;
+ autonomyLevel: AgentAutonomyLevel;
+ tools: string[];
+ enabled: boolean;
+}
+
+const EMPTY_FORM: AgentFormState = {
+ name: '',
+ description: '',
+ instructions: '',
+ model: 'gpt-5',
+ projectId: '',
+ autonomyLevel: 'suggest',
+ tools: ['issue.read'],
+ enabled: true,
+};
+
+function formFromAgent(agent: AgentApiResponse): AgentFormState {
+ return {
+ name: agent.name,
+ description: agent.description ?? '',
+ instructions: agent.instructions ?? '',
+ model: agent.model || 'gpt-5',
+ projectId: agent.project_id ?? '',
+ autonomyLevel: agent.autonomy_level,
+ tools: agent.tool_permissions?.map((permission) => permission.tool) ?? [],
+ enabled: agent.enabled,
+ };
+}
+
+export function AgentSettingsPanel({ workspaceSlug, projects }: AgentSettingsPanelProps) {
+ const [agents, setAgents] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [editingAgent, setEditingAgent] = useState(null);
+ const [modalOpen, setModalOpen] = useState(false);
+ const [form, setForm] = useState(EMPTY_FORM);
+ const [saving, setSaving] = useState(false);
+ const [deletingId, setDeletingId] = useState(null);
+ const [togglingId, setTogglingId] = useState(null);
+
+ const loadAgents = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ setAgents(await agentService.list(workspaceSlug));
+ } catch (err) {
+ setError(getApiErrorMessage(err));
+ } finally {
+ setLoading(false);
+ }
+ }, [workspaceSlug]);
+
+ useEffect(() => {
+ void loadAgents();
+ }, [loadAgents]);
+
+ const enabledCount = useMemo(() => agents.filter((agent) => agent.enabled).length, [agents]);
+ const toolCount = useMemo(
+ () => new Set(agents.flatMap((agent) => agent.tool_permissions?.map((p) => p.tool) ?? [])).size,
+ [agents],
+ );
+
+ const openCreate = () => {
+ setEditingAgent(null);
+ setForm({ ...EMPTY_FORM, tools: [...EMPTY_FORM.tools] });
+ setError(null);
+ setModalOpen(true);
+ };
+
+ const openEdit = (agent: AgentApiResponse) => {
+ setEditingAgent(agent);
+ setForm(formFromAgent(agent));
+ setError(null);
+ setModalOpen(true);
+ };
+
+ const saveAgent = async () => {
+ if (!form.name.trim()) return;
+ setSaving(true);
+ setError(null);
+ const payload: AgentUpsertRequest = {
+ name: form.name.trim(),
+ description: form.description.trim(),
+ instructions: form.instructions.trim(),
+ model: form.model,
+ enabled: form.enabled,
+ autonomy_level: form.autonomyLevel,
+ tool_permissions: form.tools.map((tool) => ({ tool, scope: 'workspace', config: {} })),
+ };
+ if (!editingAgent) payload.project_id = form.projectId || null;
+
+ try {
+ const saved = editingAgent
+ ? await agentService.update(workspaceSlug, editingAgent.id, payload)
+ : await agentService.create(workspaceSlug, payload);
+ setAgents((current) => {
+ const exists = current.some((agent) => agent.id === saved.id);
+ return exists
+ ? current.map((agent) => (agent.id === saved.id ? saved : agent))
+ : [...current, saved].sort((a, b) => a.name.localeCompare(b.name));
+ });
+ setModalOpen(false);
+ } catch (err) {
+ setError(getApiErrorMessage(err));
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const toggleAgent = async (agent: AgentApiResponse) => {
+ setTogglingId(agent.id);
+ setError(null);
+ try {
+ const updated = await agentService.update(workspaceSlug, agent.id, {
+ name: agent.name,
+ enabled: !agent.enabled,
+ });
+ setAgents((current) => current.map((item) => (item.id === agent.id ? updated : item)));
+ } catch (err) {
+ setError(getApiErrorMessage(err));
+ } finally {
+ setTogglingId(null);
+ }
+ };
+
+ const deleteAgent = async (agent: AgentApiResponse) => {
+ if (!window.confirm(`Delete ${agent.name}? Existing run history will be retained.`)) return;
+ setDeletingId(agent.id);
+ setError(null);
+ try {
+ await agentService.delete(workspaceSlug, agent.id);
+ setAgents((current) => current.filter((item) => item.id !== agent.id));
+ } catch (err) {
+ setError(getApiErrorMessage(err));
+ } finally {
+ setDeletingId(null);
+ }
+ };
+
+ return (
+
+
+
+
Agents
+
+ Configure autonomous teammates, their boundaries, and the tools they can use.
+
+
+
+
+
+
+ {[
+ ['Total agents', agents.length],
+ ['Enabled', enabledCount],
+ ['Tools in use', toolCount],
+ ].map(([label, value], index) => (
+
0 ? 'border-l border-(--border-subtle)' : ''}`}
+ >
+
{value}
+
{label}
+
+ ))}
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {loading ? (
+
Loading agents...
+ ) : agents.length === 0 ? (
+
+
+
No agents yet
+
+ Create one, give it a job, then assign it from any work item.
+
+
+
+ ) : (
+
+ {agents.map((agent) => {
+ const project = projects.find((item) => item.id === agent.project_id);
+ return (
+
+
+
+
+
+
{agent.name}
+
+ {autonomyLabel(agent.autonomy_level)}
+
+
+ {project ? project.name : 'All projects'}
+
+
+
+ {agent.description || 'No description provided.'}
+
+
+ {(agent.tool_permissions ?? []).length ? (
+ agent.tool_permissions.map((permission) => (
+
+ {toolLabel(permission.tool)}
+
+ ))
+ ) : (
+ No tools granted
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+
!saving && setModalOpen(false)}
+ title={editingAgent ? 'Edit agent' : 'Create agent'}
+ className="max-h-[calc(100dvh-2rem)] max-w-2xl overflow-y-auto"
+ footer={
+ <>
+
+
+ >
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/agents/IssueAgentPanel.tsx b/apps/web/src/components/agents/IssueAgentPanel.tsx
new file mode 100644
index 00000000..000b06e8
--- /dev/null
+++ b/apps/web/src/components/agents/IssueAgentPanel.tsx
@@ -0,0 +1,296 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { Bot, ChevronDown, Pencil, Play, Settings2 } from 'lucide-react';
+import { Link } from 'react-router-dom';
+import type {
+ AgentApiResponse,
+ AgentIssueAssignmentApiResponse,
+ AgentRunApiResponse,
+} from '../../api/types';
+import { getApiErrorMessage } from '../../api/client';
+import { agentService } from '../../services/agentService';
+import { Button, Card, CardContent, CardHeader } from '../ui';
+import { AgentMark, AgentRunStatusBadge } from './agentUi';
+import { autonomyLabel } from './agentOptions';
+
+interface IssueAgentPanelProps {
+ workspaceSlug: string;
+ projectId: string;
+ issueId: string;
+}
+
+function relativeTime(value: string): string {
+ const timestamp = new Date(value).getTime();
+ const minutes = Math.max(0, Math.floor((Date.now() - timestamp) / 60_000));
+ if (minutes < 1) return 'just now';
+ if (minutes < 60) return `${minutes}m ago`;
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) return `${hours}h ago`;
+ return `${Math.floor(hours / 24)}d ago`;
+}
+
+export function IssueAgentPanel({ workspaceSlug, projectId, issueId }: IssueAgentPanelProps) {
+ const [agents, setAgents] = useState([]);
+ const [assignments, setAssignments] = useState([]);
+ const [runs, setRuns] = useState([]);
+ const [selectedAgentId, setSelectedAgentId] = useState('');
+ const [reason, setReason] = useState('');
+ const [prompt, setPrompt] = useState('');
+ const [loading, setLoading] = useState(true);
+ const [assigning, setAssigning] = useState(false);
+ const [running, setRunning] = useState(false);
+ const [showAssignmentForm, setShowAssignmentForm] = useState(false);
+ const [error, setError] = useState(null);
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const [agentList, assignmentList, runList] = await Promise.all([
+ agentService.list(workspaceSlug, projectId),
+ agentService.listAssignments(workspaceSlug, projectId, issueId),
+ agentService.listRuns(workspaceSlug, projectId, issueId),
+ ]);
+ setAgents(agentList);
+ setAssignments(assignmentList);
+ setRuns(runList);
+ const latestActive = [...assignmentList]
+ .reverse()
+ .find((assignment) => assignment.status === 'active');
+ const initialAgentId =
+ latestActive?.agent_id ?? agentList.find((agent) => agent.enabled)?.id ?? '';
+ setSelectedAgentId(initialAgentId);
+ setReason(latestActive?.reason ?? '');
+ setShowAssignmentForm(!latestActive);
+ } catch (err) {
+ setError(getApiErrorMessage(err));
+ } finally {
+ setLoading(false);
+ }
+ }, [issueId, projectId, workspaceSlug]);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ const enabledAgents = useMemo(() => agents.filter((agent) => agent.enabled), [agents]);
+ const latestActiveAssignment = useMemo(
+ () => [...assignments].reverse().find((assignment) => assignment.status === 'active') ?? null,
+ [assignments],
+ );
+ const assignedAgent = latestActiveAssignment
+ ? (agents.find((agent) => agent.id === latestActiveAssignment.agent_id) ?? null)
+ : null;
+ const selectedAgent = agents.find((agent) => agent.id === selectedAgentId) ?? null;
+
+ const assignAgent = async () => {
+ if (!selectedAgentId) return;
+ setAssigning(true);
+ setError(null);
+ try {
+ const assignment = await agentService.assign(
+ workspaceSlug,
+ projectId,
+ issueId,
+ selectedAgentId,
+ reason.trim(),
+ );
+ setAssignments((current) => {
+ const exists = current.some((item) => item.id === assignment.id);
+ return exists
+ ? current.map((item) => (item.id === assignment.id ? assignment : item))
+ : [...current, assignment];
+ });
+ setShowAssignmentForm(false);
+ } catch (err) {
+ setError(getApiErrorMessage(err));
+ } finally {
+ setAssigning(false);
+ }
+ };
+
+ const runAgent = async () => {
+ const agentId = assignedAgent?.id ?? selectedAgentId;
+ if (!agentId) return;
+ setRunning(true);
+ setError(null);
+ try {
+ const run = await agentService.run(workspaceSlug, projectId, issueId, agentId, prompt);
+ setRuns((current) => [run, ...current.filter((item) => item.id !== run.id)]);
+ setPrompt('');
+ } catch (err) {
+ setError(getApiErrorMessage(err));
+ } finally {
+ setRunning(false);
+ }
+ };
+
+ return (
+
+
+
+
+ Agent
+
+
+
+
+
+
+ {loading ? (
+ Loading agents...
+ ) : enabledAgents.length === 0 ? (
+
+
No agents available
+
+ Create an agent
+
+
+ ) : (
+ <>
+ {assignedAgent && !showAssignmentForm ? (
+
+
+
+
+
+ {assignedAgent.name}
+
+
+ Assigned
+
+
+
+ {autonomyLabel(assignedAgent.autonomy_level)}
+
+ {latestActiveAssignment?.reason && (
+
+ {latestActiveAssignment.reason}
+
+ )}
+
+
+
+ ) : (
+
+
+
+
+
+
setReason(event.target.value)}
+ placeholder="What should this agent own?"
+ className="w-full rounded-md border border-(--border-subtle) bg-(--bg-surface-1) px-3 py-2 text-xs text-(--txt-primary) outline-none placeholder:text-(--txt-placeholder) focus:border-(--border-strong)"
+ />
+
+ {assignedAgent && (
+
+ )}
+
+
+
+ )}
+
+ {assignedAgent && !showAssignmentForm && (
+
+
+
+
+ )}
+
+ {runs.length > 0 && (
+
+
+
Recent runs
+
{runs.length} total
+
+
+ {runs.slice(0, 4).map((run) => {
+ const agent = agents.find((item) => item.id === run.agent_id);
+ const runPrompt =
+ typeof run.input?.prompt === 'string' ? run.input.prompt : 'Manual run';
+ return (
+
+
+
+ {runPrompt}
+
+
+ {agent?.name ?? 'Agent'} ยท {relativeTime(run.queued_at)}
+
+
+
+
+ );
+ })}
+
+
+ )}
+ >
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ );
+}
diff --git a/apps/web/src/components/agents/agentOptions.ts b/apps/web/src/components/agents/agentOptions.ts
new file mode 100644
index 00000000..a3709e36
--- /dev/null
+++ b/apps/web/src/components/agents/agentOptions.ts
@@ -0,0 +1,48 @@
+import type { AgentAutonomyLevel } from '../../api/types';
+
+export const AUTONOMY_OPTIONS: Array<{
+ value: AgentAutonomyLevel;
+ label: string;
+ description: string;
+}> = [
+ {
+ value: 'suggest',
+ label: 'Suggest only',
+ description: 'Propose actions without changing data.',
+ },
+ { value: 'comment', label: 'Comment', description: 'Read work and post comments.' },
+ {
+ value: 'modify_issue',
+ label: 'Modify work items',
+ description: 'Update fields and create child work.',
+ },
+ {
+ value: 'github_draft',
+ label: 'Draft pull requests',
+ description: 'Create branches and draft pull requests.',
+ },
+ {
+ value: 'github_reviewed',
+ label: 'Reviewed GitHub changes',
+ description: 'Continue GitHub work after human approval.',
+ },
+];
+
+export const TOOL_OPTIONS = [
+ { value: 'issue.read', label: 'Read work items' },
+ { value: 'issue.comment', label: 'Post comments' },
+ { value: 'issue.update', label: 'Update work items' },
+ { value: 'issue.create_child', label: 'Create child work items' },
+ { value: 'project.read', label: 'Read project context' },
+ { value: 'github.read', label: 'Read GitHub' },
+ { value: 'github.comment', label: 'Comment on GitHub' },
+ { value: 'github.draft_pr', label: 'Create draft pull requests' },
+] as const;
+
+export function autonomyLabel(level: AgentAutonomyLevel): string {
+ return AUTONOMY_OPTIONS.find((option) => option.value === level)?.label ?? level;
+}
+
+export function toolLabel(tool: string): string {
+ return TOOL_OPTIONS.find((option) => option.value === tool)?.label ?? tool;
+}
diff --git a/apps/web/src/components/agents/agentUi.tsx b/apps/web/src/components/agents/agentUi.tsx
new file mode 100644
index 00000000..6efa87f6
--- /dev/null
+++ b/apps/web/src/components/agents/agentUi.tsx
@@ -0,0 +1,50 @@
+import { Bot, CheckCircle2, CircleAlert, Clock3, LoaderCircle, PauseCircle } from 'lucide-react';
+import type { AgentRunStatus } from '../../api/types';
+
+export function AgentMark({ name, enabled = true }: { name: string; enabled?: boolean }) {
+ return (
+
+
+
+
+ );
+}
+
+const RUN_STATUS: Record<
+ AgentRunStatus,
+ { label: string; className: string; icon: typeof Clock3 }
+> = {
+ queued: { label: 'Queued', className: 'text-(--txt-warning-primary)', icon: Clock3 },
+ running: { label: 'Running', className: 'text-(--txt-accent-primary)', icon: LoaderCircle },
+ needs_review: {
+ label: 'Needs review',
+ className: 'text-(--txt-warning-primary)',
+ icon: CircleAlert,
+ },
+ completed: { label: 'Completed', className: 'text-(--txt-success-primary)', icon: CheckCircle2 },
+ failed: { label: 'Failed', className: 'text-(--txt-danger-primary)', icon: CircleAlert },
+ cancelled: { label: 'Cancelled', className: 'text-(--txt-tertiary)', icon: PauseCircle },
+};
+
+export function AgentRunStatusBadge({ status }: { status: AgentRunStatus }) {
+ const config = RUN_STATUS[status];
+ const Icon = config.icon;
+ return (
+
+
+ {config.label}
+
+ );
+}
diff --git a/apps/web/src/components/settings/SettingsNav.tsx b/apps/web/src/components/settings/SettingsNav.tsx
index 53030f33..2e0ae26d 100644
--- a/apps/web/src/components/settings/SettingsNav.tsx
+++ b/apps/web/src/components/settings/SettingsNav.tsx
@@ -186,7 +186,7 @@ export function SettingsNav({
Administration
- {WORKSPACE_SECTIONS.slice(0, 4).map(({ id, label, icon }) => (
+ {WORKSPACE_SECTIONS.filter(({ id }) => id !== 'webhooks').map(({ id, label, icon }) => (
Developer
- {WORKSPACE_SECTIONS.slice(4).map(({ id, label, icon }) => (
+ {WORKSPACE_SECTIONS.filter(({ id }) => id === 'webhooks').map(({ id, label, icon }) => (
[] = [
{ id: 'general', label: 'General', icon: },
{ id: 'members', label: 'Members', icon: },
+ { id: 'agents', label: 'Agents', icon: },
{ id: 'integrations', label: 'Integrations', icon: },
{ id: 'exports', label: 'Exports', icon: },
{ id: 'webhooks', label: 'Webhooks', icon: },
diff --git a/apps/web/src/pages/IssueDetailPage.tsx b/apps/web/src/pages/IssueDetailPage.tsx
index 683d9e23..eaf3d92b 100644
--- a/apps/web/src/pages/IssueDetailPage.tsx
+++ b/apps/web/src/pages/IssueDetailPage.tsx
@@ -27,6 +27,7 @@ import { IssueRelationsPanel } from '../components/work-item/IssueRelationsPanel
import { IssueAttachmentsPanel } from '../components/work-item/IssueAttachmentsPanel';
import { MoveWorkItemModal } from '../components/work-item/MoveWorkItemModal';
import { SubscribeButton } from '../components/notifications/SubscribeButton';
+import { IssueAgentPanel } from '../components/agents/IssueAgentPanel';
import {
PriorityIcon,
StatePill,
@@ -746,6 +747,14 @@ export function IssueDetailPage() {
issueId={issue.id}
/>
)}
+ {workspaceSlug && (
+
+ )}
+
{workspaceSlug && (
)}
+ {!isAccountTab && !isProjectsTab && section === 'agents' && workspaceSlug && (
+
+ )}
+
{!isAccountTab && !isProjectsTab && section === 'exports' && (
diff --git a/apps/web/src/services/agentService.ts b/apps/web/src/services/agentService.ts
new file mode 100644
index 00000000..e3685336
--- /dev/null
+++ b/apps/web/src/services/agentService.ts
@@ -0,0 +1,100 @@
+import { apiClient } from '../api/client';
+import type {
+ AgentApiResponse,
+ AgentIssueAssignmentApiResponse,
+ AgentRunApiResponse,
+ AgentUpsertRequest,
+} from '../api/types';
+
+const workspaceBase = (slug: string) => `/api/workspaces/${encodeURIComponent(slug)}/agents`;
+
+const issueBase = (slug: string, projectId: string, issueId: string) =>
+ `/api/workspaces/${encodeURIComponent(slug)}/projects/${encodeURIComponent(projectId)}/issues/${encodeURIComponent(issueId)}`;
+
+export const agentService = {
+ async list(workspaceSlug: string, projectId?: string): Promise
{
+ const url = projectId
+ ? `/api/workspaces/${encodeURIComponent(workspaceSlug)}/projects/${encodeURIComponent(projectId)}/agents/`
+ : `${workspaceBase(workspaceSlug)}/`;
+ const { data } = await apiClient.get(url);
+ return Array.isArray(data) ? data : [];
+ },
+
+ async create(workspaceSlug: string, payload: AgentUpsertRequest): Promise {
+ const { data } = await apiClient.post(
+ `${workspaceBase(workspaceSlug)}/`,
+ payload,
+ );
+ return data;
+ },
+
+ async update(
+ workspaceSlug: string,
+ agentId: string,
+ payload: Partial,
+ ): Promise {
+ const { data } = await apiClient.patch(
+ `${workspaceBase(workspaceSlug)}/${encodeURIComponent(agentId)}/`,
+ payload,
+ );
+ return data;
+ },
+
+ async delete(workspaceSlug: string, agentId: string): Promise {
+ await apiClient.delete(`${workspaceBase(workspaceSlug)}/${encodeURIComponent(agentId)}/`);
+ },
+
+ async listAssignments(
+ workspaceSlug: string,
+ projectId: string,
+ issueId: string,
+ ): Promise {
+ const { data } = await apiClient.get(
+ `${issueBase(workspaceSlug, projectId, issueId)}/agent-assignments/`,
+ );
+ return Array.isArray(data) ? data : [];
+ },
+
+ async assign(
+ workspaceSlug: string,
+ projectId: string,
+ issueId: string,
+ agentId: string,
+ reason: string,
+ ): Promise {
+ const { data } = await apiClient.post(
+ `${issueBase(workspaceSlug, projectId, issueId)}/agent-assignments/`,
+ { agent_id: agentId, reason },
+ );
+ return data;
+ },
+
+ async listRuns(
+ workspaceSlug: string,
+ projectId: string,
+ issueId: string,
+ ): Promise {
+ const { data } = await apiClient.get(
+ `${issueBase(workspaceSlug, projectId, issueId)}/agent-runs/`,
+ );
+ return Array.isArray(data) ? data : [];
+ },
+
+ async run(
+ workspaceSlug: string,
+ projectId: string,
+ issueId: string,
+ agentId: string,
+ prompt?: string,
+ ): Promise {
+ const { data } = await apiClient.post(
+ `${issueBase(workspaceSlug, projectId, issueId)}/agent-runs/`,
+ {
+ agent_id: agentId,
+ trigger: 'manual',
+ input: prompt?.trim() ? { prompt: prompt.trim() } : {},
+ },
+ );
+ return data;
+ },
+};
diff --git a/apps/web/src/services/index.ts b/apps/web/src/services/index.ts
index 61cb3f27..f8fcb69a 100644
--- a/apps/web/src/services/index.ts
+++ b/apps/web/src/services/index.ts
@@ -3,3 +3,4 @@ export { instanceService, instanceSettingsService, instanceAdminService } from '
export { authService } from './authService';
export { integrationService } from './integrationService';
export { epicService } from './epicService';
+export { agentService } from './agentService';
diff --git a/proposals/agent-workflows.md b/proposals/agent-workflows.md
index 2226a0fb..bc3d7a2c 100644
--- a/proposals/agent-workflows.md
+++ b/proposals/agent-workflows.md
@@ -15,14 +15,16 @@ This document proposes the product and technical shape for making agents first-c
## Implementation checkpoint
-The initial backend slice adds the core persistence and REST surface for Phase 1:
+The initial Phase 1 slice now adds the core persistence, REST surface, and supervised UI:
- workspace/project agent roster records with explicit tool permissions
+- a workspace settings roster for creating, editing, enabling, and scoping agents
- manual work item agent assignments that do not replace human assignees
+- an issue sidebar for assigning agents, providing run instructions, and viewing run status
- queued agent run records with durable input/output/error fields
- issue activity events for agent assignment and queued runs
-This intentionally stops before autonomous execution. Later phases can attach the queue consumer, model execution, approval UI, and GitHub branch/PR tooling to these records.
+This intentionally stops before autonomous execution. Later phases can attach the queue consumer, model execution, approval UI, and GitHub branch/PR tooling to these records and controls.
## Goals