Add agent Mine inbox API surface

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-03-26 20:06:29 -05:00
parent 4fd62a3d91
commit ce4536d1fa
9 changed files with 114 additions and 4 deletions

View file

@ -119,6 +119,16 @@ export const ISSUE_STATUSES = [
] as const;
export type IssueStatus = (typeof ISSUE_STATUSES)[number];
export const INBOX_MINE_ISSUE_STATUSES = [
"backlog",
"todo",
"in_progress",
"in_review",
"blocked",
"done",
] as const;
export const INBOX_MINE_ISSUE_STATUS_FILTER = INBOX_MINE_ISSUE_STATUSES.join(",");
export const ISSUE_PRIORITIES = ["critical", "high", "medium", "low"] as const;
export type IssuePriority = (typeof ISSUE_PRIORITIES)[number];

View file

@ -9,6 +9,8 @@ export {
AGENT_ROLE_LABELS,
AGENT_ICON_NAMES,
ISSUE_STATUSES,
INBOX_MINE_ISSUE_STATUSES,
INBOX_MINE_ISSUE_STATUS_FILTER,
ISSUE_PRIORITIES,
ISSUE_ORIGIN_KINDS,
GOAL_LEVELS,
@ -344,6 +346,7 @@ export {
upsertAgentInstructionsFileSchema,
updateAgentInstructionsPathSchema,
createAgentKeySchema,
agentMineInboxQuerySchema,
wakeAgentSchema,
resetAgentSessionSchema,
testAdapterEnvironmentSchema,
@ -356,6 +359,7 @@ export {
type UpsertAgentInstructionsFile,
type UpdateAgentInstructionsPath,
type CreateAgentKey,
type AgentMineInboxQuery,
type WakeAgent,
type ResetAgentSession,
type TestAdapterEnvironment,

View file

@ -4,6 +4,7 @@ import {
AGENT_ICON_NAMES,
AGENT_ROLES,
AGENT_STATUSES,
INBOX_MINE_ISSUE_STATUS_FILTER,
} from "../constants.js";
import { envConfigSchema } from "./secret.js";
@ -93,6 +94,13 @@ export const createAgentKeySchema = z.object({
export type CreateAgentKey = z.infer<typeof createAgentKeySchema>;
export const agentMineInboxQuerySchema = z.object({
userId: z.string().trim().min(1),
status: z.string().trim().min(1).optional().default(INBOX_MINE_ISSUE_STATUS_FILTER),
});
export type AgentMineInboxQuery = z.infer<typeof agentMineInboxQuerySchema>;
export const wakeAgentSchema = z.object({
source: z.enum(["timer", "assignment", "on_demand", "automation"]).optional().default("on_demand"),
triggerDetail: z.enum(["manual", "ping", "callback", "system"]).optional(),

View file

@ -85,6 +85,7 @@ export {
upsertAgentInstructionsFileSchema,
updateAgentInstructionsPathSchema,
createAgentKeySchema,
agentMineInboxQuerySchema,
wakeAgentSchema,
resetAgentSessionSchema,
testAdapterEnvironmentSchema,
@ -97,6 +98,7 @@ export {
type UpsertAgentInstructionsFile,
type UpdateAgentInstructionsPath,
type CreateAgentKey,
type AgentMineInboxQuery,
type WakeAgent,
type ResetAgentSession,
type TestAdapterEnvironment,

View file

@ -1,6 +1,7 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { INBOX_MINE_ISSUE_STATUS_FILTER } from "@paperclipai/shared";
import { agentRoutes } from "../routes/agents.js";
import { errorHandler } from "../middleware/index.js";
@ -272,4 +273,42 @@ describe("agent permission routes", () => {
expect(res.body.access.canAssignTasks).toBe(true);
expect(res.body.access.taskAssignSource).toBe("agent_creator");
});
it("exposes a dedicated agent route for the inbox mine view", async () => {
mockIssueService.list.mockResolvedValue([
{
id: "issue-1",
identifier: "PAP-910",
title: "Inbox follow-up",
status: "todo",
},
]);
const app = createApp({
type: "agent",
agentId,
companyId,
runId: "run-1",
source: "agent_key",
});
const res = await request(app)
.get("/api/agents/me/inbox/mine")
.query({ userId: "board-user" });
expect(res.status).toBe(200);
expect(mockIssueService.list).toHaveBeenCalledWith(companyId, {
touchedByUserId: "board-user",
inboxArchivedByUserId: "board-user",
status: INBOX_MINE_ISSUE_STATUS_FILTER,
});
expect(res.body).toEqual([
{
id: "issue-1",
identifier: "PAP-910",
title: "Inbox follow-up",
status: "todo",
},
]);
});
});

View file

@ -6,6 +6,7 @@ import { agents as agentsTable, companies, heartbeatRuns } from "@paperclipai/db
import { and, desc, eq, inArray, not, sql } from "drizzle-orm";
import {
agentSkillSyncSchema,
agentMineInboxQuerySchema,
createAgentKeySchema,
createAgentHireSchema,
createAgentSchema,
@ -1006,6 +1007,23 @@ export function agentRoutes(db: Db) {
);
});
router.get("/agents/me/inbox/mine", async (req, res) => {
if (req.actor.type !== "agent" || !req.actor.agentId || !req.actor.companyId) {
res.status(401).json({ error: "Agent authentication required" });
return;
}
const query = agentMineInboxQuerySchema.parse(req.query);
const issuesSvc = issueService(db);
const rows = await issuesSvc.list(req.actor.companyId, {
touchedByUserId: query.userId,
inboxArchivedByUserId: query.userId,
status: query.status,
});
res.json(rows);
});
router.get("/agents/:id", async (req, res) => {
const id = req.params.id as string;
const agent = await svc.getById(id);

View file

@ -255,6 +255,7 @@ PATCH /api/agents/{agentId}/instructions-path
| ----------------------------------------- | ------------------------------------------------------------------------------------------ |
| My identity | `GET /api/agents/me` |
| My compact inbox | `GET /api/agents/me/inbox-lite` |
| Report a user's Mine inbox view | `GET /api/agents/me/inbox/mine?userId=:userId` |
| My assignments | `GET /api/companies/:companyId/issues?assigneeAgentId=:id&status=todo,in_progress,blocked` |
| Checkout task | `POST /api/issues/:issueId/checkout` |
| Get task + ancestors | `GET /api/issues/:issueId` |

View file

@ -226,6 +226,34 @@ PATCH /api/issues/issue-99
{ "comment": "JWT signing done. Still need token refresh logic. Will continue next heartbeat." }
```
### Worked Example: Report A Board User's Mine Inbox
When a board user asks "what's in my inbox?", an agent can derive that user's id from the triggering issue or comment metadata and fetch the same Mine-tab issue set the UI uses.
```
# Board user created the requesting issue.
GET /api/issues/issue-200
-> { id: "issue-200", createdByUserId: "user-7", ... }
# Fetch the board user's Mine inbox issues.
GET /api/agents/me/inbox/mine?userId=user-7
-> [
{
id: "issue-310",
identifier: "PAP-310",
title: "Review CEO strategy revision",
status: "in_review",
myLastTouchAt: "2026-03-26T18:00:00.000Z",
lastExternalCommentAt: "2026-03-26T19:10:00.000Z",
isUnreadForMe: true
}
]
# Summarize it back to the board in a comment or document.
PATCH /api/issues/issue-200
{ "comment": "Your Mine inbox has 1 unread issue: [PAP-310](/PAP/issues/PAP-310)." }
```
---
## Worked Example: Manager Heartbeat
@ -566,6 +594,7 @@ Terminal states: `done`, `cancelled`
| Method | Path | Description |
| ------ | ---------------------------------- | ------------------------------------ |
| GET | `/api/agents/me` | Your agent record + chain of command |
| GET | `/api/agents/me/inbox/mine?userId=:userId` | Mine-tab issue list for a specific board user |
| GET | `/api/agents/:agentId` | Agent details + chain of command |
| GET | `/api/companies/:companyId/agents` | List all agents in company |
| GET | `/api/companies/:companyId/org` | Org chart tree |

View file

@ -1,6 +1,7 @@
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useLocation, useNavigate } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { INBOX_MINE_ISSUE_STATUS_FILTER } from "@paperclipai/shared";
import { approvalsApi } from "../api/approvals";
import { accessApi } from "../api/access";
import { ApiError } from "../api/client";
@ -68,8 +69,6 @@ type SectionKey =
| "work_items"
| "alerts";
const INBOX_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done";
function firstNonEmptyLine(value: string | null | undefined): string | null {
if (!value) return null;
const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean);
@ -595,7 +594,7 @@ export function Inbox() {
issuesApi.list(selectedCompanyId!, {
touchedByUserId: "me",
inboxArchivedByUserId: "me",
status: INBOX_ISSUE_STATUSES,
status: INBOX_MINE_ISSUE_STATUS_FILTER,
}),
enabled: !!selectedCompanyId,
});
@ -607,7 +606,7 @@ export function Inbox() {
queryFn: () =>
issuesApi.list(selectedCompanyId!, {
touchedByUserId: "me",
status: INBOX_ISSUE_STATUSES,
status: INBOX_MINE_ISSUE_STATUS_FILTER,
}),
enabled: !!selectedCompanyId,
});