import { z } from "zod"; import { addIssueCommentSchema, askUserQuestionsPayloadSchema, checkoutIssueSchema, createApprovalSchema, createIssueSchema, issueThreadInteractionContinuationPolicySchema, 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) => Promise<{ content: Array<{ type: "text"; text: string }>; }>; } function makeTool( name: string, description: string, schema: z.ZodObject, execute: (input: z.infer) => Promise, ): 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(createIssueSchema); 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 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 | null { if (!context || typeof context !== "object") return null; const workspace = (context as { currentExecutionWorkspace?: unknown }).currentExecutionWorkspace; return workspace && typeof workspace === "object" ? workspace as Record : null; } function readWorkspaceRuntimeServices(workspace: Record | null): Array> { const raw = workspace?.runtimeServices; return Array.isArray(raw) ? raw.filter((entry): entry is Record => Boolean(entry) && typeof entry === "object") : []; } function selectRuntimeService( services: Array>, 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> | 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( "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), }); }, ), ]; }