4d5322c821
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agent work is coordinated through issues, comments, interactions, and approval-style handoffs. > - Existing issue-thread interactions could ask questions, suggest tasks, and request confirmation, but they did not support a structured checkbox confirmation payload for choosing one or more options. > - That gap made board/user confirmations harder to validate consistently across API callers, plugin helpers, CLI tooling, and the UI. > - This pull request adds the shared checkbox confirmation contract, server handling, client helpers, and issue-thread UI needed to render and submit structured selections. > - The benefit is that agents can request bounded multi-select confirmations in the same audited issue-thread flow as other Paperclip interactions. ## Linked Issues or Issue Description - No public GitHub issue found for this exact branch. Internal Paperclip issue: PAP-10415 / PAP-10441 requested creating this PR for the checkbox confirmation issue-thread UI component work. - GitHub duplicate search performed for checkbox confirmation / issue-thread interaction PRs; no matching open PR was found. - Related issue search result `#7497` was unrelated company file cleanup work, so it is not linked as a related issue. ## What Changed - Added shared types, validators, constants, and tests for `request_checkbox_confirmation` interactions. - Extended server issue-thread interaction service and routes for checkbox confirmation creation, validation, expiration, and response handling. - Added CLI, MCP, and plugin SDK helper coverage so external callers can create the new interaction shape consistently. - Updated the issue-thread interaction UI to render checkbox confirmations with min/max bounds, selection summaries, stale-target states, and accept/decline flows. - Documented the checkbox confirmation interaction contract in the Paperclip skill/API reference. ## Verification - Rebased cleanly on `paperclipai/paperclip` `master` fetched into `public-gh/master` at `a4fa0eaf5`. - Confirmed the PR diff does not include `pnpm-lock.yaml` or `.github/workflows` changes. - Ran focused tests with `NODE_ENV=test`: ```sh NODE_ENV=test pnpm run preflight:workspace-links NODE_ENV=test pnpm exec vitest run packages/shared/src/issue-thread-interactions.test.ts server/src/__tests__/issue-thread-interaction-routes.test.ts server/src/__tests__/issue-thread-interactions-service.test.ts ui/src/components/IssueThreadInteractionCard.test.tsx ui/src/lib/issue-thread-interactions.test.ts cli/src/__tests__/issue-subresources.test.ts cli/src/__tests__/project-goal.test.ts packages/mcp-server/src/tools.test.ts packages/plugins/sdk/tests/testing-actions.test.ts ``` Result: 8 test files passed, 78 tests passed. - CI on latest head `63b9e55` is green. - Greptile Review passed on latest head; GraphQL review-thread check shows all Greptile threads resolved. ## Risks - Medium surface area because the interaction contract touches shared validators, server routes/services, UI rendering, CLI, MCP, plugin SDK helpers, and docs. - No database migrations are included. - `pnpm-lock.yaml` is intentionally excluded per repository lockfile policy. - UI screenshots are not attached because the task explicitly requested not to add design screenshots or images unless they were part of the work; component tests cover the new rendering and interaction states. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex coding agent based on GPT-5, with repository file access, shell command execution, git/GitHub CLI tooling, and Paperclip control-plane API access. Exact hosted model ID/context-window metadata is not exposed inside this runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
634 lines
24 KiB
TypeScript
634 lines
24 KiB
TypeScript
import { z } from "zod";
|
|
import {
|
|
addIssueCommentSchema,
|
|
askUserQuestionsPayloadSchema,
|
|
checkoutIssueSchema,
|
|
createApprovalSchema,
|
|
createIssueInputSchema,
|
|
issueThreadInteractionContinuationPolicySchema,
|
|
requestCheckboxConfirmationPayloadSchema,
|
|
requestConfirmationPayloadSchema,
|
|
suggestTasksPayloadSchema,
|
|
updateIssueSchema,
|
|
upsertIssueDocumentSchema,
|
|
linkIssueApprovalSchema,
|
|
} from "@paperclipai/shared";
|
|
import { PaperclipApiClient } from "./client.js";
|
|
import { formatErrorResponse, formatTextResponse } from "./format.js";
|
|
|
|
export interface ToolDefinition {
|
|
name: string;
|
|
description: string;
|
|
schema: z.AnyZodObject;
|
|
execute: (input: Record<string, unknown>) => Promise<{
|
|
content: Array<{ type: "text"; text: string }>;
|
|
}>;
|
|
}
|
|
|
|
function makeTool<TSchema extends z.ZodRawShape>(
|
|
name: string,
|
|
description: string,
|
|
schema: z.ZodObject<TSchema>,
|
|
execute: (input: z.infer<typeof schema>) => Promise<unknown>,
|
|
): ToolDefinition {
|
|
return {
|
|
name,
|
|
description,
|
|
schema,
|
|
execute: async (input) => {
|
|
try {
|
|
const parsed = schema.parse(input);
|
|
return formatTextResponse(await execute(parsed));
|
|
} catch (error) {
|
|
return formatErrorResponse(error);
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
function parseOptionalJson(raw: string | undefined | null): unknown {
|
|
if (!raw || raw.trim().length === 0) return undefined;
|
|
return JSON.parse(raw);
|
|
}
|
|
|
|
const companyIdOptional = z.string().uuid().optional().nullable();
|
|
const agentIdOptional = z.string().uuid().optional().nullable();
|
|
const issueIdSchema = z.string().min(1);
|
|
const projectIdSchema = z.string().min(1);
|
|
const goalIdSchema = z.string().uuid();
|
|
const approvalIdSchema = z.string().uuid();
|
|
const documentKeySchema = z.string().trim().min(1).max(64);
|
|
|
|
const listIssuesSchema = z.object({
|
|
companyId: companyIdOptional,
|
|
status: z.string().optional(),
|
|
projectId: z.string().uuid().optional(),
|
|
assigneeAgentId: z.string().uuid().optional(),
|
|
participantAgentId: z.string().uuid().optional(),
|
|
assigneeUserId: z.string().optional(),
|
|
touchedByUserId: z.string().optional(),
|
|
inboxArchivedByUserId: z.string().optional(),
|
|
unreadForUserId: z.string().optional(),
|
|
labelId: z.string().uuid().optional(),
|
|
executionWorkspaceId: z.string().uuid().optional(),
|
|
originKind: z.string().optional(),
|
|
originId: z.string().optional(),
|
|
includeRoutineExecutions: z.boolean().optional(),
|
|
q: z.string().optional(),
|
|
});
|
|
|
|
const listCommentsSchema = z.object({
|
|
issueId: issueIdSchema,
|
|
after: z.string().uuid().optional(),
|
|
order: z.enum(["asc", "desc"]).optional(),
|
|
limit: z.number().int().positive().max(500).optional(),
|
|
});
|
|
|
|
const upsertDocumentToolSchema = z.object({
|
|
issueId: issueIdSchema,
|
|
key: documentKeySchema,
|
|
title: z.string().trim().max(200).nullable().optional(),
|
|
format: z.enum(["markdown"]).default("markdown"),
|
|
body: z.string().max(524288),
|
|
changeSummary: z.string().trim().max(500).nullable().optional(),
|
|
baseRevisionId: z.string().uuid().nullable().optional(),
|
|
});
|
|
|
|
const createIssueToolSchema = z.object({
|
|
companyId: companyIdOptional,
|
|
}).merge(createIssueInputSchema);
|
|
|
|
const updateIssueToolSchema = z.object({
|
|
issueId: issueIdSchema,
|
|
}).merge(updateIssueSchema);
|
|
|
|
const checkoutIssueToolSchema = z.object({
|
|
issueId: issueIdSchema,
|
|
agentId: agentIdOptional,
|
|
expectedStatuses: checkoutIssueSchema.shape.expectedStatuses.optional(),
|
|
});
|
|
|
|
const addCommentToolSchema = z.object({
|
|
issueId: issueIdSchema,
|
|
}).merge(addIssueCommentSchema);
|
|
|
|
const createSuggestTasksToolSchema = z.object({
|
|
issueId: issueIdSchema,
|
|
idempotencyKey: z.string().trim().max(255).nullable().optional(),
|
|
sourceCommentId: z.string().uuid().nullable().optional(),
|
|
sourceRunId: z.string().uuid().nullable().optional(),
|
|
title: z.string().trim().max(240).nullable().optional(),
|
|
summary: z.string().trim().max(1000).nullable().optional(),
|
|
continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("wake_assignee"),
|
|
payload: suggestTasksPayloadSchema,
|
|
});
|
|
|
|
const createAskUserQuestionsToolSchema = z.object({
|
|
issueId: issueIdSchema,
|
|
idempotencyKey: z.string().trim().max(255).nullable().optional(),
|
|
sourceCommentId: z.string().uuid().nullable().optional(),
|
|
sourceRunId: z.string().uuid().nullable().optional(),
|
|
title: z.string().trim().max(240).nullable().optional(),
|
|
summary: z.string().trim().max(1000).nullable().optional(),
|
|
continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("wake_assignee"),
|
|
payload: askUserQuestionsPayloadSchema,
|
|
});
|
|
|
|
const createRequestConfirmationToolSchema = z.object({
|
|
issueId: issueIdSchema,
|
|
idempotencyKey: z.string().trim().max(255).nullable().optional(),
|
|
sourceCommentId: z.string().uuid().nullable().optional(),
|
|
sourceRunId: z.string().uuid().nullable().optional(),
|
|
title: z.string().trim().max(240).nullable().optional(),
|
|
summary: z.string().trim().max(1000).nullable().optional(),
|
|
continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("none"),
|
|
payload: requestConfirmationPayloadSchema,
|
|
});
|
|
|
|
const createRequestCheckboxConfirmationToolSchema = z.object({
|
|
issueId: issueIdSchema,
|
|
idempotencyKey: z.string().trim().max(255).nullable().optional(),
|
|
sourceCommentId: z.string().uuid().nullable().optional(),
|
|
sourceRunId: z.string().uuid().nullable().optional(),
|
|
title: z.string().trim().max(240).nullable().optional(),
|
|
summary: z.string().trim().max(1000).nullable().optional(),
|
|
continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("wake_assignee"),
|
|
payload: requestCheckboxConfirmationPayloadSchema,
|
|
});
|
|
|
|
const approvalDecisionSchema = z.object({
|
|
approvalId: approvalIdSchema,
|
|
action: z.enum(["approve", "reject", "requestRevision", "resubmit"]),
|
|
decisionNote: z.string().optional(),
|
|
payloadJson: z.string().optional(),
|
|
});
|
|
|
|
const createApprovalToolSchema = z.object({
|
|
companyId: companyIdOptional,
|
|
}).merge(createApprovalSchema);
|
|
|
|
const apiRequestSchema = z.object({
|
|
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
|
|
path: z.string().min(1),
|
|
jsonBody: z.string().optional(),
|
|
});
|
|
|
|
const workspaceRuntimeControlTargetSchema = z.object({
|
|
workspaceCommandId: z.string().min(1).optional().nullable(),
|
|
runtimeServiceId: z.string().uuid().optional().nullable(),
|
|
serviceIndex: z.number().int().nonnegative().optional().nullable(),
|
|
});
|
|
|
|
const issueWorkspaceRuntimeControlSchema = z.object({
|
|
issueId: issueIdSchema,
|
|
action: z.enum(["start", "stop", "restart"]),
|
|
}).merge(workspaceRuntimeControlTargetSchema);
|
|
|
|
const waitForIssueWorkspaceServiceSchema = z.object({
|
|
issueId: issueIdSchema,
|
|
runtimeServiceId: z.string().uuid().optional().nullable(),
|
|
serviceName: z.string().min(1).optional().nullable(),
|
|
timeoutSeconds: z.number().int().positive().max(300).optional(),
|
|
});
|
|
|
|
function sleep(ms: number) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function readCurrentExecutionWorkspace(context: unknown): Record<string, unknown> | null {
|
|
if (!context || typeof context !== "object") return null;
|
|
const workspace = (context as { currentExecutionWorkspace?: unknown }).currentExecutionWorkspace;
|
|
return workspace && typeof workspace === "object" ? workspace as Record<string, unknown> : null;
|
|
}
|
|
|
|
function readWorkspaceRuntimeServices(workspace: Record<string, unknown> | null): Array<Record<string, unknown>> {
|
|
const raw = workspace?.runtimeServices;
|
|
return Array.isArray(raw)
|
|
? raw.filter((entry): entry is Record<string, unknown> => Boolean(entry) && typeof entry === "object")
|
|
: [];
|
|
}
|
|
|
|
function selectRuntimeService(
|
|
services: Array<Record<string, unknown>>,
|
|
input: { runtimeServiceId?: string | null; serviceName?: string | null },
|
|
) {
|
|
if (input.runtimeServiceId) {
|
|
return services.find((service) => service.id === input.runtimeServiceId) ?? null;
|
|
}
|
|
if (input.serviceName) {
|
|
return services.find((service) => service.serviceName === input.serviceName) ?? null;
|
|
}
|
|
return services.find((service) => service.status === "running" || service.status === "starting")
|
|
?? services[0]
|
|
?? null;
|
|
}
|
|
|
|
async function getIssueWorkspaceRuntime(client: PaperclipApiClient, issueId: string) {
|
|
const context = await client.requestJson("GET", `/issues/${encodeURIComponent(issueId)}/heartbeat-context`);
|
|
const workspace = readCurrentExecutionWorkspace(context);
|
|
return {
|
|
context,
|
|
workspace,
|
|
runtimeServices: readWorkspaceRuntimeServices(workspace),
|
|
};
|
|
}
|
|
|
|
export function createToolDefinitions(client: PaperclipApiClient): ToolDefinition[] {
|
|
return [
|
|
makeTool(
|
|
"paperclipMe",
|
|
"Get the current authenticated Paperclip actor details",
|
|
z.object({}),
|
|
async () => client.requestJson("GET", "/agents/me"),
|
|
),
|
|
makeTool(
|
|
"paperclipInboxLite",
|
|
"Get the current authenticated agent inbox-lite assignment list",
|
|
z.object({}),
|
|
async () => client.requestJson("GET", "/agents/me/inbox-lite"),
|
|
),
|
|
makeTool(
|
|
"paperclipListAgents",
|
|
"List agents in a company",
|
|
z.object({ companyId: companyIdOptional }),
|
|
async ({ companyId }) => client.requestJson("GET", `/companies/${client.resolveCompanyId(companyId)}/agents`),
|
|
),
|
|
makeTool(
|
|
"paperclipGetAgent",
|
|
"Get a single agent by id",
|
|
z.object({ agentId: z.string().min(1), companyId: companyIdOptional }),
|
|
async ({ agentId, companyId }) => {
|
|
const qs = companyId ? `?companyId=${encodeURIComponent(companyId)}` : "";
|
|
return client.requestJson("GET", `/agents/${encodeURIComponent(agentId)}${qs}`);
|
|
},
|
|
),
|
|
makeTool(
|
|
"paperclipListIssues",
|
|
"List issues for a company with optional filters",
|
|
listIssuesSchema,
|
|
async (input) => {
|
|
const companyId = client.resolveCompanyId(input.companyId);
|
|
const params = new URLSearchParams();
|
|
for (const [key, value] of Object.entries(input)) {
|
|
if (key === "companyId" || value === undefined || value === null) continue;
|
|
params.set(key, String(value));
|
|
}
|
|
const qs = params.toString();
|
|
return client.requestJson("GET", `/companies/${companyId}/issues${qs ? `?${qs}` : ""}`);
|
|
},
|
|
),
|
|
makeTool(
|
|
"paperclipGetIssue",
|
|
"Get a single issue by UUID or identifier",
|
|
z.object({ issueId: issueIdSchema }),
|
|
async ({ issueId }) => client.requestJson("GET", `/issues/${encodeURIComponent(issueId)}`),
|
|
),
|
|
makeTool(
|
|
"paperclipGetHeartbeatContext",
|
|
"Get compact heartbeat context for an issue",
|
|
z.object({ issueId: issueIdSchema, wakeCommentId: z.string().uuid().optional() }),
|
|
async ({ issueId, wakeCommentId }) => {
|
|
const qs = wakeCommentId ? `?wakeCommentId=${encodeURIComponent(wakeCommentId)}` : "";
|
|
return client.requestJson("GET", `/issues/${encodeURIComponent(issueId)}/heartbeat-context${qs}`);
|
|
},
|
|
),
|
|
makeTool(
|
|
"paperclipListComments",
|
|
"List issue comments with incremental options",
|
|
listCommentsSchema,
|
|
async ({ issueId, after, order, limit }) => {
|
|
const params = new URLSearchParams();
|
|
if (after) params.set("after", after);
|
|
if (order) params.set("order", order);
|
|
if (limit) params.set("limit", String(limit));
|
|
const qs = params.toString();
|
|
return client.requestJson("GET", `/issues/${encodeURIComponent(issueId)}/comments${qs ? `?${qs}` : ""}`);
|
|
},
|
|
),
|
|
makeTool(
|
|
"paperclipGetComment",
|
|
"Get a specific issue comment by id",
|
|
z.object({ issueId: issueIdSchema, commentId: z.string().uuid() }),
|
|
async ({ issueId, commentId }) =>
|
|
client.requestJson("GET", `/issues/${encodeURIComponent(issueId)}/comments/${encodeURIComponent(commentId)}`),
|
|
),
|
|
makeTool(
|
|
"paperclipListIssueApprovals",
|
|
"List approvals linked to an issue",
|
|
z.object({ issueId: issueIdSchema }),
|
|
async ({ issueId }) => client.requestJson("GET", `/issues/${encodeURIComponent(issueId)}/approvals`),
|
|
),
|
|
makeTool(
|
|
"paperclipListDocuments",
|
|
"List issue documents",
|
|
z.object({ issueId: issueIdSchema }),
|
|
async ({ issueId }) => client.requestJson("GET", `/issues/${encodeURIComponent(issueId)}/documents`),
|
|
),
|
|
makeTool(
|
|
"paperclipGetDocument",
|
|
"Get one issue document by key",
|
|
z.object({ issueId: issueIdSchema, key: documentKeySchema }),
|
|
async ({ issueId, key }) =>
|
|
client.requestJson("GET", `/issues/${encodeURIComponent(issueId)}/documents/${encodeURIComponent(key)}`),
|
|
),
|
|
makeTool(
|
|
"paperclipListDocumentRevisions",
|
|
"List revisions for an issue document",
|
|
z.object({ issueId: issueIdSchema, key: documentKeySchema }),
|
|
async ({ issueId, key }) =>
|
|
client.requestJson(
|
|
"GET",
|
|
`/issues/${encodeURIComponent(issueId)}/documents/${encodeURIComponent(key)}/revisions`,
|
|
),
|
|
),
|
|
makeTool(
|
|
"paperclipListProjects",
|
|
"List projects in a company",
|
|
z.object({ companyId: companyIdOptional }),
|
|
async ({ companyId }) => client.requestJson("GET", `/companies/${client.resolveCompanyId(companyId)}/projects`),
|
|
),
|
|
makeTool(
|
|
"paperclipGetProject",
|
|
"Get a project by id or company-scoped short reference",
|
|
z.object({ projectId: projectIdSchema, companyId: companyIdOptional }),
|
|
async ({ projectId, companyId }) => {
|
|
const qs = companyId ? `?companyId=${encodeURIComponent(companyId)}` : "";
|
|
return client.requestJson("GET", `/projects/${encodeURIComponent(projectId)}${qs}`);
|
|
},
|
|
),
|
|
makeTool(
|
|
"paperclipGetIssueWorkspaceRuntime",
|
|
"Get the current execution workspace and runtime services for an issue, including service URLs",
|
|
z.object({ issueId: issueIdSchema }),
|
|
async ({ issueId }) => getIssueWorkspaceRuntime(client, issueId),
|
|
),
|
|
makeTool(
|
|
"paperclipControlIssueWorkspaceServices",
|
|
"Start, stop, or restart the current issue execution workspace runtime services",
|
|
issueWorkspaceRuntimeControlSchema,
|
|
async ({ issueId, action, ...target }) => {
|
|
const runtime = await getIssueWorkspaceRuntime(client, issueId);
|
|
const workspaceId = typeof runtime.workspace?.id === "string" ? runtime.workspace.id : null;
|
|
if (!workspaceId) {
|
|
throw new Error("Issue has no current execution workspace");
|
|
}
|
|
return client.requestJson(
|
|
"POST",
|
|
`/execution-workspaces/${encodeURIComponent(workspaceId)}/runtime-services/${action}`,
|
|
{ body: target },
|
|
);
|
|
},
|
|
),
|
|
makeTool(
|
|
"paperclipWaitForIssueWorkspaceService",
|
|
"Wait until an issue execution workspace runtime service is running and has a URL when one is exposed",
|
|
waitForIssueWorkspaceServiceSchema,
|
|
async ({ issueId, runtimeServiceId, serviceName, timeoutSeconds }) => {
|
|
const deadline = Date.now() + (timeoutSeconds ?? 60) * 1000;
|
|
let latest: Awaited<ReturnType<typeof getIssueWorkspaceRuntime>> | null = null;
|
|
while (Date.now() <= deadline) {
|
|
latest = await getIssueWorkspaceRuntime(client, issueId);
|
|
const service = selectRuntimeService(latest.runtimeServices, { runtimeServiceId, serviceName });
|
|
if (service?.status === "running" && service.healthStatus !== "unhealthy") {
|
|
return {
|
|
workspace: latest.workspace,
|
|
service,
|
|
};
|
|
}
|
|
await sleep(1000);
|
|
}
|
|
|
|
return {
|
|
timedOut: true,
|
|
latestWorkspace: latest?.workspace ?? null,
|
|
latestRuntimeServices: latest?.runtimeServices ?? [],
|
|
};
|
|
},
|
|
),
|
|
makeTool(
|
|
"paperclipListGoals",
|
|
"List goals in a company",
|
|
z.object({ companyId: companyIdOptional }),
|
|
async ({ companyId }) => client.requestJson("GET", `/companies/${client.resolveCompanyId(companyId)}/goals`),
|
|
),
|
|
makeTool(
|
|
"paperclipGetGoal",
|
|
"Get a goal by id",
|
|
z.object({ goalId: goalIdSchema }),
|
|
async ({ goalId }) => client.requestJson("GET", `/goals/${encodeURIComponent(goalId)}`),
|
|
),
|
|
makeTool(
|
|
"paperclipListApprovals",
|
|
"List approvals in a company",
|
|
z.object({ companyId: companyIdOptional, status: z.string().optional() }),
|
|
async ({ companyId, status }) => {
|
|
const qs = status ? `?status=${encodeURIComponent(status)}` : "";
|
|
return client.requestJson("GET", `/companies/${client.resolveCompanyId(companyId)}/approvals${qs}`);
|
|
},
|
|
),
|
|
makeTool(
|
|
"paperclipCreateApproval",
|
|
"Create a board approval request, optionally linked to one or more issues",
|
|
createApprovalToolSchema,
|
|
async ({ companyId, ...body }) =>
|
|
client.requestJson("POST", `/companies/${client.resolveCompanyId(companyId)}/approvals`, {
|
|
body,
|
|
}),
|
|
),
|
|
makeTool(
|
|
"paperclipGetApproval",
|
|
"Get an approval by id",
|
|
z.object({ approvalId: approvalIdSchema }),
|
|
async ({ approvalId }) => client.requestJson("GET", `/approvals/${encodeURIComponent(approvalId)}`),
|
|
),
|
|
makeTool(
|
|
"paperclipGetApprovalIssues",
|
|
"List issues linked to an approval",
|
|
z.object({ approvalId: approvalIdSchema }),
|
|
async ({ approvalId }) => client.requestJson("GET", `/approvals/${encodeURIComponent(approvalId)}/issues`),
|
|
),
|
|
makeTool(
|
|
"paperclipListApprovalComments",
|
|
"List comments for an approval",
|
|
z.object({ approvalId: approvalIdSchema }),
|
|
async ({ approvalId }) => client.requestJson("GET", `/approvals/${encodeURIComponent(approvalId)}/comments`),
|
|
),
|
|
makeTool(
|
|
"paperclipCreateIssue",
|
|
"Create a new issue",
|
|
createIssueToolSchema,
|
|
async ({ companyId, ...body }) =>
|
|
client.requestJson("POST", `/companies/${client.resolveCompanyId(companyId)}/issues`, { body }),
|
|
),
|
|
makeTool(
|
|
"paperclipUpdateIssue",
|
|
"Patch an issue, optionally including a comment; include resume=true when intentionally requesting follow-up on resumable closed work",
|
|
updateIssueToolSchema,
|
|
async ({ issueId, ...body }) =>
|
|
client.requestJson("PATCH", `/issues/${encodeURIComponent(issueId)}`, { body }),
|
|
),
|
|
makeTool(
|
|
"paperclipCheckoutIssue",
|
|
"Checkout an issue for an agent",
|
|
checkoutIssueToolSchema,
|
|
async ({ issueId, agentId, expectedStatuses }) =>
|
|
client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/checkout`, {
|
|
body: {
|
|
agentId: client.resolveAgentId(agentId),
|
|
expectedStatuses: expectedStatuses ?? ["todo", "backlog", "blocked"],
|
|
},
|
|
}),
|
|
),
|
|
makeTool(
|
|
"paperclipReleaseIssue",
|
|
"Release an issue checkout",
|
|
z.object({ issueId: issueIdSchema }),
|
|
async ({ issueId }) => client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/release`, { body: {} }),
|
|
),
|
|
makeTool(
|
|
"paperclipAddComment",
|
|
"Add a comment to an issue; include resume=true when intentionally requesting follow-up on resumable closed work",
|
|
addCommentToolSchema,
|
|
async ({ issueId, ...body }) =>
|
|
client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/comments`, { body }),
|
|
),
|
|
makeTool(
|
|
"paperclipSuggestTasks",
|
|
"Create a suggest_tasks interaction on an issue",
|
|
createSuggestTasksToolSchema,
|
|
async ({ issueId, ...body }) =>
|
|
client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/interactions`, {
|
|
body: {
|
|
kind: "suggest_tasks",
|
|
...body,
|
|
},
|
|
}),
|
|
),
|
|
makeTool(
|
|
"paperclipAskUserQuestions",
|
|
"Create an ask_user_questions interaction on an issue",
|
|
createAskUserQuestionsToolSchema,
|
|
async ({ issueId, ...body }) =>
|
|
client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/interactions`, {
|
|
body: {
|
|
kind: "ask_user_questions",
|
|
...body,
|
|
},
|
|
}),
|
|
),
|
|
makeTool(
|
|
"paperclipRequestConfirmation",
|
|
"Create a request_confirmation interaction on an issue",
|
|
createRequestConfirmationToolSchema,
|
|
async ({ issueId, ...body }) =>
|
|
client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/interactions`, {
|
|
body: {
|
|
kind: "request_confirmation",
|
|
...body,
|
|
},
|
|
}),
|
|
),
|
|
makeTool(
|
|
"paperclipRequestCheckboxConfirmation",
|
|
"Create a request_checkbox_confirmation interaction on an issue",
|
|
createRequestCheckboxConfirmationToolSchema,
|
|
async ({ issueId, ...body }) =>
|
|
client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/interactions`, {
|
|
body: {
|
|
kind: "request_checkbox_confirmation",
|
|
...body,
|
|
},
|
|
}),
|
|
),
|
|
makeTool(
|
|
"paperclipUpsertIssueDocument",
|
|
"Create or update an issue document",
|
|
upsertDocumentToolSchema,
|
|
async ({ issueId, key, ...body }) =>
|
|
client.requestJson(
|
|
"PUT",
|
|
`/issues/${encodeURIComponent(issueId)}/documents/${encodeURIComponent(key)}`,
|
|
{ body },
|
|
),
|
|
),
|
|
makeTool(
|
|
"paperclipRestoreIssueDocumentRevision",
|
|
"Restore a prior revision of an issue document",
|
|
z.object({
|
|
issueId: issueIdSchema,
|
|
key: documentKeySchema,
|
|
revisionId: z.string().uuid(),
|
|
}),
|
|
async ({ issueId, key, revisionId }) =>
|
|
client.requestJson(
|
|
"POST",
|
|
`/issues/${encodeURIComponent(issueId)}/documents/${encodeURIComponent(key)}/revisions/${encodeURIComponent(revisionId)}/restore`,
|
|
{ body: {} },
|
|
),
|
|
),
|
|
makeTool(
|
|
"paperclipLinkIssueApproval",
|
|
"Link an approval to an issue",
|
|
z.object({ issueId: issueIdSchema }).merge(linkIssueApprovalSchema),
|
|
async ({ issueId, approvalId }) =>
|
|
client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/approvals`, {
|
|
body: { approvalId },
|
|
}),
|
|
),
|
|
makeTool(
|
|
"paperclipUnlinkIssueApproval",
|
|
"Unlink an approval from an issue",
|
|
z.object({ issueId: issueIdSchema, approvalId: approvalIdSchema }),
|
|
async ({ issueId, approvalId }) =>
|
|
client.requestJson(
|
|
"DELETE",
|
|
`/issues/${encodeURIComponent(issueId)}/approvals/${encodeURIComponent(approvalId)}`,
|
|
),
|
|
),
|
|
makeTool(
|
|
"paperclipApprovalDecision",
|
|
"Approve, reject, request revision, or resubmit an approval",
|
|
approvalDecisionSchema,
|
|
async ({ approvalId, action, decisionNote, payloadJson }) => {
|
|
const path =
|
|
action === "approve"
|
|
? `/approvals/${encodeURIComponent(approvalId)}/approve`
|
|
: action === "reject"
|
|
? `/approvals/${encodeURIComponent(approvalId)}/reject`
|
|
: action === "requestRevision"
|
|
? `/approvals/${encodeURIComponent(approvalId)}/request-revision`
|
|
: `/approvals/${encodeURIComponent(approvalId)}/resubmit`;
|
|
|
|
const body =
|
|
action === "resubmit"
|
|
? { payload: parseOptionalJson(payloadJson) ?? {} }
|
|
: { decisionNote };
|
|
|
|
return client.requestJson("POST", path, { body });
|
|
},
|
|
),
|
|
makeTool(
|
|
"paperclipAddApprovalComment",
|
|
"Add a comment to an approval",
|
|
z.object({ approvalId: approvalIdSchema, body: z.string().min(1) }),
|
|
async ({ approvalId, body }) =>
|
|
client.requestJson("POST", `/approvals/${encodeURIComponent(approvalId)}/comments`, {
|
|
body: { body },
|
|
}),
|
|
),
|
|
makeTool(
|
|
"paperclipApiRequest",
|
|
"Make a JSON request to an existing Paperclip /api endpoint for unsupported operations",
|
|
apiRequestSchema,
|
|
async ({ method, path, jsonBody }) => {
|
|
if (!path.startsWith("/") || path.includes("..")) {
|
|
throw new Error("path must start with / and be relative to /api, and must not contain '..'");
|
|
}
|
|
return client.requestJson(method, path, {
|
|
body: parseOptionalJson(jsonBody),
|
|
});
|
|
},
|
|
),
|
|
];
|
|
}
|