// Package paperclip is a thin read/write client for the Paperclip control plane. // // It holds no state. Every value it returns came from Paperclip on this call. // Nothing here caches, and nothing here writes to disk. package paperclip import ( "bytes" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "strings" "time" ) // Client talks to one Paperclip instance. type Client struct { BaseURL string Company string token string http *http.Client } // New builds a client from the environment. // // The token is read from PAPERCLIP_BOARD_KEY (the person's key) and only falls // back to PAPERCLIP_API_KEY for read-only use. The token is never logged. func New() (*Client, error) { base := os.Getenv("PAPERCLIP_API_URL") if base == "" { base = "http://127.0.0.1:3100" } company := os.Getenv("PAPERCLIP_COMPANY_ID") if company == "" { company = "d4921013-e5da-4d88-8016-57d5746e2d03" } token := os.Getenv("PAPERCLIP_BOARD_KEY") if token == "" { token = os.Getenv("PAPERCLIP_API_KEY") } if token == "" { return nil, errors.New("no key in the environment: set PAPERCLIP_BOARD_KEY") } return &Client{ BaseURL: strings.TrimRight(base, "/"), Company: company, token: token, http: &http.Client{Timeout: 20 * time.Second}, }, nil } // UsingUserKey reports whether the person's key is in use. Writes need it. func (c *Client) UsingUserKey() bool { return os.Getenv("PAPERCLIP_BOARD_KEY") != "" } type Issue struct { ID string `json:"id"` Description string `json:"description"` Identifier string `json:"identifier"` Title string `json:"title"` Status string `json:"status"` Priority string `json:"priority"` ProjectID string `json:"projectId"` ParentID string `json:"parentId"` AssigneeAgent string `json:"assigneeAgentId"` AssigneeUser string `json:"assigneeUserId"` UpdatedAt string `json:"updatedAt"` // Workspace is where Paperclip put this task's own copy of the code. It is // null when the instance does not make worktrees. Workspace *struct { CWD string `json:"cwd"` Branch string `json:"branchName"` BaseRef string `json:"baseRef"` Strategy string `json:"strategyType"` } `json:"currentExecutionWorkspace"` } // Run is one heartbeat run: when it started, how it ended, what it cost. type Run struct { ID string `json:"runId"` Status string `json:"status"` Source string `json:"invocationSource"` AgentID string `json:"agentId"` StartedAt string `json:"startedAt"` FinishedAt string `json:"finishedAt"` ErrorCode string `json:"errorCode"` } // Activity is one structural event on a task. type Activity struct { ID string `json:"id"` ActorType string `json:"actorType"` Action string `json:"action"` CreatedAt string `json:"createdAt"` } // Runs returns a task's run history, newest first. func (c *Client) Runs(issueID string) ([]Run, error) { var out []Run err := c.do("GET", "/api/issues/"+issueID+"/runs", nil, &out) return out, err } // Activity returns a task's structural events, newest first. func (c *Client) Activity(issueID string) ([]Activity, error) { var out []Activity err := c.do("GET", "/api/issues/"+issueID+"/activity", nil, &out) return out, err } // CreateTask creates a task, unassigned. Blockers are never accepted at // creation: they must be a separate call, or the write is ignored. type NewTask struct { Title string Description string ProjectID string } func (c *Client) CreateTask(t NewTask) (Issue, error) { if !c.UsingUserKey() { return Issue{}, errors.New("refusing to write with the agent key: set PAPERCLIP_BOARD_KEY") } var out Issue body := map[string]any{ "title": t.Title, "description": t.Description, "projectId": t.ProjectID, "status": "todo", } err := c.do("POST", "/api/companies/"+c.Company+"/issues", body, &out) return out, err } // SetBlockers replaces a task's blocked-by list. func (c *Client) SetBlockers(issueID string, blockerIDs []string) (Issue, error) { if !c.UsingUserKey() { return Issue{}, errors.New("refusing to write with the agent key: set PAPERCLIP_BOARD_KEY") } var out Issue err := c.do("PATCH", "/api/issues/"+issueID, map[string]any{"blockedByIssueIds": blockerIDs}, &out) return out, err } // Assign gives a task to an agent. It is always a separate call from creation: // an assigned task is claimed by that agent's run within seconds, and the run // then owns the task's fields. func (c *Client) Assign(issueID, agentID string) (Issue, error) { if !c.UsingUserKey() { return Issue{}, errors.New("refusing to write with the agent key: set PAPERCLIP_BOARD_KEY") } var out Issue err := c.do("PATCH", "/api/issues/"+issueID, map[string]any{"assigneeAgentId": agentID}, &out) return out, err } type Project struct { ID string `json:"id"` Name string `json:"name"` URLKey string `json:"urlKey"` Status string `json:"status"` } // Comment is one entry on a task's thread. type Comment struct { ID string `json:"id"` Body string `json:"body"` AuthorType string `json:"authorType"` AuthorID string `json:"authorAgentId"` AuthorUser string `json:"authorUserId"` CreatedAt string `json:"createdAt"` } // BlockerInfo is what Paperclip says about one task's blockers. // // The list endpoint does not carry blockers, so the tree asks per task. type BlockerInfo struct { IssueID string UnresolvedCount int AllBlockersDone bool DependencyReady bool Diagnosis string BlockingIdentities []string } type blockerResponse struct { Diagnosis string `json:"diagnosis"` Readiness struct { AllBlockersDone bool `json:"allBlockersDone"` DependencyReady bool `json:"isDependencyReady"` UnresolvedBlockerCount int `json:"unresolvedBlockerCount"` } `json:"readiness"` Blockers []struct { Identifier string `json:"identifier"` Title string `json:"title"` Status string `json:"status"` } `json:"blockers"` } func (c *Client) do(method, path string, body any, out any) error { var rd io.Reader if body != nil { b, err := json.Marshal(body) if err != nil { return err } rd = bytes.NewReader(b) } req, err := http.NewRequest(method, c.BaseURL+path, rd) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+c.token) req.Header.Set("Accept", "application/json") if body != nil { req.Header.Set("Content-Type", "application/json") } resp, err := c.http.Do(req) if err != nil { var ue *url.Error if errors.As(err, &ue) { return fmt.Errorf("cannot reach Paperclip at %s: %w", c.BaseURL, ue.Err) } return err } defer resp.Body.Close() raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) if err != nil { return err } if resp.StatusCode < 200 || resp.StatusCode > 299 { return fmt.Errorf("%s %s returned %d: %s", method, path, resp.StatusCode, snippet(raw)) } if out == nil || len(raw) == 0 { return nil } if err := json.Unmarshal(raw, out); err != nil { return fmt.Errorf("%s %s returned unreadable JSON: %w", method, path, err) } return nil } func snippet(b []byte) string { s := strings.TrimSpace(string(b)) if len(s) > 300 { s = s[:300] + "…" } return s } // Projects lists the company's projects. func (c *Client) Projects() ([]Project, error) { var out []Project err := c.do("GET", "/api/companies/"+c.Company+"/projects", nil, &out) return out, err } // ResolveProject accepts a project id or url key and returns the project. func (c *Client) ResolveProject(ref string) (Project, error) { if ref == "" { return Project{}, errors.New("no project given: use -project ") } ps, err := c.Projects() if err != nil { return Project{}, err } for _, p := range ps { if p.ID == ref || p.URLKey == ref { return p, nil } } return Project{}, fmt.Errorf("no project matches %q", ref) } // Issues returns every task in the company. The caller filters by project, // because the list endpoint takes no project filter. func (c *Client) Issues() ([]Issue, error) { var out []Issue err := c.do("GET", "/api/companies/"+c.Company+"/issues", nil, &out) return out, err } // Blockers asks Paperclip what blocks one task. func (c *Client) Blockers(issueID string) (BlockerInfo, error) { var raw blockerResponse err := c.do("GET", "/api/issues/"+issueID+"/diagnostics/blockers", nil, &raw) info := BlockerInfo{ IssueID: issueID, UnresolvedCount: raw.Readiness.UnresolvedBlockerCount, AllBlockersDone: raw.Readiness.AllBlockersDone, DependencyReady: raw.Readiness.DependencyReady, Diagnosis: raw.Diagnosis, } for _, b := range raw.Blockers { info.BlockingIdentities = append(info.BlockingIdentities, b.Identifier+" "+b.Status) } return info, err } // Comments returns a task's thread, oldest first. func (c *Client) Comments(issueID string) ([]Comment, error) { var out []Comment err := c.do("GET", "/api/issues/"+issueID+"/comments?order=asc", nil, &out) return out, err } // PostComment writes a comment as the person, which requires the user key. func (c *Client) PostComment(issueID, body string) (Comment, error) { if !c.UsingUserKey() { return Comment{}, errors.New("refusing to write with the agent key: set PAPERCLIP_BOARD_KEY") } var out Comment err := c.do("POST", "/api/issues/"+issueID+"/comments", map[string]string{"body": body}, &out) return out, err } // SetStatus changes one task's status as the person. func (c *Client) SetStatus(issueID, status, comment string) (Issue, error) { if !c.UsingUserKey() { return Issue{}, errors.New("refusing to write with the agent key: set PAPERCLIP_BOARD_KEY") } payload := map[string]any{"status": status} if comment != "" { payload["comment"] = comment } var out Issue err := c.do("PATCH", "/api/issues/"+issueID, payload, &out) return out, err } type Agent struct { ID string `json:"id"` Name string `json:"name"` Status string `json:"status"` BudgetCents int `json:"budgetMonthlyCents"` SpentCents int `json:"spentMonthlyCents"` } // Agents lists the company's agents, used only to put a name on a task. func (c *Client) Agents() ([]Agent, error) { var out []Agent err := c.do("GET", "/api/companies/"+c.Company+"/agents", nil, &out) return out, err } // Issue returns one task by id. func (c *Client) Issue(issueID string) (Issue, error) { var out Issue err := c.do("GET", "/api/issues/"+issueID, nil, &out) return out, err } // Cost is what one task has cost so far. type Cost struct { CostCents int `json:"costCents"` InputTokens int `json:"inputTokens"` OutputTokens int `json:"outputTokens"` RunCount int `json:"runCount"` RuntimeMs int `json:"runtimeMs"` } // CostSummary returns the task's recorded spend. func (c *Client) CostSummary(issueID string) (Cost, error) { var out Cost err := c.do("GET", "/api/issues/"+issueID+"/cost-summary", nil, &out) return out, err } // Health asks the control plane whether it is answering at all. func (c *Client) Health() error { return c.do("GET", "/api/health", nil, nil) } // AgentRun is one heartbeat run in the company, whichever agent ran it. type AgentRun struct { ID string `json:"id"` AgentID string `json:"agentId"` Status string `json:"status"` Source string `json:"invocationSource"` StartedAt string `json:"createdAt"` FinishedAt string `json:"finishedAt"` ErrorCode string `json:"errorCode"` LastOutputAt string `json:"lastOutputAt"` LastOutputBytes int `json:"lastOutputBytes"` // The task a run worked on lives inside the run's context snapshot, not on // the run itself. Snapshot struct { IssueID string `json:"issueId"` TaskKey string `json:"taskKey"` } `json:"contextSnapshot"` } // Runs returns every run in the company, newest first. func (c *Client) CompanyRuns() ([]AgentRun, error) { var out []AgentRun err := c.do("GET", "/api/companies/"+c.Company+"/heartbeat-runs", nil, &out) return out, err } // RunLog returns the recorded output of one run. func (c *Client) RunLog(runID string, maxBytes int) (string, error) { var raw []byte req, err := http.NewRequest("GET", c.BaseURL+"/api/heartbeat-runs/"+runID+"/log", nil) if err != nil { return "", err } req.Header.Set("Authorization", "Bearer "+c.token) resp, err := c.http.Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode > 299 { b, _ := io.ReadAll(io.LimitReader(resp.Body, 400)) return "", fmt.Errorf("run log returned %d: %s", resp.StatusCode, snippet(b)) } raw, err = io.ReadAll(io.LimitReader(resp.Body, int64(maxBytes))) if err != nil { return "", err } if len(raw) == maxBytes { return string(raw) + "\n… (only the last " + fmt.Sprint(maxBytes) + " bytes are shown)", nil } return string(raw), nil } // PauseAgent and ResumeAgent are the two ways to stop and start an agent. func (c *Client) PauseAgent(agentID string) error { return c.do("POST", "/api/agents/"+agentID+"/pause", map[string]any{}, nil) } func (c *Client) ResumeAgent(agentID string) error { return c.do("POST", "/api/agents/"+agentID+"/resume", map[string]any{}, nil) } // WakeAgent asks Paperclip to start a run for this agent now. func (c *Client) WakeAgent(agentID string) error { return c.do("POST", "/api/agents/"+agentID+"/wakeup", map[string]any{"source": "on_demand", "triggerDetail": "manual", "forceFreshSession": false}, nil) } // UpdateAgent changes an agent's own record. Paperclip owns it, so this is a // PATCH with only the fields that changed; a field left out is not touched. func (c *Client) UpdateAgent(agentID string, body map[string]any) error { if !c.UsingUserKey() { return errors.New("refusing to write with the agent key: set PAPERCLIP_BOARD_KEY") } return c.do("PATCH", "/api/agents/"+agentID, body, nil) } // AgentRaw reads one agent's full record, including the configuration blocks, // so an edit can change one field and leave the rest exactly as it was. func (c *Client) AgentRaw(agentID string) (map[string]any, error) { var out map[string]any err := c.do("GET", "/api/agents/"+agentID, nil, &out) return out, err } // Dashboard is the company's own summary: counts, spend and recent run activity. // One call, 1.8 KB, cheap enough to poll. type Dashboard struct { Agents struct { Active int `json:"active"` Running int `json:"running"` Paused int `json:"paused"` Error int `json:"error"` } `json:"agents"` Tasks struct { Open int `json:"open"` InProgress int `json:"inProgress"` Blocked int `json:"blocked"` Done int `json:"done"` } `json:"tasks"` Costs struct { MonthSpendCents int `json:"monthSpendCents"` MonthBudgetCents int `json:"monthBudgetCents"` MonthUtilizationPercent float64 `json:"monthUtilizationPercent"` } `json:"costs"` PendingApprovals int `json:"pendingApprovals"` Budgets struct { ActiveIncidents int `json:"activeIncidents"` PausedAgents int `json:"pausedAgents"` PausedProjects int `json:"pausedProjects"` } `json:"budgets"` RunActivity []struct { Date string `json:"date"` Total int `json:"total"` Succeeded int `json:"succeeded"` Failed int `json:"failed"` } `json:"runActivity"` } // DashboardSummary reads the company's dashboard. func (c *Client) DashboardSummary() (Dashboard, error) { var out Dashboard err := c.do("GET", "/api/companies/"+c.Company+"/dashboard", nil, &out) return out, err }