From d900bd118e4946c59618b4d9f4085a2a36303838 Mon Sep 17 00:00:00 2001 From: Tom Phillips Date: Wed, 1 Jul 2026 21:53:21 +0100 Subject: [PATCH 01/10] Add LoopQuest: package.json --- components/loopquest/package.json | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 components/loopquest/package.json diff --git a/components/loopquest/package.json b/components/loopquest/package.json new file mode 100644 index 0000000000000..fdc2ec74401c7 --- /dev/null +++ b/components/loopquest/package.json @@ -0,0 +1,19 @@ +{ + "name": "@pipedream/loopquest", + "version": "0.0.1", + "description": "Pipedream components for LoopQuest — human-in-the-loop review.", + "main": "loopquest.app.mjs", + "type": "module", + "keywords": [ + "pipedream", + "loopquest" + ], + "homepage": "https://pipedream.com/apps/loopquest", + "author": "Pipedream (https://pipedream.com/)", + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@pipedream/platform": "^3.1.1" + } +} From c0ee31076344e78dc5e08a03206152fb671df661 Mon Sep 17 00:00:00 2001 From: Tom Phillips Date: Wed, 1 Jul 2026 21:53:22 +0100 Subject: [PATCH 02/10] Add LoopQuest: loopquest.app.mjs --- components/loopquest/loopquest.app.mjs | 89 ++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 components/loopquest/loopquest.app.mjs diff --git a/components/loopquest/loopquest.app.mjs b/components/loopquest/loopquest.app.mjs new file mode 100644 index 0000000000000..c8b61ee8bc830 --- /dev/null +++ b/components/loopquest/loopquest.app.mjs @@ -0,0 +1,89 @@ +import { axios } from "@pipedream/platform"; +import { buildTaskBody } from "./common/body.mjs"; + +export default { + type: "app", + app: "loopquest", + propDefinitions: { + content: { type: "string", label: "Content", description: "The AI/automation output a human should review." }, + title: { type: "string", label: "Title", optional: true }, + module: { + type: "string", + label: "Game", + optional: true, + default: "swiper", + description: "How the reviewer sees the item.", + options: [ + { label: "Swiper — approve or reject", value: "swiper" }, + { label: "Versus — pick the better of two", value: "versus" }, + { label: "Sorter — bucket into categories", value: "sorter" }, + { label: "Detective — spot the problem", value: "detective" }, + { label: "Fixer — correct the output", value: "fixer" }, + { label: "Redact — mask sensitive text", value: "redact" }, + { label: "Grounding — verify a claim against a source", value: "grounding" }, + ], + }, + mode: { + type: "string", + label: "Mode", + optional: true, + default: "monitor", + description: "`gate` blocks a downstream step until a human approves (pair with the New Verdict trigger). `monitor` reviews in the background without pausing.", + options: ["monitor", "gate"], + }, + claim: { type: "string", label: "Claim", optional: true, description: "Grounding only: the statement to verify." }, + sourceText: { type: "string", label: "Source Text", optional: true, description: "Grounding only: the reference text the claim is checked against." }, + timeoutSeconds: { type: "integer", label: "Timeout (seconds)", optional: true, description: "Gate only: apply the fallback if no one reviews in time (30–2592000)." }, + onTimeout: { + type: "string", + label: "On Timeout", + optional: true, + description: "Gate only: what to do if the timeout is hit. Defaults to escalate (fail-closed).", + options: ["escalate", "reject", "approve"], + }, + source: { type: "string", label: "Source", optional: true, default: "pipedream", description: "A label for where this came from." }, + externalId: { type: "string", label: "External ID", optional: true, description: "Your own id for the item — echoed back in the verdict so you can correlate it." }, + callbackUrl: { type: "string", label: "Callback URL", optional: true, description: "Optional. A single webhook for this task's verdict. Leave blank if you use the New Verdict trigger." }, + reviewsRequired: { type: "integer", label: "Reviewers Required", optional: true }, + taskId: { type: "string", label: "Task ID" }, + }, + methods: { + _baseUrl() { + return (this.$auth.base_url || "https://loopquest.tomphillips.uk").replace(/\/+$/, ""); + }, + _headers() { + return { authorization: `Bearer ${this.$auth.api_key}`, "content-type": "application/json" }; + }, + async createTask({ $, props }) { + return axios($, { + method: "POST", + url: `${this._baseUrl()}/api/v1/tasks`, + headers: this._headers(), + data: buildTaskBody(props), + }); + }, + async getTask({ $, taskId }) { + return axios($, { + method: "GET", + url: `${this._baseUrl()}/api/v1/tasks/${taskId}`, + headers: this._headers(), + }); + }, + // Verdict subscriptions — power the New Verdict source (REST-hook style). + async subscribeVerdicts($, url) { + return axios($, { + method: "POST", + url: `${this._baseUrl()}/api/v1/hooks`, + headers: this._headers(), + data: { url }, + }); + }, + async unsubscribeVerdicts($, id) { + return axios($, { + method: "DELETE", + url: `${this._baseUrl()}/api/v1/hooks/${id}`, + headers: this._headers(), + }); + }, + }, +}; From 877113febc2a1173bae3e771431ffb871880652f Mon Sep 17 00:00:00 2001 From: Tom Phillips Date: Wed, 1 Jul 2026 21:53:23 +0100 Subject: [PATCH 03/10] Add LoopQuest: common/body.mjs --- components/loopquest/common/body.mjs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 components/loopquest/common/body.mjs diff --git a/components/loopquest/common/body.mjs b/components/loopquest/common/body.mjs new file mode 100644 index 0000000000000..5d3c7094bbd7e --- /dev/null +++ b/components/loopquest/common/body.mjs @@ -0,0 +1,20 @@ +/** Build the POST /api/v1/tasks body from action props. Pure — unit tested. */ +export function buildTaskBody(p = {}) { + const payload = { content: p.content, body: p.content }; + if (p.claim) payload.claim = p.claim; + if (p.sourceText) payload.source = p.sourceText; + + const body = { + module: p.module || "swiper", + mode: p.mode || "monitor", + payload, + card: { title: p.title || "Review", body: p.content }, + }; + if (p.source) body.source = p.source; + if (p.externalId) body.external_id = p.externalId; + if (p.callbackUrl) body.callback_url = p.callbackUrl; + if (p.timeoutSeconds) body.timeout_seconds = p.timeoutSeconds; + if (p.onTimeout) body.on_timeout = p.onTimeout; + if (p.reviewsRequired) body.reviews_required = p.reviewsRequired; + return body; +} From 7f67f63454a9769065661e2f723181c0ecd17316 Mon Sep 17 00:00:00 2001 From: Tom Phillips Date: Wed, 1 Jul 2026 21:53:24 +0100 Subject: [PATCH 04/10] Add LoopQuest: actions/create-review-task/create-review-task.mjs --- .../create-review-task/create-review-task.mjs | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 components/loopquest/actions/create-review-task/create-review-task.mjs diff --git a/components/loopquest/actions/create-review-task/create-review-task.mjs b/components/loopquest/actions/create-review-task/create-review-task.mjs new file mode 100644 index 0000000000000..522583d13136a --- /dev/null +++ b/components/loopquest/actions/create-review-task/create-review-task.mjs @@ -0,0 +1,45 @@ +import app from "../../loopquest.app.mjs"; + +export default { + key: "loopquest-create-review-task", + name: "Create Review Task", + description: "Send AI/automation output to a human. Gate a downstream action until it's approved, or monitor quality in the background. [See the docs](https://loopquest.tomphillips.uk/docs).", + version: "0.0.1", + type: "action", + props: { + loopquest: app, + content: { propDefinition: [app, "content"] }, + title: { propDefinition: [app, "title"] }, + module: { propDefinition: [app, "module"] }, + mode: { propDefinition: [app, "mode"] }, + claim: { propDefinition: [app, "claim"] }, + sourceText: { propDefinition: [app, "sourceText"] }, + timeoutSeconds: { propDefinition: [app, "timeoutSeconds"] }, + onTimeout: { propDefinition: [app, "onTimeout"] }, + source: { propDefinition: [app, "source"] }, + externalId: { propDefinition: [app, "externalId"] }, + callbackUrl: { propDefinition: [app, "callbackUrl"] }, + reviewsRequired: { propDefinition: [app, "reviewsRequired"] }, + }, + async run({ $ }) { + const res = await this.loopquest.createTask({ + $, + props: { + content: this.content, + title: this.title, + module: this.module, + mode: this.mode, + claim: this.claim, + sourceText: this.sourceText, + timeoutSeconds: this.timeoutSeconds, + onTimeout: this.onTimeout, + source: this.source, + externalId: this.externalId, + callbackUrl: this.callbackUrl, + reviewsRequired: this.reviewsRequired, + }, + }); + $.export("$summary", `Submitted review task ${res.id}`); + return res; + }, +}; From 561cdb6450f8b6283ba2600b52af9914093d6155 Mon Sep 17 00:00:00 2001 From: Tom Phillips Date: Wed, 1 Jul 2026 21:53:26 +0100 Subject: [PATCH 05/10] Add LoopQuest: actions/get-task-status/get-task-status.mjs --- .../get-task-status/get-task-status.mjs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 components/loopquest/actions/get-task-status/get-task-status.mjs diff --git a/components/loopquest/actions/get-task-status/get-task-status.mjs b/components/loopquest/actions/get-task-status/get-task-status.mjs new file mode 100644 index 0000000000000..b97e256a16f61 --- /dev/null +++ b/components/loopquest/actions/get-task-status/get-task-status.mjs @@ -0,0 +1,18 @@ +import app from "../../loopquest.app.mjs"; + +export default { + key: "loopquest-get-task-status", + name: "Get Task Status", + description: "Check a LoopQuest task's status / verdict. [See the docs](https://loopquest.tomphillips.uk/docs).", + version: "0.0.1", + type: "action", + props: { + loopquest: app, + taskId: { propDefinition: [app, "taskId"] }, + }, + async run({ $ }) { + const res = await this.loopquest.getTask({ $, taskId: this.taskId }); + $.export("$summary", `Task ${this.taskId} is ${res.status}`); + return res; + }, +}; From a0f73d844ca56a62843667e713d40b7f0441ddab Mon Sep 17 00:00:00 2001 From: Tom Phillips Date: Wed, 1 Jul 2026 21:53:27 +0100 Subject: [PATCH 06/10] Add LoopQuest: sources/new-verdict/new-verdict.mjs --- .../sources/new-verdict/new-verdict.mjs | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 components/loopquest/sources/new-verdict/new-verdict.mjs diff --git a/components/loopquest/sources/new-verdict/new-verdict.mjs b/components/loopquest/sources/new-verdict/new-verdict.mjs new file mode 100644 index 0000000000000..9bc35c99a8955 --- /dev/null +++ b/components/loopquest/sources/new-verdict/new-verdict.mjs @@ -0,0 +1,46 @@ +import app from "../../loopquest.app.mjs"; + +export default { + key: "loopquest-new-verdict", + name: "New Verdict", + description: "Emit an event the moment a human reviewer resolves a task — approve, flag, escalate or timeout. Use it to resume a gated action or act on a monitored review. [See the docs](https://loopquest.tomphillips.uk/docs).", + version: "0.0.1", + type: "source", + dedupe: "unique", + props: { + loopquest: app, + http: { type: "$.interface.http", customResponse: true }, + db: "$.service.db", + }, + hooks: { + // Register this source's HTTP endpoint as a verdict subscription. LoopQuest + // then POSTs every resolved verdict here (idempotent by URL server-side). + async activate() { + const { id } = await this.loopquest.subscribeVerdicts(this, this.http.endpoint); + this.db.set("hookId", id); + }, + async deactivate() { + const hookId = this.db.get("hookId"); + if (hookId) await this.loopquest.unsubscribeVerdicts(this, hookId); + }, + }, + async run(event) { + // Ack immediately so LoopQuest marks the delivery successful. + this.http.respond({ status: 200 }); + + const body = event.body; + if (!body || !body.task_id) return; + + const verdict = + body.verdict === true ? "approved" + : body.verdict === false ? "flagged" + : body.escalated ? "escalated" + : "resolved"; + + this.$emit(body, { + id: body.task_id, + summary: `Verdict: ${verdict}${body.external_id ? ` (${body.external_id})` : ""}`, + ts: Date.now(), + }); + }, +}; From 6c16e47256e7c88efd8f16886641daa8293af133 Mon Sep 17 00:00:00 2001 From: Tom Phillips Date: Wed, 1 Jul 2026 22:13:14 +0100 Subject: [PATCH 07/10] Address CodeRabbit review: loopquest.app.mjs --- components/loopquest/loopquest.app.mjs | 40 ++++++++++---------------- 1 file changed, 15 insertions(+), 25 deletions(-) diff --git a/components/loopquest/loopquest.app.mjs b/components/loopquest/loopquest.app.mjs index c8b61ee8bc830..b76d8962cec26 100644 --- a/components/loopquest/loopquest.app.mjs +++ b/components/loopquest/loopquest.app.mjs @@ -6,7 +6,7 @@ export default { app: "loopquest", propDefinitions: { content: { type: "string", label: "Content", description: "The AI/automation output a human should review." }, - title: { type: "string", label: "Title", optional: true }, + title: { type: "string", label: "Title", optional: true, description: "Optional heading shown on the review card." }, module: { type: "string", label: "Game", @@ -44,8 +44,8 @@ export default { source: { type: "string", label: "Source", optional: true, default: "pipedream", description: "A label for where this came from." }, externalId: { type: "string", label: "External ID", optional: true, description: "Your own id for the item — echoed back in the verdict so you can correlate it." }, callbackUrl: { type: "string", label: "Callback URL", optional: true, description: "Optional. A single webhook for this task's verdict. Leave blank if you use the New Verdict trigger." }, - reviewsRequired: { type: "integer", label: "Reviewers Required", optional: true }, - taskId: { type: "string", label: "Task ID" }, + reviewsRequired: { type: "integer", label: "Reviewers Required", optional: true, description: "How many people must review before the verdict resolves. Defaults to 1." }, + taskId: { type: "string", label: "Task ID", description: "The task id returned by **Create Review Task** — used by **Get Task Status**." }, }, methods: { _baseUrl() { @@ -54,36 +54,26 @@ export default { _headers() { return { authorization: `Bearer ${this.$auth.api_key}`, "content-type": "application/json" }; }, - async createTask({ $, props }) { + // Single place that constructs the axios call — every method delegates here. + _makeRequest({ $, path, ...opts }) { return axios($, { - method: "POST", - url: `${this._baseUrl()}/api/v1/tasks`, + url: `${this._baseUrl()}${path}`, headers: this._headers(), - data: buildTaskBody(props), + ...opts, }); }, + async createTask({ $, props }) { + return this._makeRequest({ $, method: "POST", path: "/api/v1/tasks", data: buildTaskBody(props) }); + }, async getTask({ $, taskId }) { - return axios($, { - method: "GET", - url: `${this._baseUrl()}/api/v1/tasks/${taskId}`, - headers: this._headers(), - }); + return this._makeRequest({ $, method: "GET", path: `/api/v1/tasks/${taskId}` }); }, // Verdict subscriptions — power the New Verdict source (REST-hook style). - async subscribeVerdicts($, url) { - return axios($, { - method: "POST", - url: `${this._baseUrl()}/api/v1/hooks`, - headers: this._headers(), - data: { url }, - }); + async subscribeVerdicts({ $, url }) { + return this._makeRequest({ $, method: "POST", path: "/api/v1/hooks", data: { url } }); }, - async unsubscribeVerdicts($, id) { - return axios($, { - method: "DELETE", - url: `${this._baseUrl()}/api/v1/hooks/${id}`, - headers: this._headers(), - }); + async unsubscribeVerdicts({ $, id }) { + return this._makeRequest({ $, method: "DELETE", path: `/api/v1/hooks/${id}` }); }, }, }; From 250fd222e3dd032b050762cc619c46f01be4032a Mon Sep 17 00:00:00 2001 From: Tom Phillips Date: Wed, 1 Jul 2026 22:13:15 +0100 Subject: [PATCH 08/10] Address CodeRabbit review: actions/create-review-task/create-review-task.mjs --- .../actions/create-review-task/create-review-task.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/components/loopquest/actions/create-review-task/create-review-task.mjs b/components/loopquest/actions/create-review-task/create-review-task.mjs index 522583d13136a..1a68137b345bf 100644 --- a/components/loopquest/actions/create-review-task/create-review-task.mjs +++ b/components/loopquest/actions/create-review-task/create-review-task.mjs @@ -3,9 +3,14 @@ import app from "../../loopquest.app.mjs"; export default { key: "loopquest-create-review-task", name: "Create Review Task", - description: "Send AI/automation output to a human. Gate a downstream action until it's approved, or monitor quality in the background. [See the docs](https://loopquest.tomphillips.uk/docs).", + description: "Send AI/automation output to a human. Gate a downstream action until it's approved, or monitor quality in the background. [See the documentation](https://loopquest.tomphillips.uk/docs).", version: "0.0.1", type: "action", + annotations: { + readOnlyHint: false, + destructiveHint: false, + openWorldHint: true, + }, props: { loopquest: app, content: { propDefinition: [app, "content"] }, From 510c357465368fd1e9eb7edf6c05b4c04683edbf Mon Sep 17 00:00:00 2001 From: Tom Phillips Date: Wed, 1 Jul 2026 22:13:16 +0100 Subject: [PATCH 09/10] Address CodeRabbit review: actions/get-task-status/get-task-status.mjs --- .../loopquest/actions/get-task-status/get-task-status.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/components/loopquest/actions/get-task-status/get-task-status.mjs b/components/loopquest/actions/get-task-status/get-task-status.mjs index b97e256a16f61..bf8da1bcb2ece 100644 --- a/components/loopquest/actions/get-task-status/get-task-status.mjs +++ b/components/loopquest/actions/get-task-status/get-task-status.mjs @@ -3,9 +3,14 @@ import app from "../../loopquest.app.mjs"; export default { key: "loopquest-get-task-status", name: "Get Task Status", - description: "Check a LoopQuest task's status / verdict. [See the docs](https://loopquest.tomphillips.uk/docs).", + description: "Check a LoopQuest task's status / verdict. [See the documentation](https://loopquest.tomphillips.uk/docs).", version: "0.0.1", type: "action", + annotations: { + readOnlyHint: true, + destructiveHint: false, + openWorldHint: true, + }, props: { loopquest: app, taskId: { propDefinition: [app, "taskId"] }, From 75760e7f5e39126304d19b604e726afbab0067d6 Mon Sep 17 00:00:00 2001 From: Tom Phillips Date: Wed, 1 Jul 2026 22:13:18 +0100 Subject: [PATCH 10/10] Address CodeRabbit review: sources/new-verdict/new-verdict.mjs --- .../loopquest/sources/new-verdict/new-verdict.mjs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/components/loopquest/sources/new-verdict/new-verdict.mjs b/components/loopquest/sources/new-verdict/new-verdict.mjs index 9bc35c99a8955..1dbab9d07eeac 100644 --- a/components/loopquest/sources/new-verdict/new-verdict.mjs +++ b/components/loopquest/sources/new-verdict/new-verdict.mjs @@ -3,7 +3,7 @@ import app from "../../loopquest.app.mjs"; export default { key: "loopquest-new-verdict", name: "New Verdict", - description: "Emit an event the moment a human reviewer resolves a task — approve, flag, escalate or timeout. Use it to resume a gated action or act on a monitored review. [See the docs](https://loopquest.tomphillips.uk/docs).", + description: "Emit an event the moment a human reviewer resolves a task — approve, flag, escalate or timeout. Use it to resume a gated action or act on a monitored review. [See the documentation](https://loopquest.tomphillips.uk/docs).", version: "0.0.1", type: "source", dedupe: "unique", @@ -16,12 +16,12 @@ export default { // Register this source's HTTP endpoint as a verdict subscription. LoopQuest // then POSTs every resolved verdict here (idempotent by URL server-side). async activate() { - const { id } = await this.loopquest.subscribeVerdicts(this, this.http.endpoint); + const { id } = await this.loopquest.subscribeVerdicts({ $: this, url: this.http.endpoint }); this.db.set("hookId", id); }, async deactivate() { const hookId = this.db.get("hookId"); - if (hookId) await this.loopquest.unsubscribeVerdicts(this, hookId); + if (hookId) await this.loopquest.unsubscribeVerdicts({ $: this, id: hookId }); }, }, async run(event) { @@ -38,7 +38,9 @@ export default { : "resolved"; this.$emit(body, { - id: body.task_id, + // Unique per delivery: a task's state (approved/flagged/escalated) makes + // the id stable for the same resolution but distinct if state ever differs. + id: `${body.task_id}:${verdict}`, summary: `Verdict: ${verdict}${body.external_id ? ` (${body.external_id})` : ""}`, ts: Date.now(), });