diff --git a/.use-case-library/catalog.json b/.use-case-library/catalog.json index 286ffa99..6ffa72e3 100644 --- a/.use-case-library/catalog.json +++ b/.use-case-library/catalog.json @@ -19,7 +19,7 @@ }, { "slug": "ugc-creator-programme", - "reason": "Extracted to a directory; still excluded \u2014 SQLite-backed, like the other stateful apps. Promote when ready." + "reason": "Extracted to a directory; still excluded — SQLite-backed, like the other stateful apps. Promote when ready." }, { "slug": "corpus-search", @@ -71,15 +71,15 @@ }, { "slug": "review-library", - "reason": "Install-eval data shows 1/7 installs pass (14% pass, 14% value rate) \u2014 hits the hide-recommendation threshold (\u22655 installs, <30% value). Hide while we investigate and rework. See /agent/brain/rax/team/kyra/use-case-migration-plan.md (bucket 2)." + "reason": "Install-eval data shows 1/7 installs pass (14% pass, 14% value rate) — hits the hide-recommendation threshold (≥5 installs, <30% value). Hide while we investigate and rework. See /agent/brain/rax/team/kyra/use-case-migration-plan.md (bucket 2)." }, { "slug": "weekly-performance-deck", - "reason": "install-config.json auto-runs app-create + app-build + reminder-add at install \u2014 three v2-schema violations. 1/1 install in the eval window failed. Hide until bucket 3 of the v2 migration ships a clean customize-weekly-performance-deck flow." + "reason": "install-config.json auto-runs app-create + app-build + reminder-add at install — three v2-schema violations. 1/1 install in the eval window failed. Hide until bucket 3 of the v2 migration ships a clean customize-weekly-performance-deck flow." }, { "slug": "building-integrations", - "reason": "Being removed from the use case library entirely \u2014 moving into runneth-volume as part of the agent's standing capability. Hide preemptively." + "reason": "Being removed from the use case library entirely — moving into runneth-volume as part of the agent's standing capability. Hide preemptively." }, { "slug": "performance-bundle", @@ -124,6 +124,10 @@ { "slug": "health-alerts", "reason": "Agent-infrastructure tooling, not a creative-strategy use case. Pulled to keep the public library creative-strategy focused." + }, + { + "slug": "voc-data-pull", + "reason": "Installer package (auto-installs on VoC platform connect), not a public-site use case. Card files exist for internal review only." } ] -} \ No newline at end of file +} diff --git a/package-index.json b/package-index.json index ddfb614d..047bc7c1 100644 --- a/package-index.json +++ b/package-index.json @@ -1,5 +1,33 @@ { "schemaVersion": 1, - "indexRevision": "initial-empty", - "packages": [] + "indexRevision": "voc-data-pull-0.1.0", + "packages": [ + { + "id": "voc-data-pull", + "name": "VoC Data Pull", + "description": "Pull raw voice-of-customer data - reviews, support conversations, and ad comments - from a connected VoC platform into standardized files in the org brain, one file per item.", + "version": "0.1.0", + "packageManagerVersion": 1, + "categories": [ + "integration:judge_me", + "integration:trustpilot", + "integration:yotpo", + "integration:junip", + "integration:gorgias_oauth", + "integration:intercom", + "integration:okendo", + "integration:stamped" + ], + "source": { + "type": "github", + "owner": "Motion-Creative", + "repo": "runneth-apps", + "path": "voc-data-pull", + "ref": "main" + }, + "installPolicy": "auto", + "updatePolicy": "auto", + "uninstallPolicy": "allowed" + } + ] } diff --git a/scripts/validate-runneth-package-index.mjs b/scripts/validate-runneth-package-index.mjs index eef87b71..d3dc0564 100644 --- a/scripts/validate-runneth-package-index.mjs +++ b/scripts/validate-runneth-package-index.mjs @@ -32,8 +32,11 @@ const RESOURCE_TARGET_ROOTS = new Set([ 'agent_skills', 'agent_tools', ]) +const INSTALL_POLICIES = new Set(['auto', 'manual']) +const PACKAGE_MANAGER_VERSIONS = new Set([1, 2]) const UPDATE_POLICIES = new Set(['auto', 'manual']) const UNINSTALL_POLICIES = new Set(['allowed', 'protected']) +const MAX_TASK_TIMEOUT_MS = 2_147_483_647 const abs = (path) => resolve(ROOT, path) const readJSON = (path) => JSON.parse(readFileSync(abs(path), 'utf8')) @@ -55,6 +58,11 @@ const assertNonEmptyString = (value, label) => { assert.ok(value.trim().length > 0, `${label}: must not be empty`) } +const assertTaskName = (value, label) => { + assertNonEmptyString(value, label) + assert.ok(value.trim().length <= 200, `${label}: must be at most 200 characters`) +} + const assertPackageId = (value, label) => { assertNonEmptyString(value, label) assert.ok(PACKAGE_ID.test(value), `${label}: must be kebab-case package id`) @@ -70,11 +78,31 @@ const assertSemver = (value, label) => { assert.ok(SEMVER.test(value), `${label}: must be semver X.Y.Z`) } +const assertOptionalNonEmptyString = (object, key, label) => { + if (!(key in object)) { + return + } + assertNonEmptyString(object[key], `${label}.${key}`) +} + +const assertOptionalJsonValue = (object, key, label) => { + if (!(key in object)) { + return + } + + assert.notEqual(object[key], undefined, `${label}.${key}: must be JSON`) +} + +const assertRequiredJsonValue = (object, key, label) => { + assert.ok(key in object, `${label}.${key}: is required`) + assert.notEqual(object[key], undefined, `${label}.${key}: must be JSON`) +} + const assertSource = (source, label) => { assert.ok(isRecord(source), `${label}: must be an object`) assert.equal(typeof source.type, 'string', `${label}.type: must be a string`) - if (source.type === 'github') { + if (source.type === 'github' || source.type === 'backend-github') { assertKeys(source, ['owner', 'path', 'ref', 'repo', 'type'], label) assert.ok(GITHUB_OWNER.test(source.owner), `${label}.owner: invalid GitHub owner`) assert.ok(GITHUB_REPO.test(source.repo), `${label}.repo: invalid GitHub repo`) @@ -85,7 +113,7 @@ const assertSource = (source, label) => { return } - assert.fail(`${label}.type: must be github`) + assert.fail(`${label}.type: must be github or backend-github`) } const assertTarget = (target, label) => { @@ -125,13 +153,103 @@ const assertPackageResource = (resource, label) => { assert.fail(`${label}.type: must be file, directory, or package_instruction`) } -const assertPackageManifest = (manifest, label) => { +const assertTaskSpec = (spec, label) => { + assert.ok(isRecord(spec), `${label}: must be an object`) + assert.equal(typeof spec.kind, 'string', `${label}.kind: must be a string`) + + if (spec.kind === 'agent') { + const expectedKeys = ['kind', 'prompt'] + if ('name' in spec) { + expectedKeys.push('name') + } + if ('outputSchema' in spec) { + expectedKeys.push('outputSchema') + } + assertKeys(spec, expectedKeys, label) + assertNonEmptyString(spec.prompt, `${label}.prompt`) + if ('name' in spec) { + assertTaskName(spec.name, `${label}.name`) + } + assertOptionalJsonValue(spec, 'outputSchema', label) + return + } + + if (spec.kind === 'bash') { + const expectedKeys = ['kind', 'script'] + if ('cwd' in spec) { + expectedKeys.push('cwd') + } + if ('env' in spec) { + expectedKeys.push('env') + } + if ('timeoutMs' in spec) { + expectedKeys.push('timeoutMs') + } + assertKeys(spec, expectedKeys, label) + assertNonEmptyString(spec.script, `${label}.script`) + assertOptionalNonEmptyString(spec, 'cwd', label) + if ('env' in spec) { + assert.ok(isRecord(spec.env), `${label}.env: must be an object`) + for (const [key, value] of Object.entries(spec.env)) { + assert.equal(typeof value, 'string', `${label}.env.${key}: must be a string`) + } + } + if ('timeoutMs' in spec) { + assert.equal(Number.isInteger(spec.timeoutMs), true, `${label}.timeoutMs: must be an integer`) + assert.ok(spec.timeoutMs > 0, `${label}.timeoutMs: must be positive`) + assert.ok( + spec.timeoutMs <= MAX_TASK_TIMEOUT_MS, + `${label}.timeoutMs: must be at most ${MAX_TASK_TIMEOUT_MS}`, + ) + } + return + } + + if (spec.kind === 'workflow') { + assertKeys(spec, ['input', 'kind', 'workflow'], label) + assertRequiredJsonValue(spec, 'input', label) + assertTaskName(spec.workflow, `${label}.workflow`) + return + } + + assert.fail(`${label}.kind: must be agent, bash, or workflow`) +} + +const assertPackageTask = (task, label) => { + assert.ok(isRecord(task), `${label}: must be an object`) + assertKeys(task, ['name', 'spec'], label) + assertTaskName(task.name, `${label}.name`) + assertTaskSpec(task.spec, `${label}.spec`) +} + +const assertPackageWorkflow = (workflow, label) => { + assert.ok(isRecord(workflow), `${label}: must be an object`) + const expectedKeys = ['name', 'sourcePath'] + if ('entry' in workflow) { + expectedKeys.push('entry') + } + if ('inputSchema' in workflow) { + expectedKeys.push('inputSchema') + } + if ('outputSchema' in workflow) { + expectedKeys.push('outputSchema') + } + assertKeys(workflow, expectedKeys, label) + assertTaskName(workflow.name, `${label}.name`) + assertRelativePath(workflow.sourcePath, `${label}.sourcePath`) + assertOptionalNonEmptyString(workflow, 'entry', label) + assertOptionalJsonValue(workflow, 'inputSchema', label) + assertOptionalJsonValue(workflow, 'outputSchema', label) +} + +const assertPackageManifestV1 = (manifest, label) => { assert.ok(isRecord(manifest), `${label}: must be an object`) assertKeys( manifest, [ 'description', 'id', + 'installPolicy', 'name', 'resources', 'schemaVersion', @@ -146,6 +264,7 @@ const assertPackageManifest = (manifest, label) => { assertNonEmptyString(manifest.name, `${label}.name`) assertNonEmptyString(manifest.description, `${label}.description`) assertSemver(manifest.version, `${label}.version`) + assert.ok(INSTALL_POLICIES.has(manifest.installPolicy), `${label}.installPolicy: invalid`) assert.ok(UPDATE_POLICIES.has(manifest.updatePolicy), `${label}.updatePolicy: invalid`) assert.ok( UNINSTALL_POLICIES.has(manifest.uninstallPolicy), @@ -157,6 +276,71 @@ const assertPackageManifest = (manifest, label) => { }) } +const assertPackageManifestV2 = (manifest, label) => { + assert.ok(isRecord(manifest), `${label}: must be an object`) + const expectedKeys = [ + 'description', + 'id', + 'installPolicy', + 'name', + 'resources', + 'schemaVersion', + 'uninstallPolicy', + 'updatePolicy', + 'version', + ] + if ('tasks' in manifest) { + expectedKeys.push('tasks') + } + if ('workflows' in manifest) { + expectedKeys.push('workflows') + } + assertKeys(manifest, expectedKeys, label) + assert.equal(manifest.schemaVersion, 2, `${label}.schemaVersion: must be 2`) + assertPackageId(manifest.id, `${label}.id`) + assertNonEmptyString(manifest.name, `${label}.name`) + assertNonEmptyString(manifest.description, `${label}.description`) + assertNonEmptyString(manifest.version, `${label}.version`) + assert.ok(INSTALL_POLICIES.has(manifest.installPolicy), `${label}.installPolicy: invalid`) + assert.ok(UPDATE_POLICIES.has(manifest.updatePolicy), `${label}.updatePolicy: invalid`) + assert.ok( + UNINSTALL_POLICIES.has(manifest.uninstallPolicy), + `${label}.uninstallPolicy: invalid`, + ) + assert.ok(Array.isArray(manifest.resources), `${label}.resources: must be array`) + manifest.resources.forEach((resource, index) => { + assertPackageResource(resource, `${label}.resources[${index}]`) + }) + if ('tasks' in manifest) { + assert.ok(Array.isArray(manifest.tasks), `${label}.tasks: must be array`) + manifest.tasks.forEach((task, index) => { + assertPackageTask(task, `${label}.tasks[${index}]`) + }) + } + if ('workflows' in manifest) { + assert.ok(Array.isArray(manifest.workflows), `${label}.workflows: must be array`) + manifest.workflows.forEach((workflow, index) => { + assertPackageWorkflow(workflow, `${label}.workflows[${index}]`) + }) + } +} + +const assertPackageManifest = (manifest, label) => { + assert.ok(isRecord(manifest), `${label}: must be an object`) + + if (manifest.schemaVersion === 1) { + assertPackageManifestV1(manifest, label) + return + } + + if (manifest.schemaVersion === 2) { + assertPackageManifestV2(manifest, label) + return + } + + assert.fail(`${label}.schemaVersion: must be 1 or 2`) +} + const assertIndexEntry = (entry, label) => { assert.ok(isRecord(entry), `${label}: must be an object`) assertKeys( @@ -165,6 +349,7 @@ const assertIndexEntry = (entry, label) => { 'categories', 'description', 'id', + 'installPolicy', 'name', 'packageManagerVersion', 'source', @@ -174,11 +359,15 @@ const assertIndexEntry = (entry, label) => { ], label, ) - assert.equal(entry.packageManagerVersion, 1, `${label}.packageManagerVersion: must be 1`) + assert.ok( + PACKAGE_MANAGER_VERSIONS.has(entry.packageManagerVersion), + `${label}.packageManagerVersion: must be 1 or 2`, + ) assertPackageId(entry.id, `${label}.id`) assertNonEmptyString(entry.name, `${label}.name`) assertNonEmptyString(entry.description, `${label}.description`) assertSemver(entry.version, `${label}.version`) + assert.ok(INSTALL_POLICIES.has(entry.installPolicy), `${label}.installPolicy: invalid`) assert.ok(UPDATE_POLICIES.has(entry.updatePolicy), `${label}.updatePolicy: invalid`) assert.ok(UNINSTALL_POLICIES.has(entry.uninstallPolicy), `${label}.uninstallPolicy: invalid`) assert.ok(Array.isArray(entry.categories), `${label}.categories: must be array`) @@ -205,7 +394,7 @@ const validatePackageIndex = (index) => { } const localManifestPathForSource = (source) => { - return `${source.path}/runneth-package.json` + return `${source.path}/package.json` } const assertPathHasNoSymlinkSegments = (relativePath, label) => { @@ -256,12 +445,22 @@ const assertManifestMatchesIndexEntry = (entry, manifest, manifestPath) => { entry.version, `${entry.id}: manifest version does not match index version`, ) + assert.equal( + manifest.schemaVersion, + entry.packageManagerVersion, + `${entry.id}: manifest schemaVersion does not match index packageManagerVersion`, + ) assert.equal(manifest.name, entry.name, `${entry.id}: manifest name does not match index name`) assert.equal( manifest.description, entry.description, `${entry.id}: manifest description does not match index description`, ) + assert.equal( + manifest.installPolicy, + entry.installPolicy, + `${entry.id}: manifest installPolicy does not match index installPolicy`, + ) assert.equal( manifest.updatePolicy, entry.updatePolicy, @@ -272,7 +471,22 @@ const assertManifestMatchesIndexEntry = (entry, manifest, manifestPath) => { entry.uninstallPolicy, `${entry.id}: manifest uninstallPolicy does not match index uninstallPolicy`, ) - assertManifestResourceFilesExist(manifest, manifestPath.replace(/\/runneth-package\.json$/, '')) + const manifestRootPath = manifestPath.replace(/\/package\.json$/, '') + assertManifestResourceFilesExist(manifest, manifestRootPath) + assertManifestAssetFilesExist(manifest, manifestRootPath) +} + +const assertManifestAssetFilesExist = (manifest, manifestRootPath) => { + if (manifest.schemaVersion !== 2 || !Array.isArray(manifest.workflows)) { + return + } + + for (const [index, workflow] of manifest.workflows.entries()) { + assertExistingFile( + `${manifestRootPath}/${workflow.sourcePath}`, + `${manifest.id}: workflows[${index}] ${workflow.name}.sourcePath`, + ) + } } const getIndexedPackageById = (index) => @@ -310,11 +524,13 @@ const readPullRequestLabels = () => { return event.pull_request?.labels?.map((label) => label.name).filter(Boolean) ?? [] } -const isAutoInstallable = (entry) => entry.updatePolicy === 'auto' +const affectsManagedSync = (entry) => + entry.installPolicy === 'auto' || entry.updatePolicy === 'auto' const sourceFingerprint = (entry) => JSON.stringify({ categories: [...entry.categories].sort(), + installPolicy: entry.installPolicy, source: entry.source, uninstallPolicy: entry.uninstallPolicy, updatePolicy: entry.updatePolicy, @@ -324,8 +540,8 @@ const sourceFingerprint = (entry) => const fleetImpactMessages = (baseIndex, nextIndex) => { if (baseIndex === null) { return nextIndex.packages - .filter(isAutoInstallable) - .map((entry) => `${entry.id}: new auto package`) + .filter(affectsManagedSync) + .map((entry) => `${entry.id}: new managed-sync package`) } const baseById = getIndexedPackageById(baseIndex) @@ -335,25 +551,25 @@ const fleetImpactMessages = (baseIndex, nextIndex) => { for (const nextEntry of nextIndex.packages) { const baseEntry = baseById.get(nextEntry.id) if (!baseEntry) { - if (isAutoInstallable(nextEntry)) { - messages.push(`${nextEntry.id}: new auto package`) + if (affectsManagedSync(nextEntry)) { + messages.push(`${nextEntry.id}: new managed-sync package`) } continue } - if (!isAutoInstallable(baseEntry) && isAutoInstallable(nextEntry)) { - messages.push(`${nextEntry.id}: changed to auto package`) + if (!affectsManagedSync(baseEntry) && affectsManagedSync(nextEntry)) { + messages.push(`${nextEntry.id}: changed to managed-sync package`) continue } - if (isAutoInstallable(baseEntry) && sourceFingerprint(baseEntry) !== sourceFingerprint(nextEntry)) { - messages.push(`${nextEntry.id}: changed auto package version, source, policy, or categories`) + if (affectsManagedSync(baseEntry) && sourceFingerprint(baseEntry) !== sourceFingerprint(nextEntry)) { + messages.push(`${nextEntry.id}: changed managed-sync package version, source, policy, or categories`) } } for (const baseEntry of baseIndex.packages) { - if (isAutoInstallable(baseEntry) && !nextById.has(baseEntry.id)) { - messages.push(`${baseEntry.id}: removed auto package`) + if (affectsManagedSync(baseEntry) && !nextById.has(baseEntry.id)) { + messages.push(`${baseEntry.id}: removed managed-sync package`) } } @@ -364,7 +580,143 @@ test('package-index.json matches the package index contract', () => { validatePackageIndex(readJSON(INDEX_PATH)) }) -test('indexed packages match their runneth-package.json manifests', () => { +test('package index contract accepts package manager v2 entries', () => { + validatePackageIndex({ + indexRevision: 'test', + packages: [ + { + categories: ['analytics'], + description: 'Analytics utilities', + id: 'analytics', + installPolicy: 'manual', + name: 'Analytics', + packageManagerVersion: 2, + source: { + owner: PACKAGE_SOURCE_OWNER, + path: 'analytics', + ref: PACKAGE_SOURCE_REF, + repo: PACKAGE_SOURCE_REPO, + type: 'backend-github', + }, + uninstallPolicy: 'allowed', + updatePolicy: 'auto', + version: '1.0.0', + }, + ], + schemaVersion: 1, + }) +}) + +test('package manifest contract accepts v2 tasks and workflows', () => { + assertPackageManifest( + { + description: 'Analytics utilities', + id: 'analytics', + installPolicy: 'manual', + name: 'Analytics', + resources: [ + { + executable: true, + id: 'cli', + sourcePath: 'bin/analytics', + target: { + path: 'analytics', + root: 'agent_tools', + }, + type: 'file', + }, + { + executablePaths: ['scripts/run'], + id: 'scripts', + sourcePath: 'scripts', + target: { + path: 'analytics/scripts', + root: 'agent_tools', + }, + type: 'directory', + }, + { + id: 'instructions', + sourcePath: 'instructions.md', + type: 'package_instruction', + }, + ], + schemaVersion: 2, + tasks: [ + { + name: 'Classify creative', + spec: { + kind: 'agent', + name: 'creative-classifier', + outputSchema: { + type: 'object', + }, + prompt: 'Classify this creative.', + }, + }, + { + name: 'Normalize rows', + spec: { + cwd: './scripts', + env: { + MODE: 'strict', + }, + kind: 'bash', + script: 'node normalize.js', + timeoutMs: 30_000, + }, + }, + { + name: 'Summarize rows', + spec: { + input: { + limit: 10, + }, + kind: 'workflow', + workflow: 'summarize-rows', + }, + }, + ], + uninstallPolicy: 'allowed', + updatePolicy: 'auto', + version: '1.0.0', + workflows: [ + { + entry: 'wf', + inputSchema: { + type: 'object', + }, + name: 'summarize-rows', + outputSchema: null, + sourcePath: 'workflows/summarize.ts', + }, + ], + }, + 'package.json', + ) +}) + +test('package manifest contract keeps tasks and workflows out of v1', () => { + assert.throws(() => { + assertPackageManifest( + { + description: 'Analytics utilities', + id: 'analytics', + installPolicy: 'manual', + name: 'Analytics', + resources: [], + schemaVersion: 1, + tasks: [], + uninstallPolicy: 'allowed', + updatePolicy: 'auto', + version: '1.0.0', + }, + 'package.json', + ) + }) +}) + +test('indexed packages match their package.json manifests', () => { const index = readJSON(INDEX_PATH) for (const entry of index.packages) { const manifestPath = localManifestPathForSource(entry.source) @@ -376,7 +728,7 @@ test('indexed packages match their runneth-package.json manifests', () => { } }) -test('auto package changes require explicit fleet approval', () => { +test('managed-sync package changes require explicit fleet approval', () => { const nextIndex = readJSON(INDEX_PATH) const messages = fleetImpactMessages(readBaseIndex(), nextIndex) if (messages.length === 0) { @@ -387,7 +739,7 @@ test('auto package changes require explicit fleet approval', () => { assert.ok( labels.includes(FLEET_APPROVAL_LABEL), [ - 'This PR changes auto-installable Runneth packages.', + 'This PR changes managed-sync Runneth packages.', 'These changes may sync to matching VMs after merge.', `Add the ${FLEET_APPROVAL_LABEL} label after core engineering approval.`, '', diff --git a/voc-data-pull/README.md b/voc-data-pull/README.md new file mode 100644 index 00000000..8781794d --- /dev/null +++ b/voc-data-pull/README.md @@ -0,0 +1,47 @@ +# voc-data-pull + +Installer package that pulls raw voice-of-customer (VoC) data - product reviews, support +conversations, and ad comments - from a connected platform into standardized files in the +org brain: **one file per review/ticket/comment**, metadata header + body. + +## How it's built + +- `package.json` - the installer manifest. One directory resource installs `skill/` to + `agent_skills/voc-data-pull`. +- `skill/SKILL.md` - the pull workflow: resolve the connection path (Pipedream OAuth vs + stored secret vs Motion native), follow the platform recipe, write files under + `/agent/brain/data-sources//`, report. +- `skill/references/platform-recipes.md` - per-platform endpoints, pagination, discovery + steps, and unified-template field mappings, with evidence levels (live-verified vs + doc-grounded). +- `skill/templates/review.md` and `skill/templates/support-conversation.md` - copyable file + skeletons for the two output shapes. + +## How it installs + +The root `package-index.json` lists this package with one `integration:` category per +VoC platform. When a sandbox's package intent gains one of those connected-integration slugs +(automatically on connect, or manually via `package intent add-integration `), the +reconciler selects this package and the installer installs it. Installing is desired-state: +re-installs and double-fires are no-ops. + +Covered platform slugs: `judge_me`, `trustpilot`, `yotpo`, `junip`, `gorgias_oauth`, +`intercom`, plus the secrets-path platforms `okendo` and `stamped` (no Pipedream connect +exists for those two; add the intent manually or via the CSM-prompted path). + +## The output contract + +One flat metadata record shape for every VoC item - all fields always present, `null` when +the source lacks the concept - with the review text or full conversation as the file body +and the untouched platform payload preserved at the bottom. See `skill/SKILL.md` for the +field table. Raw data files are deliberately separate from integration guides: nothing is +ever written into `/agent/brain/integrations//`. + +## Known v1 gaps + +- Junip has no verified working API key; its recipe is doc-grounded. +- Okendo and Stamped need customer API keys stored as secrets before any pull. +- Trustpilot and Yotpo recipes are doc-grounded pending first connects. +- Nothing triggers the pull automatically post-install yet; a CSM or user prompt starts it + (routine triggers land separately). +- `author_contact` stays null in output files pending the PII policy call. diff --git a/voc-data-pull/marketing.md b/voc-data-pull/marketing.md new file mode 100644 index 00000000..dcafcd9a --- /dev/null +++ b/voc-data-pull/marketing.md @@ -0,0 +1,28 @@ +--- +hero_headline: "Every review, support ticket, and ad comment - filed and ready to use." +hero_subhead: "Connect your VoC platform once. The raw customer voice lands in your brain as clean, consistent files." +install_time: "Installs automatically when a VoC platform connects" +requires: "A connected reviews or support platform (or a stored API key for Okendo/Stamped)" +--- + +## Super powers this unlocks + +- One file per review, support conversation, or ad comment - metadata header plus the full text. +- The same flat metadata shape across every platform, so downstream packages never care where the data came from. +- Covers reviews (Judge.me, Trustpilot, Yotpo, Junip, Okendo, Stamped), support (Gorgias, Intercom), and Meta ad comments. +- Bounded, read-only pulls with the untouched platform payload preserved in every file. + +## How it works + +When a voice-of-customer platform connects, this package installs into the org's Runneth +automatically. Prompted to pull, Runneth follows the platform's recipe - discovery step, +pagination, date bounds - and writes one standardized file per item under +`data-sources//` in the org brain. Creative strategy packages read those files +directly. + +## A real example + +An org connects Gorgias. Runneth pulls the last year of tickets - one file each, with +status, channel, tags, and the org's own custom fields in the header and the full +conversation below. A week later the strategist asks "what do customers complain about +after their first order?" and Runneth answers from real tickets, quoting real customers. diff --git a/voc-data-pull/package.json b/voc-data-pull/package.json new file mode 100644 index 00000000..2217ea31 --- /dev/null +++ b/voc-data-pull/package.json @@ -0,0 +1,22 @@ +{ + "schemaVersion": 1, + "id": "voc-data-pull", + "name": "VoC Data Pull", + "version": "0.1.0", + "description": "Pull raw voice-of-customer data - reviews, support conversations, and ad comments - from a connected VoC platform into standardized files in the org brain, one file per item.", + "installPolicy": "auto", + "updatePolicy": "auto", + "uninstallPolicy": "allowed", + "resources": [ + { + "id": "voc-data-pull-skill", + "type": "directory", + "sourcePath": "skill", + "target": { + "root": "agent_skills", + "path": "voc-data-pull" + }, + "executablePaths": [] + } + ] +} diff --git a/voc-data-pull/skill/SKILL.md b/voc-data-pull/skill/SKILL.md new file mode 100644 index 00000000..1b4bfc30 --- /dev/null +++ b/voc-data-pull/skill/SKILL.md @@ -0,0 +1,160 @@ +--- +name: voc-data-pull +description: | + Pull raw voice-of-customer data - product reviews, support conversations, and ad comments - + from a connected VoC platform into standardized files in the org's brain, one file per + review/ticket/comment. Use when a reviews or support platform (Judge.me, Trustpilot, Yotpo, + Junip, Okendo, Stamped, Gorgias, Intercom) is connected and its data should land in files, + or when the user asks to "pull the reviews", "dump the reviews", "pull support tickets", + "sync customer conversations to files", or "run the VoC data pull". + Do NOT use for analyzing reviews (analyzing skill), building integration guides, or one-off + API questions about a platform. +--- + +# VoC Data Pull + +Pull raw voice-of-customer (VoC) items from a connected platform and write them into the +org's brain as standardized files: **one file per review, support ticket/conversation, or ad +comment**, each with a metadata header and the content body. Creative strategy packages build +on these files, so shape consistency matters more than volume. + +## When to use + +- A VoC platform was just connected (the platform's package intent installed this package) + and a CSM or user asks to pull its data. +- The user asks to refresh or extend an existing VoC pull. +- The user asks for customer reviews/support conversations "in files" or "in the brain". + +Run one platform per pull unless asked otherwise. Confirm the platform and, when relevant, +the date range before starting. + +## Hard boundaries + +- **Read-only against platforms.** List/read endpoints only. Never write, reply, or delete + through a VoC platform API. +- **Bounded pulls.** Default to the trailing 12 months and cap paging (see per-platform page + caps in the recipes). Only Yotpo bounds by date server-side; everywhere else, page in + newest-first order where supported and stop client-side once items are older than the + cutoff. +- **Raw data files are separate from integration guides.** Never write pulled data into + `/agent/brain/integrations//` - the integration guide spec explicitly forbids raw + dumps in guides. VoC data lives only under `/agent/brain/data-sources/`. +- **PII: leave `author_contact` null.** The unified template keeps the field, but the policy + call on storing customer emails is pending. Do not populate it until told the policy allows + it. + +## Step 1 - Resolve the platform and connection path + +Two connection paths exist and the pull mechanics differ: + +| Path | Platforms | How to call the API | +|---|---|---| +| Pipedream OAuth | `judge_me`, `trustpilot`, `yotpo`, `gorgias_oauth`, `intercom`, `junip` (keys-auth in Pipedream) | `integrations` CLI: check `integrations status --app `, pick the account with `integrations accounts --app `, then `integrations proxy --app --account --method GET --path ` (or the registered app command) | +| Stored secret (customer API key) | `okendo`, `stamped` | `secure-fetch` (`n run --url --secret-key ...`) per `/runneth/references/secure-fetch-cli--command-contracts.md`. If no stored key exists, request one via the secret-collection flow - never ask for the key in chat. | +| Motion native | Meta ad comments | `motion meta creative-comments` (no Runneth connect involved) | + +Exact endpoints, pagination, discovery steps, and field mappings for every platform are in +`references/platform-recipes.md` in this skill folder. Read the recipe for the target +platform before calling anything. + +## Step 2 - Pull with the platform recipe + +Follow the recipe exactly: run its discovery step first when it has one (Trustpilot +businessUnitId, Yotpo appKey, Okendo storeId, Stamped storeHash), then page through the list +endpoint with the recipe's pagination style, applying the date bound. + +For support platforms (Gorgias, Intercom), also fetch each conversation's messages so the +file body can carry the full conversation. + +## Step 3 - Write the files + +### Folder convention + +Root: `/agent/brain/data-sources//`. Use the platform's registry slug as the folder +name (`judge_me`, `gorgias_oauth`, ...; use `meta-ads` for ad comments). + +- Reviews: `/agent/brain/data-sources//reviews/review-.md` +- Support tickets/conversations: `/agent/brain/data-sources//daily//ticket-.md` + (the Ramy Brook Gorgias precedent; `` is the pull run date, `YYYY-MM-DD`) +- Ad comments: `/agent/brain/data-sources/meta-ads/comments/comment-.md` + +If the org brain already has an established convention under `data-sources/`, follow the +existing convention instead and say so. The exact convention is pending confirmation from +creative strategy; do not invent additional hierarchy beyond the above. + +Re-pulls: reviews and comments are immutable - skip files that already exist. Support tickets +live over time - re-writing a ticket file with fresher `updated_at`/messages is correct. + +### File format - the unified metadata template + +Every file is markdown: YAML frontmatter (the metadata header), then the body. The +frontmatter is ONE flat record shape for every VoC item. **All fields are always present; +use `null` when the source lacks the concept.** Never drop a field and never add org-specific +fields at the top level (org-specific platform fields ride in `custom`). + +Common fields (every item): + +| Field | Meaning | +|---|---| +| `source_platform` | Registry slug (`judge_me`, `gorgias_oauth`, `meta-ads`, ...) | +| `source_type` | `review` \| `support_conversation` \| `ad_comment` | +| `external_id` | The platform's id for the item | +| `created_at` | ISO 8601 | +| `title` | Review title / support subject; null when absent | +| `body` | Always populated in the file body section (see below), not duplicated in frontmatter | +| `author_name` | Reviewer/customer/commenter display name | +| `author_contact` | **Always null for now** (PII policy pending) | +| `reply_count` | Number of replies/messages beyond the root item; null when unknown | +| `parent_ref` | For ad-comment replies: the parent comment's `external_id`. Null for root items. | +| `source_url` | Link back to the item on the platform, when the platform provides one | + +Review fields (null for support and ad comments): + +| Field | Meaning | +|---|---| +| `rating` | 1-5 integer. Intercom CSAT (`conversation_rating`) maps here too. | +| `product_ref` | Platform product reference. Null for Trustpilot (company-level reviews). | +| `verified` | Verified-buyer boolean | + +Support-conversation fields (null for reviews and ad comments): + +| Field | Meaning | +|---|---| +| `status` | open/closed/resolved <- Gorgias `status`, Intercom `state` | +| `channel` | email/chat/phone/social <- Gorgias `channel`/`via` | +| `tags` | List of tag names <- Gorgias `tags[]` (e.g. `csat_excluded`) | +| `updated_at` | ISO 8601 - tickets live over time | +| `custom` | Pass-through object of platform custom fields <- Gorgias `custom_fields`, Intercom `custom_attributes`. Carry keys as-is; do not enumerate or rename. | + +Ad-comment fields (null elsewhere): + +| Field | Meaning | +|---|---| +| `reactions_total` | Total reactions on the comment | + +Body and raw payload, after the frontmatter: + +- `## Content` - the review text, or the **full conversation** for support items (one + `### - ` subsection per message, in order), or the comment text. +- `## Raw payload` - the untouched platform payload for the item as a fenced `json` block. + The template is never lossy; keep the raw payload even when it repeats mapped fields. + +Copyable file skeletons are in `templates/review.md` and +`templates/support-conversation.md` in this skill folder. Per-platform field mappings +(`rating` <- Judge.me `rating` / Trustpilot `stars` / Yotpo `score` / Stamped `reviewRating`, +and so on) are in the recipes reference - each platform adapter is a field-mapping exercise, +not design work. + +## Step 4 - Report + +After the pull, report: platform, account used, date bound, item count written, folder path, +and any items skipped or pages capped. If the platform recipe was doc-grounded (not +live-verified), say which calls you verified live during this pull. + +## Known v1 gaps - state these honestly when relevant + +- **Junip**: no working API key verified yet; the recipe is doc-grounded and the pull must + start with a bounded verification call. +- **Okendo / Stamped**: need a customer API key stored as a secret before any pull. +- **Trustpilot / Yotpo**: recipes are doc-grounded; verify grant coverage and the discovery + step on first connect before promising data. diff --git a/voc-data-pull/skill/references/platform-recipes.md b/voc-data-pull/skill/references/platform-recipes.md new file mode 100644 index 00000000..c05493b6 --- /dev/null +++ b/voc-data-pull/skill/references/platform-recipes.md @@ -0,0 +1,141 @@ +# VoC platform pull recipes + +Per-platform endpoints, pagination, discovery steps, and unified-template field mappings. +Evidence levels: **live-verified** (probed through the real Connect proxy on a dev account) +vs **doc-grounded** (provider docs, unprobed - verify with a bounded call before promising +data). Registry slugs are the Builder integration registry's; use them as `--app` values and +as the `data-sources//` folder name. + +Pagination defaults for every platform: page size 100 (or the platform max), hard cap of 50 +pages per pull unless the user asks for full history, and a client-side date cutoff on the +item's created date except where a server-side bound exists (Yotpo only). + +--- + +## judge_me (Pipedream OAuth) - endpoint live-verified, payload doc-grounded + +- Base: `https://judge.me/api/v1` +- List reviews: `GET /reviews?page=1&per_page=100` - `per_page` max 100, page-numbered; + iterate `page` until a short page. +- Product-scoped: `GET /reviews?product_id={judgeMeProductId}&page=1`. Resolve a Judge.me + product id from a Shopify product id via `GET /products/-1?external_id={shopifyProductId}`. +- Date bound: none on the API - page through and cut off client-side by `created_at`. +- Field mapping: `rating` <- `rating` (1-5 int); body <- `body`; `title` <- `title`; + `product_ref` <- `product_external_id` (Shopify product id; `product_handle` also exists); + `author_name` <- `reviewer.name`; `created_at` <- `created_at`; `verified` <- `verified`; + media in `pictures[]` (keep in raw payload only). + +## trustpilot (Pipedream OAuth) - doc-grounded, verify on first connect + +Two-step: + +1. Discovery: `GET /v1/business-units/find?name={domain}` -> `businessUnitId` +2. List: `GET /v1/business-units/{businessUnitId}/reviews?perPage=100&page=1` (public + reviews; `stars` filter available). A private-reviews variant exists at + `/v1/private/business-units/{businessUnitId}/reviews` - scope-dependent, verify the grant + on connect. + +- Date bound: none - client-side cutoff. +- Field mapping: `rating` <- `stars`; body <- `text`; `title` <- `title`; + `author_name` <- `consumer.displayName`; `created_at` <- `createdAt`; `companyReply` stays + in the raw payload. +- **`product_ref` is always null**: Trustpilot core is company-level reviews. Product reviews + are a separate API surface - verify grant coverage before using it. + +## yotpo (Pipedream OAuth) - doc-grounded, verify on first connect + +- Discovery: every call needs the per-account `{appKey}`. Where it comes from on a fresh + connect (connected-account metadata vs an API discovery call) must be verified when the + account exists - treat it as the first-call gap. +- List: `GET https://api.yotpo.com/v1/apps/{appKey}/reviews?count=100&page=1` with `star={n}` + and `updated_at_min=YYYY-MM-DD` filters. +- Date bound: `updated_at_min` - **the only platform here with a native date bound**. +- Field mapping: `rating` <- `score`; body <- `content`; `title` <- `title`; + `author_name` <- `user.display_name`; `created_at` <- `created_at`; `product_ref` <- `sku`; + `verified` <- `verified_buyer`; votes stay in the raw payload. + +## junip (keys-auth in Pipedream) - BLOCKED: no working key verified + +- The registry entry's only example is `GET /v1/stores` on `https://api.juniphq.com` (a + connection check). Junip's docs describe `GET /v1/product_reviews` (cursor-paginated). +- Doc-grounded with no scope truth: start with a bounded read (one small page) before + promising anything. If the stored key 401s, the key is dead - route to reconnection, do + not retry. + +## okendo (secrets path - NOT in Pipedream's catalog) + +- Auth: customer API key stored as a secret; call with `secure-fetch`. "Connect" for Okendo + means storing a key. +- Discovery: the store id. +- List: `GET https://api.okendo.io/v1/stores/{storeId}/reviews` - cursor-paginated. +- Field mapping (doc-grounded; confirm names against a real key): `rating` <- `rating`; + body <- `body`; `title` <- `title`; `product_ref` <- `productId`; + `author_name` <- reviewer name field; `created_at` <- `dateCreated`; + `verified` <- verified status field. + +## stamped (secrets path - NOT in Pipedream's catalog) + +- Auth: customer API key + storeHash, via `secure-fetch`. +- List: `GET /api/v2/dashboard/reviews?storeHash=...` (dashboard API). +- Field mapping (doc-grounded; confirm against a real key): `rating` <- `reviewRating`; + body <- `reviewMessage`; `title` <- `reviewTitle`; `product_ref` <- `productId` + (`productTitle` also exists); `author_name` <- `author`; `created_at` <- `dateCreated`; + `verified` <- `reviewVerifiedType`. + +## gorgias (registry: `gorgias_oauth`) - live-verified. Support conversations, not reviews. + +- `source_type: support_conversation`. +- List: `GET /api/tickets?limit=N&order_by=updated_datetime:desc` - cursor pagination via + `meta.next_cursor` (observed live). `GET /api/account` verifies the connection. +- Messages: fetch per ticket with `GET /api/messages?ticket_id=...` so the file body carries + the full conversation. +- Date bound: none - newest-first ordering plus client-side cutoff. +- Field mapping: `title` <- `subject`; `status` <- `status`; `channel` <- `channel`/`via`; + `tags` <- `tags[]` names; `author_name` <- `customer.name`; `created_at` <- + `created_datetime`; `updated_at` <- `updated_datetime`; `reply_count` <- `messages_count`; + `custom` <- `custom_fields` (pass through as-is - this is where org-specific headers like + Category/Detail/Customer tier come from); `rating` is null (no CSAT on the ticket object). + +## intercom (Pipedream OAuth) - live-verified. Conversations + CSAT, not reviews. + +- `source_type: support_conversation`. +- List: `GET /conversations?per_page=N` - cursor pagination via `pages.next.starting_after` + (plus `total_count`). Send the pinned `Intercom-Version` header the registry documents. +- Date-bounded pulls: `POST /conversations/search` (live-verified) is the date-boundable + path - prefer it for bounded pulls. +- Messages: conversation parts, fetched per conversation. +- Field mapping: `title` <- `source.subject`; body <- `source.body` plus conversation parts; + `status` <- `state`; `author_name` <- `source.author` / contact name; + `created_at`/`updated_at` <- `created_at`/`updated_at`; `custom` <- `custom_attributes`; + `rating` <- `conversation_rating` (CSAT - the review-like signal); `channel` from the + source type when present. +- Intercom workspaces can be large: keep `per_page` small on the first call and stay + deliberate about pull size. + +## meta ad comments (Motion native - NOT a Runneth integration) + +- `source_type: ad_comment`, platform folder `meta-ads`. +- Rides the org's existing Motion Meta connection; no Runneth connect at all. +- Pull: `motion meta creative-comments` (per the motion-cli skill). Per creative asset, max + 50 ids per request; root comments bounded at 1,000 per ad unit (explicitly non-exhaustive - + the tool reports coverage level, gaps, and warnings). Served from Motion's cache, so fresh + comments can lag. Junk comments are filtered by default; replies and reactions are opt-in. + Output lands as a JSON file in the workdir - transform that into the per-comment files. +- Field mapping: `external_id` <- `id`; body <- `text`; `author_name` <- `authorName`; + `created_at` <- `createdAt`; `reactions_total` <- `reactions.total`; + `reply_count` <- `replyCount`; replies become their own files with `parent_ref` set to the + parent comment id; `rating`, `product_ref`, and `verified` are null. + +--- + +## Variation summary (what actually changes per platform) + +| dimension | judge_me | trustpilot | yotpo | okendo | stamped | +|---|---|---|---|---|---| +| rating field | `rating` | `stars` | `score` | `rating` | `reviewRating` | +| text field | `body` | `text` | `content` | `body` | `reviewMessage` | +| product ref | Shopify product id | **none (company-level)** | `sku` | `productId` | `productId` | +| date bound on API | none (client-side) | none (client-side) | **`updated_at_min`** | TBD | TBD | +| pagination | page number | page number | page number | cursor | page number | +| discovery step | none | businessUnitId | **appKey** | storeId | storeHash | +| connection path | OAuth registry | OAuth registry | OAuth registry | **secret key** | **secret key** | diff --git a/voc-data-pull/skill/templates/review.md b/voc-data-pull/skill/templates/review.md new file mode 100644 index 00000000..5aa5e973 --- /dev/null +++ b/voc-data-pull/skill/templates/review.md @@ -0,0 +1,51 @@ +# Template: review file + +Path: `/agent/brain/data-sources//reviews/review-.md` + +Every frontmatter field is always present; `null` when the source lacks the concept. +`author_contact` stays `null` until the PII policy call is made. + +````markdown +--- +source_platform: judge_me +source_type: review +external_id: "31274522" +created_at: "2026-06-14T09:12:44Z" +title: "Finally something that works" +author_name: "Dana M." +author_contact: null +reply_count: 0 +parent_ref: null +source_url: "https://judge.me/reviews/31274522" +rating: 5 +product_ref: "8641242349791" +verified: true +status: null +channel: null +tags: null +updated_at: null +custom: null +reactions_total: null +--- + +## Content + +Finally something that works. I'd given up on strapless options entirely until a friend +recommended this - wore it for a full wedding day and forgot I had it on. + +## Raw payload + +```json +{ + "id": 31274522, + "rating": 5, + "title": "Finally something that works", + "body": "Finally something that works. I'd given up on strapless options entirely...", + "product_external_id": 8641242349791, + "reviewer": { "name": "Dana M.", "email": "dana@example.com" }, + "created_at": "2026-06-14T09:12:44Z", + "verified": "buyer", + "pictures": [] +} +``` +```` diff --git a/voc-data-pull/skill/templates/support-conversation.md b/voc-data-pull/skill/templates/support-conversation.md new file mode 100644 index 00000000..c04153e9 --- /dev/null +++ b/voc-data-pull/skill/templates/support-conversation.md @@ -0,0 +1,82 @@ +# Template: support conversation file + +Path: `/agent/brain/data-sources//daily//ticket-.md` +(the Ramy Brook Gorgias precedent - metadata top, full conversation below). + +Every frontmatter field is always present; `null` when the source lacks the concept. +`custom` passes platform custom fields through as-is (Gorgias `custom_fields`, Intercom +`custom_attributes`) - this is where org-specific headers like Category / Detail / +Customer tier come from. Do not rename or enumerate its keys. + +````markdown +--- +source_platform: gorgias_oauth +source_type: support_conversation +external_id: "88213307" +created_at: "2026-07-18T14:02:11Z" +title: "Order 4821 arrived with the wrong size" +author_name: "Priya S." +author_contact: null +reply_count: 4 +parent_ref: null +source_url: "https://example.gorgias.com/app/ticket/88213307" +rating: null +product_ref: null +verified: null +status: "closed" +channel: "email" +tags: + - "sizing" + - "csat_excluded" +updated_at: "2026-07-19T10:44:03Z" +custom: + Category: "Order issue" + Detail: "Wrong size shipped" + Customer tier: "Repeat" +reactions_total: null +--- + +## Content + +### Priya S. (customer) - 2026-07-18T14:02:11Z + +Hi - my order 4821 arrived today but it's a medium, I ordered a small. Can you swap it? + +### Support (agent) - 2026-07-18T15:30:47Z + +So sorry about that, Priya! I've set up a replacement in a small shipping out today - +keep or donate the medium, no return needed. + +### Priya S. (customer) - 2026-07-19T10:44:03Z + +That's amazing, thank you! You've made a customer for life. + +## Raw payload + +```json +{ + "id": 88213307, + "status": "closed", + "channel": "email", + "via": "email", + "customer": { "id": 5512, "email": "priya@example.com", "name": "Priya S." }, + "subject": "Order 4821 arrived with the wrong size", + "tags": [{ "name": "sizing" }, { "name": "csat_excluded" }], + "messages_count": 4, + "created_datetime": "2026-07-18T14:02:11Z", + "updated_datetime": "2026-07-19T10:44:03Z", + "custom_fields": { + "Category": "Order issue", + "Detail": "Wrong size shipped", + "Customer tier": "Repeat" + } +} +``` +```` + +Notes: + +- For Intercom, `rating` carries the CSAT (`conversation_rating`) when present - it is the + one support field that maps into the review group. +- Support tickets live over time: re-pulls overwrite the ticket file with the fresher + `updated_at` and any new messages. diff --git a/voc-data-pull/use-case.json b/voc-data-pull/use-case.json new file mode 100644 index 00000000..9d1dd45d --- /dev/null +++ b/voc-data-pull/use-case.json @@ -0,0 +1,8 @@ +{ + "slug": "voc-data-pull", + "display_title": "Pull Every Review And Ticket Into Your Brain", + "pitch": "Connect a reviews or support platform and the raw customer voice lands in files - one per review, ticket, or comment - in a shape strategy work can build on.", + "status": "experimental", + "category": "creative-operations", + "github_path": "voc-data-pull" +}